From 8d0488444b129ef07d7ed309d685d32f7c01944e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Baumbach?= Date: Thu, 7 Dec 2017 21:38:28 +0100 Subject: [PATCH 1/5] samba-tool: implement computer management commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Usage: samba-tool computer Computer management. Available subcommands: create - Create a new computer. delete - Delete a computer. list - List all computers. move - Move a computer to an organizational unit/container. show - Display a computer AD object Signed-off-by: Björn Baumbach --- python/samba/netcmd/computer.py | 382 ++++++++++++++++++++++++++++++++++++++++ python/samba/netcmd/main.py | 1 + python/samba/samdb.py | 52 ++++++ 3 files changed, 435 insertions(+) create mode 100644 python/samba/netcmd/computer.py diff --git a/python/samba/netcmd/computer.py b/python/samba/netcmd/computer.py new file mode 100644 index 00000000000..2a7a0860608 --- /dev/null +++ b/python/samba/netcmd/computer.py @@ -0,0 +1,382 @@ +# machine account (computer) management +# +# Copyright Bjoern Baumbch 2018 +# +# based on user management +# Copyright Jelmer Vernooij 2010 +# Copyright Theresa Halloran 2011 +# +# 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 . +# + +import samba.getopt as options +import ldb +from samba.auth import system_session +from samba.samdb import SamDB +from samba import ( + credentials, + dsdb, + Ldb, + ) + +from samba.netcmd import ( + Command, + CommandError, + SuperCommand, + Option, + ) + +class cmd_computer_create(Command): + """Create a new computer. + +This command creates a new computer account in the Active Directory domain. +The computername specified on the command is the sAMaccountName without the +trailing $ (dollar sign). + +User accounts may represent physical entities, such as workstations. Computer +accounts are also referred to as security principals and are assigned a +security identifier (SID). + +Example1: +samba-tool computer create Computer1 -H ldap://samba.samdom.example.com \ + -Uadministrator%passw1rd + +Example1 shows how to create a new computer in the domain against a remote LDAP +server. The -H parameter is used to specify the remote target server. The -U +option is used to pass the userid and password authorized to issue the command +remotely. + +Example2: +sudo samba-tool computer create Computer2 + +Example2 shows how to create a new computer in the domain against the local +server. sudo is used so a user may run the command as root. + +Example3: +samba-tool computer create Computer3 --computerou='OU=OrgUnit' + +Example3 shows how to create a new computer in the OrgUnit organizational unit. + +""" + synopsis = "%prog [options]" + + takes_options = [ + Option("-H", "--URL", help="LDB URL for database or target server", + type=str, metavar="URL", dest="H"), + Option("--computerou", + help=("DN of alternative location (with or without domainDN " + "counterpart) to default CN=Users in which new computer " + "object will be created. E. g. 'OU='"), + type=str), + Option("--description", help="Computers's description", type=str), + Option("--prepare-oldjoin", + help="Prepare enabled machine account for oldjoin mechanism", + action="store_true"), + ] + + takes_args = ["computername"] + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "versionopts": options.VersionOptions, + } + + def run(self, computername, credopts=None, sambaopts=None, versionopts=None, + H=None, computerou=None, description=None, prepare_oldjoin=False): + + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp) + + try: + samdb = SamDB(url=H, session_info=system_session(), + credentials=creds, lp=lp) + samdb.newcomputer(computername, computerou=computerou, + description=description, + prepare_oldjoin=prepare_oldjoin) + except Exception, e: + raise CommandError("Failed to create computer '%s': " % + computername, e) + + self.outf.write("Computer '%s' created successfully\n" % computername) + +class cmd_computer_delete(Command): + """Delete a computer. + +This command deletes a computer account from the Active Directory domain. The +computername specified on the command is the sAMAccountName without the +trailing $ (dollar sign). + +Once the account is deleted, all permissions and memberships associated with +that account are deleted. If a new computer account is added with the same name +as a previously deleted account name, the new computer does not have the +previous permissions. The new account computer will be assigned a new security +identifier (SID) and permissions and memberships will have to be added. + +The command may be run from the root userid or another authorized +userid. The -H or --URL= option can be used to execute the command against +a remote server. + +Example1: +samba-tool computer delete Computer1 -H ldap://samba.samdom.example.com \ + -Uadministrator%passw1rd + +Example1 shows how to delete a computer in the domain against a remote LDAP +server. The -H parameter is used to specify the remote target server. The +--computername= and --password= options are used to pass the computername and +password of a computer that exists on the remote server and is authorized to +issue the command on that server. + +Example2: +sudo samba-tool computer delete Computer2 + +Example2 shows how to delete a computer in the domain against the local server. +sudo is used so a computer may run the command as root. + +""" + synopsis = "%prog [options]" + + takes_options = [ + Option("-H", "--URL", help="LDB URL for database or target server", + type=str, metavar="URL", dest="H"), + ] + + takes_args = ["computername"] + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "versionopts": options.VersionOptions, + } + + def run(self, computername, credopts=None, sambaopts=None, + versionopts=None, H=None): + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp, fallback_machine=True) + + samdb = SamDB(url=H, session_info=system_session(), + credentials=creds, lp=lp) + + samaccountname = computername + if not computername.endswith('$'): + samaccountname = "%s$" % computername + + filter = ("(&(sAMAccountName=%s)(sAMAccountType=%u))" % + (samaccountname, dsdb.ATYPE_WORKSTATION_TRUST)) + try: + res = samdb.search(base=samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=filter, + attrs=["userAccountControl"]) + computer_dn = res[0].dn + computer_ac = int(res[0]["userAccountControl"][0]) + except IndexError: + raise CommandError('Unable to find computer "%s"' % computername) + + computer_is_workstation = ( + computer_ac & dsdb.UF_WORKSTATION_TRUST_ACCOUNT) + if computer_is_workstation == False: + raise CommandError('Failed to remove computer "%s": ' + 'Computer is not a workstation - removal denied' + % computername) + try: + samdb.delete(computer_dn) + except Exception, e: + raise CommandError('Failed to remove computer "%s"' % + samaccountname, e) + self.outf.write("Deleted computer %s\n" % computername) + + +class cmd_computer_list(Command): + """List all computers.""" + + synopsis = "%prog [options]" + + takes_options = [ + Option("-H", "--URL", help="LDB URL for database or target server", + type=str, metavar="URL", dest="H"), + ] + + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "versionopts": options.VersionOptions, + } + + def run(self, sambaopts=None, credopts=None, versionopts=None, H=None): + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp, fallback_machine=True) + + samdb = SamDB(url=H, session_info=system_session(), + credentials=creds, lp=lp) + + filter = "(sAMAccountType=%u)" % (dsdb.ATYPE_WORKSTATION_TRUST) + + domain_dn = samdb.domain_dn() + res = samdb.search(domain_dn, scope=ldb.SCOPE_SUBTREE, + expression=filter, + attrs=["samaccountname"]) + if (len(res) == 0): + return + + for msg in res: + self.outf.write("%s\n" % msg.get("samaccountname", idx=0)) + +class cmd_computer_show(Command): + """Display a computer AD object. + +This command displays a computer account and it's attributes in the Active +Directory domain. +The computername specified on the command is the sAMAccountName. + +The command may be run from the root userid or another authorized +userid. + +The -H or --URL= option can be used to execute the command against a remote +server. + +Example1: +samba-tool computer show Computer1 -H ldap://samba.samdom.example.com \ + -U administrator + +Example1 shows how display a computers attributes in the domain against a +remote LDAP server. + +The -H parameter is used to specify the remote target server. + +Example2: +samba-tool computer show Computer2 + +Example2 shows how to display a computers attributes in the domain against a +local LDAP server. + +Example3: +samba-tool computer show Computer2 --attributes=objectSid,operatingSystem + +Example3 shows how to display a computers objectSid and operatingSystem +attribute. +""" + synopsis = "%prog [options]" + + takes_options = [ + Option("-H", "--URL", help="LDB URL for database or target server", + type=str, metavar="URL", dest="H"), + Option("--attributes", + help=("Comma separated list of attributes, " + "which will be printed."), + type=str, dest="computer_attrs"), + ] + + takes_args = ["computername"] + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "versionopts": options.VersionOptions, + } + + def run(self, computername, credopts=None, sambaopts=None, versionopts=None, + H=None, computer_attrs=None): + + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp, fallback_machine=True) + samdb = SamDB(url=H, session_info=system_session(), + credentials=creds, lp=lp) + + attrs = None + if computer_attrs: + attrs = computer_attrs.split(",") + + samaccountname = computername + if not computername.endswith('$'): + samaccountname = "%s$" % computername + + filter = ("(&(sAMAccountType=%d)(sAMAccountName=%s))" % + (dsdb.ATYPE_WORKSTATION_TRUST, + ldb.binary_encode(samaccountname))) + + domaindn = samdb.domain_dn() + + try: + res = samdb.search(base=domaindn, expression=filter, + scope=ldb.SCOPE_SUBTREE, attrs=attrs) + computer_dn = res[0].dn + except IndexError: + raise CommandError('Unable to find computer "%s"' % + samaccountname) + + for msg in res: + computer_ldif = samdb.write_ldif(msg, ldb.CHANGETYPE_NONE) + self.outf.write(computer_ldif) + +class cmd_computer_move(Command): + """Move a computer to an organizational unit/container.""" + + synopsis = "%prog computername [options]" + + takes_options = [ + Option("-H", "--URL", help="LDB URL for database or target server", + type=str, metavar="URL", dest="H"), + ] + + takes_args = [ "computername", "new_ou_dn" ] + takes_optiongroups = { + "sambaopts": options.SambaOptions, + "credopts": options.CredentialsOptions, + "versionopts": options.VersionOptions, + } + + def run(self, computername, new_ou_dn, credopts=None, sambaopts=None, + versionopts=None, H=None): + 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 = ldb.Dn(samdb, samdb.domain_dn()) + + samaccountname = computername + if not computername.endswith('$'): + samaccountname = "%s$" % computername + + filter = ("(&(sAMAccountName=%s)(sAMAccountType=%u))" % + (samaccountname, dsdb.ATYPE_WORKSTATION_TRUST)) + try: + res = samdb.search(base=domain_dn, + expression=filter, + scope=ldb.SCOPE_SUBTREE) + computer_dn = res[0].dn + except IndexError: + raise CommandError('Unable to find computer "%s"' % (computername)) + + full_new_ou_dn = ldb.Dn(samdb, new_ou_dn) + if not full_new_ou_dn.is_child_of(domain_dn): + full_new_ou_dn.add_base(domain_dn) + new_computer_dn = ldb.Dn(samdb, str(computer_dn)) + new_computer_dn.remove_base_components(len(computer_dn)-1) + new_computer_dn.add_base(full_new_ou_dn) + try: + samdb.rename(computer_dn, new_computer_dn) + except Exception, e: + raise CommandError('Failed to move computer "%s"' % computername, e) + self.outf.write('Moved computer "%s" to "%s"\n' % + (computername, new_ou_dn)) + + +class cmd_computer(SuperCommand): + """Computer management.""" + + subcommands = {} + subcommands["create"] = cmd_computer_create() + subcommands["delete"] = cmd_computer_delete() + subcommands["list"] = cmd_computer_list() + subcommands["show"] = cmd_computer_show() + subcommands["move"] = cmd_computer_move() diff --git a/python/samba/netcmd/main.py b/python/samba/netcmd/main.py index a9cf176a8c1..3b56f1a724e 100644 --- a/python/samba/netcmd/main.py +++ b/python/samba/netcmd/main.py @@ -58,6 +58,7 @@ class cmd_sambatool(SuperCommand): subcommands = cache_loader() + subcommands["computer"] = None subcommands["dbcheck"] = None subcommands["delegation"] = None subcommands["dns"] = None diff --git a/python/samba/samdb.py b/python/samba/samdb.py index 348bd212256..ecd67548685 100644 --- a/python/samba/samdb.py +++ b/python/samba/samdb.py @@ -27,6 +27,7 @@ import time import base64 import os +import re from samba import dsdb, dsdb_dns from samba.ndr import ndr_unpack, ndr_pack from samba.dcerpc import drsblobs, misc @@ -486,6 +487,57 @@ def newuser(self, username, password, else: self.transaction_commit() + def newcomputer(self, computername, computerou=None, description=None, + prepare_oldjoin=False): + """Adds a new user with additional parameters + + :param computername: Name of the new computer + :param computerou: Object container for new computer + :param description: Description of the new computer + :param prepare_oldjoin: Preset computer password for oldjoin mechanism + """ + + cn = re.sub(r"\$$", "", computername) + if cn.count('$'): + raise Exception('Illegal computername "%s"' % computername) + samaccountname = "%s$" % cn + + computercontainer_dn = "CN=Users,%s" % self.domain_dn() + if computerou: + computercontainer_dn = self.normalize_dn_in_domain(computerou) + + computer_dn = "CN=%s,%s" % (cn, computercontainer_dn) + + dnsdomain = ldb.Dn(self, + self.domain_dn()).canonical_str().replace("/", "") + ldbmessage = {"dn": computer_dn, + "sAMAccountName": samaccountname, + "objectClass": "computer", + } + + if description is not None: + ldbmessage["description"] = description + + accountcontrol = str(dsdb.UF_WORKSTATION_TRUST_ACCOUNT | + dsdb.UF_ACCOUNTDISABLE) + if prepare_oldjoin: + accountcontrol = str(dsdb.UF_WORKSTATION_TRUST_ACCOUNT) + ldbmessage["userAccountControl"] = accountcontrol + + self.transaction_start() + try: + self.add(ldbmessage) + + if prepare_oldjoin: + password = cn.lower() + self.setpassword(("(distinguishedName=%s)" % + ldb.binary_encode(computer_dn)), + password, False) + except: + self.transaction_cancel() + raise + else: + self.transaction_commit() def deleteuser(self, username): """Deletes a user From ecb258382396fed9611712b260024b4984332de7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Baumbach?= Date: Tue, 6 Feb 2018 22:11:12 +0100 Subject: [PATCH 2/5] tests/samba-tool: add tests for new computer management commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Björn Baumbach --- python/samba/tests/samba_tool/computer.py | 225 ++++++++++++++++++++++++++++++ source4/selftest/tests.py | 1 + 2 files changed, 226 insertions(+) create mode 100644 python/samba/tests/samba_tool/computer.py diff --git a/python/samba/tests/samba_tool/computer.py b/python/samba/tests/samba_tool/computer.py new file mode 100644 index 00000000000..8c378b8e51a --- /dev/null +++ b/python/samba/tests/samba_tool/computer.py @@ -0,0 +1,225 @@ +# Unix SMB/CIFS implementation. +# +# Copyright (C) Bjoern Baumbach 2018 +# +# based on group.py: +# Copyright (C) Michael Adam 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 . +# + +import os +import ldb +from samba.tests.samba_tool.base import SambaToolCmdTest +from samba import dsdb + +class ComputerCmdTestCase(SambaToolCmdTest): + """Tests for samba-tool computer subcommands""" + computers = [] + samdb = None + + def setUp(self): + super(ComputerCmdTestCase, self).setUp() + self.samdb = self.getSamDB("-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.computers = [] + self.computers.append(self._randomComputer({"name": "testcomputer1"})) + self.computers.append(self._randomComputer({"name": "testcomputer2"})) + self.computers.append(self._randomComputer({"name": "testcomputer3$"})) + self.computers.append(self._randomComputer({"name": "testcomputer4$"})) + + # setup the 4 computers and ensure they are correct + for computer in self.computers: + (result, out, err) = self._create_computer(computer) + + self.assertCmdSuccess(result, out, err) + self.assertEquals(err, "", "There shouldn't be any error message") + self.assertIn("Computer '%s' created successfully" % + computer["name"], out) + + found = self._find_computer(computer["name"]) + + self.assertIsNotNone(found) + + expectedname = computer["name"].rstrip('$') + expectedsamaccountname = computer["name"] + if not computer["name"].endswith('$'): + expectedsamaccountname = "%s$" % computer["name"] + self.assertEquals("%s" % found.get("name"), expectedname) + self.assertEquals("%s" % found.get("sAMAccountName"), + expectedsamaccountname) + self.assertEquals("%s" % found.get("description"), + computer["description"]) + + def tearDown(self): + super(ComputerCmdTestCase, self).tearDown() + # clean up all the left over computers, just in case + for computer in self.computers: + if self._find_computer(computer["name"]): + (result, out, err) = self.runsubcmd("computer", "delete", + "%s" % computer["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete computer '%s'" % + computer["name"]) + + + def test_newcomputer(self): + """This tests the "computer create" and "computer delete" commands""" + # try to create all the computers again, this should fail + for computer in self.computers: + (result, out, err) = self._create_computer(computer) + self.assertCmdFail(result, "Succeeded to create existing computer") + self.assertIn("already exists", err) + + # try to delete all the computers we just created + for computer in self.computers: + (result, out, err) = self.runsubcmd("computer", "delete", "%s" % + computer["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete computer '%s'" % + computer["name"]) + found = self._find_computer(computer["name"]) + self.assertIsNone(found, + "Deleted computer '%s' still exists" % + computer["name"]) + + # test creating computers + for computer in self.computers: + (result, out, err) = self.runsubcmd( + "computer", "create", "%s" % computer["name"], + "--description=%s" % computer["description"]) + + self.assertCmdSuccess(result, out, err) + self.assertEquals(err, "", "There shouldn't be any error message") + self.assertIn("Computer '%s' created successfully" % + computer["name"], out) + + found = self._find_computer(computer["name"]) + + expectedname = computer["name"].rstrip('$') + expectedsamaccountname = computer["name"] + if not computer["name"].endswith('$'): + expectedsamaccountname = "%s$" % computer["name"] + self.assertEquals("%s" % found.get("name"), expectedname) + self.assertEquals("%s" % found.get("sAMAccountName"), + expectedsamaccountname) + self.assertEquals("%s" % found.get("description"), + computer["description"]) + + def test_list(self): + (result, out, err) = self.runsubcmd("computer", "list") + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = ("(sAMAccountType=%u)" % + dsdb.ATYPE_WORKSTATION_TRUST) + + computerlist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["samaccountname"]) + + self.assertTrue(len(computerlist) > 0, "no computers found in samdb") + + for computerobj in computerlist: + name = computerobj.get("samaccountname", idx=0) + found = self.assertMatch(out, name, + "computer '%s' not found" % name) + + def test_move(self): + parentou = self._randomOU({"name": "parentOU"}) + (result, out, err) = self._create_ou(parentou) + self.assertCmdSuccess(result, out, err) + + for computer in self.computers: + olddn = self._find_computer(computer["name"]).get("dn") + + (result, out, err) = self.runsubcmd("computer", "move", + "%s" % computer["name"], + "OU=%s" % parentou["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to move computer '%s'" % + computer["name"]) + self.assertEquals(err, "", "There shouldn't be any error message") + self.assertIn('Moved computer "%s"' % computer["name"], out) + + found = self._find_computer(computer["name"]) + self.assertNotEquals(found.get("dn"), olddn, + ("Moved computer '%s' still exists with the " + "same dn" % computer["name"])) + computername = computer["name"].rstrip('$') + newexpecteddn = ldb.Dn(self.samdb, + "CN=%s,OU=%s,%s" % + (computername, parentou["name"], + self.samdb.domain_dn())) + self.assertEquals(found.get("dn"), newexpecteddn, + "Moved computer '%s' does not exist" % + computer["name"]) + + (result, out, err) = self.runsubcmd("computer", "move", + "%s" % computer["name"], + "%s" % olddn.parent()) + self.assertCmdSuccess(result, out, err, + "Failed to move computer '%s'" % + computer["name"]) + + (result, out, err) = self.runsubcmd("ou", "delete", + "OU=%s" % parentou["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete ou '%s'" % parentou["name"]) + + def _randomComputer(self, base={}): + """create a computer with random attribute values, you can specify base + attributes""" + + computer = { + "name": self.randomName(), + "description": self.randomName(count=100), + } + computer.update(base) + return computer + + def _randomOU(self, base={}): + """create an ou with random attribute values, you can specify base + attributes""" + + ou = { + "name": self.randomName(), + "description": self.randomName(count=100), + } + ou.update(base) + return ou + + def _create_computer(self, computer): + return self.runsubcmd("computer", "create", "%s" % computer["name"], + "--description=%s" % computer["description"]) + + def _create_ou(self, ou): + return self.runsubcmd("ou", "create", "OU=%s" % ou["name"], + "--description=%s" % ou["description"]) + + def _find_computer(self, name): + samaccountname = name + if not name.endswith('$'): + samaccountname = "%s$" % name + search_filter = ("(&(sAMAccountName=%s)(objectCategory=%s,%s))" % + (ldb.binary_encode(samaccountname), + "CN=Computer,CN=Schema,CN=Configuration", + self.samdb.domain_dn())) + computerlist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, attrs=[]) + if computerlist: + return computerlist[0] + else: + return None diff --git a/source4/selftest/tests.py b/source4/selftest/tests.py index cb5bbff7c07..1ab1fff7c16 100755 --- a/source4/selftest/tests.py +++ b/source4/selftest/tests.py @@ -610,6 +610,7 @@ def planoldpythontestsuite(env, module, name=None, extra_path=[], environ={}, ex planpythontestsuite("chgdcpass:local", "samba.tests.samba_tool.user_check_password_script") planpythontestsuite("ad_dc_ntvfs:local", "samba.tests.samba_tool.group") planpythontestsuite("ad_dc_ntvfs:local", "samba.tests.samba_tool.ou") +planpythontestsuite("ad_dc_ntvfs:local", "samba.tests.samba_tool.computer") planpythontestsuite("ad_dc:local", "samba.tests.samba_tool.ntacl") planpythontestsuite("none", "samba.tests.samba_tool.provision_password_check") planpythontestsuite("none", "samba.tests.samba_tool.help") From cc96adbcde9e97ed564a7e59be325fdd23dbe2c7 Mon Sep 17 00:00:00 2001 From: Joe Guo Date: Tue, 13 Mar 2018 16:47:58 +1300 Subject: [PATCH 3/5] samba-tool: improve computer management commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pathch is based on Björn Baumbach's work: 1. Add `--ip-address` option for create subcommand, to allow user set DNS A or AAAA records while creating the computer. 2. Delete above DNS records while deleting the computer. 3. Add `--service-principal-name` option for create command, to allow user set `servicePrincipalName` while creating the computer. 4. Tests. Signed-off-by: Joe Guo --- python/samba/netcmd/computer.py | 202 +++++++++++++++++++++++++++++- python/samba/samdb.py | 14 ++- python/samba/tests/samba_tool/computer.py | 124 ++++++++++++++++-- 3 files changed, 324 insertions(+), 16 deletions(-) diff --git a/python/samba/netcmd/computer.py b/python/samba/netcmd/computer.py index 2a7a0860608..17ae581441f 100644 --- a/python/samba/netcmd/computer.py +++ b/python/samba/netcmd/computer.py @@ -22,12 +22,22 @@ import samba.getopt as options import ldb +import socket +import samba +from samba import sd_utils +from samba.dcerpc import dnsserver, dnsp, security +from samba.dnsserver import ARecord, AAAARecord +from samba.ndr import ndr_unpack, ndr_pack, ndr_print +from samba.remove_dc import remove_dns_references from samba.auth import system_session from samba.samdb import SamDB + from samba import ( credentials, dsdb, Ldb, + werror, + WERRORError ) from samba.netcmd import ( @@ -37,6 +47,127 @@ Option, ) + +def _is_valid_ip(ip_string, address_families=None): + """Check ip string is valid address""" + # by default, check both ipv4 and ipv6 + if not address_families: + address_families = [socket.AF_INET, socket.AF_INET6] + + for address_family in address_families: + try: + socket.inet_pton(address_family, ip_string) + return True # if no error, return directly + except socket.error: + continue # Otherwise, check next family + return False + + +def _is_valid_ipv4(ip_string): + """Check ip string is valid ipv4 address""" + return _is_valid_ip(ip_string, address_families=[socket.AF_INET]) + + +def _is_valid_ipv6(ip_string): + """Check ip string is valid ipv6 address""" + return _is_valid_ip(ip_string, address_families=[socket.AF_INET6]) + + +def add_dns_records( + samdb, name, dns_conn, change_owner_sd, + server, ip_address_list, logger): + """Add DNS A or AAAA records while creating computer. """ + name = name.rstrip('$') + client_version = dnsserver.DNS_CLIENT_VERSION_LONGHORN + select_flags = dnsserver.DNS_RPC_VIEW_AUTHORITY_DATA | dnsserver.DNS_RPC_VIEW_NO_CHILDREN + zone = samdb.domain_dns_name() + name_found = True + sd_helper = sd_utils.SDUtils(samdb) + + try: + buflen, res = dns_conn.DnssrvEnumRecords2( + client_version, + 0, + server, + zone, + name, + None, + dnsp.DNS_TYPE_ALL, + select_flags, + None, + None, + ) + except WERRORError as e: + if e.args[0] == werror.WERR_DNS_ERROR_NAME_DOES_NOT_EXIST: + name_found = False + pass + + if name_found: + for rec in res.rec: + for record in rec.records: + if record.wType == dnsp.DNS_TYPE_A or record.wType == dnsp.DNS_TYPE_AAAA: + # delete record + del_rec_buf = dnsserver.DNS_RPC_RECORD_BUF() + del_rec_buf.rec = record + try: + dns_conn.DnssrvUpdateRecord2( + client_version, + 0, + server, + zone, + name, + None, + del_rec_buf, + ) + except WERRORError as e: + if e.args[0] != werror.WERR_DNS_ERROR_NAME_DOES_NOT_EXIST: + raise + + for ip_address in ip_address_list: + if _is_valid_ipv6(ip_address): + logger.info("Adding DNS AAAA record %s.%s for IPv6 IP: %s" % ( + name, zone, ip_address)) + rec = AAAARecord(ip_address) + elif _is_valid_ipv4(ip_address): + logger.info("Adding DNS A record %s.%s for IPv4 IP: %s" % ( + name, zone, ip_address)) + rec = ARecord(ip_address) + else: + raise ValueError('Invalid IP: {}'.format(ip_address)) + + # Add record + add_rec_buf = dnsserver.DNS_RPC_RECORD_BUF() + add_rec_buf.rec = rec + + dns_conn.DnssrvUpdateRecord2( + client_version, + 0, + server, + zone, + name, + add_rec_buf, + None, + ) + + if (len(ip_address_list) > 0): + domaindns_zone_dn = ldb.Dn( + samdb, + 'DC=DomainDnsZones,%s' % samdb.get_default_basedn(), + ) + + dns_a_dn, ldap_record = samdb.dns_lookup( + "%s.%s" % (name, zone), + dns_partition=domaindns_zone_dn, + ) + + # Make the DC own the DNS record, not the administrator + sd_helper.modify_sd_on_dn( + dns_a_dn, + change_owner_sd, + controls=["sd_flags:1:%d" % (security.SECINFO_OWNER | security.SECINFO_GROUP)], + ) + + class cmd_computer_create(Command): """Create a new computer. @@ -83,6 +214,17 @@ class cmd_computer_create(Command): Option("--prepare-oldjoin", help="Prepare enabled machine account for oldjoin mechanism", action="store_true"), + Option("--ip-address", + dest='ip_address_list', + help=("IPv4 address for the computer's A record, or IPv6 " + "address for AAAA record, can be provided multiple " + "times"), + action='append'), + Option("--service-principal-name", + dest='service_principal_name_list', + help=("Computer's Service Principal Name, can be provided " + "multiple times"), + action='append') ] takes_args = ["computername"] @@ -94,7 +236,19 @@ class cmd_computer_create(Command): } def run(self, computername, credopts=None, sambaopts=None, versionopts=None, - H=None, computerou=None, description=None, prepare_oldjoin=False): + H=None, computerou=None, description=None, prepare_oldjoin=False, + ip_address_list=None, service_principal_name_list=None): + + if ip_address_list is None: + ip_address_list = [] + + if service_principal_name_list is None: + service_principal_name_list = [] + + # check each IP address if provided + for ip_address in ip_address_list: + if not _is_valid_ip(ip_address): + raise CommandError('Invalid IP address {}'.format(ip_address)) lp = sambaopts.get_loadparm() creds = credopts.get_credentials(lp) @@ -104,13 +258,47 @@ def run(self, computername, credopts=None, sambaopts=None, versionopts=None, credentials=creds, lp=lp) samdb.newcomputer(computername, computerou=computerou, description=description, - prepare_oldjoin=prepare_oldjoin) + prepare_oldjoin=prepare_oldjoin, + ip_address_list=ip_address_list, + service_principal_name_list=service_principal_name_list, + ) + + if ip_address_list: + # if ip_address_list provided, then we need to create DNS + # records for this computer. + filters = '(&(sAMAccountName={}$)(objectclass=computer))'.format( + ldb.binary_encode(computername.rstrip('$'))) + + recs = samdb.search( + base=samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=filters, + attrs=['primaryGroupID', 'objectSid']) + + group = recs[0]['primaryGroupID'][0] + owner = ndr_unpack(security.dom_sid, recs[0]["objectSid"][0]) + + dns_conn = dnsserver.dnsserver( + "ncacn_ip_tcp:{}[sign]".format(samdb.host_dns_name()), + lp, creds) + + change_owner_sd = security.descriptor() + change_owner_sd.owner_sid = owner + change_owner_sd.group_sid = security.dom_sid( + "{}-{}".format(samdb.get_domain_sid(), group), + ) + + add_dns_records( + samdb, computername.rstrip('$'), dns_conn, + change_owner_sd, samdb.host_dns_name(), + ip_address_list, self.get_logger()) except Exception, e: raise CommandError("Failed to create computer '%s': " % computername, e) self.outf.write("Computer '%s' created successfully\n" % computername) + class cmd_computer_delete(Command): """Delete a computer. @@ -177,9 +365,13 @@ def run(self, computername, credopts=None, sambaopts=None, res = samdb.search(base=samdb.domain_dn(), scope=ldb.SCOPE_SUBTREE, expression=filter, - attrs=["userAccountControl"]) + attrs=["userAccountControl", "dNSHostName"]) computer_dn = res[0].dn computer_ac = int(res[0]["userAccountControl"][0]) + if "dNSHostName" in res[0]: + computer_dns_host_name = res[0]["dNSHostName"][0] + else: + computer_dns_host_name = None except IndexError: raise CommandError('Unable to find computer "%s"' % computername) @@ -191,6 +383,10 @@ def run(self, computername, credopts=None, sambaopts=None, % computername) try: samdb.delete(computer_dn) + if computer_dns_host_name: + remove_dns_references( + samdb, self.get_logger(), computer_dns_host_name, + ignore_no_name=True) except Exception, e: raise CommandError('Failed to remove computer "%s"' % samaccountname, e) diff --git a/python/samba/samdb.py b/python/samba/samdb.py index ecd67548685..ee30a4e7440 100644 --- a/python/samba/samdb.py +++ b/python/samba/samdb.py @@ -488,13 +488,16 @@ def newuser(self, username, password, self.transaction_commit() def newcomputer(self, computername, computerou=None, description=None, - prepare_oldjoin=False): + prepare_oldjoin=False, ip_address_list=None, + service_principal_name_list=None): """Adds a new user with additional parameters :param computername: Name of the new computer :param computerou: Object container for new computer :param description: Description of the new computer :param prepare_oldjoin: Preset computer password for oldjoin mechanism + :param ip_address_list: ip address list for DNS A or AAAA record + :param service_principal_name_list: string list of servicePincipalName """ cn = re.sub(r"\$$", "", computername) @@ -508,8 +511,6 @@ def newcomputer(self, computername, computerou=None, description=None, computer_dn = "CN=%s,%s" % (cn, computercontainer_dn) - dnsdomain = ldb.Dn(self, - self.domain_dn()).canonical_str().replace("/", "") ldbmessage = {"dn": computer_dn, "sAMAccountName": samaccountname, "objectClass": "computer", @@ -518,12 +519,19 @@ def newcomputer(self, computername, computerou=None, description=None, if description is not None: ldbmessage["description"] = description + if service_principal_name_list: + ldbmessage["servicePrincipalName"] = service_principal_name_list + accountcontrol = str(dsdb.UF_WORKSTATION_TRUST_ACCOUNT | dsdb.UF_ACCOUNTDISABLE) if prepare_oldjoin: accountcontrol = str(dsdb.UF_WORKSTATION_TRUST_ACCOUNT) ldbmessage["userAccountControl"] = accountcontrol + if ip_address_list: + ldbmessage['dNSHostName'] = '{}.{}'.format( + cn, self.domain_dns_name()) + self.transaction_start() try: self.add(ldbmessage) diff --git a/python/samba/tests/samba_tool/computer.py b/python/samba/tests/samba_tool/computer.py index 8c378b8e51a..4036d973c12 100644 --- a/python/samba/tests/samba_tool/computer.py +++ b/python/samba/tests/samba_tool/computer.py @@ -23,6 +23,8 @@ import ldb from samba.tests.samba_tool.base import SambaToolCmdTest from samba import dsdb +from samba.ndr import ndr_unpack, ndr_pack +from samba.dcerpc import dnsp class ComputerCmdTestCase(SambaToolCmdTest): """Tests for samba-tool computer subcommands""" @@ -31,13 +33,31 @@ class ComputerCmdTestCase(SambaToolCmdTest): def setUp(self): super(ComputerCmdTestCase, self).setUp() - self.samdb = self.getSamDB("-H", "ldap://%s" % os.environ["DC_SERVER"], - "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) - self.computers = [] - self.computers.append(self._randomComputer({"name": "testcomputer1"})) - self.computers.append(self._randomComputer({"name": "testcomputer2"})) - self.computers.append(self._randomComputer({"name": "testcomputer3$"})) - self.computers.append(self._randomComputer({"name": "testcomputer4$"})) + self.creds = "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"]) + self.samdb = self.getSamDB("-H", "ldap://%s" % os.environ["DC_SERVER"], self.creds) + # ips used to test --ip-address option + self.ipv4 = '10.10.10.10' + self.ipv6 = '2001:0db8:0a0b:12f0:0000:0000:0000:0001' + data = [ + { + 'name': 'testcomputer1', + 'ip_address_list': [self.ipv4] + }, + { + 'name': 'testcomputer2', + 'ip_address_list': [self.ipv6], + 'service_principal_name_list': ['SPN0'] + }, + { + 'name': 'testcomputer3$', + 'ip_address_list': [self.ipv4, self.ipv6], + 'service_principal_name_list': ['SPN0', 'SPN1'] + }, + { + 'name': 'testcomputer4$', + }, + ] + self.computers = [self._randomComputer(base=item) for item in data] # setup the 4 computers and ensure they are correct for computer in self.computers: @@ -62,6 +82,7 @@ def setUp(self): self.assertEquals("%s" % found.get("description"), computer["description"]) + def tearDown(self): super(ComputerCmdTestCase, self).tearDown() # clean up all the left over computers, just in case @@ -73,6 +94,38 @@ def tearDown(self): "Failed to delete computer '%s'" % computer["name"]) + def test_newcomputer_with_service_principal_name(self): + # Each computer should have correct servicePrincipalName as provided. + for computer in self.computers: + expected_names = computer.get('service_principal_name_list', []) + found = self._find_service_principal_name(computer['name'], expected_names) + self.assertTrue(found) + + def test_newcomputer_with_dns_records(self): + + # Each computer should have correct DNS record and ip address. + for computer in self.computers: + for ip_address in computer.get('ip_address_list', []): + found = self._find_dns_record(computer['name'], ip_address) + self.assertTrue(found) + + # try to delete all the computers we just created + for computer in self.computers: + (result, out, err) = self.runsubcmd("computer", "delete", + "%s" % computer["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete computer '%s'" % + computer["name"]) + found = self._find_computer(computer["name"]) + self.assertIsNone(found, + "Deleted computer '%s' still exists" % + computer["name"]) + + # all DNS records should be gone + for computer in self.computers: + for ip_address in computer.get('ip_address_list', []): + found = self._find_dns_record(computer['name'], ip_address) + self.assertFalse(found) def test_newcomputer(self): """This tests the "computer create" and "computer delete" commands""" @@ -82,6 +135,7 @@ def test_newcomputer(self): self.assertCmdFail(result, "Succeeded to create existing computer") self.assertIn("already exists", err) + # try to delete all the computers we just created for computer in self.computers: (result, out, err) = self.runsubcmd("computer", "delete", "%s" % @@ -98,7 +152,7 @@ def test_newcomputer(self): for computer in self.computers: (result, out, err) = self.runsubcmd( "computer", "create", "%s" % computer["name"], - "--description=%s" % computer["description"]) + "--description=%s" % computer["description"]) self.assertCmdSuccess(result, out, err) self.assertEquals(err, "", "There shouldn't be any error message") @@ -201,8 +255,18 @@ def _randomOU(self, base={}): return ou def _create_computer(self, computer): - return self.runsubcmd("computer", "create", "%s" % computer["name"], - "--description=%s" % computer["description"]) + args = '{} {} --description={}'.format( + computer['name'], self.creds, computer["description"]) + + for ip_address in computer.get('ip_address_list', []): + args += ' --ip-address={}'.format(ip_address) + + for service_principal_name in computer.get('service_principal_name_list', []): + args += ' --service-principal-name={}'.format(service_principal_name) + + args = args.split() + + return self.runsubcmd('computer', 'create', *args) def _create_ou(self, ou): return self.runsubcmd("ou", "create", "OU=%s" % ou["name"], @@ -223,3 +287,43 @@ def _find_computer(self, name): return computerlist[0] else: return None + + def _find_dns_record(self, name, ip_address): + name = name.rstrip('$') # computername + records = self.samdb.search( + base="DC=DomainDnsZones,{}".format(self.samdb.get_default_basedn()), + scope=ldb.SCOPE_SUBTREE, + expression="(&(objectClass=dnsNode)(name={}))".format(name), + attrs=['dnsRecord', 'dNSTombstoned']) + + # unpack data and compare + for record in records: + if 'dNSTombstoned' in record and str(record['dNSTombstoned']) == 'TRUE': + # if a record is dNSTombstoned, ignore it. + continue + for dns_record_bin in record['dnsRecord']: + dns_record_obj = ndr_unpack(dnsp.DnssrvRpcRecord, dns_record_bin) + ip = str(dns_record_obj.data) + + if str(ip) == str(ip_address): + return True + + return False + + def _find_service_principal_name(self, name, expected_service_principal_names): + """Find all servicePrincipalName values and compare with expected_service_principal_names""" + samaccountname = name.strip('$') + '$' + search_filter = ("(&(sAMAccountName=%s)(objectCategory=%s,%s))" % + (ldb.binary_encode(samaccountname), + "CN=Computer,CN=Schema,CN=Configuration", + self.samdb.domain_dn())) + computer_list = self.samdb.search( + base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=['servicePrincipalName']) + names = set() + for computer in computer_list: + for name in computer.get('servicePrincipalName', []): + names.add(name) + return names == set(expected_service_principal_names) From fb161491ca589d334287dc99f2630fae4ef67f82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Baumbach?= Date: Wed, 7 Mar 2018 10:15:06 +0100 Subject: [PATCH 4/5] docs-xml:samba-tool.8: document computer management commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Björn Baumbach --- docs-xml/manpages/samba-tool.8.xml | 70 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs-xml/manpages/samba-tool.8.xml b/docs-xml/manpages/samba-tool.8.xml index bcdad778992..79c0cb4d484 100644 --- a/docs-xml/manpages/samba-tool.8.xml +++ b/docs-xml/manpages/samba-tool.8.xml @@ -110,6 +110,76 @@ COMMANDS + + computer create <replaceable>computername</replaceable> [options] + Create a new computer in the Active Directory Domain. + The new computer name specified on the command is the + sAMAccountName, with or without the trailing dollar sign. + + + + --computerou=COMPUTEROU + + DN of alternative location (with or without domainDN counterpart) to + default CN=Users in which new computer object will be created. + E.g. 'OU=OUname'. + + + + + --description=DESCRIPTION + + The new computers's description. + + + + + --prepare-oldjoin + + Prepare enabled machine account for oldjoin mechanism. + + + + + + + computer delete <replaceable>computername</replaceable> [options] + Delete an existing computer account. + The computer name specified on the command is the + sAMAccountName, with or without the trailing dollar sign. + + + + computer list + List all computers. + + + + computer move <replaceable>computername</replaceable> <replaceable>new_parent_dn</replaceable> [options] + This command moves a computer account into the specified + organizational unit or container. + The computername specified on the command is the + sAMAccountName, with or without the trailing dollar sign. + The name of the organizational unit or container can be + specified as a full DN or without the domainDN component. + + + + computer show <replaceable>computername</replaceable> [options] + Display a computer AD object. + The computer name specified on the command is the + sAMAccountName, with or without the trailing dollar sign. + + + + --attributes=USER_ATTRS + + Comma separated list of attributes, which will be printed. + + + + + dbcheck Check the local AD database for errors. From 3e719ff634b638cb858697a0660363fbb9bf9382 Mon Sep 17 00:00:00 2001 From: Joe Guo Date: Fri, 16 Mar 2018 16:39:08 +1300 Subject: [PATCH 5/5] docs-xml:samba-tool.8: improve doc for computer management commands Add docs for new options: 1. --ip-address 2. --service-prinicipal-name Signed-off-by: Joe Guo --- docs-xml/manpages/samba-tool.8.xml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs-xml/manpages/samba-tool.8.xml b/docs-xml/manpages/samba-tool.8.xml index 79c0cb4d484..3cde4c5b6fb 100644 --- a/docs-xml/manpages/samba-tool.8.xml +++ b/docs-xml/manpages/samba-tool.8.xml @@ -133,6 +133,21 @@ + + --ip-address=IP_ADDRESS_LIST + + IPv4 address for the computer's A record, or IPv6 address for AAAA record, + can be provided multiple times. + + + + + --service-principal-name=SERVICE_PRINCIPAL_NAME_LIST + + Computer's Service Principal Name, can be provided multiple times. + + + --prepare-oldjoin