diff options
Diffstat (limited to 'python/samba/netcmd/domain')
39 files changed, 9913 insertions, 0 deletions
diff --git a/python/samba/netcmd/domain/__init__.py b/python/samba/netcmd/domain/__init__.py new file mode 100644 index 0000000..1c527f1 --- /dev/null +++ b/python/samba/netcmd/domain/__init__.py @@ -0,0 +1,73 @@ +# domain management +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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/>. +# + +from samba import is_ad_dc_built +from samba.netcmd import SuperCommand + +from .auth import cmd_domain_auth +from .backup import cmd_domain_backup +from .claim import cmd_domain_claim +from .classicupgrade import cmd_domain_classicupgrade +from .common import (common_join_options, common_ntvfs_options, + common_provision_join_options) +from .dcpromo import cmd_domain_dcpromo +from .demote import cmd_domain_demote +from .functional_prep import cmd_domain_functional_prep +from .info import cmd_domain_info +from .join import cmd_domain_join +from .keytab import cmd_domain_export_keytab +from .leave import cmd_domain_leave +from .level import cmd_domain_level +from .passwordsettings import cmd_domain_passwordsettings +from .provision import cmd_domain_provision +from .samba3upgrade import cmd_domain_samba3upgrade +from .schemaupgrade import cmd_domain_schema_upgrade +from .tombstones import cmd_domain_tombstones +from .trust import cmd_domain_trust + + +class cmd_domain(SuperCommand): + """Domain management.""" + + subcommands = {} + if cmd_domain_export_keytab is not None: + subcommands["exportkeytab"] = cmd_domain_export_keytab() + subcommands["info"] = cmd_domain_info() + subcommands["join"] = cmd_domain_join() + subcommands["leave"] = cmd_domain_leave() + subcommands["claim"] = cmd_domain_claim() + subcommands["auth"] = cmd_domain_auth() + if is_ad_dc_built(): + subcommands["demote"] = cmd_domain_demote() + subcommands["provision"] = cmd_domain_provision() + subcommands["dcpromo"] = cmd_domain_dcpromo() + subcommands["level"] = cmd_domain_level() + subcommands["passwordsettings"] = cmd_domain_passwordsettings() + subcommands["classicupgrade"] = cmd_domain_classicupgrade() + subcommands["samba3upgrade"] = cmd_domain_samba3upgrade() + subcommands["trust"] = cmd_domain_trust() + subcommands["tombstones"] = cmd_domain_tombstones() + subcommands["schemaupgrade"] = cmd_domain_schema_upgrade() + subcommands["functionalprep"] = cmd_domain_functional_prep() + subcommands["backup"] = cmd_domain_backup() diff --git a/python/samba/netcmd/domain/auth/__init__.py b/python/samba/netcmd/domain/auth/__init__.py new file mode 100644 index 0000000..fd74f3e --- /dev/null +++ b/python/samba/netcmd/domain/auth/__init__.py @@ -0,0 +1,35 @@ +# Unix SMB/CIFS implementation. +# +# authentication silos +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +from samba.netcmd import SuperCommand + +from .policy import cmd_domain_auth_policy +from .silo import cmd_domain_auth_silo + + +class cmd_domain_auth(SuperCommand): + """Manage authentication silos and policies on the domain.""" + + subcommands = { + "policy": cmd_domain_auth_policy(), + "silo": cmd_domain_auth_silo(), + } diff --git a/python/samba/netcmd/domain/auth/policy.py b/python/samba/netcmd/domain/auth/policy.py new file mode 100644 index 0000000..de9ce4b --- /dev/null +++ b/python/samba/netcmd/domain/auth/policy.py @@ -0,0 +1,685 @@ +# Unix SMB/CIFS implementation. +# +# authentication silos - authentication policy management +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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 samba.getopt as options +from samba.netcmd import Command, CommandError, Option, SuperCommand +from samba.netcmd.domain.models import AuthenticationPolicy,\ + AuthenticationSilo, Group +from samba.netcmd.domain.models.auth_policy import MIN_TGT_LIFETIME,\ + MAX_TGT_LIFETIME, StrongNTLMPolicy +from samba.netcmd.domain.models.exceptions import ModelError +from samba.netcmd.validators import Range + + +def check_similar_args(option, args): + """Helper method for checking similar mutually exclusive args. + + Example: --user-allowed-to-authenticate-from and + --user-allowed-to-authenticate-from-device-silo + """ + num = sum(arg is not None for arg in args) + if num > 1: + raise CommandError(f"{option} argument repeated {num} times.") + + +class UserOptions(options.OptionGroup): + """User options used by policy create and policy modify commands.""" + + def __init__(self, parser): + super().__init__(parser, "User Options") + + self.add_option("--user-tgt-lifetime-mins", + help="Ticket-Granting-Ticket lifetime for user accounts.", + dest="tgt_lifetime", type=int, action="callback", + callback=self.set_option, + validators=[Range(min=MIN_TGT_LIFETIME, max=MAX_TGT_LIFETIME)]) + self.add_option("--user-allow-ntlm-auth", + help="Allow NTLM network authentication despite the fact that the user " + "is restricted to selected devices.", + dest="allow_ntlm_auth", default=False, + action="callback", callback=self.set_option) + self.add_option("--user-allowed-to-authenticate-from", + help="SDDL Rules setting which device the user is allowed to authenticate from.", + type=str, dest="allowed_to_authenticate_from", + action="callback", callback=self.set_option, + metavar="SDDL") + self.add_option("--user-allowed-to-authenticate-from-device-silo", + help="To authenticate, the user must log in from a device in SILO.", + type=str, dest="allowed_to_authenticate_from_device_silo", + action="callback", callback=self.set_option, + metavar="SILO") + self.add_option("--user-allowed-to-authenticate-from-device-group", + help="To authenticate, the user must log in from a device in GROUP.", + type=str, dest="allowed_to_authenticate_from_device_group", + action="callback", callback=self.set_option, + metavar="GROUP") + self.add_option("--user-allowed-to-authenticate-to", + help="A target service, on a user account, requires the connecting user to match SDDL", + type=str, dest="allowed_to_authenticate_to", + action="callback", callback=self.set_option, + metavar="SDDL") + self.add_option("--user-allowed-to-authenticate-to-by-group", + help="A target service, on a user account, requires the connecting user to be in GROUP", + type=str, dest="allowed_to_authenticate_to_by_group", + action="callback", callback=self.set_option, + metavar="GROUP") + self.add_option("--user-allowed-to-authenticate-to-by-silo", + help="A target service, on a user account, requires the connecting user to be in SILO", + type=str, dest="allowed_to_authenticate_to_by_silo", + action="callback", callback=self.set_option, + metavar="SILO") + + +class ServiceOptions(options.OptionGroup): + """Service options used by policy create and policy modify commands.""" + + def __init__(self, parser): + super().__init__(parser, "Service Options") + + self.add_option("--service-tgt-lifetime-mins", + help="Ticket-Granting-Ticket lifetime for service accounts.", + dest="tgt_lifetime", type=int, action="callback", + callback=self.set_option, + validators=[Range(min=MIN_TGT_LIFETIME, max=MAX_TGT_LIFETIME)]) + self.add_option("--service-allow-ntlm-auth", + help="Allow NTLM network authentication despite " + "the fact that the service account " + "is restricted to selected devices.", + dest="allow_ntlm_auth", default=False, + action="callback", callback=self.set_option) + self.add_option("--service-allowed-to-authenticate-from", + help="SDDL Rules setting which device the " + "service account is allowed to authenticate from.", + type=str, dest="allowed_to_authenticate_from", + action="callback", callback=self.set_option, + metavar="SDDL") + self.add_option("--service-allowed-to-authenticate-from-device-silo", + help="To authenticate, the service must authenticate on a device in SILO.", + type=str, dest="allowed_to_authenticate_from_device_silo", + action="callback", callback=self.set_option, + metavar="SILO") + self.add_option("--service-allowed-to-authenticate-from-device-group", + help="To authenticate, the service must authenticate on a device in GROUP.", + type=str, dest="allowed_to_authenticate_from_device_group", + action="callback", callback=self.set_option, + metavar="GROUP") + self.add_option("--service-allowed-to-authenticate-to", + help="The target service requires the connecting user to match SDDL", + type=str, dest="allowed_to_authenticate_to", + action="callback", callback=self.set_option, + metavar="SDDL") + self.add_option("--service-allowed-to-authenticate-to-by-group", + help="The target service requires the connecting user to be in GROUP", + type=str, dest="allowed_to_authenticate_to_by_group", + action="callback", callback=self.set_option, + metavar="GROUP") + self.add_option("--service-allowed-to-authenticate-to-by-silo", + help="The target service requires the connecting user to be in SILO", + type=str, dest="allowed_to_authenticate_to_by_silo", + action="callback", callback=self.set_option, + metavar="SILO") + + +class ComputerOptions(options.OptionGroup): + """Computer options used by policy create and policy modify commands.""" + + def __init__(self, parser): + super().__init__(parser, "Computer Options") + + self.add_option("--computer-tgt-lifetime-mins", + help="Ticket-Granting-Ticket lifetime for computer accounts.", + dest="tgt_lifetime", type=int, action="callback", + callback=self.set_option, + validators=[Range(min=MIN_TGT_LIFETIME, max=MAX_TGT_LIFETIME)]) + self.add_option("--computer-allowed-to-authenticate-to", + help="The computer account (server, workstation) service requires the connecting user to match SDDL", + type=str, dest="allowed_to_authenticate_to", + action="callback", callback=self.set_option, + metavar="SDDL") + self.add_option("--computer-allowed-to-authenticate-to-by-group", + help="The computer account (server, workstation) service requires the connecting user to be in GROUP", + type=str, dest="allowed_to_authenticate_to_by_group", + action="callback", callback=self.set_option, + metavar="GROUP") + self.add_option("--computer-allowed-to-authenticate-to-by-silo", + help="The computer account (server, workstation) service requires the connecting user to be in SILO", + type=str, dest="allowed_to_authenticate_to_by_silo", + action="callback", callback=self.set_option, + metavar="SILO") + + +class cmd_domain_auth_policy_list(Command): + """List authentication policies on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--json", help="Output results in JSON format.", + dest="output_format", action="store_const", const="json"), + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, + output_format=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + # Authentication policies grouped by cn. + try: + policies = {policy.cn: policy.as_dict() + for policy in AuthenticationPolicy.query(ldb)} + except ModelError as e: + raise CommandError(e) + + # Using json output format gives more detail. + if output_format == "json": + self.print_json(policies) + else: + for policy in policies.keys(): + self.outf.write(f"{policy}\n") + + +class cmd_domain_auth_policy_view(Command): + """View an authentication policy on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--name", + help="Name of authentication policy to view (required).", + dest="name", action="store", type=str, required=True), + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, name=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + try: + policy = AuthenticationPolicy.get(ldb, cn=name) + except ModelError as e: + raise CommandError(e) + + # Check if authentication policy exists first. + if policy is None: + raise CommandError(f"Authentication policy {name} not found.") + + # Display policy as JSON. + self.print_json(policy.as_dict()) + + +class cmd_domain_auth_policy_create(Command): + """Create an authentication policy on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + "useropts": UserOptions, + "serviceopts": ServiceOptions, + "computeropts": ComputerOptions, + } + + takes_options = [ + Option("--name", help="Name of authentication policy (required).", + dest="name", action="store", type=str, required=True), + Option("--description", + help="Optional description for authentication policy.", + dest="description", action="store", type=str), + Option("--protect", + help="Protect authentication silo from accidental deletion.", + dest="protect", action="store_true"), + Option("--unprotect", + help="Unprotect authentication silo from accidental deletion.", + dest="unprotect", action="store_true"), + Option("--audit", + help="Only audit authentication policy.", + dest="audit", action="store_true"), + Option("--enforce", + help="Enforce authentication policy.", + dest="enforce", action="store_true"), + Option("--strong-ntlm-policy", + help=f"Strong NTLM Policy ({StrongNTLMPolicy.choices_str()}).", + dest="strong_ntlm_policy", type="choice", action="store", + choices=StrongNTLMPolicy.get_choices(), + default="Disabled"), + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, useropts=None, + serviceopts=None, computeropts=None, name=None, description=None, + protect=None, unprotect=None, audit=None, enforce=None, + strong_ntlm_policy=None): + + if protect and unprotect: + raise CommandError("--protect and --unprotect cannot be used together.") + if audit and enforce: + raise CommandError("--audit and --enforce cannot be used together.") + + # Check for repeated, similar arguments. + check_similar_args("--user-allowed-to-authenticate-from", + [useropts.allowed_to_authenticate_from, + useropts.allowed_to_authenticate_from_device_group, + useropts.allowed_to_authenticate_from_device_silo]) + check_similar_args("--user-allowed-to-authenticate-to", + [useropts.allowed_to_authenticate_to, + useropts.allowed_to_authenticate_to_by_group, + useropts.allowed_to_authenticate_to_by_silo]) + check_similar_args("--service-allowed-to-authenticate-from", + [serviceopts.allowed_to_authenticate_from, + serviceopts.allowed_to_authenticate_from_device_group, + serviceopts.allowed_to_authenticate_from_device_silo]) + check_similar_args("--service-allowed-to-authenticate-to", + [serviceopts.allowed_to_authenticate_to, + serviceopts.allowed_to_authenticate_to_by_group, + serviceopts.allowed_to_authenticate_to_by_silo]) + check_similar_args("--computer-allowed-to-authenticate-to", + [computeropts.allowed_to_authenticate_to, + computeropts.allowed_to_authenticate_to_by_group, + computeropts.allowed_to_authenticate_to_by_silo]) + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + # Generate SDDL for authenticating users from a device in a group + if useropts.allowed_to_authenticate_from_device_group: + group = Group.get( + ldb, cn=useropts.allowed_to_authenticate_from_device_group) + useropts.allowed_to_authenticate_from = group.get_authentication_sddl() + + # Generate SDDL for authenticating users from a device in a silo + if useropts.allowed_to_authenticate_from_device_silo: + silo = AuthenticationSilo.get( + ldb, cn=useropts.allowed_to_authenticate_from_device_silo) + useropts.allowed_to_authenticate_from = silo.get_authentication_sddl() + + # Generate SDDL for authenticating user accounts to a group + if useropts.allowed_to_authenticate_to_by_group: + group = Group.get( + ldb, cn=useropts.allowed_to_authenticate_to_by_group) + useropts.allowed_to_authenticate_to = group.get_authentication_sddl() + + # Generate SDDL for authenticating user accounts to a silo + if useropts.allowed_to_authenticate_to_by_silo: + silo = AuthenticationSilo.get( + ldb, cn=useropts.allowed_to_authenticate_to_by_silo) + useropts.allowed_to_authenticate_to = silo.get_authentication_sddl() + + # Generate SDDL for authenticating service accounts from a device in a group + if serviceopts.allowed_to_authenticate_from_device_group: + group = Group.get( + ldb, cn=serviceopts.allowed_to_authenticate_from_device_group) + serviceopts.allowed_to_authenticate_from = group.get_authentication_sddl() + + # Generate SDDL for authenticating service accounts from a device in a silo + if serviceopts.allowed_to_authenticate_from_device_silo: + silo = AuthenticationSilo.get( + ldb, cn=serviceopts.allowed_to_authenticate_from_device_silo) + serviceopts.allowed_to_authenticate_from = silo.get_authentication_sddl() + + # Generate SDDL for authenticating service accounts to a group + if serviceopts.allowed_to_authenticate_to_by_group: + group = Group.get( + ldb, cn=serviceopts.allowed_to_authenticate_to_by_group) + serviceopts.allowed_to_authenticate_to = group.get_authentication_sddl() + + # Generate SDDL for authenticating service accounts to a silo + if serviceopts.allowed_to_authenticate_to_by_silo: + silo = AuthenticationSilo.get( + ldb, cn=serviceopts.allowed_to_authenticate_to_by_silo) + serviceopts.allowed_to_authenticate_to = silo.get_authentication_sddl() + + # Generate SDDL for authenticating computer accounts to a group + if computeropts.allowed_to_authenticate_to_by_group: + group = Group.get( + ldb, cn=computeropts.allowed_to_authenticate_to_by_group) + computeropts.allowed_to_authenticate_to = group.get_authentication_sddl() + + # Generate SDDL for authenticating computer accounts to a silo + if computeropts.allowed_to_authenticate_to_by_silo: + silo = AuthenticationSilo.get( + ldb, cn=computeropts.allowed_to_authenticate_to_by_silo) + computeropts.allowed_to_authenticate_to = silo.get_authentication_sddl() + + try: + policy = AuthenticationPolicy.get(ldb, cn=name) + except ModelError as e: + raise CommandError(e) + + # Make sure authentication policy doesn't already exist. + if policy is not None: + raise CommandError(f"Authentication policy {name} already exists.") + + # New policy object. + policy = AuthenticationPolicy( + cn=name, + description=description, + strong_ntlm_policy=StrongNTLMPolicy[strong_ntlm_policy.upper()], + user_allow_ntlm_auth=useropts.allow_ntlm_auth, + user_tgt_lifetime=useropts.tgt_lifetime, + user_allowed_to_authenticate_from=useropts.allowed_to_authenticate_from, + user_allowed_to_authenticate_to=useropts.allowed_to_authenticate_to, + service_allow_ntlm_auth=serviceopts.allow_ntlm_auth, + service_tgt_lifetime=serviceopts.tgt_lifetime, + service_allowed_to_authenticate_from=serviceopts.allowed_to_authenticate_from, + service_allowed_to_authenticate_to=serviceopts.allowed_to_authenticate_to, + computer_tgt_lifetime=computeropts.tgt_lifetime, + computer_allowed_to_authenticate_to=computeropts.allowed_to_authenticate_to, + ) + + # Either --enforce will be set or --audit but never both. + # The default if both are missing is enforce=True. + if enforce is not None: + policy.enforced = enforce + else: + policy.enforced = not audit + + # Create policy. + try: + policy.save(ldb) + + if protect: + policy.protect(ldb) + except ModelError as e: + raise CommandError(e) + + # Authentication policy created successfully. + self.outf.write(f"Created authentication policy: {name}\n") + + +class cmd_domain_auth_policy_modify(Command): + """Modify authentication policies on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + "useropts": UserOptions, + "serviceopts": ServiceOptions, + "computeropts": ComputerOptions, + } + + takes_options = [ + Option("--name", help="Name of authentication policy (required).", + dest="name", action="store", type=str, required=True), + Option("--description", + help="Optional description for authentication policy.", + dest="description", action="store", type=str), + Option("--protect", + help="Protect authentication silo from accidental deletion.", + dest="protect", action="store_true"), + Option("--unprotect", + help="Unprotect authentication silo from accidental deletion.", + dest="unprotect", action="store_true"), + Option("--audit", + help="Only audit authentication policy.", + dest="audit", action="store_true"), + Option("--enforce", + help="Enforce authentication policy.", + dest="enforce", action="store_true"), + Option("--strong-ntlm-policy", + help=f"Strong NTLM Policy ({StrongNTLMPolicy.choices_str()}).", + dest="strong_ntlm_policy", type="choice", action="store", + choices=StrongNTLMPolicy.get_choices()), + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, useropts=None, + serviceopts=None, computeropts=None, name=None, description=None, + protect=None, unprotect=None, audit=None, enforce=None, + strong_ntlm_policy=None): + + if protect and unprotect: + raise CommandError("--protect and --unprotect cannot be used together.") + if audit and enforce: + raise CommandError("--audit and --enforce cannot be used together.") + + # Check for repeated, similar arguments. + check_similar_args("--user-allowed-to-authenticate-from", + [useropts.allowed_to_authenticate_from, + useropts.allowed_to_authenticate_from_device_group, + useropts.allowed_to_authenticate_from_device_silo]) + check_similar_args("--user-allowed-to-authenticate-to", + [useropts.allowed_to_authenticate_to, + useropts.allowed_to_authenticate_to_by_group, + useropts.allowed_to_authenticate_to_by_silo]) + check_similar_args("--service-allowed-to-authenticate-from", + [serviceopts.allowed_to_authenticate_from, + serviceopts.allowed_to_authenticate_from_device_group, + serviceopts.allowed_to_authenticate_from_device_silo]) + check_similar_args("--service-allowed-to-authenticate-to", + [serviceopts.allowed_to_authenticate_to, + serviceopts.allowed_to_authenticate_to_by_group, + serviceopts.allowed_to_authenticate_to_by_silo]) + check_similar_args("--computer-allowed-to-authenticate-to", + [computeropts.allowed_to_authenticate_to, + computeropts.allowed_to_authenticate_to_by_group, + computeropts.allowed_to_authenticate_to_by_silo]) + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + # Generate SDDL for authenticating users from a device in a group + if useropts.allowed_to_authenticate_from_device_group: + group = Group.get( + ldb, cn=useropts.allowed_to_authenticate_from_device_group) + useropts.allowed_to_authenticate_from = group.get_authentication_sddl() + + # Generate SDDL for authenticating users from a device in a silo + if useropts.allowed_to_authenticate_from_device_silo: + silo = AuthenticationSilo.get( + ldb, cn=useropts.allowed_to_authenticate_from_device_silo) + useropts.allowed_to_authenticate_from = silo.get_authentication_sddl() + + # Generate SDDL for authenticating user accounts to a group + if useropts.allowed_to_authenticate_to_by_group: + group = Group.get( + ldb, cn=useropts.allowed_to_authenticate_to_by_group) + useropts.allowed_to_authenticate_to = group.get_authentication_sddl() + + # Generate SDDL for authenticating user accounts to a silo + if useropts.allowed_to_authenticate_to_by_silo: + silo = AuthenticationSilo.get( + ldb, cn=useropts.allowed_to_authenticate_to_by_silo) + useropts.allowed_to_authenticate_to = silo.get_authentication_sddl() + + # Generate SDDL for authenticating users from a device a device in a group + if serviceopts.allowed_to_authenticate_from_device_group: + group = Group.get( + ldb, cn=serviceopts.allowed_to_authenticate_from_device_group) + serviceopts.allowed_to_authenticate_from = group.get_authentication_sddl() + + # Generate SDDL for authenticating service accounts from a device in a silo + if serviceopts.allowed_to_authenticate_from_device_silo: + silo = AuthenticationSilo.get( + ldb, cn=serviceopts.allowed_to_authenticate_from_device_silo) + serviceopts.allowed_to_authenticate_from = silo.get_authentication_sddl() + + # Generate SDDL for authenticating service accounts to a group + if serviceopts.allowed_to_authenticate_to_by_group: + group = Group.get( + ldb, cn=serviceopts.allowed_to_authenticate_to_by_group) + serviceopts.allowed_to_authenticate_to = group.get_authentication_sddl() + + # Generate SDDL for authenticating service accounts to a silo + if serviceopts.allowed_to_authenticate_to_by_silo: + silo = AuthenticationSilo.get( + ldb, cn=serviceopts.allowed_to_authenticate_to_by_silo) + serviceopts.allowed_to_authenticate_to = silo.get_authentication_sddl() + + # Generate SDDL for authenticating computer accounts to a group + if computeropts.allowed_to_authenticate_to_by_group: + group = Group.get( + ldb, cn=computeropts.allowed_to_authenticate_to_by_group) + computeropts.allowed_to_authenticate_to = group.get_authentication_sddl() + + # Generate SDDL for authenticating computer accounts to a silo + if computeropts.allowed_to_authenticate_to_by_silo: + silo = AuthenticationSilo.get( + ldb, cn=computeropts.allowed_to_authenticate_to_by_silo) + computeropts.allowed_to_authenticate_to = silo.get_authentication_sddl() + + try: + policy = AuthenticationPolicy.get(ldb, cn=name) + except ModelError as e: + raise CommandError(e) + + # Check if authentication policy exists. + if policy is None: + raise CommandError(f"Authentication policy {name} not found.") + + # Either --enforce will be set or --audit but never both. + if enforce: + policy.enforced = True + elif audit: + policy.enforced = False + + # Update the description. + if description is not None: + policy.description = description + + # User sign on + ############### + + if strong_ntlm_policy is not None: + policy.strong_ntlm_policy = \ + StrongNTLMPolicy[strong_ntlm_policy.upper()] + + if useropts.tgt_lifetime is not None: + policy.user_tgt_lifetime = useropts.tgt_lifetime + + if useropts.allowed_to_authenticate_from is not None: + policy.user_allowed_to_authenticate_from = \ + useropts.allowed_to_authenticate_from + + if useropts.allowed_to_authenticate_to is not None: + policy.user_allowed_to_authenticate_to = \ + useropts.allowed_to_authenticate_to + + # Service sign on + ################## + + if serviceopts.tgt_lifetime is not None: + policy.service_tgt_lifetime = serviceopts.tgt_lifetime + + if serviceopts.allowed_to_authenticate_from is not None: + policy.service_allowed_to_authenticate_from = \ + serviceopts.allowed_to_authenticate_from + + if serviceopts.allowed_to_authenticate_to is not None: + policy.service_allowed_to_authenticate_to = \ + serviceopts.allowed_to_authenticate_to + + # Computer + ########### + + if computeropts.tgt_lifetime is not None: + policy.computer_tgt_lifetime = computeropts.tgt_lifetime + + if computeropts.allowed_to_authenticate_to is not None: + policy.computer_allowed_to_authenticate_to = \ + computeropts.allowed_to_authenticate_to + + # Update policy. + try: + policy.save(ldb) + + if protect: + policy.protect(ldb) + elif unprotect: + policy.unprotect(ldb) + except ModelError as e: + raise CommandError(e) + + # Authentication policy updated successfully. + self.outf.write(f"Updated authentication policy: {name}\n") + + +class cmd_domain_auth_policy_delete(Command): + """Delete authentication policies on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--name", help="Name of authentication policy (required).", + dest="name", action="store", type=str, required=True), + Option("--force", help="Force delete protected authentication policy.", + dest="force", action="store_true") + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, name=None, + force=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + try: + policy = AuthenticationPolicy.get(ldb, cn=name) + except ModelError as e: + raise CommandError(e) + + # Check if authentication policy exists first. + if policy is None: + raise CommandError(f"Authentication policy {name} not found.") + + # Delete item, --force removes delete protection first. + try: + if force: + policy.unprotect(ldb) + + policy.delete(ldb) + except ModelError as e: + if not force: + raise CommandError( + f"{e}\nTry --force to delete protected authentication policies.") + else: + raise CommandError(e) + + # Authentication policy deleted successfully. + self.outf.write(f"Deleted authentication policy: {name}\n") + + +class cmd_domain_auth_policy(SuperCommand): + """Manage authentication policies on the domain.""" + + subcommands = { + "list": cmd_domain_auth_policy_list(), + "view": cmd_domain_auth_policy_view(), + "create": cmd_domain_auth_policy_create(), + "modify": cmd_domain_auth_policy_modify(), + "delete": cmd_domain_auth_policy_delete(), + } diff --git a/python/samba/netcmd/domain/auth/silo.py b/python/samba/netcmd/domain/auth/silo.py new file mode 100644 index 0000000..2e27761 --- /dev/null +++ b/python/samba/netcmd/domain/auth/silo.py @@ -0,0 +1,402 @@ +# Unix SMB/CIFS implementation. +# +# authentication silos - authentication silo management +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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 samba.getopt as options +from samba.netcmd import Command, CommandError, Option, SuperCommand +from samba.netcmd.domain.models import AuthenticationPolicy, AuthenticationSilo +from samba.netcmd.domain.models.exceptions import ModelError + +from .silo_member import cmd_domain_auth_silo_member + + +class cmd_domain_auth_silo_list(Command): + """List authentication silos on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--json", help="Output results in JSON format.", + dest="output_format", action="store_const", const="json"), + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, + output_format=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + # Authentication silos grouped by cn. + try: + silos = {silo.cn: silo.as_dict() + for silo in AuthenticationSilo.query(ldb)} + except ModelError as e: + raise CommandError(e) + + # Using json output format gives more detail. + if output_format == "json": + self.print_json(silos) + else: + for silo in silos.keys(): + self.outf.write(f"{silo}\n") + + +class cmd_domain_auth_silo_view(Command): + """View an authentication silo on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--name", + help="Name of authentication silo to view (required).", + dest="name", action="store", type=str, required=True), + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, name=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + try: + silo = AuthenticationSilo.get(ldb, cn=name) + except ModelError as e: + raise CommandError(e) + + # Check if silo exists first. + if silo is None: + raise CommandError(f"Authentication silo {name} not found.") + + # Display silo as JSON. + self.print_json(silo.as_dict()) + + +class cmd_domain_auth_silo_create(Command): + """Create a new authentication silo on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--name", help="Name of authentication silo (required).", + dest="name", action="store", type=str, required=True), + Option("--description", + help="Optional description for authentication silo.", + dest="description", action="store", type=str), + Option("--user-authentication-policy", + help="User account authentication policy.", + dest="user_authentication_policy", action="store", type=str, + metavar="USER_POLICY"), + Option("--service-authentication-policy", + help="Managed service account authentication policy.", + dest="service_authentication_policy", action="store", type=str, + metavar="SERVICE_POLICY"), + Option("--computer-authentication-policy", + help="Computer authentication policy.", + dest="computer_authentication_policy", action="store", type=str, + metavar="COMPUTER_POLICY"), + Option("--protect", + help="Protect authentication silo from accidental deletion.", + dest="protect", action="store_true"), + Option("--unprotect", + help="Unprotect authentication silo from accidental deletion.", + dest="unprotect", action="store_true"), + Option("--audit", + help="Only audit silo policies.", + dest="audit", action="store_true"), + Option("--enforce", + help="Enforce silo policies.", + dest="enforce", action="store_true") + ] + + @staticmethod + def get_policy(ldb, name): + """Helper function to fetch auth policy or raise CommandError. + + :param ldb: Ldb connection + :param name: Either the DN or name of authentication policy + """ + try: + return AuthenticationPolicy.lookup(ldb, name) + except (LookupError, ValueError) as e: + raise CommandError(e) + + def run(self, hostopts=None, sambaopts=None, credopts=None, + name=None, description=None, + user_authentication_policy=None, + service_authentication_policy=None, + computer_authentication_policy=None, + protect=None, unprotect=None, + audit=None, enforce=None): + + if protect and unprotect: + raise CommandError("--protect and --unprotect cannot be used together.") + if audit and enforce: + raise CommandError("--audit and --enforce cannot be used together.") + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + try: + silo = AuthenticationSilo.get(ldb, cn=name) + except ModelError as e: + raise CommandError(e) + + # Make sure silo doesn't already exist. + if silo is not None: + raise CommandError(f"Authentication silo {name} already exists.") + + # New silo object. + silo = AuthenticationSilo(cn=name, description=description) + + # Set user policy + if user_authentication_policy: + silo.user_authentication_policy = \ + self.get_policy(ldb, user_authentication_policy).dn + + # Set service policy + if service_authentication_policy: + silo.service_authentication_policy = \ + self.get_policy(ldb, service_authentication_policy).dn + + # Set computer policy + if computer_authentication_policy: + silo.computer_authentication_policy = \ + self.get_policy(ldb, computer_authentication_policy).dn + + # Either --enforce will be set or --audit but never both. + # The default if both are missing is enforce=True. + if enforce is not None: + silo.enforced = enforce + else: + silo.enforced = not audit + + # Create silo + try: + silo.save(ldb) + + if protect: + silo.protect(ldb) + except ModelError as e: + raise CommandError(e) + + # Authentication silo created successfully. + self.outf.write(f"Created authentication silo: {name}\n") + + +class cmd_domain_auth_silo_modify(Command): + """Modify an authentication silo on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--name", help="Name of authentication silo (required).", + dest="name", action="store", type=str, required=True), + Option("--description", + help="Optional description for authentication silo.", + dest="description", action="store", type=str), + Option("--user-authentication-policy", + help="User account authentication policy.", + dest="user_authentication_policy", action="store", type=str, + metavar="USER_POLICY"), + Option("--service-authentication-policy", + help="Managed service account authentication policy.", + dest="service_authentication_policy", action="store", type=str, + metavar="SERVICE_POLICY"), + Option("--computer-authentication-policy", + help="Computer authentication policy.", + dest="computer_authentication_policy", action="store", type=str, + metavar="COMPUTER_POLICY"), + Option("--protect", + help="Protect authentication silo from accidental deletion.", + dest="protect", action="store_true"), + Option("--unprotect", + help="Unprotect authentication silo from accidental deletion.", + dest="unprotect", action="store_true"), + Option("--audit", + help="Only audit silo policies.", + dest="audit", action="store_true"), + Option("--enforce", + help="Enforce silo policies.", + dest="enforce", action="store_true") + ] + + @staticmethod + def get_policy(ldb, name): + """Helper function to fetch auth policy or raise CommandError. + + :param ldb: Ldb connection + :param name: Either the DN or name of authentication policy + """ + try: + return AuthenticationPolicy.lookup(ldb, name) + except (LookupError, ModelError, ValueError) as e: + raise CommandError(e) + + def run(self, hostopts=None, sambaopts=None, credopts=None, + name=None, description=None, + user_authentication_policy=None, + service_authentication_policy=None, + computer_authentication_policy=None, + protect=None, unprotect=None, + audit=None, enforce=None): + + if audit and enforce: + raise CommandError("--audit and --enforce cannot be used together.") + if protect and unprotect: + raise CommandError("--protect and --unprotect cannot be used together.") + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + try: + silo = AuthenticationSilo.get(ldb, cn=name) + except ModelError as e: + raise CommandError(e) + + # Check if silo exists first. + if silo is None: + raise CommandError(f"Authentication silo {name} not found.") + + # Either --enforce will be set or --audit but never both. + if enforce: + silo.enforced = True + elif audit: + silo.enforced = False + + # Update the description. + if description is not None: + silo.description = description + + # Set or unset user policy. + if user_authentication_policy == "": + silo.user_authentication_policy = None + elif user_authentication_policy: + silo.user_authentication_policy = \ + self.get_policy(ldb, user_authentication_policy).dn + + # Set or unset service policy. + if service_authentication_policy == "": + silo.service_authentication_policy = None + elif service_authentication_policy: + silo.service_authentication_policy = \ + self.get_policy(ldb, service_authentication_policy).dn + + # Set or unset computer policy. + if computer_authentication_policy == "": + silo.computer_authentication_policy = None + elif computer_authentication_policy: + silo.computer_authentication_policy = \ + self.get_policy(ldb, computer_authentication_policy).dn + + # Update silo + try: + silo.save(ldb) + + if protect: + silo.protect(ldb) + elif unprotect: + silo.unprotect(ldb) + except ModelError as e: + raise CommandError(e) + + # Silo updated successfully. + self.outf.write(f"Updated authentication silo: {name}\n") + + +class cmd_domain_auth_silo_delete(Command): + """Delete an authentication silo on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--name", help="Name of authentication silo (required).", + dest="name", action="store", type=str, required=True), + Option("--force", help="Force delete protected authentication silo.", + dest="force", action="store_true") + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, name=None, + force=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + try: + silo = AuthenticationSilo.get(ldb, cn=name) + except ModelError as e: + raise CommandError(e) + + # Check if silo exists first. + if silo is None: + raise CommandError(f"Authentication silo {name} not found.") + + # Delete silo + try: + if force: + silo.unprotect(ldb) + + silo.delete(ldb) + except ModelError as e: + if not force: + raise CommandError( + f"{e}\nTry --force to delete protected authentication silos.") + else: + raise CommandError(e) + + # Authentication silo deleted successfully. + self.outf.write(f"Deleted authentication silo: {name}\n") + + +class cmd_domain_auth_silo(SuperCommand): + """Manage authentication silos on the domain.""" + + subcommands = { + "list": cmd_domain_auth_silo_list(), + "view": cmd_domain_auth_silo_view(), + "create": cmd_domain_auth_silo_create(), + "modify": cmd_domain_auth_silo_modify(), + "delete": cmd_domain_auth_silo_delete(), + "member": cmd_domain_auth_silo_member(), + } diff --git a/python/samba/netcmd/domain/auth/silo_member.py b/python/samba/netcmd/domain/auth/silo_member.py new file mode 100644 index 0000000..9b41400 --- /dev/null +++ b/python/samba/netcmd/domain/auth/silo_member.py @@ -0,0 +1,201 @@ +# Unix SMB/CIFS implementation. +# +# authentication silos - silo member management +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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 samba.getopt as options +from samba.netcmd import Command, CommandError, Option, SuperCommand +from samba.netcmd.domain.models import AuthenticationSilo, User +from samba.netcmd.domain.models.exceptions import ModelError + + +class cmd_domain_auth_silo_member_grant(Command): + """Grant a member access to an authentication silo.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--name", + help="Name of authentication silo (required).", + dest="name", action="store", type=str, required=True), + Option("--member", + help="Member to grant access to the silo (DN or account name).", + dest="member", action="store", type=str, required=True), + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, + name=None, member=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + try: + silo = AuthenticationSilo.get(ldb, cn=name) + except ModelError as e: + raise CommandError(e) + + # Check if authentication silo exists first. + if silo is None: + raise CommandError(f"Authentication silo {name} not found.") + + try: + user = User.find(ldb, member) + except ModelError as e: + raise CommandError(e) + + # Ensure the user actually exists first. + if user is None: + raise CommandError(f"User {member} not found.") + + # Grant access to member. + try: + silo.grant(ldb, user) + except ModelError as e: + raise CommandError(e) + + # Display silo assigned status. + if user.assigned_silo and user.assigned_silo == silo.dn: + status = "assigned" + else: + status = "unassigned" + + print(f"User {user} granted access to the authentication silo {name} ({status}).", + file=self.outf) + + +class cmd_domain_auth_silo_member_list(Command): + """List all members in the authentication silo.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--name", + help="Name of authentication silo (required).", + dest="name", action="store", type=str, required=True), + Option("--json", help="Output results in JSON format.", + dest="output_format", action="store_const", const="json"), + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, + name=None, output_format=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + try: + silo = AuthenticationSilo.get(ldb, cn=name) + except ModelError as e: + raise CommandError(e) + + # Check if authentication silo exists first. + if silo is None: + raise CommandError(f"Authentication silo {name} not found.") + + # Fetch all members. + try: + members = [User.get(ldb, dn=dn) for dn in silo.members] + except ModelError as e: + raise CommandError(e) + + # Using json output format gives more detail. + if output_format == "json": + self.print_json([member.as_dict() for member in members]) + else: + for member in members: + print(member.dn, file=self.outf) + + +class cmd_domain_auth_silo_member_revoke(Command): + """Revoke a member from an authentication silo.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--name", + help="Name of authentication silo (required).", + dest="name", action="store", type=str, required=True), + Option("--member", + help="Member to revoke from the silo (DN or account name).", + dest="member", action="store", type=str, required=True), + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, + name=None, member=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + try: + silo = AuthenticationSilo.get(ldb, cn=name) + except ModelError as e: + raise CommandError(e) + + # Check if authentication silo exists first. + if silo is None: + raise CommandError(f"Authentication silo {name} not found.") + + try: + user = User.find(ldb, member) + except ModelError as e: + raise CommandError(e) + + # Ensure the user actually exists first. + if user is None: + raise CommandError(f"User {member} not found.") + + # Revoke member access. + try: + silo.revoke(ldb, user) + except ModelError as e: + raise CommandError(e) + + # Display silo assigned status. + if user.assigned_silo and user.assigned_silo == silo.dn: + status = "assigned" + else: + status = "unassigned" + + print(f"User {user} revoked from the authentication silo {name} ({status}).", + file=self.outf) + + +class cmd_domain_auth_silo_member(SuperCommand): + """Manage members in an authentication silo.""" + + subcommands = { + "grant": cmd_domain_auth_silo_member_grant(), + "list": cmd_domain_auth_silo_member_list(), + "revoke": cmd_domain_auth_silo_member_revoke(), + } diff --git a/python/samba/netcmd/domain/backup.py b/python/samba/netcmd/domain/backup.py new file mode 100644 index 0000000..fc7ff53 --- /dev/null +++ b/python/samba/netcmd/domain/backup.py @@ -0,0 +1,1256 @@ +# domain_backup +# +# Copyright Andrew Bartlett <abartlet@samba.org> +# +# 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 datetime +import os +import sys +import logging +import shutil +import tempfile +import samba +import tdb +import samba.getopt as options +from samba.samdb import SamDB, get_default_backend_store +import ldb +from ldb import LdbError +from samba.samba3 import libsmb_samba_internal as libsmb +from samba.samba3 import param as s3param +from samba.ntacls import backup_online, backup_restore, backup_offline +from samba.auth import system_session +from samba.join import DCJoinContext, join_clone, DCCloneAndRenameContext +from samba.dcerpc.security import dom_sid +from samba.netcmd import Option, CommandError +from samba.dcerpc import misc, security, drsblobs +from samba import Ldb +from samba.netcmd.fsmo import cmd_fsmo_seize +from samba.provision import make_smbconf, DEFAULTSITE +from samba.upgradehelpers import update_krbtgt_account_password +from samba.remove_dc import remove_dc +from samba.provision import secretsdb_self_join +from samba.dbchecker import dbcheck +import re +from samba.provision import guess_names, determine_host_ip, determine_host_ip6 +from samba.provision.sambadns import (fill_dns_data_partitions, + get_dnsadmins_sid, + get_domainguid) +from samba.tdb_util import tdb_copy +from samba.mdb_util import mdb_copy +import errno +from subprocess import CalledProcessError +from samba import sites +from samba.dsdb import _dsdb_load_udv_v2 +from samba.ndr import ndr_pack +from samba.credentials import SMB_SIGNING_REQUIRED +from samba import safe_tarfile as tarfile + + +# work out a SID (based on a free RID) to use when the domain gets restored. +# This ensures that the restored DC's SID won't clash with any other RIDs +# already in use in the domain +def get_sid_for_restore(samdb, logger): + # Allocate a new RID without modifying the database. This should be safe, + # because we acquire the RID master role after creating an account using + # this RID during the restore process. Acquiring the RID master role + # creates a new RID pool which we will fetch RIDs from, so we shouldn't get + # duplicates. + try: + rid = samdb.next_free_rid() + except LdbError as err: + logger.info("A SID could not be allocated for restoring the domain. " + "Either no RID Set was found on this DC, " + "or the RID Set was not usable.") + logger.info("To initialise this DC's RID pools, obtain a RID Set from " + "this domain's RID master, or run samba-tool dbcheck " + "to fix the existing RID Set.") + raise CommandError("Cannot create backup", err) + + # Construct full SID + sid = dom_sid(samdb.get_domain_sid()) + sid_for_restore = str(sid) + '-' + str(rid) + + # Confirm the SID is not already in use + try: + res = samdb.search(scope=ldb.SCOPE_BASE, + base='<SID=%s>' % sid_for_restore, + attrs=[], + controls=['show_deleted:1', + 'show_recycled:1']) + if len(res) != 1: + # This case makes no sense, but neither does a corrupt RID set + raise CommandError("Cannot create backup - " + "this DC's RID pool is corrupt, " + "the next SID (%s) appears to be in use." % + sid_for_restore) + raise CommandError("Cannot create backup - " + "this DC's RID pool is corrupt, " + "the next SID %s points to existing object %s. " + "Please run samba-tool dbcheck on the source DC." % + (sid_for_restore, res[0].dn)) + except ldb.LdbError as e: + (enum, emsg) = e.args + if enum != ldb.ERR_NO_SUCH_OBJECT: + # We want NO_SUCH_OBJECT, anything else is a serious issue + raise + + return str(sid) + '-' + str(rid) + + +def smb_sysvol_conn(server, lp, creds): + """Returns an SMB connection to the sysvol share on the DC""" + # the SMB bindings rely on having a s3 loadparm + s3_lp = s3param.get_context() + s3_lp.load(lp.configfile) + + # Force signing for the connection + saved_signing_state = creds.get_smb_signing() + creds.set_smb_signing(SMB_SIGNING_REQUIRED) + conn = libsmb.Conn(server, "sysvol", lp=s3_lp, creds=creds) + # Reset signing state + creds.set_smb_signing(saved_signing_state) + return conn + + +def get_timestamp(): + return datetime.datetime.now().isoformat().replace(':', '-') + + +def backup_filepath(targetdir, name, time_str): + filename = 'samba-backup-%s-%s.tar.bz2' % (name, time_str) + return os.path.join(targetdir, filename) + + +def create_backup_tar(logger, tmpdir, backup_filepath): + # Adds everything in the tmpdir into a new tar file + logger.info("Creating backup file %s..." % backup_filepath) + tf = tarfile.open(backup_filepath, 'w:bz2') + tf.add(tmpdir, arcname='./') + tf.close() + + +def create_log_file(targetdir, lp, backup_type, server, include_secrets, + extra_info=None): + # create a summary file about the backup, which will get included in the + # tar file. This makes it easy for users to see what the backup involved, + # without having to untar the DB and interrogate it + f = open(os.path.join(targetdir, "backup.txt"), 'w') + try: + time_str = datetime.datetime.now().strftime('%Y-%b-%d %H:%M:%S') + f.write("Backup created %s\n" % time_str) + f.write("Using samba-tool version: %s\n" % lp.get('server string')) + f.write("Domain %s backup, using DC '%s'\n" % (backup_type, server)) + f.write("Backup for domain %s (NetBIOS), %s (DNS realm)\n" % + (lp.get('workgroup'), lp.get('realm').lower())) + f.write("Backup contains domain secrets: %s\n" % str(include_secrets)) + if extra_info: + f.write("%s\n" % extra_info) + finally: + f.close() + + +# Add a backup-specific marker to the DB with info that we'll use during +# the restore process +def add_backup_marker(samdb, marker, value): + m = ldb.Message() + m.dn = ldb.Dn(samdb, "@SAMBA_DSDB") + m[marker] = ldb.MessageElement(value, ldb.FLAG_MOD_ADD, marker) + samdb.modify(m) + + +def check_targetdir(logger, targetdir): + if targetdir is None: + raise CommandError('Target directory required') + + if not os.path.exists(targetdir): + logger.info('Creating targetdir %s...' % targetdir) + os.makedirs(targetdir) + elif not os.path.isdir(targetdir): + raise CommandError("%s is not a directory" % targetdir) + + +# For '--no-secrets' backups, this sets the Administrator user's password to a +# randomly-generated value. This is similar to the provision behaviour +def set_admin_password(logger, samdb): + """Sets a randomly generated password for the backup DB's admin user""" + + # match the admin user by RID + domainsid = samdb.get_domain_sid() + match_admin = "(objectsid=%s-%s)" % (domainsid, + security.DOMAIN_RID_ADMINISTRATOR) + search_expr = "(&(objectClass=user)%s)" % (match_admin,) + + # retrieve the admin username (just in case it's been renamed) + res = samdb.search(base=samdb.domain_dn(), scope=ldb.SCOPE_SUBTREE, + expression=search_expr) + username = str(res[0]['samaccountname']) + + adminpass = samba.generate_random_password(12, 32) + logger.info("Setting %s password in backup to: %s" % (username, adminpass)) + logger.info("Run 'samba-tool user setpassword %s' after restoring DB" % + username) + samdb.setpassword(search_expr, adminpass, force_change_at_next_login=False, + username=username) + + +class cmd_domain_backup_online(samba.netcmd.Command): + """Copy a running DC's current DB into a backup tar file. + + Takes a backup copy of the current domain from a running DC. If the domain + were to undergo a catastrophic failure, then the backup file can be used to + recover the domain. The backup created is similar to the DB that a new DC + would receive when it joins the domain. + + Note that: + - it's recommended to run 'samba-tool dbcheck' before taking a backup-file + and fix any errors it reports. + - all the domain's secrets are included in the backup file. + - although the DB contents can be untarred and examined manually, you need + to run 'samba-tool domain backup restore' before you can start a Samba DC + from the backup file.""" + + synopsis = "%prog --server=<DC-to-backup> --targetdir=<output-dir>" + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + } + + takes_options = [ + Option("--server", help="The DC to backup", type=str), + Option("--targetdir", type=str, + help="Directory to write the backup file to"), + Option("--no-secrets", action="store_true", default=False, + help="Exclude secret values from the backup created"), + Option("--backend-store", type="choice", metavar="BACKENDSTORE", + choices=["tdb", "mdb"], + help="Specify the database backend to be used " + "(default is %s)" % get_default_backend_store()), + ] + + def run(self, sambaopts=None, credopts=None, server=None, targetdir=None, + no_secrets=False, backend_store=None): + logger = self.get_logger() + logger.setLevel(logging.DEBUG) + + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp) + + # Make sure we have all the required args. + if server is None: + raise CommandError('Server required') + + check_targetdir(logger, targetdir) + + tmpdir = tempfile.mkdtemp(dir=targetdir) + + # Run a clone join on the remote + include_secrets = not no_secrets + try: + ctx = join_clone(logger=logger, creds=creds, lp=lp, + include_secrets=include_secrets, server=server, + dns_backend='SAMBA_INTERNAL', targetdir=tmpdir, + backend_store=backend_store) + + # get the paths used for the clone, then drop the old samdb connection + paths = ctx.paths + del ctx + + # Get a free RID to use as the new DC's SID (when it gets restored) + remote_sam = SamDB(url='ldap://' + server, credentials=creds, + session_info=system_session(), lp=lp) + new_sid = get_sid_for_restore(remote_sam, logger) + realm = remote_sam.domain_dns_name() + + # Grab the remote DC's sysvol files and bundle them into a tar file + logger.info("Backing up sysvol files (via SMB)...") + sysvol_tar = os.path.join(tmpdir, 'sysvol.tar.gz') + smb_conn = smb_sysvol_conn(server, lp, creds) + backup_online(smb_conn, sysvol_tar, remote_sam.get_domain_sid()) + + # remove the default sysvol files created by the clone (we want to + # make sure we restore the sysvol.tar.gz files instead) + shutil.rmtree(paths.sysvol) + + # Edit the downloaded sam.ldb to mark it as a backup + samdb = SamDB(url=paths.samdb, session_info=system_session(), lp=lp, + flags=ldb.FLG_DONT_CREATE_DB) + time_str = get_timestamp() + add_backup_marker(samdb, "backupDate", time_str) + add_backup_marker(samdb, "sidForRestore", new_sid) + add_backup_marker(samdb, "backupType", "online") + + # ensure the admin user always has a password set (same as provision) + if no_secrets: + set_admin_password(logger, samdb) + + # Add everything in the tmpdir to the backup tar file + backup_file = backup_filepath(targetdir, realm, time_str) + create_log_file(tmpdir, lp, "online", server, include_secrets) + create_backup_tar(logger, tmpdir, backup_file) + finally: + shutil.rmtree(tmpdir) + + +class cmd_domain_backup_restore(cmd_fsmo_seize): + """Restore the domain's DB from a backup-file. + + This restores a previously backed up copy of the domain's DB on a new DC. + + Note that the restored DB will not contain the original DC that the backup + was taken from (or any other DCs in the original domain). Only the new DC + (specified by --newservername) will be present in the restored DB. + + Samba can then be started against the restored DB. Any existing DCs for the + domain should be shutdown before the new DC is started. Other DCs can then + be joined to the new DC to recover the network. + + Note that this command should be run as the root user - it will fail + otherwise.""" + + synopsis = ("%prog --backup-file=<tar-file> --targetdir=<output-dir> " + "--newservername=<DC-name>") + takes_options = [ + Option("--backup-file", help="Path to backup file", type=str), + Option("--targetdir", help="Path to write to", type=str), + Option("--newservername", help="Name for new server", type=str), + Option("--host-ip", type="string", metavar="IPADDRESS", + help="set IPv4 ipaddress"), + Option("--host-ip6", type="string", metavar="IP6ADDRESS", + help="set IPv6 ipaddress"), + Option("--site", help="Site to add the new server in", type=str), + ] + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + } + + def register_dns_zone(self, logger, samdb, lp, ntdsguid, host_ip, + host_ip6, site): + """ + Registers the new realm's DNS objects when a renamed domain backup + is restored. + """ + names = guess_names(lp) + domaindn = names.domaindn + forestdn = samdb.get_root_basedn().get_linearized() + dnsdomain = names.dnsdomain.lower() + dnsforest = dnsdomain + hostname = names.netbiosname.lower() + domainsid = dom_sid(samdb.get_domain_sid()) + dnsadmins_sid = get_dnsadmins_sid(samdb, domaindn) + domainguid = get_domainguid(samdb, domaindn) + + # work out the IP address to use for the new DC's DNS records + host_ip = determine_host_ip(logger, lp, host_ip) + host_ip6 = determine_host_ip6(logger, lp, host_ip6) + + if host_ip is None and host_ip6 is None: + raise CommandError('Please specify a host-ip for the new server') + + logger.info("DNS realm was renamed to %s" % dnsdomain) + logger.info("Populating DNS partitions for new realm...") + + # Add the DNS objects for the new realm (note: the backup clone already + # has the root server objects, so don't add them again) + fill_dns_data_partitions(samdb, domainsid, site, domaindn, + forestdn, dnsdomain, dnsforest, hostname, + host_ip, host_ip6, domainguid, ntdsguid, + dnsadmins_sid, add_root=False) + + def fix_old_dc_references(self, samdb): + """Fixes attributes that reference the old/removed DCs""" + + # we just want to fix up DB problems here that were introduced by us + # removing the old DCs. We restrict what we fix up so that the restored + # DB matches the backed-up DB as close as possible. (There may be other + # DB issues inherited from the backed-up DC, but it's not our place to + # silently try to fix them here). + samdb.transaction_start() + chk = dbcheck(samdb, quiet=True, fix=True, yes=False, + in_transaction=True) + + # fix up stale references to the old DC + setattr(chk, 'fix_all_old_dn_string_component_mismatch', 'ALL') + attrs = ['lastKnownParent', 'interSiteTopologyGenerator'] + + # fix-up stale one-way links that point to the old DC + setattr(chk, 'remove_plausible_deleted_DN_links', 'ALL') + attrs += ['msDS-NC-Replica-Locations'] + + cross_ncs_ctrl = 'search_options:1:2' + controls = ['show_deleted:1', cross_ncs_ctrl] + chk.check_database(controls=controls, attrs=attrs) + samdb.transaction_commit() + + def create_default_site(self, samdb, logger): + """Creates the default site, if it doesn't already exist""" + + sitename = DEFAULTSITE + search_expr = "(&(cn={0})(objectclass=site))".format(sitename) + res = samdb.search(samdb.get_config_basedn(), scope=ldb.SCOPE_SUBTREE, + expression=search_expr) + + if len(res) == 0: + logger.info("Creating default site '{0}'".format(sitename)) + sites.create_site(samdb, samdb.get_config_basedn(), sitename) + + return sitename + + def remove_backup_markers(self, samdb): + """Remove DB markers added by the backup process""" + + # check what markers we need to remove (this may vary) + markers = ['sidForRestore', 'backupRename', 'backupDate', 'backupType'] + res = samdb.search(base=ldb.Dn(samdb, "@SAMBA_DSDB"), + scope=ldb.SCOPE_BASE, + attrs=markers) + + # remove any markers that exist in the DB + m = ldb.Message() + m.dn = ldb.Dn(samdb, "@SAMBA_DSDB") + + for attr in markers: + if attr in res[0]: + m[attr] = ldb.MessageElement([], ldb.FLAG_MOD_DELETE, attr) + + samdb.modify(m) + + def get_backup_type(self, samdb): + res = samdb.search(base=ldb.Dn(samdb, "@SAMBA_DSDB"), + scope=ldb.SCOPE_BASE, + attrs=['backupRename', 'backupType']) + + # note that the backupType marker won't exist on backups created on + # v4.9. However, we can still infer the type, as only rename and + # online backups are supported on v4.9 + if 'backupType' in res[0]: + backup_type = str(res[0]['backupType']) + elif 'backupRename' in res[0]: + backup_type = "rename" + else: + backup_type = "online" + + return backup_type + + def save_uptodate_vectors(self, samdb, partitions): + """Ensures the UTDV used by DRS is correct after an offline backup""" + for nc in partitions: + # load the replUpToDateVector we *should* have + utdv = _dsdb_load_udv_v2(samdb, nc) + + # convert it to NDR format and write it into the DB + utdv_blob = drsblobs.replUpToDateVectorBlob() + utdv_blob.version = 2 + utdv_blob.ctr.cursors = utdv + utdv_blob.ctr.count = len(utdv) + new_value = ndr_pack(utdv_blob) + + m = ldb.Message() + m.dn = ldb.Dn(samdb, nc) + m["replUpToDateVector"] = ldb.MessageElement(new_value, + ldb.FLAG_MOD_REPLACE, + "replUpToDateVector") + samdb.modify(m) + + def run(self, sambaopts=None, credopts=None, backup_file=None, + targetdir=None, newservername=None, host_ip=None, host_ip6=None, + site=None): + if not (backup_file and os.path.exists(backup_file)): + raise CommandError('Backup file not found.') + if targetdir is None: + raise CommandError('Please specify a target directory') + # allow restoredc to install into a directory prepopulated by selftest + if (os.path.exists(targetdir) and os.listdir(targetdir) and + os.environ.get('SAMBA_SELFTEST') != '1'): + raise CommandError('Target directory is not empty') + if not newservername: + raise CommandError('Server name required') + + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + logger.addHandler(logging.StreamHandler(sys.stdout)) + + # ldapcmp prefers the server's netBIOS name in upper-case + newservername = newservername.upper() + + # extract the backup .tar to a temp directory + targetdir = os.path.abspath(targetdir) + tf = tarfile.open(backup_file) + tf.extractall(targetdir) + tf.close() + + # use the smb.conf that got backed up, by default (save what was + # actually backed up, before we mess with it) + smbconf = os.path.join(targetdir, 'etc', 'smb.conf') + shutil.copyfile(smbconf, smbconf + ".orig") + + # if a smb.conf was specified on the cmd line, then use that instead + cli_smbconf = sambaopts.get_loadparm_path() + if cli_smbconf: + logger.info("Using %s as restored domain's smb.conf" % cli_smbconf) + shutil.copyfile(cli_smbconf, smbconf) + + lp = samba.param.LoadParm() + lp.load(smbconf) + + # open a DB connection to the restored DB + private_dir = os.path.join(targetdir, 'private') + samdb_path = os.path.join(private_dir, 'sam.ldb') + samdb = SamDB(url=samdb_path, session_info=system_session(), lp=lp, + flags=ldb.FLG_DONT_CREATE_DB) + backup_type = self.get_backup_type(samdb) + + if site is None: + # There's no great way to work out the correct site to add the + # restored DC to. By default, add it to Default-First-Site-Name, + # creating the site if it doesn't already exist + site = self.create_default_site(samdb, logger) + logger.info("Adding new DC to site '{0}'".format(site)) + + # read the naming contexts out of the DB + res = samdb.search(base="", scope=ldb.SCOPE_BASE, + attrs=['namingContexts']) + ncs = [str(r) for r in res[0].get('namingContexts')] + + # for offline backups we need to make sure the upToDateness info + # contains the invocation-ID and highest-USN of the DC we backed up. + # Otherwise replication propagation dampening won't correctly filter + # objects created by that DC + if backup_type == "offline": + self.save_uptodate_vectors(samdb, ncs) + + # Create account using the join_add_objects function in the join object + # We need namingContexts, account control flags, and the sid saved by + # the backup process. + creds = credopts.get_credentials(lp) + ctx = DCJoinContext(logger, creds=creds, lp=lp, site=site, + forced_local_samdb=samdb, + netbios_name=newservername) + ctx.nc_list = ncs + ctx.full_nc_list = ncs + ctx.userAccountControl = (samba.dsdb.UF_SERVER_TRUST_ACCOUNT | + samba.dsdb.UF_TRUSTED_FOR_DELEGATION) + + # rewrite the smb.conf to make sure it uses the new targetdir settings. + # (This doesn't update all filepaths in a customized config, but it + # corrects the same paths that get set by a new provision) + logger.info('Updating basic smb.conf settings...') + make_smbconf(smbconf, newservername, ctx.domain_name, + ctx.realm, targetdir, lp=lp, + serverrole="active directory domain controller") + + # Get the SID saved by the backup process and create account + res = samdb.search(base=ldb.Dn(samdb, "@SAMBA_DSDB"), + scope=ldb.SCOPE_BASE, + attrs=['sidForRestore']) + sid = res[0].get('sidForRestore')[0] + logger.info('Creating account with SID: ' + str(sid)) + try: + ctx.join_add_objects(specified_sid=dom_sid(str(sid))) + except LdbError as e: + (enum, emsg) = e.args + if enum != ldb.ERR_CONSTRAINT_VIOLATION: + raise + + dup_res = [] + try: + dup_res = samdb.search(base=ldb.Dn(samdb, "<SID=%s>" % sid), + scope=ldb.SCOPE_BASE, + attrs=['objectGUID'], + controls=["show_deleted:0", + "show_recycled:0"]) + except LdbError as dup_e: + (dup_enum, _) = dup_e.args + if dup_enum != ldb.ERR_NO_SUCH_OBJECT: + raise + + if (len(dup_res) != 1): + raise + + objectguid = samdb.schema_format_value("objectGUID", + dup_res[0]["objectGUID"][0]) + objectguid = objectguid.decode('utf-8') + logger.error("The RID Pool on the source DC for the backup in %s " + "may be corrupt " + "or in conflict with SIDs already allocated " + "in the domain. " % backup_file) + logger.error("Running 'samba-tool dbcheck' on the source " + "DC (and obtaining a new backup) may correct the issue.") + logger.error("Alternatively please obtain a new backup " + "against a different DC.") + logger.error("The SID we wish to use (%s) is recorded in " + "@SAMBA_DSDB as the sidForRestore attribute." + % sid) + + raise CommandError("Domain restore failed because there " + "is already an existing object (%s) " + "with SID %s and objectGUID %s. " + "This conflicts with " + "the new DC account we want to add " + "for the restored domain. " % ( + dup_res[0].dn, sid, objectguid)) + + m = ldb.Message() + m.dn = ldb.Dn(samdb, '@ROOTDSE') + ntds_guid = str(ctx.ntds_guid) + m["dsServiceName"] = ldb.MessageElement("<GUID=%s>" % ntds_guid, + ldb.FLAG_MOD_REPLACE, + "dsServiceName") + samdb.modify(m) + + # if we renamed the backed-up domain, then we need to add the DNS + # objects for the new realm (we do this in the restore, now that we + # know the new DC's IP address) + if backup_type == "rename": + self.register_dns_zone(logger, samdb, lp, ctx.ntds_guid, + host_ip, host_ip6, site) + + secrets_path = os.path.join(private_dir, 'secrets.ldb') + secrets_ldb = Ldb(secrets_path, session_info=system_session(), lp=lp, + flags=ldb.FLG_DONT_CREATE_DB) + secretsdb_self_join(secrets_ldb, domain=ctx.domain_name, + realm=ctx.realm, dnsdomain=ctx.dnsdomain, + netbiosname=ctx.myname, domainsid=ctx.domsid, + machinepass=ctx.acct_pass, + key_version_number=ctx.key_version_number, + secure_channel_type=misc.SEC_CHAN_BDC) + + # Seize DNS roles + domain_dn = samdb.domain_dn() + forest_dn = samba.dn_from_dns_name(samdb.forest_dns_name()) + dns_roles = [("domaindns", domain_dn), + ("forestdns", forest_dn)] + for role, dn in dns_roles: + if dn in ncs: + self.seize_dns_role(role, samdb, None, None, None, force=True) + + # Seize other roles + for role in ['rid', 'pdc', 'naming', 'infrastructure', 'schema']: + self.seize_role(role, samdb, force=True) + + # Get all DCs and remove them (this ensures these DCs cannot + # replicate because they will not have a password) + search_expr = "(&(objectClass=Server)(serverReference=*))" + res = samdb.search(samdb.get_config_basedn(), scope=ldb.SCOPE_SUBTREE, + expression=search_expr) + for m in res: + cn = str(m.get('cn')[0]) + if cn != newservername: + remove_dc(samdb, logger, cn) + + # Remove the repsFrom and repsTo from each NC to ensure we do + # not try (and fail) to talk to the old DCs + for nc in ncs: + msg = ldb.Message() + msg.dn = ldb.Dn(samdb, nc) + + msg["repsFrom"] = ldb.MessageElement([], + ldb.FLAG_MOD_REPLACE, + "repsFrom") + msg["repsTo"] = ldb.MessageElement([], + ldb.FLAG_MOD_REPLACE, + "repsTo") + samdb.modify(msg) + + # Update the krbtgt passwords twice, ensuring no tickets from + # the old domain are valid + update_krbtgt_account_password(samdb) + update_krbtgt_account_password(samdb) + + # restore the sysvol directory from the backup tar file, including the + # original NTACLs. Note that the backup_restore() will fail if not root + sysvol_tar = os.path.join(targetdir, 'sysvol.tar.gz') + dest_sysvol_dir = lp.get('path', 'sysvol') + if not os.path.exists(dest_sysvol_dir): + os.makedirs(dest_sysvol_dir) + backup_restore(sysvol_tar, dest_sysvol_dir, samdb, smbconf) + os.remove(sysvol_tar) + + # fix up any stale links to the old DCs we just removed + logger.info("Fixing up any remaining references to the old DCs...") + self.fix_old_dc_references(samdb) + + # Remove DB markers added by the backup process + self.remove_backup_markers(samdb) + + logger.info("Backup file successfully restored to %s" % targetdir) + logger.info("Please check the smb.conf settings are correct before " + "starting samba.") + + +class cmd_domain_backup_rename(samba.netcmd.Command): + """Copy a running DC's DB to backup file, renaming the domain in the process. + + Where <new-domain> is the new domain's NetBIOS name, and <new-dnsrealm> is + the new domain's realm in DNS form. + + This is similar to 'samba-tool backup online' in that it clones the DB of a + running DC. However, this option also renames all the domain entries in the + DB. Renaming the domain makes it possible to restore and start a new Samba + DC without it interfering with the existing Samba domain. In other words, + you could use this option to clone your production samba domain and restore + it to a separate pre-production environment that won't overlap or interfere + with the existing production Samba domain. + + Note that: + - it's recommended to run 'samba-tool dbcheck' before taking a backup-file + and fix any errors it reports. + - all the domain's secrets are included in the backup file. + - although the DB contents can be untarred and examined manually, you need + to run 'samba-tool domain backup restore' before you can start a Samba DC + from the backup file. + - GPO and sysvol information will still refer to the old realm and will + need to be updated manually. + - if you specify 'keep-dns-realm', then the DNS records will need updating + in order to work (they will still refer to the old DC's IP instead of the + new DC's address). + - we recommend that you only use this option if you know what you're doing. + """ + + synopsis = ("%prog <new-domain> <new-dnsrealm> --server=<DC-to-backup> " + "--targetdir=<output-dir>") + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + } + + takes_options = [ + Option("--server", help="The DC to backup", type=str), + Option("--targetdir", help="Directory to write the backup file", + type=str), + Option("--keep-dns-realm", action="store_true", default=False, + help="Retain the DNS entries for the old realm in the backup"), + Option("--no-secrets", action="store_true", default=False, + help="Exclude secret values from the backup created"), + Option("--backend-store", type="choice", metavar="BACKENDSTORE", + choices=["tdb", "mdb"], + help="Specify the database backend to be used " + "(default is %s)" % get_default_backend_store()), + ] + + takes_args = ["new_domain_name", "new_dns_realm"] + + def update_dns_root(self, logger, samdb, old_realm, delete_old_dns): + """Updates dnsRoot for the partition objects to reflect the rename""" + + # lookup the crossRef objects that hold the old realm's dnsRoot + partitions_dn = samdb.get_partitions_dn() + res = samdb.search(base=partitions_dn, scope=ldb.SCOPE_ONELEVEL, + attrs=["dnsRoot"], + expression='(&(objectClass=crossRef)(dnsRoot=*))') + new_realm = samdb.domain_dns_name() + + # go through and add the new realm + for res_msg in res: + # dnsRoot can be multi-valued, so only look for the old realm + for dns_root in res_msg["dnsRoot"]: + dns_root = str(dns_root) + dn = res_msg.dn + if old_realm in dns_root: + new_dns_root = re.sub('%s$' % old_realm, new_realm, + dns_root) + logger.info("Adding %s dnsRoot to %s" % (new_dns_root, dn)) + + m = ldb.Message() + m.dn = dn + m["dnsRoot"] = ldb.MessageElement(new_dns_root, + ldb.FLAG_MOD_ADD, + "dnsRoot") + samdb.modify(m) + + # optionally remove the dnsRoot for the old realm + if delete_old_dns: + logger.info("Removing %s dnsRoot from %s" % (dns_root, + dn)) + m["dnsRoot"] = ldb.MessageElement(dns_root, + ldb.FLAG_MOD_DELETE, + "dnsRoot") + samdb.modify(m) + + # Updates the CN=<domain>,CN=Partitions,CN=Configuration,... object to + # reflect the domain rename + def rename_domain_partition(self, logger, samdb, new_netbios_name): + """Renames the domain partition object and updates its nETBIOSName""" + + # lookup the crossRef object that holds the nETBIOSName (nCName has + # already been updated by this point, but the netBIOS hasn't) + base_dn = samdb.get_default_basedn() + nc_name = ldb.binary_encode(str(base_dn)) + partitions_dn = samdb.get_partitions_dn() + res = samdb.search(base=partitions_dn, scope=ldb.SCOPE_ONELEVEL, + attrs=["nETBIOSName"], + expression='ncName=%s' % nc_name) + + logger.info("Changing backup domain's NetBIOS name to %s" % + new_netbios_name) + m = ldb.Message() + m.dn = res[0].dn + m["nETBIOSName"] = ldb.MessageElement(new_netbios_name, + ldb.FLAG_MOD_REPLACE, + "nETBIOSName") + samdb.modify(m) + + # renames the object itself to reflect the change in domain + new_dn = "CN=%s,%s" % (new_netbios_name, partitions_dn) + logger.info("Renaming %s --> %s" % (res[0].dn, new_dn)) + samdb.rename(res[0].dn, new_dn, controls=['relax:0']) + + def delete_old_dns_zones(self, logger, samdb, old_realm): + # remove the top-level DNS entries for the old realm + basedn = samdb.get_default_basedn() + dn = "DC=%s,CN=MicrosoftDNS,DC=DomainDnsZones,%s" % (old_realm, basedn) + logger.info("Deleting old DNS zone %s" % dn) + samdb.delete(dn, ["tree_delete:1"]) + + forestdn = samdb.get_root_basedn().get_linearized() + dn = "DC=_msdcs.%s,CN=MicrosoftDNS,DC=ForestDnsZones,%s" % (old_realm, + forestdn) + logger.info("Deleting old DNS zone %s" % dn) + samdb.delete(dn, ["tree_delete:1"]) + + def fix_old_dn_attributes(self, samdb): + """Fixes attributes (i.e. objectCategory) that still use the old DN""" + + samdb.transaction_start() + # Just fix any mismatches in DN detected (leave any other errors) + chk = dbcheck(samdb, quiet=True, fix=True, yes=False, + in_transaction=True) + # fix up incorrect objectCategory/etc attributes + setattr(chk, 'fix_all_old_dn_string_component_mismatch', 'ALL') + cross_ncs_ctrl = 'search_options:1:2' + controls = ['show_deleted:1', cross_ncs_ctrl] + chk.check_database(controls=controls) + samdb.transaction_commit() + + def run(self, new_domain_name, new_dns_realm, sambaopts=None, + credopts=None, server=None, targetdir=None, keep_dns_realm=False, + no_secrets=False, backend_store=None): + logger = self.get_logger() + logger.setLevel(logging.INFO) + + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp) + + # Make sure we have all the required args. + if server is None: + raise CommandError('Server required') + + check_targetdir(logger, targetdir) + + delete_old_dns = not keep_dns_realm + + new_dns_realm = new_dns_realm.lower() + new_domain_name = new_domain_name.upper() + + new_base_dn = samba.dn_from_dns_name(new_dns_realm) + logger.info("New realm for backed up domain: %s" % new_dns_realm) + logger.info("New base DN for backed up domain: %s" % new_base_dn) + logger.info("New domain NetBIOS name: %s" % new_domain_name) + + tmpdir = tempfile.mkdtemp(dir=targetdir) + + # setup a join-context for cloning the remote server + include_secrets = not no_secrets + ctx = DCCloneAndRenameContext(new_base_dn, new_domain_name, + new_dns_realm, logger=logger, + creds=creds, lp=lp, + include_secrets=include_secrets, + dns_backend='SAMBA_INTERNAL', + server=server, targetdir=tmpdir, + backend_store=backend_store) + + # sanity-check we're not "renaming" the domain to the same values + old_domain = ctx.domain_name + if old_domain == new_domain_name: + shutil.rmtree(tmpdir) + raise CommandError("Cannot use the current domain NetBIOS name.") + + old_realm = ctx.realm + if old_realm == new_dns_realm: + shutil.rmtree(tmpdir) + raise CommandError("Cannot use the current domain DNS realm.") + + # do the clone/rename + ctx.do_join() + + # get the paths used for the clone, then drop the old samdb connection + del ctx.local_samdb + paths = ctx.paths + + # get a free RID to use as the new DC's SID (when it gets restored) + remote_sam = SamDB(url='ldap://' + server, credentials=creds, + session_info=system_session(), lp=lp) + new_sid = get_sid_for_restore(remote_sam, logger) + + # Grab the remote DC's sysvol files and bundle them into a tar file. + # Note we end up with 2 sysvol dirs - the original domain's files (that + # use the old realm) backed here, as well as default files generated + # for the new realm as part of the clone/join. + sysvol_tar = os.path.join(tmpdir, 'sysvol.tar.gz') + smb_conn = smb_sysvol_conn(server, lp, creds) + backup_online(smb_conn, sysvol_tar, remote_sam.get_domain_sid()) + + # connect to the local DB (making sure we use the new/renamed config) + lp.load(paths.smbconf) + samdb = SamDB(url=paths.samdb, session_info=system_session(), lp=lp, + flags=ldb.FLG_DONT_CREATE_DB) + + # Edit the cloned sam.ldb to mark it as a backup + time_str = get_timestamp() + add_backup_marker(samdb, "backupDate", time_str) + add_backup_marker(samdb, "sidForRestore", new_sid) + add_backup_marker(samdb, "backupRename", old_realm) + add_backup_marker(samdb, "backupType", "rename") + + # fix up the DNS objects that are using the old dnsRoot value + self.update_dns_root(logger, samdb, old_realm, delete_old_dns) + + # update the netBIOS name and the Partition object for the domain + self.rename_domain_partition(logger, samdb, new_domain_name) + + if delete_old_dns: + self.delete_old_dns_zones(logger, samdb, old_realm) + + logger.info("Fixing DN attributes after rename...") + self.fix_old_dn_attributes(samdb) + + # ensure the admin user always has a password set (same as provision) + if no_secrets: + set_admin_password(logger, samdb) + + # Add everything in the tmpdir to the backup tar file + backup_file = backup_filepath(targetdir, new_dns_realm, time_str) + create_log_file(tmpdir, lp, "rename", server, include_secrets, + "Original domain %s (NetBIOS), %s (DNS realm)" % + (old_domain, old_realm)) + create_backup_tar(logger, tmpdir, backup_file) + + shutil.rmtree(tmpdir) + + +class cmd_domain_backup_offline(samba.netcmd.Command): + """Backup the local domain directories safely into a tar file. + + Takes a backup copy of the current domain from the local files on disk, + with proper locking of the DB to ensure consistency. If the domain were to + undergo a catastrophic failure, then the backup file can be used to recover + the domain. + + An offline backup differs to an online backup in the following ways: + - a backup can be created even if the DC isn't currently running. + - includes non-replicated attributes that an online backup wouldn't store. + - takes a copy of the raw database files, which has the risk that any + hidden problems in the DB are preserved in the backup.""" + + synopsis = "%prog [options]" + takes_optiongroups = { + "sambaopts": options.SambaOptions, + } + + takes_options = [ + Option("--targetdir", + help="Output directory (required)", + type=str), + ] + + backup_ext = '.bak-offline' + + def offline_tdb_copy(self, path): + backup_path = path + self.backup_ext + try: + tdb_copy(path, backup_path, readonly=True) + except CalledProcessError as copy_err: + # If the copy didn't work, check if it was caused by an EINVAL + # error on opening the DB. If so, it's a mutex locked database, + # which we can safely ignore. + try: + tdb.open(path) + except Exception as e: + if hasattr(e, 'errno') and e.errno == errno.EINVAL: + return + raise e + raise copy_err + + except FileNotFoundError as e: + # tdbbackup tool was not found. + raise CommandError(e.strerror, e) + + if not os.path.exists(backup_path): + s = "tdbbackup said backup succeeded but {0} not found" + raise CommandError(s.format(backup_path)) + + + def offline_mdb_copy(self, path): + mdb_copy(path, path + self.backup_ext) + + # Secrets databases are a special case: a transaction must be started + # on the secrets.ldb file before backing up that file and secrets.tdb + def backup_secrets(self, private_dir, lp, logger): + secrets_path = os.path.join(private_dir, 'secrets') + secrets_obj = Ldb(secrets_path + '.ldb', lp=lp, + flags=ldb.FLG_DONT_CREATE_DB) + logger.info('Starting transaction on ' + secrets_path) + secrets_obj.transaction_start() + self.offline_tdb_copy(secrets_path + '.ldb') + self.offline_tdb_copy(secrets_path + '.tdb') + secrets_obj.transaction_cancel() + + # sam.ldb must have a transaction started on it before backing up + # everything in sam.ldb.d with the appropriate backup function. + # + # Obtains the sidForRestore (SID for the new DC) and returns it + # from under the transaction + def backup_smb_dbs(self, private_dir, samdb, lp, logger): + sam_ldb_path = os.path.join(private_dir, 'sam.ldb') + + # First, determine if DB backend is MDB. Assume not unless there is a + # 'backendStore' attribute on @PARTITION containing the text 'mdb' + store_label = "backendStore" + res = samdb.search(base="@PARTITION", scope=ldb.SCOPE_BASE, + attrs=[store_label]) + mdb_backend = store_label in res[0] and str(res[0][store_label][0]) == 'mdb' + + # This is needed to keep this variable in scope until the end + # of the transaction. + res_iterator = None + + copy_function = None + if mdb_backend: + logger.info('MDB backend detected. Using mdb backup function.') + copy_function = self.offline_mdb_copy + + # We can't backup with a write transaction open, so get a + # read lock with a search_iterator(). + # + # We have tests in lib/ldb/tests/python/api.py that the + # search iterator takes a read lock effective against a + # transaction. This in turn will ensure there are no + # transactions on either the main or sub-database, even if + # the read locks were not enforced globally (they are). + res_iterator = samdb.search_iterator() + else: + logger.info('Starting transaction on ' + sam_ldb_path) + copy_function = self.offline_tdb_copy + samdb.transaction_start() + + logger.info(' backing up ' + sam_ldb_path) + self.offline_tdb_copy(sam_ldb_path) + sam_ldb_d = sam_ldb_path + '.d' + for sam_file in os.listdir(sam_ldb_d): + sam_file = os.path.join(sam_ldb_d, sam_file) + if sam_file.endswith('.ldb'): + logger.info(' backing up locked/related file ' + sam_file) + copy_function(sam_file) + elif sam_file.endswith('.tdb'): + logger.info(' tdbbackup of locked/related file ' + sam_file) + self.offline_tdb_copy(sam_file) + else: + logger.info(' copying locked/related file ' + sam_file) + shutil.copyfile(sam_file, sam_file + self.backup_ext) + + sid = get_sid_for_restore(samdb, logger) + + if mdb_backend: + # Delete the iterator, release the read lock + del(res_iterator) + else: + samdb.transaction_cancel() + + return sid + + # Find where a path should go in the fixed backup archive structure. + def get_arc_path(self, path, conf_paths): + backup_dirs = {"private": conf_paths.private_dir, + "state": conf_paths.state_dir, + "etc": os.path.dirname(conf_paths.smbconf)} + matching_dirs = [(_, p) for (_, p) in backup_dirs.items() if + path.startswith(p)] + arc_path, fs_path = matching_dirs[0] + + # If more than one directory is a parent of this path, then at least + # one configured path is a subdir of another. Use closest match. + if len(matching_dirs) > 1: + arc_path, fs_path = max(matching_dirs, key=lambda p: len(p[1])) + arc_path += path[len(fs_path):] + + return arc_path + + def run(self, sambaopts=None, targetdir=None): + + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + logger.addHandler(logging.StreamHandler(sys.stdout)) + + # Get the absolute paths of all the directories we're going to backup + lp = sambaopts.get_loadparm() + + paths = samba.provision.provision_paths_from_lp(lp, lp.get('realm')) + if not (paths.samdb and os.path.exists(paths.samdb)): + logger.error("No database found at {0}".format(paths.samdb)) + raise CommandError('Please check you are root, and ' + + 'are running this command on an AD DC') + + check_targetdir(logger, targetdir) + + # Iterating over the directories in this specific order ensures that + # when the private directory contains hardlinks that are also contained + # in other directories to be backed up (such as in paths.binddns_dir), + # the hardlinks in the private directory take precedence. + backup_dirs = [paths.private_dir, paths.state_dir, + os.path.dirname(paths.smbconf)] # etc dir + logger.info('running backup on dirs: {0}'.format(' '.join(backup_dirs))) + + # Recursively get all file paths in the backup directories + all_files = [] + all_stats = set() + for backup_dir in backup_dirs: + for (working_dir, _, filenames) in os.walk(backup_dir): + if working_dir.startswith(paths.sysvol): + continue + if working_dir.endswith('.sock') or '.sock/' in working_dir: + continue + # The BIND DNS database can be regenerated, so it doesn't need + # to be backed up. + if working_dir.startswith(os.path.join(paths.binddns_dir, 'dns')): + continue + + for filename in filenames: + full_path = os.path.join(working_dir, filename) + + # Ignore files that have already been added. This prevents + # duplicates if one backup dir is a subdirectory of another, + # or if backup dirs contain hardlinks. + try: + s = os.stat(full_path, follow_symlinks=False) + except FileNotFoundError: + logger.warning(f"{full_path} does not exist!") + continue + + if (s.st_ino, s.st_dev) in all_stats: + continue + + # Assume existing backup files are from a previous backup. + # Delete and ignore. + if filename.endswith(self.backup_ext): + os.remove(full_path) + continue + + # Sock files are autogenerated at runtime, ignore. + if filename.endswith('.sock'): + continue + + all_files.append(full_path) + all_stats.add((s.st_ino, s.st_dev)) + + # We would prefer to open with FLG_RDONLY but then we can't + # start a transaction which is the strong isolation we want + # for the backup. + samdb = SamDB(url=paths.samdb, session_info=system_session(), lp=lp, + flags=ldb.FLG_DONT_CREATE_DB) + + # Backup secrets, sam.ldb and their downstream files + self.backup_secrets(paths.private_dir, lp, logger) + sid = self.backup_smb_dbs(paths.private_dir, samdb, lp, logger) + + # Get the domain SID so we can later place it in the backup + dom_sid_str = samdb.get_domain_sid() + dom_sid = security.dom_sid(dom_sid_str) + + # Close the original samdb, to avoid any confusion, we will + # not use this any more as the data has all been copied under + # the transaction + samdb = None + + # Open the new backed up samdb, flag it as backed up, and write + # the next SID so the restore tool can add objects. We use + # options=["modules:"] here to prevent any modules from loading. + # WARNING: Don't change this code unless you know what you're doing. + # Writing to a .bak file only works because the DN being + # written to happens to be top level. + samdb = Ldb(url=paths.samdb + self.backup_ext, + session_info=system_session(), lp=lp, + options=["modules:"], flags=ldb.FLG_DONT_CREATE_DB) + time_str = get_timestamp() + add_backup_marker(samdb, "backupDate", time_str) + add_backup_marker(samdb, "sidForRestore", sid) + add_backup_marker(samdb, "backupType", "offline") + + # Close the backed up samdb + samdb = None + + # Now handle all the LDB and TDB files that are not linked to + # anything else. Use transactions for LDBs. + for path in all_files: + if not os.path.exists(path + self.backup_ext): + if path.endswith('.ldb'): + logger.info('Starting transaction on solo db: ' + path) + ldb_obj = Ldb(path, lp=lp, flags=ldb.FLG_DONT_CREATE_DB) + ldb_obj.transaction_start() + logger.info(' running tdbbackup on the same file') + self.offline_tdb_copy(path) + ldb_obj.transaction_cancel() + elif path.endswith('.tdb'): + logger.info('running tdbbackup on lone tdb file ' + path) + self.offline_tdb_copy(path) + + # Now make the backup tar file and add all + # backed up files and any other files to it. + temp_tar_dir = tempfile.mkdtemp(dir=targetdir, + prefix='INCOMPLETEsambabackupfile') + temp_tar_name = os.path.join(temp_tar_dir, "samba-backup.tar.bz2") + tar = tarfile.open(temp_tar_name, 'w:bz2') + + logger.info('running offline ntacl backup of sysvol') + sysvol_tar_fn = 'sysvol.tar.gz' + sysvol_tar = os.path.join(temp_tar_dir, sysvol_tar_fn) + backup_offline(paths.sysvol, sysvol_tar, paths.smbconf, dom_sid) + tar.add(sysvol_tar, sysvol_tar_fn) + os.remove(sysvol_tar) + + create_log_file(temp_tar_dir, lp, "offline", "localhost", True) + backup_fn = os.path.join(temp_tar_dir, "backup.txt") + tar.add(backup_fn, os.path.basename(backup_fn)) + os.remove(backup_fn) + + logger.info('building backup tar') + for path in all_files: + arc_path = self.get_arc_path(path, paths) + + if os.path.exists(path + self.backup_ext): + logger.info(' adding backup ' + arc_path + self.backup_ext + + ' to tar and deleting file') + tar.add(path + self.backup_ext, arcname=arc_path) + os.remove(path + self.backup_ext) + elif path.endswith('.ldb') or path.endswith('.tdb'): + logger.info(' skipping ' + arc_path) + else: + logger.info(' adding misc file ' + arc_path) + tar.add(path, arcname=arc_path) + + tar.close() + os.rename(temp_tar_name, + os.path.join(targetdir, + 'samba-backup-{0}.tar.bz2'.format(time_str))) + os.rmdir(temp_tar_dir) + logger.info('Backup succeeded.') + + +class cmd_domain_backup(samba.netcmd.SuperCommand): + """Create or restore a backup of the domain.""" + subcommands = {'offline': cmd_domain_backup_offline(), + 'online': cmd_domain_backup_online(), + 'rename': cmd_domain_backup_rename(), + 'restore': cmd_domain_backup_restore()} diff --git a/python/samba/netcmd/domain/claim/__init__.py b/python/samba/netcmd/domain/claim/__init__.py new file mode 100644 index 0000000..de7c4bb --- /dev/null +++ b/python/samba/netcmd/domain/claim/__init__.py @@ -0,0 +1,35 @@ +# Unix SMB/CIFS implementation. +# +# claim management +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +from samba.netcmd import SuperCommand + +from .claim_type import cmd_domain_claim_claim_type +from .value_type import cmd_domain_claim_value_type + + +class cmd_domain_claim(SuperCommand): + """Manage claims on the domain.""" + + subcommands = { + "claim-type": cmd_domain_claim_claim_type(), + "value-type": cmd_domain_claim_value_type(), + } diff --git a/python/samba/netcmd/domain/claim/claim_type.py b/python/samba/netcmd/domain/claim/claim_type.py new file mode 100644 index 0000000..c0825c6 --- /dev/null +++ b/python/samba/netcmd/domain/claim/claim_type.py @@ -0,0 +1,361 @@ +# Unix SMB/CIFS implementation. +# +# claim type management +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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 binascii +import os + +import samba.getopt as options +from samba.netcmd import Command, CommandError, Option, SuperCommand +from samba.netcmd.domain.models import AttributeSchema, ClassSchema,\ + ClaimType, ValueType +from samba.netcmd.domain.models.exceptions import ModelError + + +class cmd_domain_claim_claim_type_create(Command): + """Create claim types on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--attribute", help="Attribute of claim type to create (required).", + dest="attribute_name", action="store", type=str, required=True), + Option("--class", help="Object classes to set claim type to.", + dest="class_names", action="append", type=str, required=True), + Option("--name", help="Optional display name or use attribute name.", + dest="name", action="store", type=str), + Option("--description", + help="Optional description or use from attribute.", + dest="description", action="store", type=str), + Option("--disable", help="Disable claim type.", + dest="disable", action="store_true"), + Option("--enable", help="Enable claim type.", + dest="enable", action="store_true"), + Option("--protect", + help="Protect claim type from accidental deletion.", + dest="protect", action="store_true"), + Option("--unprotect", + help="Unprotect claim type from accidental deletion.", + dest="unprotect", action="store_true") + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, name=None, + attribute_name=None, class_names=None, description=None, + disable=None, enable=None, protect=None, unprotect=None): + + # mutually exclusive attributes + if enable and disable: + raise CommandError("--enable and --disable cannot be used together.") + if protect and unprotect: + raise CommandError("--protect and --unprotect cannot be used together.") + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + display_name = name or attribute_name + try: + claim_type = ClaimType.get(ldb, display_name=display_name) + except ModelError as e: + raise CommandError(e) + + # Check if a claim type with this display name already exists. + # Note: you can register the same claim type under another display name. + if claim_type: + raise CommandError(f"Claim type {display_name} already exists, " + "but you can use --name to use another name.") + + # Lookup attribute and class names in schema. + try: + applies_to = [ClassSchema.lookup(ldb, name) for name in class_names] + attribute = AttributeSchema.lookup(ldb, attribute_name) + value_type = ValueType.lookup(ldb, attribute) + except (LookupError, ModelError, ValueError) as e: + raise CommandError(e) + + # Generate the new Claim Type cn. + # Windows creates a random number here containing 16 hex digits. + # We can achieve something similar using urandom(8) + instance = binascii.hexlify(os.urandom(8)).decode() + cn = f"ad://ext/{display_name}:{instance}" + + # adminDescription should be present but still have a fallback. + if description is None: + description = attribute.admin_description or display_name + + # claim_is_value_space_restricted is always False because we don't + # yet support creating claims with a restricted possible values list. + claim_type = ClaimType( + cn=cn, + description=description, + display_name=display_name, + enabled=not disable, + claim_attribute_source=attribute.dn, + claim_is_single_valued=attribute.is_single_valued, + claim_is_value_space_restricted=False, + claim_source_type="AD", + claim_type_applies_to_class=[obj.dn for obj in applies_to], + claim_value_type=value_type.claim_value_type, + ) + + # Either --enable will be set or --disable but never both. + # The default if both are missing is enabled=True. + if enable is not None: + claim_type.enabled = enable + else: + claim_type.enabled = not disable + + # Create claim type + try: + claim_type.save(ldb) + + if protect: + claim_type.protect(ldb) + except ModelError as e: + raise CommandError(e) + + # Claim type created successfully. + self.outf.write(f"Created claim type: {display_name}") + if attribute_name != display_name: + self.outf.write(f" ({attribute_name})\n") + else: + self.outf.write("\n") + + +class cmd_domain_claim_claim_type_modify(Command): + """Modify claim types on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--name", help="Display name of claim type to modify (required).", + dest="name", action="store", type=str, required=True), + Option("--class", help="Object classes to set claim type to.", + dest="class_names", action="append", type=str), + Option("--description", help="Set the claim type description.", + dest="description", action="store", type=str), + Option("--enable", + help="Enable claim type.", + dest="enable", action="store_true"), + Option("--disable", + help="Disable claim type.", + dest="disable", action="store_true"), + Option("--protect", + help="Protect claim type from accidental deletion.", + dest="protect", action="store_true"), + Option("--unprotect", + help="Unprotect claim type from accidental deletion.", + dest="unprotect", action="store_true") + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, name=None, + class_names=None, description=None, enable=None, disable=None, + protect=None, unprotect=None): + + if enable and disable: + raise CommandError("--enable and --disable cannot be used together.") + if protect and unprotect: + raise CommandError("--protect and --unprotect cannot be used together.") + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + try: + claim_type = ClaimType.get(ldb, display_name=name) + except ModelError as e: + raise CommandError(e) + + # Check if claim type exists. + if not claim_type: + raise CommandError(f"Claim type {name} not found.") + + # Either --enable will be set or --disable but never both. + if enable: + claim_type.enabled = True + elif disable: + claim_type.enabled = False + + # Update the description. + if description is not None: + claim_type.description = description + + # Change class names for claim type. + if class_names is not None: + try: + applies_to = [ClassSchema.lookup(ldb, name) + for name in class_names] + except (LookupError, ValueError) as e: + raise CommandError(e) + + claim_type.claim_type_applies_to_class = [obj.dn for obj in applies_to] + + # Update claim type. + try: + claim_type.save(ldb) + + if protect: + claim_type.protect(ldb) + elif unprotect: + claim_type.unprotect(ldb) + except ModelError as e: + raise CommandError(e) + + # Claim type updated successfully. + self.outf.write(f"Updated claim type: {name}\n") + + +class cmd_domain_claim_claim_type_delete(Command): + """Delete claim types on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--name", help="Display name of claim type to delete (required).", + dest="name", action="store", type=str, required=True), + Option("--force", help="Force claim type delete even if it is protected.", + dest="force", action="store_true") + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, + name=None, force=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + try: + claim_type = ClaimType.get(ldb, display_name=name) + except ModelError as e: + raise CommandError(e) + + # Check if claim type exists first. + if claim_type is None: + raise CommandError(f"Claim type {name} not found.") + + # Delete claim type. + try: + if force: + claim_type.unprotect(ldb) + + claim_type.delete(ldb) + except ModelError as e: + if not force: + raise CommandError( + f"{e}\nTry --force to delete protected claim types.") + else: + raise CommandError(e) + + # Claim type deleted successfully. + self.outf.write(f"Deleted claim type: {name}\n") + + +class cmd_domain_claim_claim_type_list(Command): + """List claim types on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--json", help="Output results in JSON format.", + dest="output_format", action="store_const", const="json"), + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, + output_format=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + # Claim types grouped by displayName. + try: + claim_types = {claim_type.display_name: claim_type.as_dict() + for claim_type in ClaimType.query(ldb)} + except ModelError as e: + raise CommandError(e) + + # Using json output format gives more detail. + if output_format == "json": + self.print_json(claim_types) + else: + for claim_type in claim_types.keys(): + self.outf.write(f"{claim_type}\n") + + +class cmd_domain_claim_claim_type_view(Command): + """View a single claim type on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--name", help="Display name of claim type to view (required).", + dest="name", action="store", type=str, required=True), + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, name=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + try: + claim_type = ClaimType.get(ldb, display_name=name) + except ModelError as e: + raise CommandError(e) + + # Check if claim type exists first. + if claim_type is None: + raise CommandError(f"Claim type {name} not found.") + + # Display claim type as JSON. + self.print_json(claim_type.as_dict()) + + +class cmd_domain_claim_claim_type(SuperCommand): + """Manage claim types on the domain.""" + + subcommands = { + "create": cmd_domain_claim_claim_type_create(), + "delete": cmd_domain_claim_claim_type_delete(), + "modify": cmd_domain_claim_claim_type_modify(), + "list": cmd_domain_claim_claim_type_list(), + "view": cmd_domain_claim_claim_type_view(), + } diff --git a/python/samba/netcmd/domain/claim/value_type.py b/python/samba/netcmd/domain/claim/value_type.py new file mode 100644 index 0000000..a261113 --- /dev/null +++ b/python/samba/netcmd/domain/claim/value_type.py @@ -0,0 +1,105 @@ +# Unix SMB/CIFS implementation. +# +# claim value type management +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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 samba.getopt as options +from samba.netcmd import Command, CommandError, Option, SuperCommand +from samba.netcmd.domain.models import ValueType +from samba.netcmd.domain.models.exceptions import ModelError + + +class cmd_domain_claim_value_type_list(Command): + """List claim values types on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--json", help="Output results in JSON format.", + dest="output_format", action="store_const", const="json"), + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, + output_format=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + # Value types grouped by display name. + try: + value_types = {value_type.display_name: value_type.as_dict() + for value_type in ValueType.query(ldb)} + except ModelError as e: + raise CommandError(e) + + # Using json output format gives more detail. + if output_format == "json": + self.print_json(value_types) + else: + for value_type in value_types.keys(): + self.outf.write(f"{value_type}\n") + + +class cmd_domain_claim_value_type_view(Command): + """View a single claim value type on the domain.""" + + synopsis = "%prog -H <URL> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "hostopts": options.HostOptions, + } + + takes_options = [ + Option("--name", + help="Display name of claim value type to view (required).", + dest="name", action="store", type=str, required=True), + ] + + def run(self, hostopts=None, sambaopts=None, credopts=None, name=None): + + ldb = self.ldb_connect(hostopts, sambaopts, credopts) + + try: + value_type = ValueType.get(ldb, display_name=name) + except ModelError as e: + raise CommandError(e) + + # Check if value type exists first. + if value_type is None: + raise CommandError(f"Value type {name} not found.") + + # Display vale type as JSON. + self.print_json(value_type.as_dict()) + + +class cmd_domain_claim_value_type(SuperCommand): + """Manage claim value types on the domain.""" + + subcommands = { + "list": cmd_domain_claim_value_type_list(), + "view": cmd_domain_claim_value_type_view(), + } diff --git a/python/samba/netcmd/domain/classicupgrade.py b/python/samba/netcmd/domain/classicupgrade.py new file mode 100644 index 0000000..5b6a8a8 --- /dev/null +++ b/python/samba/netcmd/domain/classicupgrade.py @@ -0,0 +1,189 @@ +# domain management - domain classicupgrade +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 os +import tempfile +import subprocess + +import samba +import samba.getopt as options +from samba.auth import system_session +from samba.auth_util import system_session_unix +from samba.common import get_string +from samba.netcmd import Command, CommandError, Option +from samba.samba3 import Samba3 +from samba.samba3 import param as s3param +from samba.upgrade import upgrade_from_samba3 + +from .common import common_ntvfs_options + + +def get_testparm_var(testparm, smbconf, varname): + errfile = open(os.devnull, 'w') + p = subprocess.Popen([testparm, '-s', '-l', + '--parameter-name=%s' % varname, smbconf], + stdout=subprocess.PIPE, stderr=errfile) + (out, err) = p.communicate() + errfile.close() + lines = out.split(b'\n') + if lines: + return get_string(lines[0]).strip() + return "" + + +class cmd_domain_classicupgrade(Command): + """Upgrade from Samba classic (NT4-like) database to Samba AD DC database. + + Specify either a directory with all Samba classic DC databases and state files (with --dbdir) or + the testparm utility from your classic installation (with --testparm). + """ + + synopsis = "%prog [options] <classic_smb_conf>" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions + } + + takes_options = [ + Option("--dbdir", type="string", metavar="DIR", + help="Path to samba classic DC database directory"), + Option("--testparm", type="string", metavar="PATH", + help="Path to samba classic DC testparm utility from the previous installation. This allows the default paths of the previous installation to be followed"), + Option("--targetdir", type="string", metavar="DIR", + help="Path prefix where the new Samba 4.0 AD domain should be initialised"), + Option("-q", "--quiet", help="Be quiet", action="store_true"), + Option("-v", "--verbose", help="Be verbose", action="store_true"), + Option("--dns-backend", type="choice", metavar="NAMESERVER-BACKEND", + choices=["SAMBA_INTERNAL", "BIND9_FLATFILE", "BIND9_DLZ", "NONE"], + help="The DNS server backend. SAMBA_INTERNAL is the builtin name server (default), " + "BIND9_FLATFILE uses bind9 text database to store zone information, " + "BIND9_DLZ uses samba4 AD to store zone information, " + "NONE skips the DNS setup entirely (this DC will not be a DNS server)", + default="SAMBA_INTERNAL") + ] + + ntvfs_options = [ + Option("--use-xattrs", type="choice", choices=["yes", "no", "auto"], + metavar="[yes|no|auto]", + help="Define if we should use the native fs capabilities or a tdb file for " + "storing attributes likes ntacl when --use-ntvfs is set. " + "auto tries to make an intelligent guess based on the user rights and system capabilities", + default="auto") + ] + if samba.is_ntvfs_fileserver_built(): + takes_options.extend(common_ntvfs_options) + takes_options.extend(ntvfs_options) + + takes_args = ["smbconf"] + + def run(self, smbconf=None, targetdir=None, dbdir=None, testparm=None, + quiet=False, verbose=False, use_xattrs="auto", sambaopts=None, versionopts=None, + dns_backend=None, use_ntvfs=False): + + if not os.path.exists(smbconf): + raise CommandError("File %s does not exist" % smbconf) + + if testparm and not os.path.exists(testparm): + raise CommandError("Testparm utility %s does not exist" % testparm) + + if dbdir and not os.path.exists(dbdir): + raise CommandError("Directory %s does not exist" % dbdir) + + if not dbdir and not testparm: + raise CommandError("Please specify either dbdir or testparm") + + logger = self.get_logger(verbose=verbose, quiet=quiet) + + if dbdir and testparm: + logger.warning("both dbdir and testparm specified, ignoring dbdir.") + dbdir = None + + lp = sambaopts.get_loadparm() + + s3conf = s3param.get_context() + + if sambaopts.realm: + s3conf.set("realm", sambaopts.realm) + + if targetdir is not None: + if not os.path.isdir(targetdir): + os.mkdir(targetdir) + + eadb = True + if use_xattrs == "yes": + eadb = False + elif use_xattrs == "auto" and not use_ntvfs: + eadb = False + elif not use_ntvfs: + raise CommandError("--use-xattrs=no requires --use-ntvfs (not supported for production use). " + "Please re-run with --use-xattrs omitted.") + elif use_xattrs == "auto" and not s3conf.get("posix:eadb"): + if targetdir: + tmpfile = tempfile.NamedTemporaryFile(dir=os.path.abspath(targetdir)) + else: + tmpfile = tempfile.NamedTemporaryFile(dir=os.path.abspath(os.path.dirname(lp.get("private dir")))) + try: + try: + samba.ntacls.setntacl(lp, tmpfile.name, + "O:S-1-5-32G:S-1-5-32", + "S-1-5-32", + system_session_unix(), + "native") + eadb = False + except Exception: + # FIXME: Don't catch all exceptions here + logger.info("You are not root or your system does not support xattr, using tdb backend for attributes. " + "If you intend to use this provision in production, rerun the script as root on a system supporting xattrs.") + finally: + tmpfile.close() + + # Set correct default values from dbdir or testparm + paths = {} + if dbdir: + paths["state directory"] = dbdir + paths["private dir"] = dbdir + paths["lock directory"] = dbdir + paths["smb passwd file"] = dbdir + "/smbpasswd" + else: + paths["state directory"] = get_testparm_var(testparm, smbconf, "state directory") + paths["private dir"] = get_testparm_var(testparm, smbconf, "private dir") + paths["smb passwd file"] = get_testparm_var(testparm, smbconf, "smb passwd file") + paths["lock directory"] = get_testparm_var(testparm, smbconf, "lock directory") + # "testparm" from Samba 3 < 3.4.x is not aware of the parameter + # "state directory", instead make use of "lock directory" + if len(paths["state directory"]) == 0: + paths["state directory"] = paths["lock directory"] + + for p in paths: + s3conf.set(p, paths[p]) + + # load smb.conf parameters + logger.info("Reading smb.conf") + s3conf.load(smbconf) + samba3 = Samba3(smbconf, s3conf) + + logger.info("Provisioning") + upgrade_from_samba3(samba3, logger, targetdir, session_info=system_session(), + useeadb=eadb, dns_backend=dns_backend, use_ntvfs=use_ntvfs) diff --git a/python/samba/netcmd/domain/common.py b/python/samba/netcmd/domain/common.py new file mode 100644 index 0000000..144d22b --- /dev/null +++ b/python/samba/netcmd/domain/common.py @@ -0,0 +1,64 @@ +# domain management - common code +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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/>. +# + +from samba.netcmd import Option +from samba.samdb import get_default_backend_store + +common_ntvfs_options = [ + Option("--use-ntvfs", help="Use NTVFS for the fileserver (default = no)", + action="store_true") +] + +common_provision_join_options = [ + Option("--machinepass", type="string", metavar="PASSWORD", + help="choose machine password (otherwise random)"), + Option("--plaintext-secrets", action="store_true", + help="Store secret/sensitive values as plain text on disk" + + "(default is to encrypt secret/sensitive values)"), + Option("--backend-store", type="choice", metavar="BACKENDSTORE", + choices=["tdb", "mdb"], + help="Specify the database backend to be used " + "(default is %s)" % get_default_backend_store()), + Option("--backend-store-size", type="bytes", metavar="SIZE", + help="Specify the size of the backend database, currently only " + + "supported by lmdb backends (default is 8 Gb)."), + Option("--targetdir", metavar="DIR", + help="Set target directory (where to store provision)", type=str), + Option("-q", "--quiet", help="Be quiet", action="store_true"), +] + +common_join_options = [ + Option("--server", help="DC to join", type=str), + Option("--site", help="site to join", type=str), + Option("--domain-critical-only", + help="only replicate critical domain objects", + action="store_true"), + Option("--dns-backend", type="choice", metavar="NAMESERVER-BACKEND", + choices=["SAMBA_INTERNAL", "BIND9_DLZ", "NONE"], + help="The DNS server backend. SAMBA_INTERNAL is the builtin name server (default), " + "BIND9_DLZ uses samba4 AD to store zone information, " + "NONE skips the DNS setup entirely (this DC will not be a DNS server)", + default="SAMBA_INTERNAL"), + Option("-v", "--verbose", help="Be verbose", action="store_true") +] diff --git a/python/samba/netcmd/domain/dcpromo.py b/python/samba/netcmd/domain/dcpromo.py new file mode 100644 index 0000000..bf78b74 --- /dev/null +++ b/python/samba/netcmd/domain/dcpromo.py @@ -0,0 +1,90 @@ +# domain management - domain dcpromo +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 samba +import samba.getopt as options +from samba.join import join_DC, join_RODC +from samba.net import Net +from samba.netcmd import Command, CommandError + +from .common import (common_join_options, common_ntvfs_options, + common_provision_join_options) + + +class cmd_domain_dcpromo(Command): + """Promote an existing domain member or NT4 PDC to an AD DC.""" + + synopsis = "%prog <dnsdomain> [DC|RODC] [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "credopts": options.CredentialsOptions, + } + + takes_options = [] + takes_options.extend(common_join_options) + + takes_options.extend(common_provision_join_options) + + if samba.is_ntvfs_fileserver_built(): + takes_options.extend(common_ntvfs_options) + + takes_args = ["domain", "role?"] + + def run(self, domain, role=None, sambaopts=None, credopts=None, + versionopts=None, server=None, site=None, targetdir=None, + domain_critical_only=False, machinepass=None, + use_ntvfs=False, dns_backend=None, + quiet=False, verbose=False, plaintext_secrets=False, + backend_store=None, backend_store_size=None): + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp) + + logger = self.get_logger(verbose=verbose, quiet=quiet) + + netbios_name = lp.get("netbios name") + + if role is not None: + role = role.upper() + + if role == "DC": + join_DC(logger=logger, server=server, creds=creds, lp=lp, domain=domain, + site=site, netbios_name=netbios_name, targetdir=targetdir, + domain_critical_only=domain_critical_only, + machinepass=machinepass, use_ntvfs=use_ntvfs, + dns_backend=dns_backend, + promote_existing=True, plaintext_secrets=plaintext_secrets, + backend_store=backend_store, + backend_store_size=backend_store_size) + elif role == "RODC": + join_RODC(logger=logger, server=server, creds=creds, lp=lp, domain=domain, + site=site, netbios_name=netbios_name, targetdir=targetdir, + domain_critical_only=domain_critical_only, + machinepass=machinepass, use_ntvfs=use_ntvfs, dns_backend=dns_backend, + promote_existing=True, plaintext_secrets=plaintext_secrets, + backend_store=backend_store, + backend_store_size=backend_store_size) + else: + raise CommandError("Invalid role '%s' (possible values: DC, RODC)" % role) diff --git a/python/samba/netcmd/domain/demote.py b/python/samba/netcmd/domain/demote.py new file mode 100644 index 0000000..ae4d11d --- /dev/null +++ b/python/samba/netcmd/domain/demote.py @@ -0,0 +1,335 @@ +# domain management - domain demote +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 ldb +import samba.getopt as options +from samba import dsdb, remove_dc, werror +from samba.auth import system_session +from samba.dcerpc import drsuapi, misc +from samba.drs_utils import drsuapi_connect +from samba.dsdb import ( + DS_NTDSDSA_OPT_DISABLE_INBOUND_REPL, + DS_NTDSDSA_OPT_DISABLE_OUTBOUND_REPL, + UF_PARTIAL_SECRETS_ACCOUNT, + UF_SERVER_TRUST_ACCOUNT, + UF_TRUSTED_FOR_DELEGATION, + UF_WORKSTATION_TRUST_ACCOUNT +) +from samba.net import Net +from samba.netcmd import Command, CommandError, Option +from samba.samdb import SamDB + + +class cmd_domain_demote(Command): + """Demote ourselves from the role of Domain Controller.""" + + synopsis = "%prog [options]" + + takes_options = [ + Option("--server", help="writable DC to write demotion changes on", type=str), + Option("-H", "--URL", help="LDB URL for database or target server", type=str, + metavar="URL", dest="H"), + Option("--remove-other-dead-server", help="Dead DC (name or NTDS GUID) " + "to remove ALL references to (rather than this DC)", type=str), + Option("-q", "--quiet", help="Be quiet", action="store_true"), + Option("-v", "--verbose", help="Be verbose", action="store_true"), + ] + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "versionopts": options.VersionOptions, + } + + def run(self, sambaopts=None, credopts=None, + versionopts=None, server=None, + remove_other_dead_server=None, H=None, + verbose=False, quiet=False): + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp) + + logger = self.get_logger(verbose=verbose, quiet=quiet) + + if remove_other_dead_server is not None: + if server is not None: + samdb = SamDB(url="ldap://%s" % server, + session_info=system_session(), + credentials=creds, lp=lp) + else: + samdb = SamDB(url=H, session_info=system_session(), credentials=creds, lp=lp) + try: + remove_dc.remove_dc(samdb, logger, remove_other_dead_server) + except remove_dc.DemoteException as err: + raise CommandError("Demote failed: %s" % err) + return + + netbios_name = lp.get("netbios name") + samdb = SamDB(url=H, session_info=system_session(), credentials=creds, lp=lp) + if not server: + res = samdb.search(expression='(&(objectClass=computer)(serverReferenceBL=*))', attrs=["dnsHostName", "name"]) + if (len(res) == 0): + raise CommandError("Unable to search for servers") + + if (len(res) == 1): + raise CommandError("You are the last server in the domain") + + server = None + for e in res: + if str(e["name"]).lower() != netbios_name.lower(): + server = e["dnsHostName"] + break + + ntds_guid = samdb.get_ntds_GUID() + msg = samdb.search(base=str(samdb.get_config_basedn()), + scope=ldb.SCOPE_SUBTREE, expression="(objectGUID=%s)" % ntds_guid, + attrs=['options']) + if len(msg) == 0 or "options" not in msg[0]: + raise CommandError("Failed to find options on %s" % ntds_guid) + + ntds_dn = msg[0].dn + dsa_options = int(str(msg[0]['options'])) + + res = samdb.search(expression="(fSMORoleOwner=%s)" % str(ntds_dn), + controls=["search_options:1:2"]) + + if len(res) != 0: + raise CommandError("Current DC is still the owner of %d role(s), " + "use the role command to transfer roles to " + "another DC" % + len(res)) + + self.errf.write("Using %s as partner server for the demotion\n" % + server) + (drsuapiBind, drsuapi_handle, supportedExtensions) = drsuapi_connect(server, lp, creds) + + self.errf.write("Deactivating inbound replication\n") + + nmsg = ldb.Message() + nmsg.dn = msg[0].dn + + if not (dsa_options & DS_NTDSDSA_OPT_DISABLE_OUTBOUND_REPL) and not samdb.am_rodc(): + dsa_options |= DS_NTDSDSA_OPT_DISABLE_INBOUND_REPL + nmsg["options"] = ldb.MessageElement(str(dsa_options), ldb.FLAG_MOD_REPLACE, "options") + samdb.modify(nmsg) + + self.errf.write("Asking partner server %s to synchronize from us\n" + % server) + for part in (samdb.get_schema_basedn(), + samdb.get_config_basedn(), + samdb.get_root_basedn()): + nc = drsuapi.DsReplicaObjectIdentifier() + nc.dn = str(part) + + req1 = drsuapi.DsReplicaSyncRequest1() + req1.naming_context = nc + req1.options = drsuapi.DRSUAPI_DRS_WRIT_REP + req1.source_dsa_guid = misc.GUID(ntds_guid) + + try: + drsuapiBind.DsReplicaSync(drsuapi_handle, 1, req1) + except RuntimeError as e1: + (werr, string) = e1.args + if werr == werror.WERR_DS_DRA_NO_REPLICA: + pass + else: + self.errf.write( + "Error while replicating out last local changes from '%s' for demotion, " + "re-enabling inbound replication\n" % part) + dsa_options ^= DS_NTDSDSA_OPT_DISABLE_INBOUND_REPL + nmsg["options"] = ldb.MessageElement(str(dsa_options), ldb.FLAG_MOD_REPLACE, "options") + samdb.modify(nmsg) + raise CommandError("Error while sending a DsReplicaSync for partition '%s'" % str(part), string) + try: + remote_samdb = SamDB(url="ldap://%s" % server, + session_info=system_session(), + credentials=creds, lp=lp) + + self.errf.write("Changing userControl and container\n") + res = remote_samdb.search(base=str(remote_samdb.domain_dn()), + expression="(&(objectClass=user)(sAMAccountName=%s$))" % + netbios_name.upper(), + attrs=["userAccountControl"]) + dc_dn = res[0].dn + uac = int(str(res[0]["userAccountControl"])) + + except Exception as e: + if not (dsa_options & DS_NTDSDSA_OPT_DISABLE_OUTBOUND_REPL) and not samdb.am_rodc(): + self.errf.write( + "Error while demoting, re-enabling inbound replication\n") + dsa_options ^= DS_NTDSDSA_OPT_DISABLE_INBOUND_REPL + nmsg["options"] = ldb.MessageElement(str(dsa_options), ldb.FLAG_MOD_REPLACE, "options") + samdb.modify(nmsg) + raise CommandError("Error while changing account control", e) + + if (len(res) != 1): + if not (dsa_options & DS_NTDSDSA_OPT_DISABLE_OUTBOUND_REPL) and not samdb.am_rodc(): + self.errf.write( + "Error while demoting, re-enabling inbound replication\n") + dsa_options ^= DS_NTDSDSA_OPT_DISABLE_INBOUND_REPL + nmsg["options"] = ldb.MessageElement(str(dsa_options), ldb.FLAG_MOD_REPLACE, "options") + samdb.modify(nmsg) + raise CommandError("Unable to find object with samaccountName = %s$" + " in the remote dc" % netbios_name.upper()) + + uac &= ~(UF_SERVER_TRUST_ACCOUNT | + UF_TRUSTED_FOR_DELEGATION | + UF_PARTIAL_SECRETS_ACCOUNT) + uac |= UF_WORKSTATION_TRUST_ACCOUNT + + msg = ldb.Message() + msg.dn = dc_dn + + msg["userAccountControl"] = ldb.MessageElement("%d" % uac, + ldb.FLAG_MOD_REPLACE, + "userAccountControl") + try: + remote_samdb.modify(msg) + except Exception as e: + if not (dsa_options & DS_NTDSDSA_OPT_DISABLE_OUTBOUND_REPL) and not samdb.am_rodc(): + self.errf.write( + "Error while demoting, re-enabling inbound replication\n") + dsa_options ^= DS_NTDSDSA_OPT_DISABLE_INBOUND_REPL + nmsg["options"] = ldb.MessageElement(str(dsa_options), ldb.FLAG_MOD_REPLACE, "options") + samdb.modify(nmsg) + + raise CommandError("Error while changing account control", e) + + dc_name = res[0].dn.get_rdn_value() + rdn = "CN=%s" % dc_name + + # Let's move to the Computer container + i = 0 + newrdn = str(rdn) + + computer_dn = remote_samdb.get_wellknown_dn( + remote_samdb.get_default_basedn(), + dsdb.DS_GUID_COMPUTERS_CONTAINER) + res = remote_samdb.search(base=computer_dn, expression=rdn, scope=ldb.SCOPE_ONELEVEL) + + if (len(res) != 0): + res = remote_samdb.search(base=computer_dn, expression="%s-%d" % (rdn, i), + scope=ldb.SCOPE_ONELEVEL) + while(len(res) != 0 and i < 100): + i = i + 1 + res = remote_samdb.search(base=computer_dn, expression="%s-%d" % (rdn, i), + scope=ldb.SCOPE_ONELEVEL) + + if i == 100: + if not (dsa_options & DS_NTDSDSA_OPT_DISABLE_OUTBOUND_REPL) and not samdb.am_rodc(): + self.errf.write( + "Error while demoting, re-enabling inbound replication\n") + dsa_options ^= DS_NTDSDSA_OPT_DISABLE_INBOUND_REPL + nmsg["options"] = ldb.MessageElement(str(dsa_options), ldb.FLAG_MOD_REPLACE, "options") + samdb.modify(nmsg) + + msg = ldb.Message() + msg.dn = dc_dn + + msg["userAccountControl"] = ldb.MessageElement("%d" % uac, + ldb.FLAG_MOD_REPLACE, + "userAccountControl") + + remote_samdb.modify(msg) + + raise CommandError("Unable to find a slot for renaming %s," + " all names from %s-1 to %s-%d seemed used" % + (str(dc_dn), rdn, rdn, i - 9)) + + newrdn = "%s-%d" % (rdn, i) + + try: + newdn = ldb.Dn(remote_samdb, "%s,%s" % (newrdn, str(computer_dn))) + remote_samdb.rename(dc_dn, newdn) + except Exception as e: + if not (dsa_options & DS_NTDSDSA_OPT_DISABLE_OUTBOUND_REPL) and not samdb.am_rodc(): + self.errf.write( + "Error while demoting, re-enabling inbound replication\n") + dsa_options ^= DS_NTDSDSA_OPT_DISABLE_INBOUND_REPL + nmsg["options"] = ldb.MessageElement(str(dsa_options), ldb.FLAG_MOD_REPLACE, "options") + samdb.modify(nmsg) + + msg = ldb.Message() + msg.dn = dc_dn + + msg["userAccountControl"] = ldb.MessageElement("%d" % uac, + ldb.FLAG_MOD_REPLACE, + "userAccountControl") + + remote_samdb.modify(msg) + raise CommandError("Error while renaming %s to %s" % (str(dc_dn), str(newdn)), e) + + server_dsa_dn = samdb.get_serverName() + domain = remote_samdb.get_root_basedn() + + try: + req1 = drsuapi.DsRemoveDSServerRequest1() + req1.server_dn = str(server_dsa_dn) + req1.domain_dn = str(domain) + req1.commit = 1 + + drsuapiBind.DsRemoveDSServer(drsuapi_handle, 1, req1) + except RuntimeError as e3: + (werr, string) = e3.args + if not (dsa_options & DS_NTDSDSA_OPT_DISABLE_OUTBOUND_REPL) and not samdb.am_rodc(): + self.errf.write( + "Error while demoting, re-enabling inbound replication\n") + dsa_options ^= DS_NTDSDSA_OPT_DISABLE_INBOUND_REPL + nmsg["options"] = ldb.MessageElement(str(dsa_options), ldb.FLAG_MOD_REPLACE, "options") + samdb.modify(nmsg) + + msg = ldb.Message() + msg.dn = newdn + + msg["userAccountControl"] = ldb.MessageElement("%d" % uac, + ldb.FLAG_MOD_REPLACE, + "userAccountControl") + remote_samdb.modify(msg) + remote_samdb.rename(newdn, dc_dn) + if werr == werror.WERR_DS_DRA_NO_REPLICA: + raise CommandError("The DC %s is not present on (already " + "removed from) the remote server: %s" % + (server_dsa_dn, e3)) + else: + raise CommandError("Error while sending a removeDsServer " + "of %s: %s" % + (server_dsa_dn, e3)) + + remove_dc.remove_sysvol_references(remote_samdb, logger, dc_name) + + # These are objects under the computer account that should be deleted + for s in ("CN=Enterprise,CN=NTFRS Subscriptions", + "CN=%s, CN=NTFRS Subscriptions" % lp.get("realm"), + "CN=Domain system Volumes (SYSVOL Share), CN=NTFRS Subscriptions", + "CN=NTFRS Subscriptions"): + try: + remote_samdb.delete(ldb.Dn(remote_samdb, + "%s,%s" % (s, str(newdn)))) + except ldb.LdbError: + pass + + # get dns host name for target server to demote, remove dns references + remove_dc.remove_dns_references(remote_samdb, logger, samdb.host_dns_name(), + ignore_no_name=True) + + self.errf.write("Demote successful\n") diff --git a/python/samba/netcmd/domain/functional_prep.py b/python/samba/netcmd/domain/functional_prep.py new file mode 100644 index 0000000..3e1d4e1 --- /dev/null +++ b/python/samba/netcmd/domain/functional_prep.py @@ -0,0 +1,145 @@ +# domain management - domain functional_prep +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 ldb +import samba.getopt as options +from samba.auth import system_session +from samba.dsdb import DS_DOMAIN_FUNCTION_2008, DS_DOMAIN_FUNCTION_2008_R2 +from samba.netcmd import Command, CommandError, Option +from samba.netcmd.fsmo import get_fsmo_roleowner +from samba.samdb import SamDB + +from samba import functional_level + + +class cmd_domain_functional_prep(Command): + """Domain functional level preparation""" + + synopsis = "%prog [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "credopts": options.CredentialsOptions, + } + + takes_options = [ + Option("-H", "--URL", help="LDB URL for database or target server", type=str, + metavar="URL", dest="H"), + Option("-q", "--quiet", help="Be quiet", action="store_true"), + Option("-v", "--verbose", help="Be verbose", action="store_true"), + Option("--function-level", type="choice", metavar="FUNCTION_LEVEL", + choices=["2008_R2", "2012", "2012_R2", "2016"], + help="The functional level to prepare for. Default is (Windows) 2016.", + default="2016"), + Option("--forest-prep", action="store_true", + help="Run the forest prep (by default, both the domain and forest prep are run)."), + Option("--domain-prep", action="store_true", + help="Run the domain prep (by default, both the domain and forest prep are run).") + ] + + def run(self, **kwargs): + updates_allowed_overridden = False + sambaopts = kwargs.get("sambaopts") + credopts = kwargs.get("credopts") + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp) + H = kwargs.get("H") + function_level = kwargs.get("function_level") + try: + target_level = functional_level.string_to_level(function_level) + except KeyError: + raise CommandError(f"'{function_level}' is not known to Samba as an AD functional level") + + forest_prep = kwargs.get("forest_prep") + domain_prep = kwargs.get("domain_prep") + + samdb = SamDB(url=H, session_info=system_session(), credentials=creds, lp=lp) + + # we're not going to get far if the config doesn't allow schema updates + if lp.get("dsdb:schema update allowed") is None: + lp.set("dsdb:schema update allowed", "yes") + print("Temporarily overriding 'dsdb:schema update allowed' setting") + updates_allowed_overridden = True + + if forest_prep is None and domain_prep is None: + forest_prep = True + domain_prep = True + + own_dn = ldb.Dn(samdb, samdb.get_dsServiceName()) + if forest_prep: + master = get_fsmo_roleowner(samdb, str(samdb.get_schema_basedn()), + 'schema') + if own_dn != master: + raise CommandError("This server is not the schema master.") + + if domain_prep: + domain_dn = samdb.domain_dn() + infrastructure_dn = "CN=Infrastructure," + domain_dn + master = get_fsmo_roleowner(samdb, infrastructure_dn, + 'infrastructure') + if own_dn != master: + raise CommandError("This server is not the infrastructure master.") + + exception_encountered = None + + if forest_prep and exception_encountered is None: + samdb.transaction_start() + try: + from samba.forest_update import ForestUpdate + forest = ForestUpdate(samdb, fix=True) + + forest.check_updates_iterator([11, 54, 79, 80, 81, 82, 83]) + forest.check_updates_functional_level(target_level, + DS_DOMAIN_FUNCTION_2008_R2, + update_revision=True) + + samdb.transaction_commit() + except Exception as e: + print("Exception: %s" % e) + samdb.transaction_cancel() + exception_encountered = e + + if domain_prep and exception_encountered is None: + samdb.transaction_start() + try: + from samba.domain_update import DomainUpdate + + domain = DomainUpdate(samdb, fix=True) + domain.check_updates_functional_level(target_level, + DS_DOMAIN_FUNCTION_2008, + update_revision=True) + + samdb.transaction_commit() + except Exception as e: + print("Exception: %s" % e) + samdb.transaction_cancel() + exception_encountered = e + + if updates_allowed_overridden: + lp.set("dsdb:schema update allowed", "no") + + if exception_encountered is not None: + raise CommandError('Failed to perform functional prep: %r' % + exception_encountered) diff --git a/python/samba/netcmd/domain/info.py b/python/samba/netcmd/domain/info.py new file mode 100644 index 0000000..8454cb3 --- /dev/null +++ b/python/samba/netcmd/domain/info.py @@ -0,0 +1,58 @@ +# domain management - domain info +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 samba.getopt as options +from samba.netcmd import Command, CommandError +from samba.netcmd.common import netcmd_get_domain_infos_via_cldap + + +class cmd_domain_info(Command): + """Print basic info about a domain and the DC passed as parameter.""" + + synopsis = "%prog <ip_address> [options]" + + takes_options = [ + ] + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "versionopts": options.VersionOptions, + } + + takes_args = ["address"] + + def run(self, address, credopts=None, sambaopts=None, versionopts=None): + lp = sambaopts.get_loadparm() + try: + res = netcmd_get_domain_infos_via_cldap(lp, None, address) + except RuntimeError: + raise CommandError("Invalid IP address '" + address + "'!") + self.outf.write("Forest : %s\n" % res.forest) + self.outf.write("Domain : %s\n" % res.dns_domain) + self.outf.write("Netbios domain : %s\n" % res.domain_name) + self.outf.write("DC name : %s\n" % res.pdc_dns_name) + self.outf.write("DC netbios name : %s\n" % res.pdc_name) + self.outf.write("Server site : %s\n" % res.server_site) + self.outf.write("Client site : %s\n" % res.client_site) diff --git a/python/samba/netcmd/domain/join.py b/python/samba/netcmd/domain/join.py new file mode 100644 index 0000000..936cfa8 --- /dev/null +++ b/python/samba/netcmd/domain/join.py @@ -0,0 +1,146 @@ +# domain management - domain join +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 os +import tempfile + +import samba +import samba.getopt as options +from samba import is_ad_dc_built +from samba.dcerpc import nbt +from samba.join import join_DC, join_RODC +from samba.net import LIBNET_JOIN_AUTOMATIC, Net +from samba.net_s3 import Net as s3_Net +from samba.netcmd import Command, CommandError, Option +from samba.param import default_path +from samba.samba3 import param as s3param + +from .common import common_join_options, common_provision_join_options + + +class cmd_domain_join(Command): + """Join domain as either member or backup domain controller.""" + + synopsis = "%prog <dnsdomain> [DC|RODC|MEMBER] [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "credopts": options.CredentialsOptions, + } + + ntvfs_options = [ + Option( + "--use-ntvfs", help="Use NTVFS for the fileserver (default = no)", + action="store_true") + ] + + selftest_options = [ + Option("--experimental-s4-member", action="store_true", + help="Perform member joins using the s4 Net join_member. " + "Don't choose this unless you know what you're doing") + ] + + takes_options = [ + Option("--no-dns-updates", action="store_true", + help="Disable DNS updates") + ] + takes_options.extend(common_join_options) + takes_options.extend(common_provision_join_options) + + if samba.is_ntvfs_fileserver_built(): + takes_options.extend(ntvfs_options) + + if samba.is_selftest_enabled(): + takes_options.extend(selftest_options) + + takes_args = ["domain", "role?"] + + def run(self, domain, role=None, sambaopts=None, credopts=None, + versionopts=None, server=None, site=None, targetdir=None, + domain_critical_only=False, machinepass=None, + use_ntvfs=False, experimental_s4_member=False, dns_backend=None, + quiet=False, verbose=False, no_dns_updates=False, + plaintext_secrets=False, + backend_store=None, backend_store_size=None): + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp) + net = Net(creds, lp, server=credopts.ipaddress) + + logger = self.get_logger(verbose=verbose, quiet=quiet) + + netbios_name = lp.get("netbios name") + + if role is not None: + role = role.upper() + + if role is None or role == "MEMBER": + if experimental_s4_member: + (join_password, sid, domain_name) = net.join_member( + domain, netbios_name, LIBNET_JOIN_AUTOMATIC, + machinepass=machinepass) + else: + lp.set('realm', domain) + if lp.get('workgroup') == 'WORKGROUP': + lp.set('workgroup', net.finddc(domain=domain, + flags=(nbt.NBT_SERVER_LDAP | + nbt.NBT_SERVER_DS)).domain_name) + lp.set('server role', 'member server') + smb_conf = lp.configfile if lp.configfile else default_path() + with tempfile.NamedTemporaryFile(delete=False, + dir=os.path.dirname(smb_conf)) as f: + lp.dump(False, f.name) + if os.path.exists(smb_conf): + mode = os.stat(smb_conf).st_mode + os.chmod(f.name, mode) + os.rename(f.name, smb_conf) + s3_lp = s3param.get_context() + s3_lp.load(smb_conf) + s3_net = s3_Net(creds, s3_lp, server=server) + (sid, domain_name) = s3_net.join_member(netbios_name, + machinepass=machinepass, + debug=verbose, + noDnsUpdates=no_dns_updates) + + self.errf.write("Joined domain %s (%s)\n" % (domain_name, sid)) + elif role == "DC" and is_ad_dc_built(): + join_DC(logger=logger, server=server, creds=creds, lp=lp, domain=domain, + site=site, netbios_name=netbios_name, targetdir=targetdir, + domain_critical_only=domain_critical_only, + machinepass=machinepass, use_ntvfs=use_ntvfs, + dns_backend=dns_backend, + plaintext_secrets=plaintext_secrets, + backend_store=backend_store, + backend_store_size=backend_store_size) + elif role == "RODC" and is_ad_dc_built(): + join_RODC(logger=logger, server=server, creds=creds, lp=lp, domain=domain, + site=site, netbios_name=netbios_name, targetdir=targetdir, + domain_critical_only=domain_critical_only, + machinepass=machinepass, use_ntvfs=use_ntvfs, + dns_backend=dns_backend, + plaintext_secrets=plaintext_secrets, + backend_store=backend_store, + backend_store_size=backend_store_size) + else: + raise CommandError("Invalid role '%s' (possible values: MEMBER, DC, RODC)" % role) diff --git a/python/samba/netcmd/domain/keytab.py b/python/samba/netcmd/domain/keytab.py new file mode 100644 index 0000000..b0955ca --- /dev/null +++ b/python/samba/netcmd/domain/keytab.py @@ -0,0 +1,55 @@ +# domain management - domain keytab +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 samba.getopt as options +from samba import enable_net_export_keytab +from samba.net import Net +from samba.netcmd import Command, Option + +try: + enable_net_export_keytab() +except ImportError: + cmd_domain_export_keytab = None +else: + class cmd_domain_export_keytab(Command): + """Dump Kerberos keys of the domain into a keytab.""" + + synopsis = "%prog <keytab> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "versionopts": options.VersionOptions, + } + + takes_options = [ + Option("--principal", help="extract only this principal", type=str), + ] + + takes_args = ["keytab"] + + def run(self, keytab, credopts=None, sambaopts=None, versionopts=None, principal=None): + lp = sambaopts.get_loadparm() + net = Net(None, lp) + net.export_keytab(keytab=keytab, principal=principal) diff --git a/python/samba/netcmd/domain/leave.py b/python/samba/netcmd/domain/leave.py new file mode 100644 index 0000000..0d58360 --- /dev/null +++ b/python/samba/netcmd/domain/leave.py @@ -0,0 +1,59 @@ +# domain management - domain leave +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 samba.getopt as options +from samba.net_s3 import Net as s3_Net +from samba.netcmd import Command, Option +from samba.param import default_path +from samba.samba3 import param as s3param + + +class cmd_domain_leave(Command): + """Cause a domain member to leave the joined domain.""" + + synopsis = "%prog [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "credopts": options.CredentialsOptions, + } + + takes_options = [ + Option("--keep-account", action="store_true", + help="Disable the machine account instead of deleting it.") + ] + + takes_args = [] + + def run(self, sambaopts=None, credopts=None, versionopts=None, + keep_account=False): + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp) + + s3_lp = s3param.get_context() + smb_conf = lp.configfile if lp.configfile else default_path() + s3_lp.load(smb_conf) + s3_net = s3_Net(creds, s3_lp) + s3_net.leave(keep_account) diff --git a/python/samba/netcmd/domain/level.py b/python/samba/netcmd/domain/level.py new file mode 100644 index 0000000..eefe360 --- /dev/null +++ b/python/samba/netcmd/domain/level.py @@ -0,0 +1,250 @@ +# domain management - domain level +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 ldb +import samba.getopt as options +from samba.auth import system_session +from samba.dsdb import check_and_update_fl, DS_DOMAIN_FUNCTION_2000 +from samba.netcmd import Command, CommandError, Option +from samba.samdb import SamDB + +from samba import functional_level + + +class cmd_domain_level(Command): + """Raise domain and forest function levels.""" + + synopsis = "%prog (show|raise <options>) [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "versionopts": options.VersionOptions, + } + + takes_options = [ + Option("-H", "--URL", help="LDB URL for database or target server", type=str, + metavar="URL", dest="H"), + Option("-q", "--quiet", help="Be quiet", action="store_true"), # unused + Option("--forest-level", type="choice", choices=["2003", "2008", "2008_R2", "2012", "2012_R2", "2016"], + help="The forest function level (2003 | 2008 | 2008_R2 | 2012 | 2012_R2 | 2016)"), + Option("--domain-level", type="choice", choices=["2003", "2008", "2008_R2", "2012", "2012_R2", "2016"], + help="The domain function level (2003 | 2008 | 2008_R2 | 2012 | 2012_R2 | 2016)") + ] + + takes_args = ["subcommand"] + + def run(self, subcommand, H=None, forest_level=None, domain_level=None, + quiet=False, credopts=None, sambaopts=None, versionopts=None): + if subcommand not in ["show", "raise"]: + raise CommandError("invalid argument: '%s' (choose from 'show', 'raise')" % subcommand) + + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp, fallback_machine=True) + + samdb = SamDB(url=H, session_info=system_session(), + credentials=creds, lp=lp) + + domain_dn = samdb.domain_dn() + + in_transaction = False + if subcommand == "raise" and (H is None or not H.startswith("ldap")): + samdb.transaction_start() + in_transaction = True + try: + check_and_update_fl(samdb, lp) + except Exception as e: + samdb.transaction_cancel() + raise e + + try: + res_forest = samdb.search("CN=Partitions,%s" % samdb.get_config_basedn(), + scope=ldb.SCOPE_BASE, attrs=["msDS-Behavior-Version"]) + assert len(res_forest) == 1 + + res_domain = samdb.search(domain_dn, scope=ldb.SCOPE_BASE, + attrs=["msDS-Behavior-Version", "nTMixedDomain"]) + assert len(res_domain) == 1 + + res_domain_cross = samdb.search("CN=Partitions,%s" % samdb.get_config_basedn(), + scope=ldb.SCOPE_SUBTREE, + expression="(&(objectClass=crossRef)(nCName=%s))" % domain_dn, + attrs=["msDS-Behavior-Version"]) + assert len(res_domain_cross) == 1 + + res_dc_s = samdb.search("CN=Sites,%s" % samdb.get_config_basedn(), + scope=ldb.SCOPE_SUBTREE, expression="(objectClass=nTDSDSA)", + attrs=["msDS-Behavior-Version"]) + assert len(res_dc_s) >= 1 + + # default values, since "msDS-Behavior-Version" does not exist on Windows 2000 AD + level_forest = DS_DOMAIN_FUNCTION_2000 + level_domain = DS_DOMAIN_FUNCTION_2000 + + if "msDS-Behavior-Version" in res_forest[0]: + level_forest = int(res_forest[0]["msDS-Behavior-Version"][0]) + if "msDS-Behavior-Version" in res_domain[0]: + level_domain = int(res_domain[0]["msDS-Behavior-Version"][0]) + level_domain_mixed = int(res_domain[0]["nTMixedDomain"][0]) + + min_level_dc = None + for msg in res_dc_s: + if "msDS-Behavior-Version" in msg: + if min_level_dc is None or int(msg["msDS-Behavior-Version"][0]) < min_level_dc: + min_level_dc = int(msg["msDS-Behavior-Version"][0]) + else: + min_level_dc = DS_DOMAIN_FUNCTION_2000 + # well, this is the least + break + + if level_forest < DS_DOMAIN_FUNCTION_2000 or level_domain < DS_DOMAIN_FUNCTION_2000: + raise CommandError("Domain and/or forest function level(s) is/are invalid. Correct them or reprovision!") + if min_level_dc < DS_DOMAIN_FUNCTION_2000: + raise CommandError("Lowest function level of a DC is invalid. Correct this or reprovision!") + if level_forest > level_domain: + raise CommandError("Forest function level is higher than the domain level(s). Correct this or reprovision!") + if level_domain > min_level_dc: + raise CommandError("Domain function level is higher than the lowest function level of a DC. Correct this or reprovision!") + except Exception as e: + if in_transaction: + samdb.transaction_cancel() + raise e + + def do_show(): + self.message("Domain and forest function level for domain '%s'" % domain_dn) + if level_forest == DS_DOMAIN_FUNCTION_2000 and level_domain_mixed != 0: + self.message("\nATTENTION: You run SAMBA 4 on a forest function level lower than Windows 2000 (Native). This isn't supported! Please raise!") + if level_domain == DS_DOMAIN_FUNCTION_2000 and level_domain_mixed != 0: + self.message("\nATTENTION: You run SAMBA 4 on a domain function level lower than Windows 2000 (Native). This isn't supported! Please raise!") + if min_level_dc == DS_DOMAIN_FUNCTION_2000 and level_domain_mixed != 0: + self.message("\nATTENTION: You run SAMBA 4 on a lowest function level of a DC lower than Windows 2003. This isn't supported! Please step-up or upgrade the concerning DC(s)!") + + self.message("") + + outstr = functional_level.level_to_string(level_forest) + self.message("Forest function level: (Windows) " + outstr) + + if level_domain == DS_DOMAIN_FUNCTION_2000 and level_domain_mixed: + outstr = "2000 mixed (NT4 DC support)" + else: + outstr = functional_level.level_to_string(level_domain) + self.message("Domain function level: (Windows) " + outstr) + + outstr = functional_level.level_to_string(min_level_dc) + self.message("Lowest function level of a DC: (Windows) " + outstr) + + def do_raise(): + msgs = [] + + current_level_domain = level_domain + + if domain_level is not None: + try: + new_level_domain = functional_level.string_to_level(domain_level) + except KeyError: + raise CommandError(f"New functional level '{domain_level}' is not known to Samba as an AD functional level") + + if new_level_domain <= level_domain and level_domain_mixed == 0: + raise CommandError("Domain function level can't be smaller than or equal to the actual one!") + if new_level_domain > min_level_dc: + raise CommandError("Domain function level can't be higher than the lowest function level of a DC!") + + # Deactivate mixed/interim domain support + if level_domain_mixed != 0: + # Directly on the base DN + m = ldb.Message() + m.dn = ldb.Dn(samdb, domain_dn) + m["nTMixedDomain"] = ldb.MessageElement("0", + ldb.FLAG_MOD_REPLACE, "nTMixedDomain") + samdb.modify(m) + # Under partitions + m = ldb.Message() + m.dn = res_domain_cross[0].dn + m["nTMixedDomain"] = ldb.MessageElement("0", + ldb.FLAG_MOD_REPLACE, "nTMixedDomain") + try: + samdb.modify(m) + except ldb.LdbError as e: + (enum, emsg) = e.args + if enum != ldb.ERR_UNWILLING_TO_PERFORM: + raise + + # Directly on the base DN + m = ldb.Message() + m.dn = ldb.Dn(samdb, domain_dn) + m["msDS-Behavior-Version"] = ldb.MessageElement( + str(new_level_domain), ldb.FLAG_MOD_REPLACE, + "msDS-Behavior-Version") + samdb.modify(m) + # Under partitions + m = ldb.Message() + m.dn = res_domain_cross[0].dn + m["msDS-Behavior-Version"] = ldb.MessageElement( + str(new_level_domain), ldb.FLAG_MOD_REPLACE, + "msDS-Behavior-Version") + try: + samdb.modify(m) + except ldb.LdbError as e2: + (enum, emsg) = e2.args + if enum != ldb.ERR_UNWILLING_TO_PERFORM: + raise + + current_level_domain = new_level_domain + msgs.append("Domain function level changed!") + + if forest_level is not None: + new_level_forest = functional_level.string_to_level(forest_level) + + if new_level_forest <= level_forest: + raise CommandError("Forest function level can't be smaller than or equal to the actual one!") + if new_level_forest > current_level_domain: + raise CommandError("Forest function level can't be higher than the domain function level(s). Please raise it/them first!") + + m = ldb.Message() + m.dn = ldb.Dn(samdb, "CN=Partitions,%s" % samdb.get_config_basedn()) + m["msDS-Behavior-Version"] = ldb.MessageElement( + str(new_level_forest), ldb.FLAG_MOD_REPLACE, + "msDS-Behavior-Version") + samdb.modify(m) + msgs.append("Forest function level changed!") + msgs.append("All changes applied successfully!") + self.message("\n".join(msgs)) + return + + if subcommand == "show": + assert not in_transaction + do_show() + return + elif subcommand == "raise": + try: + do_raise() + except Exception as e: + if in_transaction: + samdb.transaction_cancel() + raise e + if in_transaction: + samdb.transaction_commit() + return + + raise AssertionError("Internal Error subcommand[%s] not handled" % subcommand) diff --git a/python/samba/netcmd/domain/models/__init__.py b/python/samba/netcmd/domain/models/__init__.py new file mode 100644 index 0000000..8a6b254 --- /dev/null +++ b/python/samba/netcmd/domain/models/__init__.py @@ -0,0 +1,32 @@ +# Unix SMB/CIFS implementation. +# +# Samba domain models. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +from .auth_policy import AuthenticationPolicy +from .auth_silo import AuthenticationSilo +from .claim_type import ClaimType +from .group import Group +from .model import MODELS +from .schema import AttributeSchema, ClassSchema +from .site import Site +from .subnet import Subnet +from .user import User +from .value_type import ValueType diff --git a/python/samba/netcmd/domain/models/auth_policy.py b/python/samba/netcmd/domain/models/auth_policy.py new file mode 100644 index 0000000..c56966c --- /dev/null +++ b/python/samba/netcmd/domain/models/auth_policy.py @@ -0,0 +1,109 @@ +# Unix SMB/CIFS implementation. +# +# Authentication policy model. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +from enum import IntEnum +from ldb import Dn + +from .fields import (BooleanField, EnumField, IntegerField, SDDLField, + StringField) +from .model import Model + +# Ticket-Granting-Ticket lifetimes. +MIN_TGT_LIFETIME = 45 +MAX_TGT_LIFETIME = 2147483647 + + +class StrongNTLMPolicy(IntEnum): + DISABLED = 0 + OPTIONAL = 1 + REQUIRED = 2 + + @classmethod + def get_choices(cls): + return sorted([choice.capitalize() for choice in cls._member_names_]) + + @classmethod + def choices_str(cls): + return ", ".join(cls.get_choices()) + + +class AuthenticationPolicy(Model): + description = StringField("description") + enforced = BooleanField("msDS-AuthNPolicyEnforced") + strong_ntlm_policy = EnumField("msDS-StrongNTLMPolicy", StrongNTLMPolicy) + user_allow_ntlm_network_auth = BooleanField( + "msDS-UserAllowedNTLMNetworkAuthentication") + user_tgt_lifetime = IntegerField("msDS-UserTGTLifetime") + service_allow_ntlm_network_auth = BooleanField( + "msDS-ServiceAllowedNTLMNetworkAuthentication") + service_tgt_lifetime = IntegerField("msDS-ServiceTGTLifetime") + computer_tgt_lifetime = IntegerField("msDS-ComputerTGTLifetime") + user_allowed_to_authenticate_from = SDDLField( + "msDS-UserAllowedToAuthenticateFrom", allow_device_in_sddl=False) + user_allowed_to_authenticate_to = SDDLField( + "msDS-UserAllowedToAuthenticateTo") + service_allowed_to_authenticate_from = SDDLField( + "msDS-ServiceAllowedToAuthenticateFrom", allow_device_in_sddl=False) + service_allowed_to_authenticate_to = SDDLField( + "msDS-ServiceAllowedToAuthenticateTo") + computer_allowed_to_authenticate_to = SDDLField( + "msDS-ComputerAllowedToAuthenticateTo") + + @staticmethod + def get_base_dn(ldb): + """Return the base DN for the AuthenticationPolicy model. + + :param ldb: Ldb connection + :return: Dn object of container + """ + base_dn = ldb.get_config_basedn() + base_dn.add_child( + "CN=AuthN Policies,CN=AuthN Policy Configuration,CN=Services") + return base_dn + + @staticmethod + def get_object_class(): + return "msDS-AuthNPolicy" + + @staticmethod + def lookup(ldb, name): + """Helper function to return auth policy or raise LookupError. + + :param ldb: Ldb connection + :param name: Either DN or name of Authentication Policy + :raises: LookupError if not found + :raises: ValueError if name is not set + """ + if not name: + raise ValueError("Attribute 'name' is required.") + + try: + # It's possible name is already a Dn. + dn = name if isinstance(name, Dn) else Dn(ldb, name) + policy = AuthenticationPolicy.get(ldb, dn=dn) + except ValueError: + policy = AuthenticationPolicy.get(ldb, cn=name) + + if policy is None: + raise LookupError(f"Authentication policy {name} not found.") + + return policy diff --git a/python/samba/netcmd/domain/models/auth_silo.py b/python/samba/netcmd/domain/models/auth_silo.py new file mode 100644 index 0000000..9747671 --- /dev/null +++ b/python/samba/netcmd/domain/models/auth_silo.py @@ -0,0 +1,104 @@ +# Unix SMB/CIFS implementation. +# +# Authentication silo model. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +from ldb import FLAG_MOD_ADD, FLAG_MOD_DELETE, LdbError, Message, MessageElement + +from samba.sd_utils import escaped_claim_id + +from .exceptions import GrantMemberError, RevokeMemberError +from .fields import DnField, BooleanField, StringField +from .model import Model + + +class AuthenticationSilo(Model): + description = StringField("description") + enforced = BooleanField("msDS-AuthNPolicySiloEnforced") + user_authentication_policy = DnField("msDS-UserAuthNPolicy") + service_authentication_policy = DnField("msDS-ServiceAuthNPolicy") + computer_authentication_policy = DnField("msDS-ComputerAuthNPolicy") + members = DnField("msDS-AuthNPolicySiloMembers", many=True) + + @staticmethod + def get_base_dn(ldb): + """Return the base DN for the AuthenticationSilo model. + + :param ldb: Ldb connection + :return: Dn object of container + """ + base_dn = ldb.get_config_basedn() + base_dn.add_child( + "CN=AuthN Silos,CN=AuthN Policy Configuration,CN=Services") + return base_dn + + @staticmethod + def get_object_class(): + return "msDS-AuthNPolicySilo" + + def grant(self, ldb, member): + """Grant a member access to the Authentication Silo. + + Rather than saving the silo object and writing the entire member + list out again, just add one member only. + + :param ldb: Ldb connection + :param member: Member to grant access to silo + """ + # Create a message with only an add member operation. + message = Message(dn=self.dn) + message.add(MessageElement(str(member.dn), FLAG_MOD_ADD, + "msDS-AuthNPolicySiloMembers")) + + # Update authentication silo. + try: + ldb.modify(message) + except LdbError as e: + raise GrantMemberError(f"Failed to grant access to silo member: {e}") + + # If the modify operation was successful refresh members field. + self.refresh(ldb, fields=["members"]) + + def revoke(self, ldb, member): + """Revoke a member from the Authentication Silo. + + Rather than saving the silo object and writing the entire member + list out again, just remove one member only. + + :param ldb: Ldb connection + :param member: Member to revoke from silo + """ + # Create a message with only a remove member operation. + message = Message(dn=self.dn) + message.add(MessageElement(str(member.dn), FLAG_MOD_DELETE, + "msDS-AuthNPolicySiloMembers")) + + # Update authentication silo. + try: + ldb.modify(message) + except LdbError as e: + raise RevokeMemberError(f"Failed to revoke silo member: {e}") + + # If the modify operation was successful refresh members field. + self.refresh(ldb, fields=["members"]) + + def get_authentication_sddl(self): + return ('O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/' + f'AuthenticationSilo == "{escaped_claim_id(self.name)}"))') diff --git a/python/samba/netcmd/domain/models/claim_type.py b/python/samba/netcmd/domain/models/claim_type.py new file mode 100644 index 0000000..7e1c816 --- /dev/null +++ b/python/samba/netcmd/domain/models/claim_type.py @@ -0,0 +1,58 @@ +# Unix SMB/CIFS implementation. +# +# Claim type model. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +from .fields import BooleanField, DnField, IntegerField,\ + PossibleClaimValuesField, StringField +from .model import Model + + +class ClaimType(Model): + enabled = BooleanField("Enabled") + description = StringField("description") + display_name = StringField("displayName") + claim_attribute_source = DnField("msDS-ClaimAttributeSource") + claim_is_single_valued = BooleanField("msDS-ClaimIsSingleValued") + claim_is_value_space_restricted = BooleanField( + "msDS-ClaimIsValueSpaceRestricted") + claim_possible_values = PossibleClaimValuesField("msDS-ClaimPossibleValues") + claim_source_type = StringField("msDS-ClaimSourceType") + claim_type_applies_to_class = DnField( + "msDS-ClaimTypeAppliesToClass", many=True) + claim_value_type = IntegerField("msDS-ClaimValueType") + + @staticmethod + def get_base_dn(ldb): + """Return the base DN for the ClaimType model. + + :param ldb: Ldb connection + :return: Dn object of container + """ + base_dn = ldb.get_config_basedn() + base_dn.add_child("CN=Claim Types,CN=Claims Configuration,CN=Services") + return base_dn + + @staticmethod + def get_object_class(): + return "msDS-ClaimType" + + def __str__(self): + return str(self.display_name) diff --git a/python/samba/netcmd/domain/models/exceptions.py b/python/samba/netcmd/domain/models/exceptions.py new file mode 100644 index 0000000..14ebd77 --- /dev/null +++ b/python/samba/netcmd/domain/models/exceptions.py @@ -0,0 +1,64 @@ +# Unix SMB/CIFS implementation. +# +# Model and ORM exceptions. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +class ModelError(Exception): + pass + + +class FieldError(ModelError): + """A ModelError on a specific field.""" + + def __init__(self, *args, field=None): + self.field = field + super().__init__(*args) + + def __str__(self): + message = super().__str__() + return f"{self.field.name}: {message}" + + +class MultipleObjectsReturned(ModelError): + pass + + +class DoesNotExist(ModelError): + pass + + +class GrantMemberError(ModelError): + pass + + +class RevokeMemberError(ModelError): + pass + + +class ProtectError(ModelError): + pass + + +class UnprotectError(ModelError): + pass + + +class DeleteError(ModelError): + pass diff --git a/python/samba/netcmd/domain/models/fields.py b/python/samba/netcmd/domain/models/fields.py new file mode 100644 index 0000000..0b7e1eb --- /dev/null +++ b/python/samba/netcmd/domain/models/fields.py @@ -0,0 +1,507 @@ +# Unix SMB/CIFS implementation. +# +# Model fields. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +from enum import IntEnum + +import io +from abc import ABCMeta, abstractmethod +from datetime import datetime +from xml.etree import ElementTree + +from ldb import Dn, MessageElement, string_to_time, timestring +from samba.dcerpc import security +from samba.dcerpc.misc import GUID +from samba.ndr import ndr_pack, ndr_unpack + + +class Field(metaclass=ABCMeta): + """Base class for all fields. + + Each field will need to implement from_db_value and to_db_value. + + A field must correctly support converting both single valued fields, + and list type fields. + + The only thing many=True does is say the field "prefers" to be a list, + but really any field can be a list or single value. + """ + + def __init__(self, name, many=False, default=None, hidden=False, + readonly=False): + """Creates a new field, should be subclassed. + + :param name: Ldb field name. + :param many: If true always convert field to a list when loaded. + :param default: Default value or callback method (obj is first argument) + :param hidden: If this is True, exclude the field when calling as_dict() + :param readonly: If true don't write this value when calling save. + """ + self.name = name + self.many = many + self.hidden = hidden + self.readonly = readonly + + # This ensures that fields with many=True are always lists. + # If this is inconsistent anywhere, it isn't so great to use. + if self.many and default is None: + self.default = [] + else: + self.default = default + + @abstractmethod + def from_db_value(self, ldb, value): + """Converts value read from the database to Python value. + + :param ldb: Ldb connection + :param value: MessageElement value from the database + :returns: Parsed value as Python type + """ + pass + + @abstractmethod + def to_db_value(self, ldb, value, flags): + """Converts value to database value. + + This should return a MessageElement or None, where None means + the field will be unset on the next save. + + :param ldb: Ldb connection + :param value: Input value from Python field + :param flags: MessageElement flags + :returns: MessageElement or None + """ + pass + + +class IntegerField(Field): + """A simple integer field, can be an int or list of int.""" + + def from_db_value(self, ldb, value): + """Convert MessageElement to int or list of int.""" + if value is None: + return + elif len(value) > 1 or self.many: + return [int(item) for item in value] + else: + return int(value[0]) + + def to_db_value(self, ldb, value, flags): + """Convert int or list of int to MessageElement.""" + if value is None: + return + elif isinstance(value, list): + return MessageElement( + [str(item) for item in value], flags, self.name) + else: + return MessageElement(str(value), flags, self.name) + + +class BinaryField(Field): + """Similar to StringField but using bytes instead of str. + + This tends to be quite easy because a MessageElement already uses bytes. + """ + + def from_db_value(self, ldb, value): + """Convert MessageElement to bytes or list of bytes. + + The values on the MessageElement should already be bytes so the + cast to bytes() is likely not needed in from_db_value. + """ + if value is None: + return + elif len(value) > 1 or self.many: + return [bytes(item) for item in value] + else: + return bytes(value[0]) + + def to_db_value(self, ldb, value, flags): + """Convert bytes or list of bytes to MessageElement.""" + if value is None: + return + elif isinstance(value, list): + return MessageElement( + [bytes(item) for item in value], flags, self.name) + else: + return MessageElement(bytes(value), flags, self.name) + + +class StringField(Field): + """A simple string field, may contain str or list of str.""" + + def from_db_value(self, ldb, value): + """Convert MessageElement to str or list of str.""" + if value is None: + return + elif len(value) > 1 or self.many: + return [str(item) for item in value] + else: + return str(value) + + def to_db_value(self, ldb, value, flags): + """Convert str or list of str to MessageElement.""" + if value is None: + return + elif isinstance(value, list): + return MessageElement( + [str(item) for item in value], flags, self.name) + else: + return MessageElement(str(value), flags, self.name) + + +class EnumField(Field): + """A field based around Python's Enum type.""" + + def __init__(self, name, enum, many=False, default=None): + """Create a new EnumField for the given enum class.""" + self.enum = enum + super().__init__(name, many, default) + + def enum_from_value(self, value): + """Return Enum instance from value. + + Has a special case for IntEnum as the constructor only accepts int. + """ + if issubclass(self.enum, IntEnum): + return self.enum(int(str(value))) + else: + return self.enum(str(value)) + + def from_db_value(self, ldb, value): + """Convert MessageElement to enum or list of enum.""" + if value is None: + return + elif len(value) > 1 or self.many: + return [self.enum_from_value(item) for item in value] + else: + return self.enum_from_value(value) + + def to_db_value(self, ldb, value, flags): + """Convert enum or list of enum to MessageElement.""" + if value is None: + return + elif isinstance(value, list): + return MessageElement( + [str(item.value) for item in value], flags, self.name) + else: + return MessageElement(str(value.value), flags, self.name) + + +class DateTimeField(Field): + """A field for parsing ldb timestamps into Python datetime.""" + + def from_db_value(self, ldb, value): + """Convert MessageElement to datetime or list of datetime.""" + if value is None: + return + elif len(value) > 1 or self.many: + return [datetime.fromtimestamp(string_to_time(str(item))) + for item in value] + else: + return datetime.fromtimestamp(string_to_time(str(value))) + + def to_db_value(self, ldb, value, flags): + """Convert datetime or list of datetime to MessageElement.""" + if value is None: + return + elif isinstance(value, list): + return MessageElement( + [timestring(int(datetime.timestamp(item))) for item in value], + flags, self.name) + else: + return MessageElement(timestring(int(datetime.timestamp(value))), + flags, self.name) + + +class RelatedField(Field): + """A field that automatically fetches the related objects. + + Use sparingly, can be a little slow. If in doubt just use DnField instead. + """ + + def __init__(self, name, model, many=False, default=None): + """Create a new RelatedField for the given model.""" + self.model = model + super().__init__(name, many, default) + + def from_db_value(self, ldb, value): + """Convert Message element to related object or list of objects. + + Note that fetching related items is not using any sort of lazy + loading so use this field sparingly. + """ + if value is None: + return + elif len(value) > 1 or self.many: + return [self.model.get(ldb, dn=Dn(ldb, str(item))) for item in value] + else: + return self.model.get(ldb, dn=Dn(ldb, str(value))) + + def to_db_value(self, ldb, value, flags): + """Convert related object or list of objects to MessageElement.""" + if value is None: + return + elif isinstance(value, list): + return MessageElement( + [str(item.dn) for item in value], flags, self.name) + else: + return MessageElement(str(value.dn), flags, self.name) + + +class DnField(Field): + """A Dn field parses the current field into a Dn object.""" + + def from_db_value(self, ldb, value): + """Convert MessageElement to a Dn object or list of Dn objects.""" + if value is None: + return + elif isinstance(value, Dn): + return value + elif len(value) > 1 or self.many: + return [Dn(ldb, str(item)) for item in value] + else: + return Dn(ldb, str(value)) + + def to_db_value(self, ldb, value, flags): + """Convert Dn object or list of Dn objects into a MessageElement.""" + if value is None: + return + elif isinstance(value, list): + return MessageElement( + [str(item) for item in value], flags, self.name) + else: + return MessageElement(str(value), flags, self.name) + + +class GUIDField(Field): + """A GUID field decodes fields containing binary GUIDs.""" + + def from_db_value(self, ldb, value): + """Convert MessageElement with a GUID into a str or list of str.""" + if value is None: + return + elif len(value) > 1 or self.many: + return [str(ndr_unpack(GUID, item)) for item in value] + else: + return str(ndr_unpack(GUID, value[0])) + + def to_db_value(self, ldb, value, flags): + """Convert str with GUID into MessageElement.""" + if value is None: + return + elif isinstance(value, list): + return MessageElement( + [ndr_pack(GUID(item)) for item in value], flags, self.name) + else: + return MessageElement(ndr_pack(GUID(value)), flags, self.name) + + +class SIDField(Field): + """A SID field encodes and decodes SID data.""" + + def from_db_value(self, ldb, value): + """Convert MessageElement with a GUID into a str or list of str.""" + if value is None: + return + elif len(value) > 1 or self.many: + return [str(ndr_unpack(security.dom_sid, item)) for item in value] + else: + return str(ndr_unpack(security.dom_sid, value[0])) + + def to_db_value(self, ldb, value, flags): + """Convert str with GUID into MessageElement.""" + if value is None: + return + elif isinstance(value, list): + return MessageElement( + [ndr_pack(security.dom_sid(item)) for item in value], + flags, self.name) + else: + return MessageElement(ndr_pack(security.dom_sid(value)), + flags, self.name) + + +class SDDLField(Field): + """A SDDL field encodes and decodes SDDL data.""" + + def __init__(self, + name, + *, + many=False, + default=None, + hidden=False, + allow_device_in_sddl=True): + """Create a new SDDLField.""" + self.allow_device_in_sddl = allow_device_in_sddl + super().__init__(name, many=many, default=default, hidden=hidden) + + def from_db_value(self, ldb, value): + if value is None: + return + elif len(value) > 1 or self.many: + return [ndr_unpack(security.descriptor, item).as_sddl() + for item in value] + else: + return ndr_unpack(security.descriptor, value[0]).as_sddl() + + def to_db_value(self, ldb, value, flags): + domain_sid = security.dom_sid(ldb.get_domain_sid()) + if value is None: + return + elif isinstance(value, list): + return MessageElement([ndr_pack(security.descriptor.from_sddl( + item, + domain_sid, + allow_device_in_sddl=self.allow_device_in_sddl)) + for item in value], + flags, + self.name) + else: + return MessageElement( + ndr_pack(security.descriptor.from_sddl( + value, + domain_sid, + allow_device_in_sddl=self.allow_device_in_sddl)), + flags, + self.name + ) + + +class BooleanField(Field): + """A simple boolean field, can be a bool or list of bool.""" + + def from_db_value(self, ldb, value): + """Convert MessageElement into a bool or list of bool.""" + if value is None: + return + elif len(value) > 1 or self.many: + return [str(item) == "TRUE" for item in value] + else: + return str(value) == "TRUE" + + def to_db_value(self, ldb, value, flags): + """Convert bool or list of bool into a MessageElement.""" + if value is None: + return + elif isinstance(value, list): + return MessageElement( + [str(bool(item)).upper() for item in value], flags, self.name) + else: + return MessageElement(str(bool(value)).upper(), flags, self.name) + + +class PossibleClaimValuesField(Field): + """Field for parsing possible values XML for claim types. + + This field will be represented by a list of dicts as follows: + + [ + {"ValueGUID": <GUID>}, + {"ValueDisplayName: "Display name"}, + {"ValueDescription: "Optional description or None for no description"}, + {"Value": <Value>}, + ] + + Note that the GUID needs to be created client-side when adding entries, + leaving it as None then saving it doesn't generate the GUID. + + The field itself just converts the XML to list and vice versa, it doesn't + automatically generate GUIDs for entries, this is entirely up to the caller. + """ + + # Namespaces for PossibleValues xml parsing. + NAMESPACE = { + "xsd": "http://www.w3.org/2001/XMLSchema", + "xsi": "http://www.w3.org/2001/XMLSchema-instance", + "": "http://schemas.microsoft.com/2010/08/ActiveDirectory/PossibleValues" + } + + def from_db_value(self, ldb, value): + """Parse MessageElement with XML to list of dicts.""" + if value is not None: + root = ElementTree.fromstring(str(value)) + string_list = root.find("StringList", self.NAMESPACE) + + values = [] + for item in string_list.findall("Item", self.NAMESPACE): + values.append({ + "ValueGUID": item.find("ValueGUID", self.NAMESPACE).text, + "ValueDisplayName": item.find("ValueDisplayName", + self.NAMESPACE).text, + "ValueDescription": item.find("ValueDescription", + self.NAMESPACE).text, + "Value": item.find("Value", self.NAMESPACE).text, + }) + + return values + + def to_db_value(self, ldb, value, flags): + """Convert list of dicts back to XML as a MessageElement.""" + if value is None: + return + + # Possible values should always be a list of dict, but for consistency + # with other fields just wrap a single value into a list and continue. + if isinstance(value, list): + possible_values = value + else: + possible_values = [value] + + # No point storing XML of an empty list. + # Return None, the field will be unset on the next save. + if len(possible_values) == 0: + return + + # root node + root = ElementTree.Element("PossibleClaimValues") + for name, url in self.NAMESPACE.items(): + if name == "": + root.set("xmlns", url) + else: + root.set(f"xmlns:{name}", url) + + # StringList node + string_list = ElementTree.SubElement(root, "StringList") + + # List of values + for item_dict in possible_values: + item = ElementTree.SubElement(string_list, "Item") + item_guid = ElementTree.SubElement(item, "ValueGUID") + item_guid.text = item_dict["ValueGUID"] + item_name = ElementTree.SubElement(item, "ValueDisplayName") + item_name.text = item_dict["ValueDisplayName"] + item_desc = ElementTree.SubElement(item, "ValueDescription") + item_desc.text = item_dict["ValueDescription"] + item_value = ElementTree.SubElement(item, "Value") + item_value.text = item_dict["Value"] + + # NOTE: indent was only added in Python 3.9 so can't be used yet. + # ElementTree.indent(root, space="\t", level=0) + + out = io.BytesIO() + ElementTree.ElementTree(root).write(out, + encoding="utf-16", + xml_declaration=True, + short_empty_elements=False) + + # Back to str as that is what MessageElement needs. + return MessageElement(out.getvalue().decode("utf-16"), flags, self.name) diff --git a/python/samba/netcmd/domain/models/group.py b/python/samba/netcmd/domain/models/group.py new file mode 100644 index 0000000..9473127 --- /dev/null +++ b/python/samba/netcmd/domain/models/group.py @@ -0,0 +1,42 @@ +# Unix SMB/CIFS implementation. +# +# Group model. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +from .fields import BooleanField, DnField, IntegerField, SIDField, StringField +from .model import Model + + +class Group(Model): + admin_count = IntegerField("adminCount") + description = StringField("description") + is_critical_system_object = BooleanField("isCriticalSystemObject", + default=False, readonly=True) + member = DnField("member", many=True) + object_sid = SIDField("objectSid") + system_flags = IntegerField("systemFlags") + + @staticmethod + def get_object_class(): + return "group" + + def get_authentication_sddl(self): + return "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of_any {SID(%s)}))" % ( + self.object_sid) diff --git a/python/samba/netcmd/domain/models/model.py b/python/samba/netcmd/domain/models/model.py new file mode 100644 index 0000000..602c6ca --- /dev/null +++ b/python/samba/netcmd/domain/models/model.py @@ -0,0 +1,426 @@ +# Unix SMB/CIFS implementation. +# +# Model and basic ORM for the Ldb database. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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 inspect +from abc import ABCMeta, abstractmethod + +from ldb import ERR_NO_SUCH_OBJECT, FLAG_MOD_ADD, FLAG_MOD_REPLACE, LdbError,\ + Message, MessageElement, SCOPE_BASE, SCOPE_SUBTREE, binary_encode +from samba.sd_utils import SDUtils + +from .exceptions import DeleteError, DoesNotExist, FieldError,\ + ProtectError, UnprotectError +from .fields import DateTimeField, DnField, Field, GUIDField, IntegerField,\ + StringField +from .query import Query + +# Keeps track of registered models. +# This gets populated by the ModelMeta class. +MODELS = {} + + +class ModelMeta(ABCMeta): + + def __new__(mcls, name, bases, namespace, **kwargs): + cls = super().__new__(mcls, name, bases, namespace, **kwargs) + + if cls.__name__ != "Model": + cls.fields = dict(inspect.getmembers(cls, lambda f: isinstance(f, Field))) + cls.meta = mcls + MODELS[name] = cls + + return cls + + +class Model(metaclass=ModelMeta): + cn = StringField("cn") + distinguished_name = DnField("distinguishedName") + dn = DnField("dn") + ds_core_propagation_data = DateTimeField("dsCorePropagationData", + hidden=True) + instance_type = IntegerField("instanceType") + name = StringField("name") + object_category = DnField("objectCategory") + object_class = StringField("objectClass", + default=lambda obj: obj.get_object_class()) + object_guid = GUIDField("objectGUID") + usn_changed = IntegerField("uSNChanged", hidden=True) + usn_created = IntegerField("uSNCreated", hidden=True) + when_changed = DateTimeField("whenChanged", hidden=True) + when_created = DateTimeField("whenCreated", hidden=True) + + def __init__(self, **kwargs): + """Create a new model instance and optionally populate fields. + + Does not save the object to the database, call .save() for that. + + :param kwargs: Optional input fields to populate object with + """ + # Used by the _apply method, holds the original ldb Message, + # which is used by save() to determine what fields changed. + self._message = None + + for field_name, field in self.fields.items(): + if field_name in kwargs: + default = kwargs[field_name] + elif callable(field.default): + default = field.default(self) + else: + default = field.default + + setattr(self, field_name, default) + + def __repr__(self): + """Return object representation for this model.""" + return f"<{self.__class__.__name__}: {self}>" + + def __str__(self): + """Stringify model instance to implement in each model.""" + return str(self.cn) + + def __eq__(self, other): + """Basic object equality check only really checks if the dn matches. + + :param other: The other object to compare with + """ + if other is None: + return False + else: + return self.dn == other.dn + + def __json__(self): + """Automatically called by custom JSONEncoder class. + + When turning an object into json any fields of type RelatedField + will also end up calling this method. + """ + if self.dn is not None: + return str(self.dn) + + @staticmethod + def get_base_dn(ldb): + """Return the base DN for the container of this model. + + :param ldb: Ldb connection + :return: Dn to use for new objects + """ + return ldb.get_default_basedn() + + @classmethod + def get_search_dn(cls, ldb): + """Return the DN used for querying. + + By default, this just calls get_base_dn, but it is possible to + return a different Dn for querying. + + :param ldb: Ldb connection + :return: Dn to use for searching + """ + return cls.get_base_dn(ldb) + + @staticmethod + @abstractmethod + def get_object_class(): + """Returns the objectClass for this model.""" + pass + + @classmethod + def from_message(cls, ldb, message): + """Create a new model instance from the Ldb Message object. + + :param ldb: Ldb connection + :param message: Ldb Message object to create instance from + """ + obj = cls() + obj._apply(ldb, message) + return obj + + def _apply(self, ldb, message): + """Internal method to apply Ldb Message to current object. + + :param ldb: Ldb connection + :param message: Ldb Message object to apply + """ + # Store the ldb Message so that in save we can see what changed. + self._message = message + + for attr, field in self.fields.items(): + if field.name in message: + setattr(self, attr, field.from_db_value(ldb, message[field.name])) + + def refresh(self, ldb, fields=None): + """Refresh object from database. + + :param ldb: Ldb connection + :param fields: Optional list of field names to refresh + """ + attrs = [self.fields[f].name for f in fields] if fields else None + + # This shouldn't normally happen but in case the object refresh fails. + try: + res = ldb.search(self.dn, scope=SCOPE_BASE, attrs=attrs) + except LdbError as e: + if e.args[0] == ERR_NO_SUCH_OBJECT: + raise DoesNotExist(f"Refresh failed, object gone: {self.dn}") + raise + + self._apply(ldb, res[0]) + + def as_dict(self, include_hidden=False): + """Returns a dict representation of the model. + + :param include_hidden: Include fields with hidden=True when set + :returns: dict representation of model using Ldb field names as keys + """ + obj_dict = {} + + for attr, field in self.fields.items(): + if not field.hidden or include_hidden: + value = getattr(self, attr) + if value is not None: + obj_dict[field.name] = value + + return obj_dict + + @classmethod + def build_expression(cls, **kwargs): + """Build LDAP search expression from kwargs. + + :kwargs: fields to use for expression using model field names + """ + # Take a copy, never modify the original if it can be avoided. + # Then always add the object_class to the search criteria. + criteria = dict(kwargs) + criteria["object_class"] = cls.get_object_class() + + # Build search expression. + num_fields = len(criteria) + expression = "" if num_fields == 1 else "(&" + + for field_name, value in criteria.items(): + field = cls.fields.get(field_name) + if not field: + raise ValueError(f"Unknown field '{field_name}'") + expression += f"({field.name}={binary_encode(value)})" + + if num_fields > 1: + expression += ")" + + return expression + + @classmethod + def query(cls, ldb, **kwargs): + """Returns a search query for this model. + + :param ldb: Ldb connection + :param kwargs: Search criteria as keyword args + """ + base_dn = cls.get_search_dn(ldb) + + # If the container does not exist produce a friendly error message. + try: + result = ldb.search(base_dn, + scope=SCOPE_SUBTREE, + expression=cls.build_expression(**kwargs)) + except LdbError as e: + if e.args[0] == ERR_NO_SUCH_OBJECT: + raise DoesNotExist(f"Container does not exist: {base_dn}") + raise + + return Query(cls, ldb, result) + + @classmethod + def get(cls, ldb, **kwargs): + """Get one object, must always return one item. + + Either find object by dn=, or any combination of attributes via kwargs. + If there are more than one result, MultipleObjectsReturned is raised. + + :param ldb: Ldb connection + :param kwargs: Search criteria as keyword args + :returns: Model instance or None if not found + :raises: MultipleObjects returned if there are more than one results + """ + # If a DN is provided use that to get the object directly. + # Otherwise, build a search expression using kwargs provided. + dn = kwargs.get("dn") + + if dn: + # Handle LDAP error 32 LDAP_NO_SUCH_OBJECT, but raise for the rest. + # Return None if the User does not exist. + try: + res = ldb.search(dn, scope=SCOPE_BASE) + except LdbError as e: + if e.args[0] == ERR_NO_SUCH_OBJECT: + return None + else: + raise + + return cls.from_message(ldb, res[0]) + else: + return cls.query(ldb, **kwargs).get() + + @classmethod + def create(cls, ldb, **kwargs): + """Create object constructs object and calls save straight after. + + :param ldb: Ldb connection + :param kwargs: Fields to populate object from + :returns: object + """ + obj = cls(**kwargs) + obj.save(ldb) + return obj + + @classmethod + def get_or_create(cls, ldb, defaults=None, **kwargs): + """Retrieve object and if it doesn't exist create a new instance. + + :param ldb: Ldb connection + :param defaults: Attributes only used for create but not search + :param kwargs: Attributes used for searching existing object + :returns: (object, bool created) + """ + obj = cls.get(ldb, **kwargs) + if obj is None: + attrs = dict(kwargs) + if defaults is not None: + attrs.update(defaults) + return cls.create(ldb, **attrs), True + else: + return obj, False + + def save(self, ldb): + """Save model to Ldb database. + + The save operation will save all fields excluding fields that + return None when calling their `to_db_value` methods. + + The `to_db_value` method can either return a ldb Message object, + or None if the field is to be excluded. + + For updates, the existing object is fetched and only fields + that are changed are included in the update ldb Message. + + Also for updates, any fields that currently have a value, + but are to be set to None will be seen as a delete operation. + + After the save operation the object is refreshed from the server, + as often the server will populate some fields. + + :param ldb: Ldb connection + """ + if self.dn is None: + dn = self.get_base_dn(ldb) + dn.add_child(f"CN={self.cn or self.name}") + self.dn = dn + + message = Message(dn=self.dn) + for attr, field in self.fields.items(): + if attr != "dn" and not field.readonly: + value = getattr(self, attr) + try: + db_value = field.to_db_value(ldb, value, FLAG_MOD_ADD) + except ValueError as e: + raise FieldError(e, field=field) + + # Don't add empty fields. + if db_value is not None and len(db_value): + message.add(db_value) + + # Create object + ldb.add(message) + + # Fetching object refreshes any automatically populated fields. + res = ldb.search(dn, scope=SCOPE_BASE) + self._apply(ldb, res[0]) + else: + # Existing Message was stored to work out what fields changed. + existing_obj = self.from_message(ldb, self._message) + + # Only modify replace or modify fields that have changed. + # Any fields that are set to None or an empty list get unset. + message = Message(dn=self.dn) + for attr, field in self.fields.items(): + if attr != "dn" and not field.readonly: + value = getattr(self, attr) + old_value = getattr(existing_obj, attr) + + if value != old_value: + try: + db_value = field.to_db_value(ldb, value, + FLAG_MOD_REPLACE) + except ValueError as e: + raise FieldError(e, field=field) + + # When a field returns None or empty list, delete attr. + if db_value in (None, []): + db_value = MessageElement([], + FLAG_MOD_REPLACE, + field.name) + message.add(db_value) + + # Saving nothing only triggers an error. + if len(message): + ldb.modify(message) + + # Fetching object refreshes any automatically populated fields. + self.refresh(ldb) + + def delete(self, ldb): + """Delete item from Ldb database. + + If self.dn is None then the object has not yet been saved. + + :param ldb: Ldb connection + """ + if self.dn is None: + raise DeleteError("Cannot delete object that doesn't have a dn.") + + try: + ldb.delete(self.dn) + except LdbError as e: + raise DeleteError(f"Delete failed: {e}") + + def protect(self, ldb): + """Protect object from accidental deletion. + + :param ldb: Ldb connection + """ + utils = SDUtils(ldb) + + try: + utils.dacl_add_ace(self.dn, "(D;;DTSD;;;WD)") + except LdbError as e: + raise ProtectError(f"Failed to protect object: {e}") + + def unprotect(self, ldb): + """Unprotect object from accidental deletion. + + :param ldb: Ldb connection + """ + utils = SDUtils(ldb) + + try: + utils.dacl_delete_aces(self.dn, "(D;;DTSD;;;WD)") + except LdbError as e: + raise UnprotectError(f"Failed to unprotect object: {e}") diff --git a/python/samba/netcmd/domain/models/query.py b/python/samba/netcmd/domain/models/query.py new file mode 100644 index 0000000..9cdb650 --- /dev/null +++ b/python/samba/netcmd/domain/models/query.py @@ -0,0 +1,81 @@ +# Unix SMB/CIFS implementation. +# +# Query class for the ORM to the Ldb database. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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 re + +from .exceptions import DoesNotExist, MultipleObjectsReturned + +RE_SPLIT_CAMELCASE = re.compile(r"[A-Z](?:[a-z]+|[A-Z]*(?=[A-Z]|$))") + + +class Query: + """Simple Query class used by the `Model.query` method.""" + + def __init__(self, model, ldb, result): + self.model = model + self.ldb = ldb + self.result = result + self.count = result.count + self.name = " ".join(RE_SPLIT_CAMELCASE.findall(model.__name__)).lower() + + def __iter__(self): + """Loop over Query class yields Model instances.""" + for message in self.result: + yield self.model.from_message(self.ldb, message) + + def first(self): + """Returns the first item in the Query or None for no results.""" + if self.result.count: + return self.model.from_message(self.ldb, self.result[0]) + + def last(self): + """Returns the last item in the Query or None for no results.""" + if self.result.count: + return self.model.from_message(self.ldb, self.result[-1]) + + def get(self): + """Returns one item or None if no results were found. + + :returns: Model instance or None if not found. + :raises MultipleObjectsReturned: if more than one results were returned + """ + if self.count > 1: + raise MultipleObjectsReturned( + f"More than one {self.name} objects returned (got {self.count}).") + elif self.count: + return self.model.from_message(self.ldb, self.result[0]) + + def one(self): + """Must return EXACTLY one item or raise an exception. + + :returns: Model instance + :raises DoesNotExist: if no results were returned + :raises MultipleObjectsReturned: if more than one results were returned + """ + if self.count < 1: + raise DoesNotExist( + f"{self.name.capitalize()} matching query not found") + elif self.count > 1: + raise MultipleObjectsReturned( + f"More than one {self.name} objects returned (got {self.count}).") + else: + return self.model.from_message(self.ldb, self.result[0]) diff --git a/python/samba/netcmd/domain/models/schema.py b/python/samba/netcmd/domain/models/schema.py new file mode 100644 index 0000000..59ece05 --- /dev/null +++ b/python/samba/netcmd/domain/models/schema.py @@ -0,0 +1,124 @@ +# Unix SMB/CIFS implementation. +# +# Class and attribute schema models. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +from .fields import BinaryField, BooleanField, DnField, GUIDField,\ + IntegerField, StringField +from .model import Model + + +class ClassSchema(Model): + default_object_category = DnField("defaultObjectCategory") + governs_id = StringField("governsID") + schema_id_guid = GUIDField("schemaIDGUID") + subclass_of = StringField("subclassOf") + admin_description = StringField("adminDescription") + admin_display_name = StringField("adminDisplayName") + default_hiding_value = BooleanField("defaultHidingValue") + default_security_descriptor = BinaryField("defaultSecurityDescriptor") + ldap_display_name = StringField("lDAPDisplayName") + may_contain = StringField("mayContain", many=True) + poss_superiors = StringField("possSuperiors", many=True) + rdn_att_id = StringField("rDNAttID") + show_in_advanced_view_only = BooleanField("showInAdvancedViewOnly") + system_only = BooleanField("systemOnly", readonly=True) + + @staticmethod + def get_base_dn(ldb): + """Return the base DN for the ClassSchema model. + + This is the same as AttributeSchema, but the objectClass is different. + + :param ldb: Ldb connection + :return: Dn object of container + """ + return ldb.get_schema_basedn() + + @staticmethod + def get_object_class(): + return "classSchema" + + @classmethod + def lookup(cls, ldb, name): + """Helper function to lookup class or raise LookupError. + + :param ldb: Ldb connection + :param name: Class name + :raises: LookupError if not found + :raises: ValueError if name is not provided + """ + if not name: + raise ValueError("Class name is required.") + + attr = cls.get(ldb, ldap_display_name=name) + if attr is None: + raise LookupError(f"Could not locate {name} in class schema.") + + return attr + + +class AttributeSchema(Model): + attribute_id = StringField("attributeID") + attribute_syntax = StringField("attributeSyntax") + is_single_valued = BooleanField("isSingleValued") + ldap_display_name = StringField("lDAPDisplayName") + om_syntax = IntegerField("oMSyntax") + admin_description = StringField("adminDescription") + admin_display_name = StringField("adminDisplayName") + attribute_security_guid = GUIDField("attributeSecurityGUID") + schema_flags_ex = IntegerField("schemaFlagsEx") + search_flags = IntegerField("searchFlags") + show_in_advanced_view_only = BooleanField("showInAdvancedViewOnly") + system_flags = IntegerField("systemFlags", readonly=True) + system_only = BooleanField("systemOnly", readonly=True) + + @staticmethod + def get_base_dn(ldb): + """Return the base DN for the AttributeSchema model. + + This is the same as ClassSchema, but the objectClass is different. + + :param ldb: Ldb connection + :return: Dn object of container + """ + return ldb.get_schema_basedn() + + @staticmethod + def get_object_class(): + return "attributeSchema" + + @classmethod + def lookup(cls, ldb, name): + """Helper function to lookup attribute or raise LookupError. + + :param ldb: Ldb connection + :param name: Attribute name + :raises: LookupError if not found + :raises: ValueError if name is not provided + """ + if not name: + raise ValueError("Attribute name is required.") + + attr = cls.get(ldb, ldap_display_name=name) + if attr is None: + raise LookupError(f"Could not locate {name} in attribute schema.") + + return attr diff --git a/python/samba/netcmd/domain/models/site.py b/python/samba/netcmd/domain/models/site.py new file mode 100644 index 0000000..44643f3 --- /dev/null +++ b/python/samba/netcmd/domain/models/site.py @@ -0,0 +1,47 @@ +# Unix SMB/CIFS implementation. +# +# Site model. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +from .fields import BooleanField, DnField, IntegerField +from .model import Model + + +class Site(Model): + show_in_advanced_view_only = BooleanField("showInAdvancedViewOnly") + system_flags = IntegerField("systemFlags", readonly=True) + + # Backlinks + site_object_bl = DnField("siteObjectBL", readonly=True) + + @staticmethod + def get_base_dn(ldb): + """Return the base DN for the Site model. + + :param ldb: Ldb connection + :return: Dn to use for new objects + """ + base_dn = ldb.get_config_basedn() + base_dn.add_child("CN=Sites") + return base_dn + + @staticmethod + def get_object_class(): + return "site" diff --git a/python/samba/netcmd/domain/models/subnet.py b/python/samba/netcmd/domain/models/subnet.py new file mode 100644 index 0000000..bb249d4 --- /dev/null +++ b/python/samba/netcmd/domain/models/subnet.py @@ -0,0 +1,45 @@ +# Unix SMB/CIFS implementation. +# +# Subnet model. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +from .fields import BooleanField, DnField, IntegerField +from .model import Model + + +class Subnet(Model): + show_in_advanced_view_only = BooleanField("showInAdvancedViewOnly") + site_object = DnField("siteObject") + system_flags = IntegerField("systemFlags", readonly=True) + + @staticmethod + def get_base_dn(ldb): + """Return the base DN for the Subnet model. + + :param ldb: Ldb connection + :return: Dn to use for new objects + """ + base_dn = ldb.get_config_basedn() + base_dn.add_child("CN=Subnets,CN=Sites") + return base_dn + + @staticmethod + def get_object_class(): + return "subnet" diff --git a/python/samba/netcmd/domain/models/user.py b/python/samba/netcmd/domain/models/user.py new file mode 100644 index 0000000..7b0785a --- /dev/null +++ b/python/samba/netcmd/domain/models/user.py @@ -0,0 +1,75 @@ +# Unix SMB/CIFS implementation. +# +# User model. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +from ldb import Dn + +from samba.dsdb import DS_GUID_USERS_CONTAINER + +from .fields import DnField, SIDField, StringField +from .model import Model + + +class User(Model): + username = StringField("sAMAccountName") + assigned_policy = DnField("msDS-AssignedAuthNPolicy") + assigned_silo = DnField("msDS-AssignedAuthNPolicySilo") + object_sid = SIDField("objectSid") + + def __str__(self): + """Return username rather than cn for User model.""" + return self.username + + @staticmethod + def get_base_dn(ldb): + """Return the base DN for the User model. + + :param ldb: Ldb connection + :return: Dn to use for new objects + """ + return ldb.get_wellknown_dn(ldb.get_default_basedn(), + DS_GUID_USERS_CONTAINER) + + @classmethod + def get_search_dn(cls, ldb): + """Return Dn used for searching so Computers will also be found. + + :param ldb: Ldb connection + :return: Dn to use for searching + """ + return ldb.get_root_basedn() + + @staticmethod + def get_object_class(): + return "user" + + @classmethod + def find(cls, ldb, name): + """Helper function to find a user first by Dn then username. + + If the Dn can't be parsed, use sAMAccountName instead. + """ + try: + query = {"dn": Dn(ldb, name)} + except ValueError: + query = {"username": name} + + return cls.get(ldb, **query) diff --git a/python/samba/netcmd/domain/models/value_type.py b/python/samba/netcmd/domain/models/value_type.py new file mode 100644 index 0000000..00a4e07 --- /dev/null +++ b/python/samba/netcmd/domain/models/value_type.py @@ -0,0 +1,96 @@ +# Unix SMB/CIFS implementation. +# +# Claim value type model. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# 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/>. +# + +from .fields import BooleanField, DnField, IntegerField, StringField +from .model import Model + +# LDAP Syntax to Value Type CN lookup table. +# These are the lookups used by known AD attributes, add new ones as required. +SYNTAX_TO_VALUE_TYPE_CN = { + "2.5.5.1": "MS-DS-Text", # Object(DS-DN) + "2.5.5.2": "MS-DS-Text", # String(Object-Identifier) + "2.5.5.8": "MS-DS-YesNo", # Boolean + "2.5.5.9": "MS-DS-Number", # Integer + "2.5.5.12": "MS-DS-Text", # String(Unicode) + "2.5.5.15": "MS-DS-Text", # String(NT-Sec-Desc) + "2.5.5.16": "MS-DS-Number", # LargeInteger +} + + +class ValueType(Model): + description = StringField("description") + display_name = StringField("displayName") + claim_is_single_valued = BooleanField("msDS-ClaimIsSingleValued") + claim_is_value_space_restricted = BooleanField( + "msDS-ClaimIsValueSpaceRestricted") + claim_value_type = IntegerField("msDS-ClaimValueType") + is_possible_values_present = BooleanField("msDS-IsPossibleValuesPresent") + show_in_advanced_view_only = BooleanField("showInAdvancedViewOnly") + + # Backlinks + value_type_reference_bl = DnField( + "msDS-ValueTypeReferenceBL", readonly=True) + + @staticmethod + def get_base_dn(ldb): + """Return the base DN for the ValueType model. + + :param ldb: Ldb connection + :return: Dn object of container + """ + base_dn = ldb.get_config_basedn() + base_dn.add_child("CN=Value Types,CN=Claims Configuration,CN=Services") + return base_dn + + @staticmethod + def get_object_class(): + return "msDS-ValueType" + + @classmethod + def lookup(cls, ldb, attribute): + """Helper function to get ValueType by attribute or raise LookupError. + + :param ldb: Ldb connection + :param attribute: AttributeSchema object + :raises: LookupError if not found + :raises: ValueError for unknown attribute syntax + """ + # If attribute is None. + if not attribute: + raise ValueError("Attribute is required for value type lookup.") + + # Unknown attribute syntax as it isn't in the lookup table. + syntax = attribute.attribute_syntax + cn = SYNTAX_TO_VALUE_TYPE_CN.get(syntax) + if not cn: + raise ValueError(f"Unable to process attribute syntax {syntax}") + + # This should always return something but should still be handled. + value_type = cls.get(ldb, cn=cn) + if value_type is None: + raise LookupError( + f"Could not find claim value type for {attribute}.") + + return value_type + + def __str__(self): + return str(self.display_name) diff --git a/python/samba/netcmd/domain/passwordsettings.py b/python/samba/netcmd/domain/passwordsettings.py new file mode 100644 index 0000000..d0cf47b --- /dev/null +++ b/python/samba/netcmd/domain/passwordsettings.py @@ -0,0 +1,316 @@ +# domain management - domain passwordsettings +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 ldb +import samba.getopt as options +from samba.auth import system_session +from samba.dcerpc.samr import (DOMAIN_PASSWORD_COMPLEX, + DOMAIN_PASSWORD_STORE_CLEARTEXT) +from samba.netcmd import Command, CommandError, Option, SuperCommand +from samba.netcmd.common import (NEVER_TIMESTAMP, timestamp_to_days, + timestamp_to_mins) +from samba.netcmd.pso import cmd_domain_passwordsettings_pso +from samba.samdb import SamDB + + +class cmd_domain_passwordsettings_show(Command): + """Display current password settings for the domain.""" + + synopsis = "%prog [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "credopts": options.CredentialsOptions, + } + + takes_options = [ + Option("-H", "--URL", help="LDB URL for database or target server", type=str, + metavar="URL", dest="H"), + ] + + def run(self, H=None, credopts=None, sambaopts=None, versionopts=None): + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp) + + samdb = SamDB(url=H, session_info=system_session(), + credentials=creds, lp=lp) + + domain_dn = samdb.domain_dn() + res = samdb.search(domain_dn, scope=ldb.SCOPE_BASE, + attrs=["pwdProperties", "pwdHistoryLength", "minPwdLength", + "minPwdAge", "maxPwdAge", "lockoutDuration", "lockoutThreshold", + "lockOutObservationWindow"]) + assert(len(res) == 1) + try: + pwd_props = int(res[0]["pwdProperties"][0]) + pwd_hist_len = int(res[0]["pwdHistoryLength"][0]) + cur_min_pwd_len = int(res[0]["minPwdLength"][0]) + # ticks -> days + cur_min_pwd_age = timestamp_to_days(res[0]["minPwdAge"][0]) + cur_max_pwd_age = timestamp_to_days(res[0]["maxPwdAge"][0]) + + cur_account_lockout_threshold = int(res[0]["lockoutThreshold"][0]) + + # ticks -> mins + cur_account_lockout_duration = timestamp_to_mins(res[0]["lockoutDuration"][0]) + cur_reset_account_lockout_after = timestamp_to_mins(res[0]["lockOutObservationWindow"][0]) + except Exception as e: + raise CommandError("Could not retrieve password properties!", e) + + self.message("Password information for domain '%s'" % domain_dn) + self.message("") + if pwd_props & DOMAIN_PASSWORD_COMPLEX != 0: + self.message("Password complexity: on") + else: + self.message("Password complexity: off") + if pwd_props & DOMAIN_PASSWORD_STORE_CLEARTEXT != 0: + self.message("Store plaintext passwords: on") + else: + self.message("Store plaintext passwords: off") + self.message("Password history length: %d" % pwd_hist_len) + self.message("Minimum password length: %d" % cur_min_pwd_len) + self.message("Minimum password age (days): %d" % cur_min_pwd_age) + self.message("Maximum password age (days): %d" % cur_max_pwd_age) + self.message("Account lockout duration (mins): %d" % cur_account_lockout_duration) + self.message("Account lockout threshold (attempts): %d" % cur_account_lockout_threshold) + self.message("Reset account lockout after (mins): %d" % cur_reset_account_lockout_after) + + +class cmd_domain_passwordsettings_set(Command): + """Set password settings. + + Password complexity, password lockout policy, history length, + minimum password length, the minimum and maximum password age) on + a Samba AD DC server. + + Use against a Windows DC is possible, but group policy will override it. + """ + + synopsis = "%prog <options> [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "credopts": options.CredentialsOptions, + } + + takes_options = [ + Option("-H", "--URL", help="LDB URL for database or target server", type=str, + metavar="URL", dest="H"), + Option("-q", "--quiet", help="Be quiet", action="store_true"), # unused + Option("--complexity", type="choice", choices=["on", "off", "default"], + help="The password complexity (on | off | default). Default is 'on'"), + Option("--store-plaintext", type="choice", choices=["on", "off", "default"], + help="Store plaintext passwords where account have 'store passwords with reversible encryption' set (on | off | default). Default is 'off'"), + Option("--history-length", + help="The password history length (<integer> | default). Default is 24.", type=str), + Option("--min-pwd-length", + help="The minimum password length (<integer> | default). Default is 7.", type=str), + Option("--min-pwd-age", + help="The minimum password age (<integer in days> | default). Default is 1.", type=str), + Option("--max-pwd-age", + help="The maximum password age (<integer in days> | default). Default is 43.", type=str), + Option("--account-lockout-duration", + help="The length of time an account is locked out after exceeding the limit on bad password attempts (<integer in mins> | default). Default is 30 mins.", type=str), + Option("--account-lockout-threshold", + help="The number of bad password attempts allowed before locking out the account (<integer> | default). Default is 0 (never lock out).", type=str), + Option("--reset-account-lockout-after", + help="After this time is elapsed, the recorded number of attempts restarts from zero (<integer> | default). Default is 30.", type=str), + ] + + def run(self, H=None, min_pwd_age=None, max_pwd_age=None, + quiet=False, complexity=None, store_plaintext=None, history_length=None, + min_pwd_length=None, account_lockout_duration=None, account_lockout_threshold=None, + reset_account_lockout_after=None, credopts=None, sambaopts=None, + versionopts=None): + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp) + + samdb = SamDB(url=H, session_info=system_session(), + credentials=creds, lp=lp) + + domain_dn = samdb.domain_dn() + msgs = [] + m = ldb.Message() + m.dn = ldb.Dn(samdb, domain_dn) + pwd_props = int(samdb.get_pwdProperties()) + + # get the current password age settings + max_pwd_age_ticks = samdb.get_maxPwdAge() + min_pwd_age_ticks = samdb.get_minPwdAge() + + if complexity is not None: + if complexity == "on" or complexity == "default": + pwd_props = pwd_props | DOMAIN_PASSWORD_COMPLEX + msgs.append("Password complexity activated!") + elif complexity == "off": + pwd_props = pwd_props & (~DOMAIN_PASSWORD_COMPLEX) + msgs.append("Password complexity deactivated!") + + if store_plaintext is not None: + if store_plaintext == "on" or store_plaintext == "default": + pwd_props = pwd_props | DOMAIN_PASSWORD_STORE_CLEARTEXT + msgs.append("Plaintext password storage for changed passwords activated!") + elif store_plaintext == "off": + pwd_props = pwd_props & (~DOMAIN_PASSWORD_STORE_CLEARTEXT) + msgs.append("Plaintext password storage for changed passwords deactivated!") + + if complexity is not None or store_plaintext is not None: + m["pwdProperties"] = ldb.MessageElement(str(pwd_props), + ldb.FLAG_MOD_REPLACE, "pwdProperties") + + if history_length is not None: + if history_length == "default": + pwd_hist_len = 24 + else: + pwd_hist_len = int(history_length) + + if pwd_hist_len < 0 or pwd_hist_len > 24: + raise CommandError("Password history length must be in the range of 0 to 24!") + + m["pwdHistoryLength"] = ldb.MessageElement(str(pwd_hist_len), + ldb.FLAG_MOD_REPLACE, "pwdHistoryLength") + msgs.append("Password history length changed!") + + if min_pwd_length is not None: + if min_pwd_length == "default": + min_pwd_len = 7 + else: + min_pwd_len = int(min_pwd_length) + + if min_pwd_len < 0 or min_pwd_len > 14: + raise CommandError("Minimum password length must be in the range of 0 to 14!") + + m["minPwdLength"] = ldb.MessageElement(str(min_pwd_len), + ldb.FLAG_MOD_REPLACE, "minPwdLength") + msgs.append("Minimum password length changed!") + + if min_pwd_age is not None: + if min_pwd_age == "default": + min_pwd_age = 1 + else: + min_pwd_age = int(min_pwd_age) + + if min_pwd_age < 0 or min_pwd_age > 998: + raise CommandError("Minimum password age must be in the range of 0 to 998!") + + # days -> ticks + min_pwd_age_ticks = -int(min_pwd_age * (24 * 60 * 60 * 1e7)) + + m["minPwdAge"] = ldb.MessageElement(str(min_pwd_age_ticks), + ldb.FLAG_MOD_REPLACE, "minPwdAge") + msgs.append("Minimum password age changed!") + + if max_pwd_age is not None: + if max_pwd_age == "default": + max_pwd_age = 43 + else: + max_pwd_age = int(max_pwd_age) + + if max_pwd_age < 0 or max_pwd_age > 999: + raise CommandError("Maximum password age must be in the range of 0 to 999!") + + # days -> ticks + if max_pwd_age == 0: + max_pwd_age_ticks = NEVER_TIMESTAMP + else: + max_pwd_age_ticks = -int(max_pwd_age * (24 * 60 * 60 * 1e7)) + + m["maxPwdAge"] = ldb.MessageElement(str(max_pwd_age_ticks), + ldb.FLAG_MOD_REPLACE, "maxPwdAge") + msgs.append("Maximum password age changed!") + + if account_lockout_duration is not None: + if account_lockout_duration == "default": + account_lockout_duration = 30 + else: + account_lockout_duration = int(account_lockout_duration) + + if account_lockout_duration < 0 or account_lockout_duration > 99999: + raise CommandError("Account lockout duration " + "must be in the range of 0 to 99999!") + + # minutes -> ticks + if account_lockout_duration == 0: + account_lockout_duration_ticks = NEVER_TIMESTAMP + else: + account_lockout_duration_ticks = -int(account_lockout_duration * (60 * 1e7)) + + m["lockoutDuration"] = ldb.MessageElement(str(account_lockout_duration_ticks), + ldb.FLAG_MOD_REPLACE, "lockoutDuration") + msgs.append("Account lockout duration changed!") + + if account_lockout_threshold is not None: + if account_lockout_threshold == "default": + account_lockout_threshold = 0 + else: + account_lockout_threshold = int(account_lockout_threshold) + + m["lockoutThreshold"] = ldb.MessageElement(str(account_lockout_threshold), + ldb.FLAG_MOD_REPLACE, "lockoutThreshold") + msgs.append("Account lockout threshold changed!") + + if reset_account_lockout_after is not None: + if reset_account_lockout_after == "default": + reset_account_lockout_after = 30 + else: + reset_account_lockout_after = int(reset_account_lockout_after) + + if reset_account_lockout_after < 0 or reset_account_lockout_after > 99999: + raise CommandError("Maximum password age must be in the range of 0 to 99999!") + + # minutes -> ticks + if reset_account_lockout_after == 0: + reset_account_lockout_after_ticks = NEVER_TIMESTAMP + else: + reset_account_lockout_after_ticks = -int(reset_account_lockout_after * (60 * 1e7)) + + m["lockOutObservationWindow"] = ldb.MessageElement(str(reset_account_lockout_after_ticks), + ldb.FLAG_MOD_REPLACE, "lockOutObservationWindow") + msgs.append("Duration to reset account lockout after changed!") + + if max_pwd_age or min_pwd_age: + # If we're setting either min or max password, make sure the max is + # still greater overall. As either setting could be None, we use the + # ticks here (which are always set) and work backwards. + max_pwd_age = timestamp_to_days(max_pwd_age_ticks) + min_pwd_age = timestamp_to_days(min_pwd_age_ticks) + if max_pwd_age != 0 and min_pwd_age >= max_pwd_age: + raise CommandError("Maximum password age (%d) must be greater than minimum password age (%d)!" % (max_pwd_age, min_pwd_age)) + + if len(m) == 0: + raise CommandError("You must specify at least one option to set. Try --help") + samdb.modify(m) + msgs.append("All changes applied successfully!") + self.message("\n".join(msgs)) + + +class cmd_domain_passwordsettings(SuperCommand): + """Manage password policy settings.""" + + subcommands = {} + subcommands["pso"] = cmd_domain_passwordsettings_pso() + subcommands["show"] = cmd_domain_passwordsettings_show() + subcommands["set"] = cmd_domain_passwordsettings_set() diff --git a/python/samba/netcmd/domain/provision.py b/python/samba/netcmd/domain/provision.py new file mode 100644 index 0000000..8f13e54 --- /dev/null +++ b/python/samba/netcmd/domain/provision.py @@ -0,0 +1,405 @@ +# domain management - domain provision +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 os +import sys +import tempfile + +import samba +import samba.getopt as options +from samba.auth import system_session +from samba.auth_util import system_session_unix +from samba.dcerpc import security +from samba.dsdb import ( + DS_DOMAIN_FUNCTION_2000, + DS_DOMAIN_FUNCTION_2003, + DS_DOMAIN_FUNCTION_2008, + DS_DOMAIN_FUNCTION_2008_R2, + DS_DOMAIN_FUNCTION_2012, + DS_DOMAIN_FUNCTION_2012_R2, + DS_DOMAIN_FUNCTION_2016 +) +from samba.netcmd import Command, CommandError, Option +from samba.provision import DEFAULT_MIN_PWD_LENGTH, ProvisioningError, provision +from samba.provision.common import FILL_DRS, FILL_FULL, FILL_NT4SYNC +from samba.samdb import get_default_backend_store +from samba import functional_level + +from .common import common_ntvfs_options, common_provision_join_options + + +class cmd_domain_provision(Command): + """Provision a domain.""" + + synopsis = "%prog [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + } + + takes_options = [ + Option("--interactive", help="Ask for names", action="store_true"), + Option("--domain", type="string", metavar="DOMAIN", + help="NetBIOS domain name to use"), + Option("--domain-guid", type="string", metavar="GUID", + help="set domainguid (otherwise random)"), + Option("--domain-sid", type="string", metavar="SID", + help="set domainsid (otherwise random)"), + Option("--ntds-guid", type="string", metavar="GUID", + help="set NTDS object GUID (otherwise random)"), + Option("--invocationid", type="string", metavar="GUID", + help="set invocationid (otherwise random)"), + Option("--host-name", type="string", metavar="HOSTNAME", + help="set hostname"), + Option("--host-ip", type="string", metavar="IPADDRESS", + help="set IPv4 ipaddress"), + Option("--host-ip6", type="string", metavar="IP6ADDRESS", + help="set IPv6 ipaddress"), + Option("--site", type="string", metavar="SITENAME", + help="set site name"), + Option("--adminpass", type="string", metavar="PASSWORD", + help="choose admin password (otherwise random)"), + Option("--krbtgtpass", type="string", metavar="PASSWORD", + help="choose krbtgt password (otherwise random)"), + Option("--dns-backend", type="choice", metavar="NAMESERVER-BACKEND", + choices=["SAMBA_INTERNAL", "BIND9_FLATFILE", "BIND9_DLZ", "NONE"], + help="The DNS server backend. SAMBA_INTERNAL is the builtin name server (default), " + "BIND9_FLATFILE uses bind9 text database to store zone information, " + "BIND9_DLZ uses samba4 AD to store zone information, " + "NONE skips the DNS setup entirely (not recommended)", + default="SAMBA_INTERNAL"), + Option("--dnspass", type="string", metavar="PASSWORD", + help="choose dns password (otherwise random)"), + Option("--root", type="string", metavar="USERNAME", + help="choose 'root' unix username"), + Option("--nobody", type="string", metavar="USERNAME", + help="choose 'nobody' user"), + Option("--users", type="string", metavar="GROUPNAME", + help="choose 'users' group"), + Option("--blank", action="store_true", + help="do not add users or groups, just the structure"), + Option("--server-role", type="choice", metavar="ROLE", + choices=["domain controller", "dc", "member server", "member", "standalone"], + help="The server role (domain controller | dc | member server | member | standalone). Default is dc.", + default="domain controller"), + Option("--function-level", type="choice", metavar="FOR-FUN-LEVEL", + choices=["2000", "2003", "2008", "2008_R2", "2016"], + help="The domain and forest function level (2000 | 2003 | 2008 | 2008_R2 - always native | 2016). Default is (Windows) 2008_R2 Native.", + default="2008_R2"), + Option("--base-schema", type="choice", metavar="BASE-SCHEMA", + choices=["2008_R2", "2008_R2_old", "2012", "2012_R2", "2016", "2019"], + help="The base schema files to use. Default is (Windows) 2019.", + default="2019"), + Option("--adprep-level", type="choice", metavar="FUNCTION_LEVEL", + choices=["SKIP", "2008_R2", "2012", "2012_R2", "2016"], + help="The highest functional level to prepare for. Default is based on --base-schema", + default=None), + Option("--next-rid", type="int", metavar="NEXTRID", default=1000, + help="The initial nextRid value (only needed for upgrades). Default is 1000."), + Option("--partitions-only", + help="Configure Samba's partitions, but do not modify them (ie, join a BDC)", action="store_true"), + Option("--use-rfc2307", action="store_true", help="Use AD to store posix attributes (default = no)"), + ] + + ntvfs_options = [ + Option("--use-xattrs", type="choice", choices=["yes", "no", "auto"], + metavar="[yes|no|auto]", + help="Define if we should use the native fs capabilities or a tdb file for " + "storing attributes likes ntacl when --use-ntvfs is set. " + "auto tries to make an intelligent guess based on the user rights and system capabilities", + default="auto") + ] + + takes_options.extend(common_provision_join_options) + + if samba.is_ntvfs_fileserver_built(): + takes_options.extend(common_ntvfs_options) + takes_options.extend(ntvfs_options) + + takes_args = [] + + def run(self, sambaopts=None, versionopts=None, + interactive=None, + domain=None, + domain_guid=None, + domain_sid=None, + ntds_guid=None, + invocationid=None, + host_name=None, + host_ip=None, + host_ip6=None, + adminpass=None, + site=None, + krbtgtpass=None, + machinepass=None, + dns_backend=None, + dns_forwarder=None, + dnspass=None, + ldapadminpass=None, + root=None, + nobody=None, + users=None, + quiet=None, + blank=None, + server_role=None, + function_level=None, + adprep_level=None, + next_rid=None, + partitions_only=None, + targetdir=None, + use_xattrs="auto", + use_ntvfs=False, + use_rfc2307=None, + base_schema=None, + plaintext_secrets=False, + backend_store=None, + backend_store_size=None): + + self.logger = self.get_logger(name="provision", quiet=quiet) + + lp = sambaopts.get_loadparm() + smbconf = lp.configfile + + if dns_forwarder is not None: + suggested_forwarder = dns_forwarder + else: + suggested_forwarder = self._get_nameserver_ip() + if suggested_forwarder is None: + suggested_forwarder = "none" + + if not self.raw_argv: + interactive = True + + if interactive: + from getpass import getpass + import socket + + def ask(prompt, default=None): + if default is not None: + print("%s [%s]: " % (prompt, default), end=' ') + else: + print("%s: " % (prompt,), end=' ') + sys.stdout.flush() + return sys.stdin.readline().rstrip("\n") or default + + try: + default = socket.getfqdn().split(".", 1)[1].upper() + except IndexError: + default = None + realm = ask("Realm", default) + if realm in (None, ""): + raise CommandError("No realm set!") + + try: + default = realm.split(".")[0] + except IndexError: + default = None + domain = ask("Domain", default) + if domain is None: + raise CommandError("No domain set!") + + server_role = ask("Server Role (dc, member, standalone)", "dc") + + dns_backend = ask("DNS backend (SAMBA_INTERNAL, BIND9_FLATFILE, BIND9_DLZ, NONE)", "SAMBA_INTERNAL") + if dns_backend in (None, ''): + raise CommandError("No DNS backend set!") + + if dns_backend == "SAMBA_INTERNAL": + dns_forwarder = ask("DNS forwarder IP address (write 'none' to disable forwarding)", suggested_forwarder) + if dns_forwarder.lower() in (None, 'none'): + suggested_forwarder = None + dns_forwarder = None + + while True: + adminpassplain = getpass("Administrator password: ") + issue = self._adminpass_issue(adminpassplain) + if issue: + self.errf.write("%s.\n" % issue) + else: + adminpassverify = getpass("Retype password: ") + if not adminpassplain == adminpassverify: + self.errf.write("Sorry, passwords do not match.\n") + else: + adminpass = adminpassplain + break + + else: + realm = sambaopts._lp.get('realm') + if realm is None: + raise CommandError("No realm set!") + if domain is None: + raise CommandError("No domain set!") + + if adminpass: + issue = self._adminpass_issue(adminpass) + if issue: + raise CommandError(issue) + else: + self.logger.info("Administrator password will be set randomly!") + + try: + dom_for_fun_level = functional_level.string_to_level(function_level) + except KeyError: + raise CommandError(f"'{function_level}' is not a valid domain level") + + if adprep_level is None: + # Select the adprep_level default based + # on what the base schema permits + if base_schema in ["2008_R2", "2008_R2_old"]: + # without explicit --adprep-level=2008_R2 + # we will skip the adprep step on + # provision + adprep_level = "SKIP" + elif base_schema in ["2012"]: + adprep_level = "2012" + elif base_schema in ["2012_R2"]: + adprep_level = "2012_R2" + else: + adprep_level = "2016" + + if adprep_level == "SKIP": + provision_adprep_level = None + elif adprep_level == "2008R2": + provision_adprep_level = DS_DOMAIN_FUNCTION_2008_R2 + elif adprep_level == "2012": + provision_adprep_level = DS_DOMAIN_FUNCTION_2012 + elif adprep_level == "2012_R2": + provision_adprep_level = DS_DOMAIN_FUNCTION_2012_R2 + elif adprep_level == "2016": + provision_adprep_level = DS_DOMAIN_FUNCTION_2016 + + if dns_backend == "SAMBA_INTERNAL" and dns_forwarder is None: + dns_forwarder = suggested_forwarder + + samdb_fill = FILL_FULL + if blank: + samdb_fill = FILL_NT4SYNC + elif partitions_only: + samdb_fill = FILL_DRS + + if targetdir is not None: + if not os.path.isdir(targetdir): + os.makedirs(targetdir) + + eadb = True + + if use_xattrs == "yes": + eadb = False + elif use_xattrs == "auto" and not use_ntvfs: + eadb = False + elif not use_ntvfs: + raise CommandError("--use-xattrs=no requires --use-ntvfs (not supported for production use). " + "Please re-run with --use-xattrs omitted.") + elif use_xattrs == "auto" and not lp.get("posix:eadb"): + if targetdir: + file = tempfile.NamedTemporaryFile(dir=os.path.abspath(targetdir)) + else: + file = tempfile.NamedTemporaryFile(dir=os.path.abspath(os.path.dirname(lp.get("private dir")))) + try: + try: + samba.ntacls.setntacl(lp, file.name, + "O:S-1-5-32G:S-1-5-32", + "S-1-5-32", + system_session_unix(), + "native") + eadb = False + except Exception: + self.logger.info("You are not root or your system does not support xattr, using tdb backend for attributes. ") + finally: + file.close() + + if eadb: + self.logger.info("not using extended attributes to store ACLs and other metadata. If you intend to use this provision in production, rerun the script as root on a system supporting xattrs.") + + if domain_sid is not None: + domain_sid = security.dom_sid(domain_sid) + + session = system_session() + if backend_store is None: + backend_store = get_default_backend_store() + try: + result = provision(self.logger, + session, smbconf=smbconf, targetdir=targetdir, + samdb_fill=samdb_fill, realm=realm, domain=domain, + domainguid=domain_guid, domainsid=domain_sid, + hostname=host_name, + hostip=host_ip, hostip6=host_ip6, + sitename=site, ntdsguid=ntds_guid, + invocationid=invocationid, adminpass=adminpass, + krbtgtpass=krbtgtpass, machinepass=machinepass, + dns_backend=dns_backend, dns_forwarder=dns_forwarder, + dnspass=dnspass, root=root, nobody=nobody, + users=users, + serverrole=server_role, dom_for_fun_level=dom_for_fun_level, + useeadb=eadb, next_rid=next_rid, lp=lp, use_ntvfs=use_ntvfs, + use_rfc2307=use_rfc2307, skip_sysvolacl=False, + base_schema=base_schema, + adprep_level=provision_adprep_level, + plaintext_secrets=plaintext_secrets, + backend_store=backend_store, + backend_store_size=backend_store_size) + + except ProvisioningError as e: + raise CommandError("Provision failed", e) + + result.report_logger(self.logger) + + def _get_nameserver_ip(self): + """Grab the nameserver IP address from /etc/resolv.conf.""" + from os import path + RESOLV_CONF = "/etc/resolv.conf" + + if not path.isfile(RESOLV_CONF): + self.logger.warning("Failed to locate %s" % RESOLV_CONF) + return None + + handle = None + try: + handle = open(RESOLV_CONF, 'r') + for line in handle: + if not line.startswith('nameserver'): + continue + # we want the last non-space continuous string of the line + return line.strip().split()[-1] + finally: + if handle is not None: + handle.close() + + self.logger.warning("No nameserver found in %s" % RESOLV_CONF) + + def _adminpass_issue(self, adminpass): + """Returns error string for a bad administrator password, + or None if acceptable""" + if isinstance(adminpass, bytes): + adminpass = adminpass.decode('utf8') + if len(adminpass) < DEFAULT_MIN_PWD_LENGTH: + return "Administrator password does not meet the default minimum" \ + " password length requirement (%d characters)" \ + % DEFAULT_MIN_PWD_LENGTH + elif not samba.check_password_quality(adminpass): + return "Administrator password does not meet the default" \ + " quality standards" + else: + return None diff --git a/python/samba/netcmd/domain/samba3upgrade.py b/python/samba/netcmd/domain/samba3upgrade.py new file mode 100644 index 0000000..67f4b42 --- /dev/null +++ b/python/samba/netcmd/domain/samba3upgrade.py @@ -0,0 +1,34 @@ +# domain management - domain samba3upgrade +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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/>. +# + +from .classicupgrade import cmd_domain_classicupgrade + + +class cmd_domain_samba3upgrade(cmd_domain_classicupgrade): + __doc__ = cmd_domain_classicupgrade.__doc__ + + # This command is present for backwards compatibility only, + # and should not be shown. + + hidden = True diff --git a/python/samba/netcmd/domain/schemaupgrade.py b/python/samba/netcmd/domain/schemaupgrade.py new file mode 100644 index 0000000..ff00a77 --- /dev/null +++ b/python/samba/netcmd/domain/schemaupgrade.py @@ -0,0 +1,350 @@ +# domain management - domain schemaupgrade +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 os +import shutil +import subprocess +import tempfile + +import ldb +import samba.getopt as options +from samba.auth import system_session +from samba.netcmd import Command, CommandError, Option +from samba.netcmd.fsmo import get_fsmo_roleowner +from samba.provision import setup_path +from samba.samdb import SamDB + + +class ldif_schema_update: + """Helper class for applying LDIF schema updates""" + + def __init__(self): + self.is_defunct = False + self.unknown_oid = None + self.dn = None + self.ldif = "" + + def can_ignore_failure(self, error): + """Checks if we can safely ignore failure to apply an LDIF update""" + (num, errstr) = error.args + + # Microsoft has marked objects as defunct that Samba doesn't know about + if num == ldb.ERR_NO_SUCH_OBJECT and self.is_defunct: + print("Defunct object %s doesn't exist, skipping" % self.dn) + return True + elif self.unknown_oid is not None: + print("Skipping unknown OID %s for object %s" % (self.unknown_oid, self.dn)) + return True + + return False + + def apply(self, samdb): + """Applies a single LDIF update to the schema""" + + try: + try: + samdb.modify_ldif(self.ldif, controls=['relax:0']) + except ldb.LdbError as e: + if e.args[0] == ldb.ERR_INVALID_ATTRIBUTE_SYNTAX: + + # REFRESH after a failed change + + # Otherwise the OID-to-attribute mapping in + # _apply_updates_in_file() won't work, because it + # can't lookup the new OID in the schema + samdb.set_schema_update_now() + + samdb.modify_ldif(self.ldif, controls=['relax:0']) + else: + raise + except ldb.LdbError as e: + if self.can_ignore_failure(e): + return 0 + else: + print("Exception: %s" % e) + print("Encountered while trying to apply the following LDIF") + print("----------------------------------------------------") + print("%s" % self.ldif) + + raise + + return 1 + + +class cmd_domain_schema_upgrade(Command): + """Domain schema upgrading""" + + synopsis = "%prog [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "credopts": options.CredentialsOptions, + } + + takes_options = [ + Option("-H", "--URL", help="LDB URL for database or target server", type=str, + metavar="URL", dest="H"), + Option("-q", "--quiet", help="Be quiet", action="store_true"), # unused + Option("-v", "--verbose", help="Be verbose", action="store_true"), + Option("--schema", type="choice", metavar="SCHEMA", + choices=["2012", "2012_R2", "2016", "2019"], + help="The schema file to upgrade to. Default is (Windows) 2019.", + default="2019"), + Option("--ldf-file", type=str, default=None, + help="Just apply the schema updates in the adprep/.LDF file(s) specified"), + Option("--base-dir", type=str, default=None, + help="Location of ldf files Default is ${SETUPDIR}/adprep.") + ] + + def _apply_updates_in_file(self, samdb, ldif_file): + """ + Applies a series of updates specified in an .LDIF file. The .LDIF file + is based on the adprep Schema updates provided by Microsoft. + """ + count = 0 + ldif_op = ldif_schema_update() + + # parse the file line by line and work out each update operation to apply + for line in ldif_file: + + line = line.rstrip() + + # the operations in the .LDIF file are separated by blank lines. If + # we hit a blank line, try to apply the update we've parsed so far + if line == '': + + # keep going if we haven't parsed anything yet + if ldif_op.ldif == '': + continue + + # Apply the individual change + count += ldif_op.apply(samdb) + + # start storing the next operation from scratch again + ldif_op = ldif_schema_update() + continue + + # replace the placeholder domain name in the .ldif file with the real domain + if line.upper().endswith('DC=X'): + line = line[:-len('DC=X')] + str(samdb.get_default_basedn()) + elif line.upper().endswith('CN=X'): + line = line[:-len('CN=X')] + str(samdb.get_default_basedn()) + + values = line.split(':') + + if values[0].lower() == 'dn': + ldif_op.dn = values[1].strip() + + # replace the Windows-specific operation with the Samba one + if values[0].lower() == 'changetype': + line = line.lower().replace(': ntdsschemaadd', + ': add') + line = line.lower().replace(': ntdsschemamodify', + ': modify') + line = line.lower().replace(': ntdsschemamodrdn', + ': modrdn') + line = line.lower().replace(': ntdsschemadelete', + ': delete') + + if values[0].lower() in ['rdnattid', 'subclassof', + 'systemposssuperiors', + 'systemmaycontain', + 'systemauxiliaryclass']: + _, value = values + + # The Microsoft updates contain some OIDs we don't recognize. + # Query the DB to see if we can work out the OID this update is + # referring to. If we find a match, then replace the OID with + # the ldapDisplayname + if '.' in value: + res = samdb.search(base=samdb.get_schema_basedn(), + expression="(|(attributeId=%s)(governsId=%s))" % + (value, value), + attrs=['ldapDisplayName']) + + if len(res) != 1: + ldif_op.unknown_oid = value + else: + display_name = str(res[0]['ldapDisplayName'][0]) + line = line.replace(value, ' ' + display_name) + + # Microsoft has marked objects as defunct that Samba doesn't know about + if values[0].lower() == 'isdefunct' and values[1].strip().lower() == 'true': + ldif_op.is_defunct = True + + # Samba has added the showInAdvancedViewOnly attribute to all objects, + # so rather than doing an add, we need to do a replace + if values[0].lower() == 'add' and values[1].strip().lower() == 'showinadvancedviewonly': + line = 'replace: showInAdvancedViewOnly' + + # Add the line to the current LDIF operation (including the newline + # we stripped off at the start of the loop) + ldif_op.ldif += line + '\n' + + return count + + def _apply_update(self, samdb, update_file, base_dir): + """Wrapper function for parsing an LDIF file and applying the updates""" + + print("Applying %s updates..." % update_file) + + ldif_file = None + try: + ldif_file = open(os.path.join(base_dir, update_file)) + + count = self._apply_updates_in_file(samdb, ldif_file) + + finally: + if ldif_file: + ldif_file.close() + + print("%u changes applied" % count) + + return count + + def run(self, **kwargs): + try: + from samba.ms_schema_markdown import read_ms_markdown + except ImportError as e: + self.outf.write("Exception in importing markdown: %s\n" % e) + raise CommandError('Failed to import module markdown') + from samba.schema import Schema + + updates_allowed_overridden = False + sambaopts = kwargs.get("sambaopts") + credopts = kwargs.get("credopts") + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp) + H = kwargs.get("H") + target_schema = kwargs.get("schema") + ldf_files = kwargs.get("ldf_file") + base_dir = kwargs.get("base_dir") + + temp_folder = None + + samdb = SamDB(url=H, session_info=system_session(), credentials=creds, lp=lp) + + # we're not going to get far if the config doesn't allow schema updates + if lp.get("dsdb:schema update allowed") is None: + lp.set("dsdb:schema update allowed", "yes") + print("Temporarily overriding 'dsdb:schema update allowed' setting") + updates_allowed_overridden = True + + own_dn = ldb.Dn(samdb, samdb.get_dsServiceName()) + master = get_fsmo_roleowner(samdb, str(samdb.get_schema_basedn()), + 'schema') + if own_dn != master: + raise CommandError("This server is not the schema master.") + + # if specific LDIF files were specified, just apply them + if ldf_files: + schema_updates = ldf_files.split(",") + else: + schema_updates = [] + + # work out the version of the target schema we're upgrading to + end = Schema.get_version(target_schema) + + # work out the version of the schema we're currently using + res = samdb.search(base=samdb.get_schema_basedn(), + scope=ldb.SCOPE_BASE, attrs=['objectVersion']) + + if len(res) != 1: + raise CommandError('Could not determine current schema version') + start = int(res[0]['objectVersion'][0]) + 1 + + diff_dir = setup_path("adprep/WindowsServerDocs") + if base_dir is None: + # Read from the Schema-Updates.md file + temp_folder = tempfile.mkdtemp() + + update_file = setup_path("adprep/WindowsServerDocs/Schema-Updates.md") + + try: + read_ms_markdown(update_file, temp_folder) + except Exception as e: + print("Exception in markdown parsing: %s" % e) + shutil.rmtree(temp_folder) + raise CommandError('Failed to upgrade schema') + + base_dir = temp_folder + + for version in range(start, end + 1): + update = 'Sch%d.ldf' % version + schema_updates.append(update) + + # Apply patches if we parsed the Schema-Updates.md file + diff = os.path.abspath(os.path.join(diff_dir, update + '.diff')) + if temp_folder and os.path.exists(diff): + try: + p = subprocess.Popen(['patch', update, '-i', diff], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, cwd=temp_folder) + except (OSError, IOError): + shutil.rmtree(temp_folder) + raise CommandError("Failed to upgrade schema. " + "Is '/usr/bin/patch' missing?") + + stdout, stderr = p.communicate() + + if p.returncode: + print("Exception in patch: %s\n%s" % (stdout, stderr)) + shutil.rmtree(temp_folder) + raise CommandError('Failed to upgrade schema') + + print("Patched %s using %s" % (update, diff)) + + if base_dir is None: + base_dir = setup_path("adprep") + + samdb.transaction_start() + count = 0 + error_encountered = False + + try: + # Apply the schema updates needed to move to the new schema version + for ldif_file in schema_updates: + count += self._apply_update(samdb, ldif_file, base_dir) + + if count > 0: + samdb.transaction_commit() + print("Schema successfully updated") + else: + print("No changes applied to schema") + samdb.transaction_cancel() + except Exception as e: + print("Exception: %s" % e) + print("Error encountered, aborting schema upgrade") + samdb.transaction_cancel() + error_encountered = True + + if updates_allowed_overridden: + lp.set("dsdb:schema update allowed", "no") + + if temp_folder: + shutil.rmtree(temp_folder) + + if error_encountered: + raise CommandError('Failed to upgrade schema') diff --git a/python/samba/netcmd/domain/tombstones.py b/python/samba/netcmd/domain/tombstones.py new file mode 100644 index 0000000..673bb9a --- /dev/null +++ b/python/samba/netcmd/domain/tombstones.py @@ -0,0 +1,116 @@ +# domain management - domain tombstones +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 time + +import ldb +import samba.getopt as options +from samba.auth import system_session +from samba.netcmd import Command, CommandError, Option, SuperCommand +from samba.samdb import SamDB + + +class cmd_domain_tombstones_expunge(Command): + """Expunge tombstones from the database. + +This command expunges tombstones from the database.""" + synopsis = "%prog NC [NC [...]] [options]" + + takes_options = [ + Option("-H", "--URL", help="LDB URL for database or target server", type=str, + metavar="URL", dest="H"), + Option("--current-time", + help="The current time to evaluate the tombstone lifetime from, expressed as YYYY-MM-DD", + type=str), + Option("--tombstone-lifetime", help="Number of days a tombstone should be preserved for", type=int), + ] + + takes_args = ["nc*"] + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "versionopts": options.VersionOptions, + } + + def run(self, *ncs, **kwargs): + sambaopts = kwargs.get("sambaopts") + credopts = kwargs.get("credopts") + H = kwargs.get("H") + current_time_string = kwargs.get("current_time") + tombstone_lifetime = kwargs.get("tombstone_lifetime") + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp) + samdb = SamDB(url=H, session_info=system_session(), + credentials=creds, lp=lp) + + if current_time_string is None and tombstone_lifetime is None: + print("Note: without --current-time or --tombstone-lifetime " + "only tombstones already scheduled for deletion will " + "be deleted.", file=self.outf) + print("To remove all tombstones, use --tombstone-lifetime=0.", + file=self.outf) + + if current_time_string is not None: + current_time_obj = time.strptime(current_time_string, "%Y-%m-%d") + current_time = int(time.mktime(current_time_obj)) + + else: + current_time = int(time.time()) + + if len(ncs) == 0: + res = samdb.search(expression="", base="", scope=ldb.SCOPE_BASE, + attrs=["namingContexts"]) + + ncs = [] + for nc in res[0]["namingContexts"]: + ncs.append(str(nc)) + else: + ncs = list(ncs) + + started_transaction = False + try: + samdb.transaction_start() + started_transaction = True + (removed_objects, + removed_links) = samdb.garbage_collect_tombstones(ncs, + current_time=current_time, + tombstone_lifetime=tombstone_lifetime) + + except Exception as err: + if started_transaction: + samdb.transaction_cancel() + raise CommandError("Failed to expunge / garbage collect tombstones", err) + + samdb.transaction_commit() + + self.outf.write("Removed %d objects and %d links successfully\n" + % (removed_objects, removed_links)) + + +class cmd_domain_tombstones(SuperCommand): + """Domain tombstone and recycled object management.""" + + subcommands = {} + subcommands["expunge"] = cmd_domain_tombstones_expunge() diff --git a/python/samba/netcmd/domain/trust.py b/python/samba/netcmd/domain/trust.py new file mode 100644 index 0000000..e930f00 --- /dev/null +++ b/python/samba/netcmd/domain/trust.py @@ -0,0 +1,2338 @@ +# domain management - domain trust +# +# Copyright Matthias Dieter Wallnoefer 2009 +# Copyright Andrew Kroeger 2009 +# Copyright Jelmer Vernooij 2007-2012 +# Copyright Giampaolo Lauria 2011 +# Copyright Matthieu Patou <mat@matws.net> 2011 +# Copyright Andrew Bartlett 2008-2015 +# Copyright Stefan Metzmacher 2012 +# +# 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 ctypes +from getpass import getpass + +import ldb +import samba.getopt as options +import samba.ntacls +from samba import NTSTATUSError, ntstatus, string_to_byte_array, werror +from samba.auth import system_session +from samba.dcerpc import drsblobs, lsa, nbt, netlogon, security +from samba.net import Net +from samba.netcmd import Command, CommandError, Option, SuperCommand +from samba.samdb import SamDB +from samba.trust_utils import CreateTrustedDomainRelax + + +class LocalDCCredentialsOptions(options.CredentialsOptions): + def __init__(self, parser): + options.CredentialsOptions.__init__(self, parser, special_name="local-dc") + + +class DomainTrustCommand(Command): + """List domain trusts.""" + + def __init__(self): + Command.__init__(self) + self.local_lp = None + + self.local_server = None + self.local_binding_string = None + self.local_creds = None + + self.remote_server = None + self.remote_binding_string = None + self.remote_creds = None + + def _uint32(self, v): + return ctypes.c_uint32(v).value + + def check_runtime_error(self, runtime, val): + if runtime is None: + return False + + err32 = self._uint32(runtime.args[0]) + if err32 == val: + return True + + return False + + class LocalRuntimeError(CommandError): + def __init__(exception_self, self, runtime, message): + err32 = self._uint32(runtime.args[0]) + errstr = runtime.args[1] + msg = "LOCAL_DC[%s]: %s - ERROR(0x%08X) - %s" % ( + self.local_server, message, err32, errstr) + CommandError.__init__(exception_self, msg) + + class RemoteRuntimeError(CommandError): + def __init__(exception_self, self, runtime, message): + err32 = self._uint32(runtime.args[0]) + errstr = runtime.args[1] + msg = "REMOTE_DC[%s]: %s - ERROR(0x%08X) - %s" % ( + self.remote_server, message, err32, errstr) + CommandError.__init__(exception_self, msg) + + class LocalLdbError(CommandError): + def __init__(exception_self, self, ldb_error, message): + errval = ldb_error.args[0] + errstr = ldb_error.args[1] + msg = "LOCAL_DC[%s]: %s - ERROR(%d) - %s" % ( + self.local_server, message, errval, errstr) + CommandError.__init__(exception_self, msg) + + def setup_local_server(self, sambaopts, localdcopts): + if self.local_server is not None: + return self.local_server + + lp = sambaopts.get_loadparm() + + local_server = localdcopts.ipaddress + if local_server is None: + server_role = lp.server_role() + if server_role != "ROLE_ACTIVE_DIRECTORY_DC": + raise CommandError("Invalid server_role %s" % (server_role)) + local_server = lp.get('netbios name') + local_transport = "ncalrpc" + local_binding_options = "" + local_binding_options += ",auth_type=ncalrpc_as_system" + local_ldap_url = None + local_creds = None + else: + local_transport = "ncacn_np" + local_binding_options = "" + local_ldap_url = "ldap://%s" % local_server + local_creds = localdcopts.get_credentials(lp) + + self.local_lp = lp + + self.local_server = local_server + self.local_binding_string = "%s:%s[%s]" % (local_transport, local_server, local_binding_options) + self.local_ldap_url = local_ldap_url + self.local_creds = local_creds + return self.local_server + + def new_local_lsa_connection(self): + return lsa.lsarpc(self.local_binding_string, self.local_lp, self.local_creds) + + def new_local_netlogon_connection(self): + return netlogon.netlogon(self.local_binding_string, self.local_lp, self.local_creds) + + def new_local_ldap_connection(self): + return SamDB(url=self.local_ldap_url, + session_info=system_session(), + credentials=self.local_creds, + lp=self.local_lp) + + def setup_remote_server(self, credopts, domain, + require_pdc=True, + require_writable=True): + + if require_pdc: + assert require_writable + + if self.remote_server is not None: + return self.remote_server + + self.remote_server = "__unknown__remote_server__.%s" % domain + assert self.local_server is not None + + remote_creds = credopts.get_credentials(self.local_lp) + remote_server = credopts.ipaddress + remote_binding_options = "" + + # TODO: we should also support NT4 domains + # we could use local_netlogon.netr_DsRGetDCNameEx2() with the remote domain name + # and delegate NBT or CLDAP to the local netlogon server + try: + remote_net = Net(remote_creds, self.local_lp, server=remote_server) + remote_flags = nbt.NBT_SERVER_LDAP | nbt.NBT_SERVER_DS + if require_writable: + remote_flags |= nbt.NBT_SERVER_WRITABLE + if require_pdc: + remote_flags |= nbt.NBT_SERVER_PDC + remote_info = remote_net.finddc(flags=remote_flags, domain=domain, address=remote_server) + except NTSTATUSError as error: + raise CommandError("Failed to find a writeable DC for domain '%s': %s" % + (domain, error.args[1])) + except Exception: + raise CommandError("Failed to find a writeable DC for domain '%s'" % domain) + flag_map = { + nbt.NBT_SERVER_PDC: "PDC", + nbt.NBT_SERVER_GC: "GC", + nbt.NBT_SERVER_LDAP: "LDAP", + nbt.NBT_SERVER_DS: "DS", + nbt.NBT_SERVER_KDC: "KDC", + nbt.NBT_SERVER_TIMESERV: "TIMESERV", + nbt.NBT_SERVER_CLOSEST: "CLOSEST", + nbt.NBT_SERVER_WRITABLE: "WRITABLE", + nbt.NBT_SERVER_GOOD_TIMESERV: "GOOD_TIMESERV", + nbt.NBT_SERVER_NDNC: "NDNC", + nbt.NBT_SERVER_SELECT_SECRET_DOMAIN_6: "SELECT_SECRET_DOMAIN_6", + nbt.NBT_SERVER_FULL_SECRET_DOMAIN_6: "FULL_SECRET_DOMAIN_6", + nbt.NBT_SERVER_ADS_WEB_SERVICE: "ADS_WEB_SERVICE", + nbt.NBT_SERVER_DS_8: "DS_8", + nbt.NBT_SERVER_DS_9: "DS_9", + nbt.NBT_SERVER_DS_10: "DS_10", + nbt.NBT_SERVER_HAS_DNS_NAME: "HAS_DNS_NAME", + nbt.NBT_SERVER_IS_DEFAULT_NC: "IS_DEFAULT_NC", + nbt.NBT_SERVER_FOREST_ROOT: "FOREST_ROOT", + } + server_type_string = self.generic_bitmap_to_string(flag_map, + remote_info.server_type, names_only=True) + self.outf.write("RemoteDC Netbios[%s] DNS[%s] ServerType[%s]\n" % ( + remote_info.pdc_name, + remote_info.pdc_dns_name, + server_type_string)) + + self.remote_server = remote_info.pdc_dns_name + self.remote_binding_string = "ncacn_np:%s[%s]" % (self.remote_server, remote_binding_options) + self.remote_creds = remote_creds + return self.remote_server + + def new_remote_lsa_connection(self): + return lsa.lsarpc(self.remote_binding_string, self.local_lp, self.remote_creds) + + def new_remote_netlogon_connection(self): + return netlogon.netlogon(self.remote_binding_string, self.local_lp, self.remote_creds) + + def get_lsa_info(self, conn, policy_access): + objectAttr = lsa.ObjectAttribute() + objectAttr.sec_qos = lsa.QosInfo() + + policy = conn.OpenPolicy2(b''.decode('utf-8'), + objectAttr, policy_access) + + info = conn.QueryInfoPolicy2(policy, lsa.LSA_POLICY_INFO_DNS) + + return (policy, info) + + def get_netlogon_dc_unc(self, conn, server, domain): + try: + info = conn.netr_DsRGetDCNameEx2(server, + None, 0, None, None, None, + netlogon.DS_RETURN_DNS_NAME) + return info.dc_unc + except RuntimeError: + return conn.netr_GetDcName(server, domain) + + def get_netlogon_dc_info(self, conn, server): + info = conn.netr_DsRGetDCNameEx2(server, + None, 0, None, None, None, + netlogon.DS_RETURN_DNS_NAME) + return info + + def netr_DomainTrust_to_name(self, t): + if t.trust_type == lsa.LSA_TRUST_TYPE_DOWNLEVEL: + return t.netbios_name + + return t.dns_name + + def netr_DomainTrust_to_type(self, a, t): + primary = None + primary_parent = None + for _t in a: + if _t.trust_flags & netlogon.NETR_TRUST_FLAG_PRIMARY: + primary = _t + if not _t.trust_flags & netlogon.NETR_TRUST_FLAG_TREEROOT: + primary_parent = a[_t.parent_index] + break + + if t.trust_flags & netlogon.NETR_TRUST_FLAG_IN_FOREST: + if t is primary_parent: + return "Parent" + + if t.trust_flags & netlogon.NETR_TRUST_FLAG_TREEROOT: + return "TreeRoot" + + parent = a[t.parent_index] + if parent is primary: + return "Child" + + return "Shortcut" + + if t.trust_attributes & lsa.LSA_TRUST_ATTRIBUTE_FOREST_TRANSITIVE: + return "Forest" + + return "External" + + def netr_DomainTrust_to_transitive(self, t): + if t.trust_flags & netlogon.NETR_TRUST_FLAG_IN_FOREST: + return "Yes" + + if t.trust_attributes & lsa.LSA_TRUST_ATTRIBUTE_NON_TRANSITIVE: + return "No" + + if t.trust_attributes & lsa.LSA_TRUST_ATTRIBUTE_FOREST_TRANSITIVE: + return "Yes" + + return "No" + + def netr_DomainTrust_to_direction(self, t): + if t.trust_flags & netlogon.NETR_TRUST_FLAG_INBOUND and \ + t.trust_flags & netlogon.NETR_TRUST_FLAG_OUTBOUND: + return "BOTH" + + if t.trust_flags & netlogon.NETR_TRUST_FLAG_INBOUND: + return "INCOMING" + + if t.trust_flags & netlogon.NETR_TRUST_FLAG_OUTBOUND: + return "OUTGOING" + + return "INVALID" + + def generic_enum_to_string(self, e_dict, v, names_only=False): + try: + w = e_dict[v] + except KeyError: + v32 = self._uint32(v) + w = "__unknown__%08X__" % v32 + + r = "0x%x (%s)" % (v, w) + return r + + def generic_bitmap_to_string(self, b_dict, v, names_only=False): + + s = [] + + c = v + for b in sorted(b_dict.keys()): + if not (c & b): + continue + c &= ~b + s += [b_dict[b]] + + if c != 0: + c32 = self._uint32(c) + s += ["__unknown_%08X__" % c32] + + w = ",".join(s) + if names_only: + return w + r = "0x%x (%s)" % (v, w) + return r + + def trustType_string(self, v): + types = { + lsa.LSA_TRUST_TYPE_DOWNLEVEL: "DOWNLEVEL", + lsa.LSA_TRUST_TYPE_UPLEVEL: "UPLEVEL", + lsa.LSA_TRUST_TYPE_MIT: "MIT", + lsa.LSA_TRUST_TYPE_DCE: "DCE", + } + return self.generic_enum_to_string(types, v) + + def trustDirection_string(self, v): + directions = { + lsa.LSA_TRUST_DIRECTION_INBOUND | + lsa.LSA_TRUST_DIRECTION_OUTBOUND: "BOTH", + lsa.LSA_TRUST_DIRECTION_INBOUND: "INBOUND", + lsa.LSA_TRUST_DIRECTION_OUTBOUND: "OUTBOUND", + } + return self.generic_enum_to_string(directions, v) + + def trustAttributes_string(self, v): + attributes = { + lsa.LSA_TRUST_ATTRIBUTE_NON_TRANSITIVE: "NON_TRANSITIVE", + lsa.LSA_TRUST_ATTRIBUTE_UPLEVEL_ONLY: "UPLEVEL_ONLY", + lsa.LSA_TRUST_ATTRIBUTE_QUARANTINED_DOMAIN: "QUARANTINED_DOMAIN", + lsa.LSA_TRUST_ATTRIBUTE_FOREST_TRANSITIVE: "FOREST_TRANSITIVE", + lsa.LSA_TRUST_ATTRIBUTE_CROSS_ORGANIZATION: "CROSS_ORGANIZATION", + lsa.LSA_TRUST_ATTRIBUTE_WITHIN_FOREST: "WITHIN_FOREST", + lsa.LSA_TRUST_ATTRIBUTE_TREAT_AS_EXTERNAL: "TREAT_AS_EXTERNAL", + lsa.LSA_TRUST_ATTRIBUTE_USES_RC4_ENCRYPTION: "USES_RC4_ENCRYPTION", + } + return self.generic_bitmap_to_string(attributes, v) + + def kerb_EncTypes_string(self, v): + enctypes = { + security.KERB_ENCTYPE_DES_CBC_CRC: "DES_CBC_CRC", + security.KERB_ENCTYPE_DES_CBC_MD5: "DES_CBC_MD5", + security.KERB_ENCTYPE_RC4_HMAC_MD5: "RC4_HMAC_MD5", + security.KERB_ENCTYPE_AES128_CTS_HMAC_SHA1_96: "AES128_CTS_HMAC_SHA1_96", + security.KERB_ENCTYPE_AES256_CTS_HMAC_SHA1_96: "AES256_CTS_HMAC_SHA1_96", + security.KERB_ENCTYPE_AES256_CTS_HMAC_SHA1_96_SK: "AES256_CTS_HMAC_SHA1_96-SK", + security.KERB_ENCTYPE_FAST_SUPPORTED: "FAST_SUPPORTED", + security.KERB_ENCTYPE_COMPOUND_IDENTITY_SUPPORTED: "COMPOUND_IDENTITY_SUPPORTED", + security.KERB_ENCTYPE_CLAIMS_SUPPORTED: "CLAIMS_SUPPORTED", + security.KERB_ENCTYPE_RESOURCE_SID_COMPRESSION_DISABLED: "RESOURCE_SID_COMPRESSION_DISABLED", + } + return self.generic_bitmap_to_string(enctypes, v) + + def entry_tln_status(self, e_flags, ): + if e_flags == 0: + return "Status[Enabled]" + + flags = { + lsa.LSA_TLN_DISABLED_NEW: "Disabled-New", + lsa.LSA_TLN_DISABLED_ADMIN: "Disabled", + lsa.LSA_TLN_DISABLED_CONFLICT: "Disabled-Conflicting", + } + return "Status[%s]" % self.generic_bitmap_to_string(flags, e_flags, names_only=True) + + def entry_dom_status(self, e_flags): + if e_flags == 0: + return "Status[Enabled]" + + flags = { + lsa.LSA_SID_DISABLED_ADMIN: "Disabled-SID", + lsa.LSA_SID_DISABLED_CONFLICT: "Disabled-SID-Conflicting", + lsa.LSA_NB_DISABLED_ADMIN: "Disabled-NB", + lsa.LSA_NB_DISABLED_CONFLICT: "Disabled-NB-Conflicting", + } + return "Status[%s]" % self.generic_bitmap_to_string(flags, e_flags, names_only=True) + + def write_forest_trust_info(self, fti, tln=None, collisions=None): + if tln is not None: + tln_string = " TDO[%s]" % tln + else: + tln_string = "" + + self.outf.write("Namespaces[%d]%s:\n" % ( + len(fti.entries), tln_string)) + + for i, e in enumerate(fti.entries): + + flags = e.flags + collision_string = "" + + if collisions is not None: + for c in collisions.entries: + if c.index != i: + continue + flags = c.flags + collision_string = " Collision[%s]" % (c.name.string) + + d = e.forest_trust_data + if e.type == lsa.LSA_FOREST_TRUST_TOP_LEVEL_NAME: + self.outf.write("TLN: %-32s DNS[*.%s]%s\n" % ( + self.entry_tln_status(flags), + d.string, collision_string)) + elif e.type == lsa.LSA_FOREST_TRUST_TOP_LEVEL_NAME_EX: + self.outf.write("TLN_EX: %-29s DNS[*.%s]\n" % ( + "", d.string)) + elif e.type == lsa.LSA_FOREST_TRUST_DOMAIN_INFO: + self.outf.write("DOM: %-32s DNS[%s] Netbios[%s] SID[%s]%s\n" % ( + self.entry_dom_status(flags), + d.dns_domain_name.string, + d.netbios_domain_name.string, + d.domain_sid, collision_string)) + return + + +class cmd_domain_trust_list(DomainTrustCommand): + """List domain trusts.""" + + synopsis = "%prog [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "localdcopts": LocalDCCredentialsOptions, + } + + takes_options = [ + ] + + def run(self, sambaopts=None, versionopts=None, localdcopts=None): + + local_server = self.setup_local_server(sambaopts, localdcopts) + try: + local_netlogon = self.new_local_netlogon_connection() + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to connect netlogon server") + + try: + local_netlogon_trusts = \ + local_netlogon.netr_DsrEnumerateDomainTrusts(local_server, + netlogon.NETR_TRUST_FLAG_IN_FOREST | + netlogon.NETR_TRUST_FLAG_OUTBOUND | + netlogon.NETR_TRUST_FLAG_INBOUND) + except RuntimeError as error: + if self.check_runtime_error(error, werror.WERR_RPC_S_PROCNUM_OUT_OF_RANGE): + # TODO: we could implement a fallback to lsa.EnumTrustDom() + raise CommandError("LOCAL_DC[%s]: netr_DsrEnumerateDomainTrusts not supported." % ( + local_server)) + raise self.LocalRuntimeError(self, error, "netr_DsrEnumerateDomainTrusts failed") + + a = local_netlogon_trusts.array + for t in a: + if t.trust_flags & netlogon.NETR_TRUST_FLAG_PRIMARY: + continue + self.outf.write("%-14s %-15s %-19s %s\n" % ( + "Type[%s]" % self.netr_DomainTrust_to_type(a, t), + "Transitive[%s]" % self.netr_DomainTrust_to_transitive(t), + "Direction[%s]" % self.netr_DomainTrust_to_direction(t), + "Name[%s]" % self.netr_DomainTrust_to_name(t))) + return + + +class cmd_domain_trust_show(DomainTrustCommand): + """Show trusted domain details.""" + + synopsis = "%prog NAME [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "localdcopts": LocalDCCredentialsOptions, + } + + takes_options = [ + ] + + takes_args = ["domain"] + + def run(self, domain, sambaopts=None, versionopts=None, localdcopts=None): + + self.setup_local_server(sambaopts, localdcopts) + try: + local_lsa = self.new_local_lsa_connection() + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to connect lsa server") + + try: + local_policy_access = lsa.LSA_POLICY_VIEW_LOCAL_INFORMATION + (local_policy, local_lsa_info) = self.get_lsa_info(local_lsa, local_policy_access) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to query LSA_POLICY_INFO_DNS") + + self.outf.write("LocalDomain Netbios[%s] DNS[%s] SID[%s]\n" % ( + local_lsa_info.name.string, + local_lsa_info.dns_domain.string, + local_lsa_info.sid)) + + lsaString = lsa.String() + lsaString.string = domain + try: + local_tdo_full = \ + local_lsa.QueryTrustedDomainInfoByName(local_policy, + lsaString, + lsa.LSA_TRUSTED_DOMAIN_INFO_FULL_INFO) + local_tdo_info = local_tdo_full.info_ex + local_tdo_posix = local_tdo_full.posix_offset + except NTSTATUSError as error: + if self.check_runtime_error(error, ntstatus.NT_STATUS_OBJECT_NAME_NOT_FOUND): + raise CommandError("trusted domain object does not exist for domain [%s]" % domain) + + raise self.LocalRuntimeError(self, error, "QueryTrustedDomainInfoByName(FULL_INFO) failed") + + try: + local_tdo_enctypes = \ + local_lsa.QueryTrustedDomainInfoByName(local_policy, + lsaString, + lsa.LSA_TRUSTED_DOMAIN_SUPPORTED_ENCRYPTION_TYPES) + except NTSTATUSError as error: + if self.check_runtime_error(error, ntstatus.NT_STATUS_INVALID_PARAMETER): + error = None + if self.check_runtime_error(error, ntstatus.NT_STATUS_INVALID_INFO_CLASS): + error = None + + if error is not None: + raise self.LocalRuntimeError(self, error, + "QueryTrustedDomainInfoByName(SUPPORTED_ENCRYPTION_TYPES) failed") + + local_tdo_enctypes = lsa.TrustDomainInfoSupportedEncTypes() + local_tdo_enctypes.enc_types = 0 + + try: + local_tdo_forest = None + if local_tdo_info.trust_attributes & lsa.LSA_TRUST_ATTRIBUTE_FOREST_TRANSITIVE: + local_tdo_forest = \ + local_lsa.lsaRQueryForestTrustInformation(local_policy, + lsaString, + lsa.LSA_FOREST_TRUST_DOMAIN_INFO) + except RuntimeError as error: + if self.check_runtime_error(error, ntstatus.NT_STATUS_RPC_PROCNUM_OUT_OF_RANGE): + error = None + if self.check_runtime_error(error, ntstatus.NT_STATUS_NOT_FOUND): + error = None + if error is not None: + raise self.LocalRuntimeError(self, error, "lsaRQueryForestTrustInformation failed") + + local_tdo_forest = lsa.ForestTrustInformation() + local_tdo_forest.count = 0 + local_tdo_forest.entries = [] + + self.outf.write("TrustedDomain:\n\n") + self.outf.write("NetbiosName: %s\n" % local_tdo_info.netbios_name.string) + if local_tdo_info.netbios_name.string != local_tdo_info.domain_name.string: + self.outf.write("DnsName: %s\n" % local_tdo_info.domain_name.string) + self.outf.write("SID: %s\n" % local_tdo_info.sid) + self.outf.write("Type: %s\n" % self.trustType_string(local_tdo_info.trust_type)) + self.outf.write("Direction: %s\n" % self.trustDirection_string(local_tdo_info.trust_direction)) + self.outf.write("Attributes: %s\n" % self.trustAttributes_string(local_tdo_info.trust_attributes)) + posix_offset_u32 = ctypes.c_uint32(local_tdo_posix.posix_offset).value + posix_offset_i32 = ctypes.c_int32(local_tdo_posix.posix_offset).value + self.outf.write("PosixOffset: 0x%08X (%d)\n" % (posix_offset_u32, posix_offset_i32)) + self.outf.write("kerb_EncTypes: %s\n" % self.kerb_EncTypes_string(local_tdo_enctypes.enc_types)) + + if local_tdo_info.trust_attributes & lsa.LSA_TRUST_ATTRIBUTE_FOREST_TRANSITIVE: + self.write_forest_trust_info(local_tdo_forest, + tln=local_tdo_info.domain_name.string) + + return + +class cmd_domain_trust_modify(DomainTrustCommand): + """Show trusted domain details.""" + + synopsis = "%prog NAME [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "localdcopts": LocalDCCredentialsOptions, + } + + takes_options = [ + Option("--use-aes-keys", action="store_true", + help="The trust uses AES kerberos keys.", + dest='use_aes_keys', + default=None), + Option("--no-aes-keys", action="store_true", + help="The trust does not have any support for AES kerberos keys.", + dest='disable_aes_keys', + default=None), + Option("--raw-kerb-enctypes", action="store", + help="The raw kerberos enctype bits", + dest='kerb_enctypes', + default=None), + ] + + takes_args = ["domain"] + + def run(self, domain, sambaopts=None, versionopts=None, localdcopts=None, + disable_aes_keys=None, use_aes_keys=None, kerb_enctypes=None): + + num_modifications = 0 + + enctype_args = 0 + if kerb_enctypes is not None: + enctype_args += 1 + if use_aes_keys is not None: + enctype_args += 1 + if disable_aes_keys is not None: + enctype_args += 1 + if enctype_args > 1: + raise CommandError("--no-aes-keys, --use-aes-keys and --raw-kerb-enctypes are mutually exclusive") + if enctype_args == 1: + num_modifications += 1 + + if num_modifications == 0: + raise CommandError("modification arguments are required, try --help") + + self.setup_local_server(sambaopts, localdcopts) + try: + local_lsa = self.new_local_lsa_connection() + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to connect to lsa server") + + try: + local_policy_access = lsa.LSA_POLICY_VIEW_LOCAL_INFORMATION + local_policy_access |= lsa.LSA_POLICY_TRUST_ADMIN + (local_policy, local_lsa_info) = self.get_lsa_info(local_lsa, local_policy_access) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to query LSA_POLICY_INFO_DNS") + + self.outf.write("LocalDomain Netbios[%s] DNS[%s] SID[%s]\n" % ( + local_lsa_info.name.string, + local_lsa_info.dns_domain.string, + local_lsa_info.sid)) + + if enctype_args == 1: + lsaString = lsa.String() + lsaString.string = domain + + try: + local_tdo_enctypes = \ + local_lsa.QueryTrustedDomainInfoByName(local_policy, + lsaString, + lsa.LSA_TRUSTED_DOMAIN_SUPPORTED_ENCRYPTION_TYPES) + except NTSTATUSError as error: + if self.check_runtime_error(error, ntstatus.NT_STATUS_INVALID_PARAMETER): + error = None + if self.check_runtime_error(error, ntstatus.NT_STATUS_INVALID_INFO_CLASS): + error = None + + if error is not None: + raise self.LocalRuntimeError(self, error, + "QueryTrustedDomainInfoByName(SUPPORTED_ENCRYPTION_TYPES) failed") + + local_tdo_enctypes = lsa.TrustDomainInfoSupportedEncTypes() + local_tdo_enctypes.enc_types = 0 + + self.outf.write("Old kerb_EncTypes: %s\n" % self.kerb_EncTypes_string(local_tdo_enctypes.enc_types)) + + enc_types = lsa.TrustDomainInfoSupportedEncTypes() + if kerb_enctypes is not None: + enc_types.enc_types = int(kerb_enctypes, base=0) + elif use_aes_keys is not None: + enc_types.enc_types = security.KERB_ENCTYPE_AES128_CTS_HMAC_SHA1_96 + enc_types.enc_types |= security.KERB_ENCTYPE_AES256_CTS_HMAC_SHA1_96 + elif disable_aes_keys is not None: + # CVE-2022-37966: Trust objects are no longer assumed to support + # RC4, so we must indicate support explicitly. + enc_types.enc_types = security.KERB_ENCTYPE_RC4_HMAC_MD5 + else: + raise CommandError("Internal error should be checked above") + + if enc_types.enc_types != local_tdo_enctypes.enc_types: + try: + local_tdo_enctypes = \ + local_lsa.SetTrustedDomainInfoByName(local_policy, + lsaString, + lsa.LSA_TRUSTED_DOMAIN_SUPPORTED_ENCRYPTION_TYPES, + enc_types) + self.outf.write("New kerb_EncTypes: %s\n" % self.kerb_EncTypes_string(enc_types.enc_types)) + except NTSTATUSError as error: + if error is not None: + raise self.LocalRuntimeError(self, error, + "SetTrustedDomainInfoByName(SUPPORTED_ENCRYPTION_TYPES) failed") + else: + self.outf.write("No kerb_EncTypes update needed\n") + + return + +class cmd_domain_trust_create(DomainTrustCommand): + """Create a domain or forest trust.""" + + synopsis = "%prog DOMAIN [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "credopts": options.CredentialsOptions, + "localdcopts": LocalDCCredentialsOptions, + } + + takes_options = [ + Option("--type", type="choice", metavar="TYPE", + choices=["external", "forest"], + help="The type of the trust: 'external' or 'forest'.", + dest='trust_type', + default="external"), + Option("--direction", type="choice", metavar="DIRECTION", + choices=["incoming", "outgoing", "both"], + help="The trust direction: 'incoming', 'outgoing' or 'both'.", + dest='trust_direction', + default="both"), + Option("--create-location", type="choice", metavar="LOCATION", + choices=["local", "both"], + help="Where to create the trusted domain object: 'local' or 'both'.", + dest='create_location', + default="both"), + Option("--cross-organisation", action="store_true", + help="The related domains does not belong to the same organisation.", + dest='cross_organisation', + default=False), + Option("--quarantined", type="choice", metavar="yes|no", + choices=["yes", "no", None], + help="Special SID filtering rules are applied to the trust. " + "With --type=external the default is yes. " + "With --type=forest the default is no.", + dest='quarantined_arg', + default=None), + Option("--not-transitive", action="store_true", + help="The forest trust is not transitive.", + dest='not_transitive', + default=False), + Option("--treat-as-external", action="store_true", + help="The treat the forest trust as external.", + dest='treat_as_external', + default=False), + Option("--no-aes-keys", action="store_false", + help="The trust does not use AES kerberos keys.", + dest='use_aes_keys', + default=True), + Option("--skip-validation", action="store_false", + help="Skip validation of the trust.", + dest='validate', + default=True), + ] + + takes_args = ["domain"] + + def run(self, domain, sambaopts=None, localdcopts=None, credopts=None, versionopts=None, + trust_type=None, trust_direction=None, create_location=None, + cross_organisation=False, quarantined_arg=None, + not_transitive=False, treat_as_external=False, + use_aes_keys=False, validate=True): + + lsaString = lsa.String() + + quarantined = False + if quarantined_arg is None: + if trust_type == 'external': + quarantined = True + elif quarantined_arg == 'yes': + quarantined = True + + if trust_type != 'forest': + if not_transitive: + raise CommandError("--not-transitive requires --type=forest") + if treat_as_external: + raise CommandError("--treat-as-external requires --type=forest") + + enc_types = lsa.TrustDomainInfoSupportedEncTypes() + if use_aes_keys: + enc_types.enc_types = security.KERB_ENCTYPE_AES128_CTS_HMAC_SHA1_96 + enc_types.enc_types |= security.KERB_ENCTYPE_AES256_CTS_HMAC_SHA1_96 + else: + # CVE-2022-37966: Trust objects are no longer assumed to support + # RC4, so we must indicate support explicitly. + enc_types.enc_types = security.KERB_ENCTYPE_RC4_HMAC_MD5 + + local_policy_access = lsa.LSA_POLICY_VIEW_LOCAL_INFORMATION + local_policy_access |= lsa.LSA_POLICY_TRUST_ADMIN + local_policy_access |= lsa.LSA_POLICY_CREATE_SECRET + + local_trust_info = lsa.TrustDomainInfoInfoEx() + local_trust_info.trust_type = lsa.LSA_TRUST_TYPE_UPLEVEL + local_trust_info.trust_direction = 0 + if trust_direction == "both": + local_trust_info.trust_direction |= lsa.LSA_TRUST_DIRECTION_INBOUND + local_trust_info.trust_direction |= lsa.LSA_TRUST_DIRECTION_OUTBOUND + elif trust_direction == "incoming": + local_trust_info.trust_direction |= lsa.LSA_TRUST_DIRECTION_INBOUND + elif trust_direction == "outgoing": + local_trust_info.trust_direction |= lsa.LSA_TRUST_DIRECTION_OUTBOUND + local_trust_info.trust_attributes = 0 + if cross_organisation: + local_trust_info.trust_attributes |= lsa.LSA_TRUST_ATTRIBUTE_CROSS_ORGANIZATION + if quarantined: + local_trust_info.trust_attributes |= lsa.LSA_TRUST_ATTRIBUTE_QUARANTINED_DOMAIN + if trust_type == "forest": + local_trust_info.trust_attributes |= lsa.LSA_TRUST_ATTRIBUTE_FOREST_TRANSITIVE + if not_transitive: + local_trust_info.trust_attributes |= lsa.LSA_TRUST_ATTRIBUTE_NON_TRANSITIVE + if treat_as_external: + local_trust_info.trust_attributes |= lsa.LSA_TRUST_ATTRIBUTE_TREAT_AS_EXTERNAL + + def get_password(name): + password = None + while True: + if password is not None and password != '': + return password + password = getpass("New %s Password: " % name) + passwordverify = getpass("Retype %s Password: " % name) + if not password == passwordverify: + password = None + self.outf.write("Sorry, passwords do not match.\n") + + incoming_secret = None + outgoing_secret = None + remote_policy_access = lsa.LSA_POLICY_VIEW_LOCAL_INFORMATION + if create_location == "local": + if local_trust_info.trust_direction & lsa.LSA_TRUST_DIRECTION_INBOUND: + incoming_password = get_password("Incoming Trust") + incoming_secret = string_to_byte_array(incoming_password.encode('utf-16-le')) + if local_trust_info.trust_direction & lsa.LSA_TRUST_DIRECTION_OUTBOUND: + outgoing_password = get_password("Outgoing Trust") + outgoing_secret = string_to_byte_array(outgoing_password.encode('utf-16-le')) + + remote_trust_info = None + else: + # We use 240 random bytes. + # Windows uses 28 or 240 random bytes. I guess it's + # based on the trust type external vs. forest. + # + # The initial trust password can be up to 512 bytes + # while the versioned passwords used for periodic updates + # can only be up to 498 bytes, as netr_ServerPasswordSet2() + # needs to pass the NL_PASSWORD_VERSION structure within the + # 512 bytes and a 2 bytes confounder is required. + # + def random_trust_secret(length): + pw = samba.generate_random_machine_password(length // 2, length // 2) + return string_to_byte_array(pw.encode('utf-16-le')) + + if local_trust_info.trust_direction & lsa.LSA_TRUST_DIRECTION_INBOUND: + incoming_secret = random_trust_secret(240) + if local_trust_info.trust_direction & lsa.LSA_TRUST_DIRECTION_OUTBOUND: + outgoing_secret = random_trust_secret(240) + + remote_policy_access |= lsa.LSA_POLICY_TRUST_ADMIN + remote_policy_access |= lsa.LSA_POLICY_CREATE_SECRET + + remote_trust_info = lsa.TrustDomainInfoInfoEx() + remote_trust_info.trust_type = lsa.LSA_TRUST_TYPE_UPLEVEL + remote_trust_info.trust_direction = 0 + if trust_direction == "both": + remote_trust_info.trust_direction |= lsa.LSA_TRUST_DIRECTION_INBOUND + remote_trust_info.trust_direction |= lsa.LSA_TRUST_DIRECTION_OUTBOUND + elif trust_direction == "incoming": + remote_trust_info.trust_direction |= lsa.LSA_TRUST_DIRECTION_OUTBOUND + elif trust_direction == "outgoing": + remote_trust_info.trust_direction |= lsa.LSA_TRUST_DIRECTION_INBOUND + remote_trust_info.trust_attributes = 0 + if cross_organisation: + remote_trust_info.trust_attributes |= lsa.LSA_TRUST_ATTRIBUTE_CROSS_ORGANIZATION + if quarantined: + remote_trust_info.trust_attributes |= lsa.LSA_TRUST_ATTRIBUTE_QUARANTINED_DOMAIN + if trust_type == "forest": + remote_trust_info.trust_attributes |= lsa.LSA_TRUST_ATTRIBUTE_FOREST_TRANSITIVE + if not_transitive: + remote_trust_info.trust_attributes |= lsa.LSA_TRUST_ATTRIBUTE_NON_TRANSITIVE + if treat_as_external: + remote_trust_info.trust_attributes |= lsa.LSA_TRUST_ATTRIBUTE_TREAT_AS_EXTERNAL + + local_server = self.setup_local_server(sambaopts, localdcopts) + try: + local_lsa = self.new_local_lsa_connection() + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to connect lsa server") + + try: + (local_policy, local_lsa_info) = self.get_lsa_info(local_lsa, local_policy_access) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to query LSA_POLICY_INFO_DNS") + + self.outf.write("LocalDomain Netbios[%s] DNS[%s] SID[%s]\n" % ( + local_lsa_info.name.string, + local_lsa_info.dns_domain.string, + local_lsa_info.sid)) + + try: + remote_server = self.setup_remote_server(credopts, domain) + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "failed to locate remote server") + + try: + remote_lsa = self.new_remote_lsa_connection() + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "failed to connect lsa server") + + try: + (remote_policy, remote_lsa_info) = self.get_lsa_info(remote_lsa, remote_policy_access) + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "failed to query LSA_POLICY_INFO_DNS") + + self.outf.write("RemoteDomain Netbios[%s] DNS[%s] SID[%s]\n" % ( + remote_lsa_info.name.string, + remote_lsa_info.dns_domain.string, + remote_lsa_info.sid)) + + local_trust_info.domain_name.string = remote_lsa_info.dns_domain.string + local_trust_info.netbios_name.string = remote_lsa_info.name.string + local_trust_info.sid = remote_lsa_info.sid + + if remote_trust_info: + remote_trust_info.domain_name.string = local_lsa_info.dns_domain.string + remote_trust_info.netbios_name.string = local_lsa_info.name.string + remote_trust_info.sid = local_lsa_info.sid + + try: + lsaString.string = local_trust_info.domain_name.string + local_lsa.QueryTrustedDomainInfoByName(local_policy, + lsaString, + lsa.LSA_TRUSTED_DOMAIN_INFO_FULL_INFO) + raise CommandError("TrustedDomain %s already exist'" % lsaString.string) + except NTSTATUSError as error: + if not self.check_runtime_error(error, ntstatus.NT_STATUS_OBJECT_NAME_NOT_FOUND): + raise self.LocalRuntimeError(self, error, + "QueryTrustedDomainInfoByName(%s, FULL_INFO) failed" % ( + lsaString.string)) + + try: + lsaString.string = local_trust_info.netbios_name.string + local_lsa.QueryTrustedDomainInfoByName(local_policy, + lsaString, + lsa.LSA_TRUSTED_DOMAIN_INFO_FULL_INFO) + raise CommandError("TrustedDomain %s already exist'" % lsaString.string) + except NTSTATUSError as error: + if not self.check_runtime_error(error, ntstatus.NT_STATUS_OBJECT_NAME_NOT_FOUND): + raise self.LocalRuntimeError(self, error, + "QueryTrustedDomainInfoByName(%s, FULL_INFO) failed" % ( + lsaString.string)) + + if remote_trust_info: + try: + lsaString.string = remote_trust_info.domain_name.string + remote_lsa.QueryTrustedDomainInfoByName(remote_policy, + lsaString, + lsa.LSA_TRUSTED_DOMAIN_INFO_FULL_INFO) + raise CommandError("TrustedDomain %s already exist'" % lsaString.string) + except NTSTATUSError as error: + if not self.check_runtime_error(error, ntstatus.NT_STATUS_OBJECT_NAME_NOT_FOUND): + raise self.RemoteRuntimeError(self, error, + "QueryTrustedDomainInfoByName(%s, FULL_INFO) failed" % ( + lsaString.string)) + + try: + lsaString.string = remote_trust_info.netbios_name.string + remote_lsa.QueryTrustedDomainInfoByName(remote_policy, + lsaString, + lsa.LSA_TRUSTED_DOMAIN_INFO_FULL_INFO) + raise CommandError("TrustedDomain %s already exist'" % lsaString.string) + except NTSTATUSError as error: + if not self.check_runtime_error(error, ntstatus.NT_STATUS_OBJECT_NAME_NOT_FOUND): + raise self.RemoteRuntimeError(self, error, + "QueryTrustedDomainInfoByName(%s, FULL_INFO) failed" % ( + lsaString.string)) + + try: + local_netlogon = self.new_local_netlogon_connection() + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to connect netlogon server") + + try: + local_netlogon_info = self.get_netlogon_dc_info(local_netlogon, local_server) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to get netlogon dc info") + + if remote_trust_info: + try: + remote_netlogon = self.new_remote_netlogon_connection() + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "failed to connect netlogon server") + + try: + remote_netlogon_dc_unc = self.get_netlogon_dc_unc(remote_netlogon, + remote_server, domain) + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "failed to get netlogon dc info") + + def generate_AuthInOutBlob(secret, update_time): + if secret is None: + blob = drsblobs.trustAuthInOutBlob() + blob.count = 0 + + return blob + + clear = drsblobs.AuthInfoClear() + clear.size = len(secret) + clear.password = secret + + info = drsblobs.AuthenticationInformation() + info.LastUpdateTime = samba.unix2nttime(update_time) + info.AuthType = lsa.TRUST_AUTH_TYPE_CLEAR + info.AuthInfo = clear + + array = drsblobs.AuthenticationInformationArray() + array.count = 1 + array.array = [info] + + blob = drsblobs.trustAuthInOutBlob() + blob.count = 1 + blob.current = array + + return blob + + update_time = samba.current_unix_time() + incoming_blob = generate_AuthInOutBlob(incoming_secret, update_time) + outgoing_blob = generate_AuthInOutBlob(outgoing_secret, update_time) + + local_tdo_handle = None + remote_tdo_handle = None + + try: + if remote_trust_info: + self.outf.write("Creating remote TDO.\n") + current_request = {"location": "remote", "name": "CreateTrustedDomainEx2"} + remote_tdo_handle = CreateTrustedDomainRelax(remote_lsa, + remote_policy, + remote_trust_info, + lsa.LSA_TRUSTED_DOMAIN_ALL_ACCESS, + outgoing_blob, + incoming_blob) + self.outf.write("Remote TDO created.\n") + if enc_types: + self.outf.write("Setting supported encryption types on remote TDO.\n") + current_request = {"location": "remote", "name": "SetInformationTrustedDomain"} + remote_lsa.SetInformationTrustedDomain(remote_tdo_handle, + lsa.LSA_TRUSTED_DOMAIN_SUPPORTED_ENCRYPTION_TYPES, + enc_types) + + self.outf.write("Creating local TDO.\n") + current_request = {"location": "local", "name": "CreateTrustedDomainEx2"} + local_tdo_handle = CreateTrustedDomainRelax(local_lsa, + local_policy, + local_trust_info, + lsa.LSA_TRUSTED_DOMAIN_ALL_ACCESS, + incoming_blob, + outgoing_blob) + self.outf.write("Local TDO created\n") + if enc_types: + self.outf.write("Setting supported encryption types on local TDO.\n") + current_request = {"location": "local", "name": "SetInformationTrustedDomain"} + local_lsa.SetInformationTrustedDomain(local_tdo_handle, + lsa.LSA_TRUSTED_DOMAIN_SUPPORTED_ENCRYPTION_TYPES, + enc_types) + except RuntimeError as error: + self.outf.write("Error: %s failed %sly - cleaning up\n" % ( + current_request['name'], current_request['location'])) + if remote_tdo_handle: + self.outf.write("Deleting remote TDO.\n") + remote_lsa.DeleteObject(remote_tdo_handle) + remote_tdo_handle = None + if local_tdo_handle: + self.outf.write("Deleting local TDO.\n") + local_lsa.DeleteObject(local_tdo_handle) + local_tdo_handle = None + if current_request['location'] == "remote": + raise self.RemoteRuntimeError(self, error, "%s" % ( + current_request['name'])) + raise self.LocalRuntimeError(self, error, "%s" % ( + current_request['name'])) + + if validate: + if local_trust_info.trust_attributes & lsa.LSA_TRUST_ATTRIBUTE_FOREST_TRANSITIVE: + self.outf.write("Setup local forest trust information...\n") + try: + # get all information about the remote trust + # this triggers netr_GetForestTrustInformation to the remote domain + # and lsaRSetForestTrustInformation() locally, but new top level + # names are disabled by default. + local_forest_info = \ + local_netlogon.netr_DsRGetForestTrustInformation(local_netlogon_info.dc_unc, + remote_lsa_info.dns_domain.string, + netlogon.DS_GFTI_UPDATE_TDO) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "netr_DsRGetForestTrustInformation() failed") + + try: + # here we try to enable all top level names + local_forest_collision = \ + local_lsa.lsaRSetForestTrustInformation(local_policy, + remote_lsa_info.dns_domain, + lsa.LSA_FOREST_TRUST_DOMAIN_INFO, + local_forest_info, + 0) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "lsaRSetForestTrustInformation() failed") + + self.write_forest_trust_info(local_forest_info, + tln=remote_lsa_info.dns_domain.string, + collisions=local_forest_collision) + + if remote_trust_info: + self.outf.write("Setup remote forest trust information...\n") + try: + # get all information about the local trust (from the perspective of the remote domain) + # this triggers netr_GetForestTrustInformation to our domain. + # and lsaRSetForestTrustInformation() remotely, but new top level + # names are disabled by default. + remote_forest_info = \ + remote_netlogon.netr_DsRGetForestTrustInformation(remote_netlogon_dc_unc, + local_lsa_info.dns_domain.string, + netlogon.DS_GFTI_UPDATE_TDO) + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "netr_DsRGetForestTrustInformation() failed") + + try: + # here we try to enable all top level names + remote_forest_collision = \ + remote_lsa.lsaRSetForestTrustInformation(remote_policy, + local_lsa_info.dns_domain, + lsa.LSA_FOREST_TRUST_DOMAIN_INFO, + remote_forest_info, + 0) + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "lsaRSetForestTrustInformation() failed") + + self.write_forest_trust_info(remote_forest_info, + tln=local_lsa_info.dns_domain.string, + collisions=remote_forest_collision) + + if local_trust_info.trust_direction & lsa.LSA_TRUST_DIRECTION_OUTBOUND: + self.outf.write("Validating outgoing trust...\n") + try: + local_trust_verify = local_netlogon.netr_LogonControl2Ex(local_netlogon_info.dc_unc, + netlogon.NETLOGON_CONTROL_TC_VERIFY, + 2, + remote_lsa_info.dns_domain.string) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "NETLOGON_CONTROL_TC_VERIFY failed") + + local_trust_status = self._uint32(local_trust_verify.pdc_connection_status[0]) + local_conn_status = self._uint32(local_trust_verify.tc_connection_status[0]) + + if local_trust_verify.flags & netlogon.NETLOGON_VERIFY_STATUS_RETURNED: + local_validation = "LocalValidation: DC[%s] CONNECTION[%s] TRUST[%s] VERIFY_STATUS_RETURNED" % ( + local_trust_verify.trusted_dc_name, + local_trust_verify.tc_connection_status[1], + local_trust_verify.pdc_connection_status[1]) + else: + local_validation = "LocalValidation: DC[%s] CONNECTION[%s] TRUST[%s]" % ( + local_trust_verify.trusted_dc_name, + local_trust_verify.tc_connection_status[1], + local_trust_verify.pdc_connection_status[1]) + + if local_trust_status != werror.WERR_SUCCESS or local_conn_status != werror.WERR_SUCCESS: + raise CommandError(local_validation) + else: + self.outf.write("OK: %s\n" % local_validation) + + if remote_trust_info: + if remote_trust_info.trust_direction & lsa.LSA_TRUST_DIRECTION_OUTBOUND: + self.outf.write("Validating incoming trust...\n") + try: + remote_trust_verify = \ + remote_netlogon.netr_LogonControl2Ex(remote_netlogon_dc_unc, + netlogon.NETLOGON_CONTROL_TC_VERIFY, + 2, + local_lsa_info.dns_domain.string) + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "NETLOGON_CONTROL_TC_VERIFY failed") + + remote_trust_status = self._uint32(remote_trust_verify.pdc_connection_status[0]) + remote_conn_status = self._uint32(remote_trust_verify.tc_connection_status[0]) + + if remote_trust_verify.flags & netlogon.NETLOGON_VERIFY_STATUS_RETURNED: + remote_validation = "RemoteValidation: DC[%s] CONNECTION[%s] TRUST[%s] VERIFY_STATUS_RETURNED" % ( + remote_trust_verify.trusted_dc_name, + remote_trust_verify.tc_connection_status[1], + remote_trust_verify.pdc_connection_status[1]) + else: + remote_validation = "RemoteValidation: DC[%s] CONNECTION[%s] TRUST[%s]" % ( + remote_trust_verify.trusted_dc_name, + remote_trust_verify.tc_connection_status[1], + remote_trust_verify.pdc_connection_status[1]) + + if remote_trust_status != werror.WERR_SUCCESS or remote_conn_status != werror.WERR_SUCCESS: + raise CommandError(remote_validation) + else: + self.outf.write("OK: %s\n" % remote_validation) + + if remote_tdo_handle is not None: + try: + remote_lsa.Close(remote_tdo_handle) + except RuntimeError: + pass + remote_tdo_handle = None + if local_tdo_handle is not None: + try: + local_lsa.Close(local_tdo_handle) + except RuntimeError: + pass + local_tdo_handle = None + + self.outf.write("Success.\n") + return + + +class cmd_domain_trust_delete(DomainTrustCommand): + """Delete a domain trust.""" + + synopsis = "%prog DOMAIN [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "credopts": options.CredentialsOptions, + "localdcopts": LocalDCCredentialsOptions, + } + + takes_options = [ + Option("--delete-location", type="choice", metavar="LOCATION", + choices=["local", "both"], + help="Where to delete the trusted domain object: 'local' or 'both'.", + dest='delete_location', + default="both"), + ] + + takes_args = ["domain"] + + def run(self, domain, sambaopts=None, localdcopts=None, credopts=None, versionopts=None, + delete_location=None): + + local_policy_access = lsa.LSA_POLICY_VIEW_LOCAL_INFORMATION + local_policy_access |= lsa.LSA_POLICY_TRUST_ADMIN + local_policy_access |= lsa.LSA_POLICY_CREATE_SECRET + + if delete_location == "local": + remote_policy_access = None + else: + remote_policy_access = lsa.LSA_POLICY_VIEW_LOCAL_INFORMATION + remote_policy_access |= lsa.LSA_POLICY_TRUST_ADMIN + remote_policy_access |= lsa.LSA_POLICY_CREATE_SECRET + + self.setup_local_server(sambaopts, localdcopts) + try: + local_lsa = self.new_local_lsa_connection() + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to connect lsa server") + + try: + (local_policy, local_lsa_info) = self.get_lsa_info(local_lsa, local_policy_access) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to query LSA_POLICY_INFO_DNS") + + self.outf.write("LocalDomain Netbios[%s] DNS[%s] SID[%s]\n" % ( + local_lsa_info.name.string, + local_lsa_info.dns_domain.string, + local_lsa_info.sid)) + + local_tdo_info = None + local_tdo_handle = None + remote_tdo_info = None + remote_tdo_handle = None + + lsaString = lsa.String() + try: + lsaString.string = domain + local_tdo_info = local_lsa.QueryTrustedDomainInfoByName(local_policy, + lsaString, lsa.LSA_TRUSTED_DOMAIN_INFO_INFO_EX) + except NTSTATUSError as error: + if self.check_runtime_error(error, ntstatus.NT_STATUS_OBJECT_NAME_NOT_FOUND): + raise CommandError("Failed to find trust for domain '%s'" % domain) + raise self.RemoteRuntimeError(self, error, "failed to locate remote server") + + if remote_policy_access is not None: + try: + self.setup_remote_server(credopts, domain) + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "failed to locate remote server") + + try: + remote_lsa = self.new_remote_lsa_connection() + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "failed to connect lsa server") + + try: + (remote_policy, remote_lsa_info) = self.get_lsa_info(remote_lsa, remote_policy_access) + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "failed to query LSA_POLICY_INFO_DNS") + + self.outf.write("RemoteDomain Netbios[%s] DNS[%s] SID[%s]\n" % ( + remote_lsa_info.name.string, + remote_lsa_info.dns_domain.string, + remote_lsa_info.sid)) + + if remote_lsa_info.sid != local_tdo_info.sid or \ + remote_lsa_info.name.string != local_tdo_info.netbios_name.string or \ + remote_lsa_info.dns_domain.string != local_tdo_info.domain_name.string: + raise CommandError("LocalTDO inconsistent: Netbios[%s] DNS[%s] SID[%s]" % ( + local_tdo_info.netbios_name.string, + local_tdo_info.domain_name.string, + local_tdo_info.sid)) + + try: + lsaString.string = local_lsa_info.dns_domain.string + remote_tdo_info = \ + remote_lsa.QueryTrustedDomainInfoByName(remote_policy, + lsaString, + lsa.LSA_TRUSTED_DOMAIN_INFO_INFO_EX) + except NTSTATUSError as error: + if not self.check_runtime_error(error, ntstatus.NT_STATUS_OBJECT_NAME_NOT_FOUND): + raise self.RemoteRuntimeError(self, error, "QueryTrustedDomainInfoByName(%s)" % ( + lsaString.string)) + + if remote_tdo_info is not None: + if local_lsa_info.sid != remote_tdo_info.sid or \ + local_lsa_info.name.string != remote_tdo_info.netbios_name.string or \ + local_lsa_info.dns_domain.string != remote_tdo_info.domain_name.string: + raise CommandError("RemoteTDO inconsistent: Netbios[%s] DNS[%s] SID[%s]" % ( + remote_tdo_info.netbios_name.string, + remote_tdo_info.domain_name.string, + remote_tdo_info.sid)) + + if local_tdo_info is not None: + try: + lsaString.string = local_tdo_info.domain_name.string + local_tdo_handle = \ + local_lsa.OpenTrustedDomainByName(local_policy, + lsaString, + security.SEC_STD_DELETE) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "OpenTrustedDomainByName(%s)" % ( + lsaString.string)) + + local_lsa.DeleteObject(local_tdo_handle) + local_tdo_handle = None + + if remote_tdo_info is not None: + try: + lsaString.string = remote_tdo_info.domain_name.string + remote_tdo_handle = \ + remote_lsa.OpenTrustedDomainByName(remote_policy, + lsaString, + security.SEC_STD_DELETE) + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "OpenTrustedDomainByName(%s)" % ( + lsaString.string)) + + if remote_tdo_handle is not None: + try: + remote_lsa.DeleteObject(remote_tdo_handle) + remote_tdo_handle = None + self.outf.write("RemoteTDO deleted.\n") + except RuntimeError as error: + self.outf.write("%s\n" % self.RemoteRuntimeError(self, error, "DeleteObject() failed")) + + return + + +class cmd_domain_trust_validate(DomainTrustCommand): + """Validate a domain trust.""" + + synopsis = "%prog DOMAIN [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "credopts": options.CredentialsOptions, + "localdcopts": LocalDCCredentialsOptions, + } + + takes_options = [ + Option("--validate-location", type="choice", metavar="LOCATION", + choices=["local", "both"], + help="Where to validate the trusted domain object: 'local' or 'both'.", + dest='validate_location', + default="both"), + ] + + takes_args = ["domain"] + + def run(self, domain, sambaopts=None, versionopts=None, credopts=None, localdcopts=None, + validate_location=None): + + local_policy_access = lsa.LSA_POLICY_VIEW_LOCAL_INFORMATION + + local_server = self.setup_local_server(sambaopts, localdcopts) + try: + local_lsa = self.new_local_lsa_connection() + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to connect lsa server") + + try: + (local_policy, local_lsa_info) = self.get_lsa_info(local_lsa, local_policy_access) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to query LSA_POLICY_INFO_DNS") + + self.outf.write("LocalDomain Netbios[%s] DNS[%s] SID[%s]\n" % ( + local_lsa_info.name.string, + local_lsa_info.dns_domain.string, + local_lsa_info.sid)) + + try: + lsaString = lsa.String() + lsaString.string = domain + local_tdo_info = \ + local_lsa.QueryTrustedDomainInfoByName(local_policy, + lsaString, + lsa.LSA_TRUSTED_DOMAIN_INFO_INFO_EX) + except NTSTATUSError as error: + if self.check_runtime_error(error, ntstatus.NT_STATUS_OBJECT_NAME_NOT_FOUND): + raise CommandError("trusted domain object does not exist for domain [%s]" % domain) + + raise self.LocalRuntimeError(self, error, "QueryTrustedDomainInfoByName(INFO_EX) failed") + + self.outf.write("LocalTDO Netbios[%s] DNS[%s] SID[%s]\n" % ( + local_tdo_info.netbios_name.string, + local_tdo_info.domain_name.string, + local_tdo_info.sid)) + + try: + local_netlogon = self.new_local_netlogon_connection() + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to connect netlogon server") + + try: + local_trust_verify = \ + local_netlogon.netr_LogonControl2Ex(local_server, + netlogon.NETLOGON_CONTROL_TC_VERIFY, + 2, + local_tdo_info.domain_name.string) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "NETLOGON_CONTROL_TC_VERIFY failed") + + local_trust_status = self._uint32(local_trust_verify.pdc_connection_status[0]) + local_conn_status = self._uint32(local_trust_verify.tc_connection_status[0]) + + if local_trust_verify.flags & netlogon.NETLOGON_VERIFY_STATUS_RETURNED: + local_validation = "LocalValidation: DC[%s] CONNECTION[%s] TRUST[%s] VERIFY_STATUS_RETURNED" % ( + local_trust_verify.trusted_dc_name, + local_trust_verify.tc_connection_status[1], + local_trust_verify.pdc_connection_status[1]) + else: + local_validation = "LocalValidation: DC[%s] CONNECTION[%s] TRUST[%s]" % ( + local_trust_verify.trusted_dc_name, + local_trust_verify.tc_connection_status[1], + local_trust_verify.pdc_connection_status[1]) + + if local_trust_status != werror.WERR_SUCCESS or local_conn_status != werror.WERR_SUCCESS: + raise CommandError(local_validation) + else: + self.outf.write("OK: %s\n" % local_validation) + + try: + server = local_trust_verify.trusted_dc_name.replace('\\', '') + domain_and_server = "%s\\%s" % (local_tdo_info.domain_name.string, server) + local_trust_rediscover = \ + local_netlogon.netr_LogonControl2Ex(local_server, + netlogon.NETLOGON_CONTROL_REDISCOVER, + 2, + domain_and_server) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "NETLOGON_CONTROL_REDISCOVER failed") + + local_conn_status = self._uint32(local_trust_rediscover.tc_connection_status[0]) + local_rediscover = "LocalRediscover: DC[%s] CONNECTION[%s]" % ( + local_trust_rediscover.trusted_dc_name, + local_trust_rediscover.tc_connection_status[1]) + + if local_conn_status != werror.WERR_SUCCESS: + raise CommandError(local_rediscover) + else: + self.outf.write("OK: %s\n" % local_rediscover) + + if validate_location != "local": + try: + remote_server = self.setup_remote_server(credopts, domain, require_pdc=False) + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "failed to locate remote server") + + try: + remote_netlogon = self.new_remote_netlogon_connection() + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "failed to connect netlogon server") + + try: + remote_trust_verify = \ + remote_netlogon.netr_LogonControl2Ex(remote_server, + netlogon.NETLOGON_CONTROL_TC_VERIFY, + 2, + local_lsa_info.dns_domain.string) + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "NETLOGON_CONTROL_TC_VERIFY failed") + + remote_trust_status = self._uint32(remote_trust_verify.pdc_connection_status[0]) + remote_conn_status = self._uint32(remote_trust_verify.tc_connection_status[0]) + + if remote_trust_verify.flags & netlogon.NETLOGON_VERIFY_STATUS_RETURNED: + remote_validation = "RemoteValidation: DC[%s] CONNECTION[%s] TRUST[%s] VERIFY_STATUS_RETURNED" % ( + remote_trust_verify.trusted_dc_name, + remote_trust_verify.tc_connection_status[1], + remote_trust_verify.pdc_connection_status[1]) + else: + remote_validation = "RemoteValidation: DC[%s] CONNECTION[%s] TRUST[%s]" % ( + remote_trust_verify.trusted_dc_name, + remote_trust_verify.tc_connection_status[1], + remote_trust_verify.pdc_connection_status[1]) + + if remote_trust_status != werror.WERR_SUCCESS or remote_conn_status != werror.WERR_SUCCESS: + raise CommandError(remote_validation) + else: + self.outf.write("OK: %s\n" % remote_validation) + + try: + server = remote_trust_verify.trusted_dc_name.replace('\\', '') + domain_and_server = "%s\\%s" % (local_lsa_info.dns_domain.string, server) + remote_trust_rediscover = \ + remote_netlogon.netr_LogonControl2Ex(remote_server, + netlogon.NETLOGON_CONTROL_REDISCOVER, + 2, + domain_and_server) + except RuntimeError as error: + raise self.RemoteRuntimeError(self, error, "NETLOGON_CONTROL_REDISCOVER failed") + + remote_conn_status = self._uint32(remote_trust_rediscover.tc_connection_status[0]) + + remote_rediscover = "RemoteRediscover: DC[%s] CONNECTION[%s]" % ( + remote_trust_rediscover.trusted_dc_name, + remote_trust_rediscover.tc_connection_status[1]) + + if remote_conn_status != werror.WERR_SUCCESS: + raise CommandError(remote_rediscover) + else: + self.outf.write("OK: %s\n" % remote_rediscover) + + return + + +class cmd_domain_trust_namespaces(DomainTrustCommand): + """Manage forest trust namespaces.""" + + synopsis = "%prog [DOMAIN] [options]" + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "versionopts": options.VersionOptions, + "localdcopts": LocalDCCredentialsOptions, + } + + takes_options = [ + Option("--refresh", type="choice", metavar="check|store", + choices=["check", "store", None], + help="List and maybe store refreshed forest trust information: 'check' or 'store'.", + dest='refresh', + default=None), + Option("--enable-all", action="store_true", + help="Try to update disabled entries, not allowed with --refresh=check.", + dest='enable_all', + default=False), + Option("--enable-tln", action="append", metavar='DNSDOMAIN', + help="Enable a top level name entry. Can be specified multiple times.", + dest='enable_tln', + default=[]), + Option("--disable-tln", action="append", metavar='DNSDOMAIN', + help="Disable a top level name entry. Can be specified multiple times.", + dest='disable_tln', + default=[]), + Option("--add-tln-ex", action="append", metavar='DNSDOMAIN', + help="Add a top level exclusion entry. Can be specified multiple times.", + dest='add_tln_ex', + default=[]), + Option("--delete-tln-ex", action="append", metavar='DNSDOMAIN', + help="Delete a top level exclusion entry. Can be specified multiple times.", + dest='delete_tln_ex', + default=[]), + Option("--enable-nb", action="append", metavar='NETBIOSDOMAIN', + help="Enable a netbios name in a domain entry. Can be specified multiple times.", + dest='enable_nb', + default=[]), + Option("--disable-nb", action="append", metavar='NETBIOSDOMAIN', + help="Disable a netbios name in a domain entry. Can be specified multiple times.", + dest='disable_nb', + default=[]), + Option("--enable-sid", action="append", metavar='DOMAINSID', + help="Enable a SID in a domain entry. Can be specified multiple times.", + dest='enable_sid_str', + default=[]), + Option("--disable-sid", action="append", metavar='DOMAINSID', + help="Disable a SID in a domain entry. Can be specified multiple times.", + dest='disable_sid_str', + default=[]), + Option("--add-upn-suffix", action="append", metavar='DNSDOMAIN', + help="Add a new uPNSuffixes attribute for the local forest. Can be specified multiple times.", + dest='add_upn', + default=[]), + Option("--delete-upn-suffix", action="append", metavar='DNSDOMAIN', + help="Delete an existing uPNSuffixes attribute of the local forest. Can be specified multiple times.", + dest='delete_upn', + default=[]), + Option("--add-spn-suffix", action="append", metavar='DNSDOMAIN', + help="Add a new msDS-SPNSuffixes attribute for the local forest. Can be specified multiple times.", + dest='add_spn', + default=[]), + Option("--delete-spn-suffix", action="append", metavar='DNSDOMAIN', + help="Delete an existing msDS-SPNSuffixes attribute of the local forest. Can be specified multiple times.", + dest='delete_spn', + default=[]), + ] + + takes_args = ["domain?"] + + def run(self, domain=None, sambaopts=None, localdcopts=None, versionopts=None, + refresh=None, enable_all=False, + enable_tln=None, disable_tln=None, add_tln_ex=None, delete_tln_ex=None, + enable_sid_str=None, disable_sid_str=None, enable_nb=None, disable_nb=None, + add_upn=None, delete_upn=None, add_spn=None, delete_spn=None): + + if enable_tln is None: + enable_tln = [] + if disable_tln is None: + disable_tln = [] + if add_tln_ex is None: + add_tln_ex = [] + if delete_tln_ex is None: + delete_tln_ex = [] + if enable_sid_str is None: + enable_sid_str = [] + if disable_sid_str is None: + disable_sid_str = [] + if enable_nb is None: + enable_nb = [] + if disable_nb is None: + disable_nb = [] + if add_upn is None: + add_upn = [] + if delete_upn is None: + delete_upn = [] + if add_spn is None: + add_spn = [] + if delete_spn is None: + delete_spn = [] + + require_update = False + + if domain is None: + if refresh == "store": + raise CommandError("--refresh=%s not allowed without DOMAIN" % refresh) + + if enable_all: + raise CommandError("--enable-all not allowed without DOMAIN") + + if len(enable_tln) > 0: + raise CommandError("--enable-tln not allowed without DOMAIN") + if len(disable_tln) > 0: + raise CommandError("--disable-tln not allowed without DOMAIN") + + if len(add_tln_ex) > 0: + raise CommandError("--add-tln-ex not allowed without DOMAIN") + if len(delete_tln_ex) > 0: + raise CommandError("--delete-tln-ex not allowed without DOMAIN") + + if len(enable_nb) > 0: + raise CommandError("--enable-nb not allowed without DOMAIN") + if len(disable_nb) > 0: + raise CommandError("--disable-nb not allowed without DOMAIN") + + if len(enable_sid_str) > 0: + raise CommandError("--enable-sid not allowed without DOMAIN") + if len(disable_sid_str) > 0: + raise CommandError("--disable-sid not allowed without DOMAIN") + + if len(add_upn) > 0: + for n in add_upn: + if not n.startswith("*."): + continue + raise CommandError("value[%s] specified for --add-upn-suffix should not include with '*.'" % n) + require_update = True + if len(delete_upn) > 0: + for n in delete_upn: + if not n.startswith("*."): + continue + raise CommandError("value[%s] specified for --delete-upn-suffix should not include with '*.'" % n) + require_update = True + for a in add_upn: + for d in delete_upn: + if a.lower() != d.lower(): + continue + raise CommandError("value[%s] specified for --add-upn-suffix and --delete-upn-suffix" % a) + + if len(add_spn) > 0: + for n in add_spn: + if not n.startswith("*."): + continue + raise CommandError("value[%s] specified for --add-spn-suffix should not include with '*.'" % n) + require_update = True + if len(delete_spn) > 0: + for n in delete_spn: + if not n.startswith("*."): + continue + raise CommandError("value[%s] specified for --delete-spn-suffix should not include with '*.'" % n) + require_update = True + for a in add_spn: + for d in delete_spn: + if a.lower() != d.lower(): + continue + raise CommandError("value[%s] specified for --add-spn-suffix and --delete-spn-suffix" % a) + else: + if len(add_upn) > 0: + raise CommandError("--add-upn-suffix not allowed together with DOMAIN") + if len(delete_upn) > 0: + raise CommandError("--delete-upn-suffix not allowed together with DOMAIN") + if len(add_spn) > 0: + raise CommandError("--add-spn-suffix not allowed together with DOMAIN") + if len(delete_spn) > 0: + raise CommandError("--delete-spn-suffix not allowed together with DOMAIN") + + if refresh is not None: + if refresh == "store": + require_update = True + + if enable_all and refresh != "store": + raise CommandError("--enable-all not allowed together with --refresh=%s" % refresh) + + if len(enable_tln) > 0: + raise CommandError("--enable-tln not allowed together with --refresh") + if len(disable_tln) > 0: + raise CommandError("--disable-tln not allowed together with --refresh") + + if len(add_tln_ex) > 0: + raise CommandError("--add-tln-ex not allowed together with --refresh") + if len(delete_tln_ex) > 0: + raise CommandError("--delete-tln-ex not allowed together with --refresh") + + if len(enable_nb) > 0: + raise CommandError("--enable-nb not allowed together with --refresh") + if len(disable_nb) > 0: + raise CommandError("--disable-nb not allowed together with --refresh") + + if len(enable_sid_str) > 0: + raise CommandError("--enable-sid not allowed together with --refresh") + if len(disable_sid_str) > 0: + raise CommandError("--disable-sid not allowed together with --refresh") + else: + if enable_all: + require_update = True + + if len(enable_tln) > 0: + raise CommandError("--enable-tln not allowed together with --enable-all") + + if len(enable_nb) > 0: + raise CommandError("--enable-nb not allowed together with --enable-all") + + if len(enable_sid_str) > 0: + raise CommandError("--enable-sid not allowed together with --enable-all") + + if len(enable_tln) > 0: + require_update = True + if len(disable_tln) > 0: + require_update = True + for e in enable_tln: + for d in disable_tln: + if e.lower() != d.lower(): + continue + raise CommandError("value[%s] specified for --enable-tln and --disable-tln" % e) + + if len(add_tln_ex) > 0: + for n in add_tln_ex: + if not n.startswith("*."): + continue + raise CommandError("value[%s] specified for --add-tln-ex should not include with '*.'" % n) + require_update = True + if len(delete_tln_ex) > 0: + for n in delete_tln_ex: + if not n.startswith("*."): + continue + raise CommandError("value[%s] specified for --delete-tln-ex should not include with '*.'" % n) + require_update = True + for a in add_tln_ex: + for d in delete_tln_ex: + if a.lower() != d.lower(): + continue + raise CommandError("value[%s] specified for --add-tln-ex and --delete-tln-ex" % a) + + if len(enable_nb) > 0: + require_update = True + if len(disable_nb) > 0: + require_update = True + for e in enable_nb: + for d in disable_nb: + if e.upper() != d.upper(): + continue + raise CommandError("value[%s] specified for --enable-nb and --disable-nb" % e) + + enable_sid = [] + for s in enable_sid_str: + try: + sid = security.dom_sid(s) + except (ValueError, TypeError): + raise CommandError("value[%s] specified for --enable-sid is not a valid SID" % s) + enable_sid.append(sid) + disable_sid = [] + for s in disable_sid_str: + try: + sid = security.dom_sid(s) + except (ValueError, TypeError): + raise CommandError("value[%s] specified for --disable-sid is not a valid SID" % s) + disable_sid.append(sid) + if len(enable_sid) > 0: + require_update = True + if len(disable_sid) > 0: + require_update = True + for e in enable_sid: + for d in disable_sid: + if e != d: + continue + raise CommandError("value[%s] specified for --enable-sid and --disable-sid" % e) + + local_policy_access = lsa.LSA_POLICY_VIEW_LOCAL_INFORMATION + if require_update: + local_policy_access |= lsa.LSA_POLICY_TRUST_ADMIN + + local_server = self.setup_local_server(sambaopts, localdcopts) + try: + local_lsa = self.new_local_lsa_connection() + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to connect lsa server") + + try: + (local_policy, local_lsa_info) = self.get_lsa_info(local_lsa, local_policy_access) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to query LSA_POLICY_INFO_DNS") + + self.outf.write("LocalDomain Netbios[%s] DNS[%s] SID[%s]\n" % ( + local_lsa_info.name.string, + local_lsa_info.dns_domain.string, + local_lsa_info.sid)) + + if domain is None: + try: + local_netlogon = self.new_local_netlogon_connection() + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to connect netlogon server") + + try: + local_netlogon_info = self.get_netlogon_dc_info(local_netlogon, local_server) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to get netlogon dc info") + + if local_netlogon_info.domain_name != local_netlogon_info.forest_name: + raise CommandError("The local domain [%s] is not the forest root [%s]" % ( + local_netlogon_info.domain_name, + local_netlogon_info.forest_name)) + + try: + # get all information about our own forest + own_forest_info = local_netlogon.netr_DsRGetForestTrustInformation(local_netlogon_info.dc_unc, + None, 0) + except RuntimeError as error: + if self.check_runtime_error(error, werror.WERR_RPC_S_PROCNUM_OUT_OF_RANGE): + raise CommandError("LOCAL_DC[%s]: netr_DsRGetForestTrustInformation() not supported." % ( + local_server)) + + if self.check_runtime_error(error, werror.WERR_INVALID_FUNCTION): + raise CommandError("LOCAL_DC[%s]: netr_DsRGetForestTrustInformation() not supported." % ( + local_server)) + + if self.check_runtime_error(error, werror.WERR_NERR_ACFNOTLOADED): + raise CommandError("LOCAL_DC[%s]: netr_DsRGetForestTrustInformation() not supported." % ( + local_server)) + + raise self.LocalRuntimeError(self, error, "netr_DsRGetForestTrustInformation() failed") + + self.outf.write("Own forest trust information...\n") + self.write_forest_trust_info(own_forest_info, + tln=local_lsa_info.dns_domain.string) + + try: + local_samdb = self.new_local_ldap_connection() + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to connect to SamDB") + + local_partitions_dn = "CN=Partitions,%s" % str(local_samdb.get_config_basedn()) + attrs = ['uPNSuffixes', 'msDS-SPNSuffixes'] + try: + msgs = local_samdb.search(base=local_partitions_dn, + scope=ldb.SCOPE_BASE, + expression="(objectClass=crossRefContainer)", + attrs=attrs) + stored_msg = msgs[0] + except ldb.LdbError as error: + raise self.LocalLdbError(self, error, "failed to search partition dn") + + stored_upn_vals = [] + if 'uPNSuffixes' in stored_msg: + stored_upn_vals.extend(stored_msg['uPNSuffixes']) + + stored_spn_vals = [] + if 'msDS-SPNSuffixes' in stored_msg: + stored_spn_vals.extend(stored_msg['msDS-SPNSuffixes']) + + self.outf.write("Stored uPNSuffixes attributes[%d]:\n" % len(stored_upn_vals)) + for v in stored_upn_vals: + self.outf.write("TLN: %-32s DNS[*.%s]\n" % ("", v)) + self.outf.write("Stored msDS-SPNSuffixes attributes[%d]:\n" % len(stored_spn_vals)) + for v in stored_spn_vals: + self.outf.write("TLN: %-32s DNS[*.%s]\n" % ("", v)) + + if not require_update: + return + + replace_upn = False + update_upn_vals = [] + update_upn_vals.extend(stored_upn_vals) + + replace_spn = False + update_spn_vals = [] + update_spn_vals.extend(stored_spn_vals) + + for upn in add_upn: + for v in update_upn_vals: + if str(v).lower() == upn.lower(): + raise CommandError("Entry already present for " + "value[%s] specified for " + "--add-upn-suffix" % upn) + update_upn_vals.append(upn) + replace_upn = True + + for upn in delete_upn: + idx = None + for i, v in enumerate(update_upn_vals): + if str(v).lower() != upn.lower(): + continue + idx = i + break + if idx is None: + raise CommandError("Entry not found for value[%s] specified for --delete-upn-suffix" % upn) + + update_upn_vals.pop(idx) + replace_upn = True + + for spn in add_spn: + for v in update_spn_vals: + if str(v).lower() == spn.lower(): + raise CommandError("Entry already present for " + "value[%s] specified for " + "--add-spn-suffix" % spn) + update_spn_vals.append(spn) + replace_spn = True + + for spn in delete_spn: + idx = None + for i, v in enumerate(update_spn_vals): + if str(v).lower() != spn.lower(): + continue + idx = i + break + if idx is None: + raise CommandError("Entry not found for value[%s] specified for --delete-spn-suffix" % spn) + + update_spn_vals.pop(idx) + replace_spn = True + + self.outf.write("Update uPNSuffixes attributes[%d]:\n" % len(update_upn_vals)) + for v in update_upn_vals: + self.outf.write("TLN: %-32s DNS[*.%s]\n" % ("", v)) + self.outf.write("Update msDS-SPNSuffixes attributes[%d]:\n" % len(update_spn_vals)) + for v in update_spn_vals: + self.outf.write("TLN: %-32s DNS[*.%s]\n" % ("", v)) + + update_msg = ldb.Message() + update_msg.dn = stored_msg.dn + + if replace_upn: + update_msg['uPNSuffixes'] = ldb.MessageElement(update_upn_vals, + ldb.FLAG_MOD_REPLACE, + 'uPNSuffixes') + if replace_spn: + update_msg['msDS-SPNSuffixes'] = ldb.MessageElement(update_spn_vals, + ldb.FLAG_MOD_REPLACE, + 'msDS-SPNSuffixes') + try: + local_samdb.modify(update_msg) + except ldb.LdbError as error: + raise self.LocalLdbError(self, error, "failed to update partition dn") + + try: + stored_forest_info = local_netlogon.netr_DsRGetForestTrustInformation(local_netlogon_info.dc_unc, + None, 0) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "netr_DsRGetForestTrustInformation() failed") + + self.outf.write("Stored forest trust information...\n") + self.write_forest_trust_info(stored_forest_info, + tln=local_lsa_info.dns_domain.string) + return + + try: + lsaString = lsa.String() + lsaString.string = domain + local_tdo_info = \ + local_lsa.QueryTrustedDomainInfoByName(local_policy, + lsaString, + lsa.LSA_TRUSTED_DOMAIN_INFO_INFO_EX) + except NTSTATUSError as error: + if self.check_runtime_error(error, ntstatus.NT_STATUS_OBJECT_NAME_NOT_FOUND): + raise CommandError("trusted domain object does not exist for domain [%s]" % domain) + + raise self.LocalRuntimeError(self, error, "QueryTrustedDomainInfoByName(INFO_EX) failed") + + self.outf.write("LocalTDO Netbios[%s] DNS[%s] SID[%s]\n" % ( + local_tdo_info.netbios_name.string, + local_tdo_info.domain_name.string, + local_tdo_info.sid)) + + if not local_tdo_info.trust_attributes & lsa.LSA_TRUST_ATTRIBUTE_FOREST_TRANSITIVE: + raise CommandError("trusted domain object for domain [%s] is not marked as FOREST_TRANSITIVE." % domain) + + if refresh is not None: + try: + local_netlogon = self.new_local_netlogon_connection() + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to connect netlogon server") + + try: + local_netlogon_info = self.get_netlogon_dc_info(local_netlogon, local_server) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "failed to get netlogon dc info") + + lsa_update_check = 1 + if refresh == "store": + netlogon_update_tdo = netlogon.DS_GFTI_UPDATE_TDO + if enable_all: + lsa_update_check = 0 + else: + netlogon_update_tdo = 0 + + try: + # get all information about the remote trust + # this triggers netr_GetForestTrustInformation to the remote domain + # and lsaRSetForestTrustInformation() locally, but new top level + # names are disabled by default. + fresh_forest_info = \ + local_netlogon.netr_DsRGetForestTrustInformation(local_netlogon_info.dc_unc, + local_tdo_info.domain_name.string, + netlogon_update_tdo) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "netr_DsRGetForestTrustInformation() failed") + + try: + fresh_forest_collision = \ + local_lsa.lsaRSetForestTrustInformation(local_policy, + local_tdo_info.domain_name, + lsa.LSA_FOREST_TRUST_DOMAIN_INFO, + fresh_forest_info, + lsa_update_check) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "lsaRSetForestTrustInformation() failed") + + self.outf.write("Fresh forest trust information...\n") + self.write_forest_trust_info(fresh_forest_info, + tln=local_tdo_info.domain_name.string, + collisions=fresh_forest_collision) + + if refresh == "store": + try: + lsaString = lsa.String() + lsaString.string = local_tdo_info.domain_name.string + stored_forest_info = \ + local_lsa.lsaRQueryForestTrustInformation(local_policy, + lsaString, + lsa.LSA_FOREST_TRUST_DOMAIN_INFO) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "lsaRQueryForestTrustInformation() failed") + + self.outf.write("Stored forest trust information...\n") + self.write_forest_trust_info(stored_forest_info, + tln=local_tdo_info.domain_name.string) + + return + + # + # The none --refresh path + # + + try: + lsaString = lsa.String() + lsaString.string = local_tdo_info.domain_name.string + local_forest_info = \ + local_lsa.lsaRQueryForestTrustInformation(local_policy, + lsaString, + lsa.LSA_FOREST_TRUST_DOMAIN_INFO) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "lsaRQueryForestTrustInformation() failed") + + self.outf.write("Local forest trust information...\n") + self.write_forest_trust_info(local_forest_info, + tln=local_tdo_info.domain_name.string) + + if not require_update: + return + + entries = [] + entries.extend(local_forest_info.entries) + update_forest_info = lsa.ForestTrustInformation() + update_forest_info.count = len(entries) + update_forest_info.entries = entries + + if enable_all: + for r in update_forest_info.entries: + if r.type != lsa.LSA_FOREST_TRUST_TOP_LEVEL_NAME: + continue + if r.flags == 0: + continue + r.time = 0 + r.flags &= ~lsa.LSA_TLN_DISABLED_MASK + for r in update_forest_info.entries: + if r.type != lsa.LSA_FOREST_TRUST_DOMAIN_INFO: + continue + if r.flags == 0: + continue + r.time = 0 + r.flags &= ~lsa.LSA_NB_DISABLED_MASK + r.flags &= ~lsa.LSA_SID_DISABLED_MASK + + for tln in enable_tln: + idx = None + for i, r in enumerate(update_forest_info.entries): + if r.type != lsa.LSA_FOREST_TRUST_TOP_LEVEL_NAME: + continue + if r.forest_trust_data.string.lower() != tln.lower(): + continue + idx = i + break + if idx is None: + raise CommandError("Entry not found for value[%s] specified for --enable-tln" % tln) + if not update_forest_info.entries[idx].flags & lsa.LSA_TLN_DISABLED_MASK: + raise CommandError("Entry found for value[%s] specified for --enable-tln is already enabled" % tln) + update_forest_info.entries[idx].time = 0 + update_forest_info.entries[idx].flags &= ~lsa.LSA_TLN_DISABLED_MASK + + for tln in disable_tln: + idx = None + for i, r in enumerate(update_forest_info.entries): + if r.type != lsa.LSA_FOREST_TRUST_TOP_LEVEL_NAME: + continue + if r.forest_trust_data.string.lower() != tln.lower(): + continue + idx = i + break + if idx is None: + raise CommandError("Entry not found for value[%s] specified for --disable-tln" % tln) + if update_forest_info.entries[idx].flags & lsa.LSA_TLN_DISABLED_ADMIN: + raise CommandError("Entry found for value[%s] specified for --disable-tln is already disabled" % tln) + update_forest_info.entries[idx].time = 0 + update_forest_info.entries[idx].flags &= ~lsa.LSA_TLN_DISABLED_MASK + update_forest_info.entries[idx].flags |= lsa.LSA_TLN_DISABLED_ADMIN + + for tln_ex in add_tln_ex: + idx = None + for i, r in enumerate(update_forest_info.entries): + if r.type != lsa.LSA_FOREST_TRUST_TOP_LEVEL_NAME_EX: + continue + if r.forest_trust_data.string.lower() != tln_ex.lower(): + continue + idx = i + break + if idx is not None: + raise CommandError("Entry already present for value[%s] specified for --add-tln-ex" % tln_ex) + + tln_dot = ".%s" % tln_ex.lower() + idx = None + for i, r in enumerate(update_forest_info.entries): + if r.type != lsa.LSA_FOREST_TRUST_TOP_LEVEL_NAME: + continue + r_dot = ".%s" % r.forest_trust_data.string.lower() + if tln_dot == r_dot: + raise CommandError("TLN entry present for value[%s] specified for --add-tln-ex" % tln_ex) + if not tln_dot.endswith(r_dot): + continue + idx = i + break + + if idx is None: + raise CommandError("No TLN parent present for value[%s] specified for --add-tln-ex" % tln_ex) + + r = lsa.ForestTrustRecord() + r.type = lsa.LSA_FOREST_TRUST_TOP_LEVEL_NAME_EX + r.flags = 0 + r.time = 0 + r.forest_trust_data.string = tln_ex + + entries = [] + entries.extend(update_forest_info.entries) + entries.insert(idx + 1, r) + update_forest_info.count = len(entries) + update_forest_info.entries = entries + + for tln_ex in delete_tln_ex: + idx = None + for i, r in enumerate(update_forest_info.entries): + if r.type != lsa.LSA_FOREST_TRUST_TOP_LEVEL_NAME_EX: + continue + if r.forest_trust_data.string.lower() != tln_ex.lower(): + continue + idx = i + break + if idx is None: + raise CommandError("Entry not found for value[%s] specified for --delete-tln-ex" % tln_ex) + + entries = [] + entries.extend(update_forest_info.entries) + entries.pop(idx) + update_forest_info.count = len(entries) + update_forest_info.entries = entries + + for nb in enable_nb: + idx = None + for i, r in enumerate(update_forest_info.entries): + if r.type != lsa.LSA_FOREST_TRUST_DOMAIN_INFO: + continue + if r.forest_trust_data.netbios_domain_name.string.upper() != nb.upper(): + continue + idx = i + break + if idx is None: + raise CommandError("Entry not found for value[%s] specified for --enable-nb" % nb) + if not update_forest_info.entries[idx].flags & lsa.LSA_NB_DISABLED_MASK: + raise CommandError("Entry found for value[%s] specified for --enable-nb is already enabled" % nb) + update_forest_info.entries[idx].time = 0 + update_forest_info.entries[idx].flags &= ~lsa.LSA_NB_DISABLED_MASK + + for nb in disable_nb: + idx = None + for i, r in enumerate(update_forest_info.entries): + if r.type != lsa.LSA_FOREST_TRUST_DOMAIN_INFO: + continue + if r.forest_trust_data.netbios_domain_name.string.upper() != nb.upper(): + continue + idx = i + break + if idx is None: + raise CommandError("Entry not found for value[%s] specified for --delete-nb" % nb) + if update_forest_info.entries[idx].flags & lsa.LSA_NB_DISABLED_ADMIN: + raise CommandError("Entry found for value[%s] specified for --disable-nb is already disabled" % nb) + update_forest_info.entries[idx].time = 0 + update_forest_info.entries[idx].flags &= ~lsa.LSA_NB_DISABLED_MASK + update_forest_info.entries[idx].flags |= lsa.LSA_NB_DISABLED_ADMIN + + for sid in enable_sid: + idx = None + for i, r in enumerate(update_forest_info.entries): + if r.type != lsa.LSA_FOREST_TRUST_DOMAIN_INFO: + continue + if r.forest_trust_data.domain_sid != sid: + continue + idx = i + break + if idx is None: + raise CommandError("Entry not found for value[%s] specified for --enable-sid" % sid) + if not update_forest_info.entries[idx].flags & lsa.LSA_SID_DISABLED_MASK: + raise CommandError("Entry found for value[%s] specified for --enable-sid is already enabled" % nb) + update_forest_info.entries[idx].time = 0 + update_forest_info.entries[idx].flags &= ~lsa.LSA_SID_DISABLED_MASK + + for sid in disable_sid: + idx = None + for i, r in enumerate(update_forest_info.entries): + if r.type != lsa.LSA_FOREST_TRUST_DOMAIN_INFO: + continue + if r.forest_trust_data.domain_sid != sid: + continue + idx = i + break + if idx is None: + raise CommandError("Entry not found for value[%s] specified for --delete-sid" % sid) + if update_forest_info.entries[idx].flags & lsa.LSA_SID_DISABLED_ADMIN: + raise CommandError("Entry found for value[%s] specified for --disable-sid is already disabled" % nb) + update_forest_info.entries[idx].time = 0 + update_forest_info.entries[idx].flags &= ~lsa.LSA_SID_DISABLED_MASK + update_forest_info.entries[idx].flags |= lsa.LSA_SID_DISABLED_ADMIN + + try: + update_forest_collision = local_lsa.lsaRSetForestTrustInformation(local_policy, + local_tdo_info.domain_name, + lsa.LSA_FOREST_TRUST_DOMAIN_INFO, + update_forest_info, 0) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "lsaRSetForestTrustInformation() failed") + + self.outf.write("Updated forest trust information...\n") + self.write_forest_trust_info(update_forest_info, + tln=local_tdo_info.domain_name.string, + collisions=update_forest_collision) + + try: + lsaString = lsa.String() + lsaString.string = local_tdo_info.domain_name.string + stored_forest_info = local_lsa.lsaRQueryForestTrustInformation(local_policy, + lsaString, + lsa.LSA_FOREST_TRUST_DOMAIN_INFO) + except RuntimeError as error: + raise self.LocalRuntimeError(self, error, "lsaRQueryForestTrustInformation() failed") + + self.outf.write("Stored forest trust information...\n") + self.write_forest_trust_info(stored_forest_info, + tln=local_tdo_info.domain_name.string) + return + + +class cmd_domain_trust(SuperCommand): + """Domain and forest trust management.""" + + subcommands = {} + subcommands["list"] = cmd_domain_trust_list() + subcommands["show"] = cmd_domain_trust_show() + subcommands["create"] = cmd_domain_trust_create() + subcommands["modify"] = cmd_domain_trust_modify() + subcommands["delete"] = cmd_domain_trust_delete() + subcommands["validate"] = cmd_domain_trust_validate() + subcommands["namespaces"] = cmd_domain_trust_namespaces() |