diff --git a/examples/addc.json b/examples/addc.json new file mode 100644 index 0000000..45c9ab0 --- /dev/null +++ b/examples/addc.json @@ -0,0 +1,127 @@ +{ + "samba-container-config": "v0", + "configs": { + "demo": { + "instance_features": ["addc"], + "domain_settings": "sink", + "instance_name": "dc1" + } + }, + "domain_settings": { + "sink": { + "realm": "DOMAIN1.SINK.TEST", + "short_domain": "DOMAIN1", + "admin_password": "Passw0rd" + } + }, + "domain_groups": { + "sink": [ + {"name": "supervisors"}, + {"name": "employees"}, + {"name": "characters"}, + {"name": "bulk"} + ] + }, + "domain_users": { + "sink": [ + { + "name": "bwayne", + "password": "1115Rose.", + "given_name": "Bruce", + "surname": "Wayne", + "member_of": ["supervisors", "characters", "employees"] + }, + { + "name": "ckent", + "password": "1115Rose.", + "given_name": "Clark", + "surname": "Kent", + "member_of": ["characters", "employees"] + }, + { + "name": "bbanner", + "password": "1115Rose.", + "given_name": "Bruce", + "surname": "Banner", + "member_of": ["characters", "employees"] + }, + { + "name": "pparker", + "password": "1115Rose.", + "given_name": "Peter", + "surname": "Parker", + "member_of": ["characters", "employees"] + }, + { + "name": "user0", + "password": "1115Rose.", + "given_name": "George0", + "surname": "Hue-Sir", + "member_of": ["bulk"] + }, + { + "name": "user1", + "password": "1115Rose.", + "given_name": "George1", + "surname": "Hue-Sir", + "member_of": ["bulk"] + }, + { + "name": "user2", + "password": "1115Rose.", + "given_name": "George2", + "surname": "Hue-Sir", + "member_of": ["bulk"] + }, + { + "name": "user3", + "password": "1115Rose.", + "given_name": "George3", + "surname": "Hue-Sir", + "member_of": ["bulk"] + }, + { + "name": "user4", + "password": "1115Rose.", + "given_name": "George4", + "surname": "Hue-Sir", + "member_of": ["bulk"] + }, + { + "name": "user5", + "password": "1115Rose.", + "given_name": "George5", + "surname": "Hue-Sir", + "member_of": ["bulk"] + }, + { + "name": "user6", + "password": "1115Rose.", + "given_name": "George6", + "surname": "Hue-Sir", + "member_of": ["bulk"] + }, + { + "name": "user7", + "password": "1115Rose.", + "given_name": "George7", + "surname": "Hue-Sir", + "member_of": ["bulk"] + }, + { + "name": "user8", + "password": "1115Rose.", + "given_name": "George8", + "surname": "Hue-Sir", + "member_of": ["bulk"] + }, + { + "name": "user9", + "password": "1115Rose.", + "given_name": "George9", + "surname": "Hue-Sir", + "member_of": ["bulk"] + } + ] + } +} diff --git a/sambacc/addc.py b/sambacc/addc.py new file mode 100644 index 0000000..f60a416 --- /dev/null +++ b/sambacc/addc.py @@ -0,0 +1,178 @@ +# +# sambacc: a samba container configuration tool +# Copyright (C) 2021 John Mulligan +# +# 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 logging +import subprocess +import typing + +from sambacc import samba_cmds + +_logger = logging.getLogger(__name__) + + +def provision( + realm: str, + dcname: str, + admin_password: str, + dns_backend: typing.Optional[str] = None, + domain: typing.Optional[str] = None, +) -> None: + # this function is a direct translation of a previous shell script + # as samba-tool is based on python libs, this function could possibly + # be converted to import samba's libs and use that. + _logger.info(f"Provisioning AD domain: realm={realm}") + subprocess.check_call( + _provision_cmd( + realm, + dcname, + admin_password=admin_password, + dns_backend=dns_backend, + domain=domain, + ) + ) + return + + +def join( + realm: str, + dcname: str, + admin_password: str, + dns_backend: typing.Optional[str] = None, + domain: typing.Optional[str] = None, +) -> None: + _logger.info(f"Joining AD domain: realm={realm}") + subprocess.check_call( + _join_cmd( + realm, + dcname, + admin_password=admin_password, + dns_backend=dns_backend, + ) + ) + + +def create_user( + name: str, + password: str, + surname: typing.Optional[str], + given_name: typing.Optional[str], +) -> None: + cmd = _user_create_cmd(name, password, surname, given_name) + _logger.info("Creating user: %r", name) + subprocess.check_call(cmd) + + +def create_group(name: str) -> None: + cmd = _group_add_cmd(name) + _logger.info("Creating group: %r", name) + subprocess.check_call(cmd) + + +def add_group_members(group_name: str, members: typing.List[str]) -> None: + cmd = _group_add_members_cmd(group_name, members) + _logger.info("Adding group members: %r", cmd) + subprocess.check_call(cmd) + + +def _provision_cmd( + realm: str, + dcname: str, + admin_password: str, + dns_backend: typing.Optional[str] = None, + domain: typing.Optional[str] = None, +) -> typing.List[str]: + if not dns_backend: + dns_backend = "SAMBA_INTERNAL" + if not domain: + domain = realm.split(".")[0].upper() + cmd = samba_cmds.sambatool[ + "domain", + "provision", + f"--option=netbios name={dcname}", + "--use-rfc2307", + f"--dns-backend={dns_backend}", + "--server-role=dc", + f"--realm={realm}", + f"--domain={domain}", + f"--adminpass={admin_password}", + ].argv() + return cmd + + +def _join_cmd( + realm: str, + dcname: str, + admin_password: str, + dns_backend: typing.Optional[str] = None, + domain: typing.Optional[str] = None, +) -> typing.List[str]: + if not dns_backend: + dns_backend = "SAMBA_INTERNAL" + if not domain: + domain = realm.split(".")[0].upper() + cmd = samba_cmds.sambatool[ + "domain", + "join", + realm, + "DC", + f"-U{domain}\\Administrator", + f"--option=netbios name={dcname}", + f"--dns-backend={dns_backend}", + f"--password={admin_password}", + ].argv() + return cmd + + +def _user_create_cmd( + name: str, + password: str, + surname: typing.Optional[str], + given_name: typing.Optional[str], +) -> typing.List[str]: + cmd = samba_cmds.sambatool[ + "user", + "create", + name, + password, + ].argv() + if surname: + cmd.append(f"--surname={surname}") + if given_name: + cmd.append(f"--given-name={given_name}") + return cmd + + +def _group_add_cmd(name: str) -> typing.List[str]: + cmd = samba_cmds.sambatool[ + "group", + "add", + name, + ].argv() + return cmd + + +def _group_add_members_cmd( + group_name: str, members: typing.List[str] +) -> typing.List[str]: + cmd = samba_cmds.sambatool[ + "group", + "addmembers", + group_name, + ",".join(members), + ].argv() + return cmd diff --git a/sambacc/commands/addc.py b/sambacc/commands/addc.py new file mode 100644 index 0000000..876a23c --- /dev/null +++ b/sambacc/commands/addc.py @@ -0,0 +1,168 @@ +# +# sambacc: a samba container configuration tool +# Copyright (C) 2021 John Mulligan +# +# 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 logging +import os +import shutil + +from sambacc import addc +from sambacc import samba_cmds + +from .cli import best_waiter, CommandBuilder, Context, Fail + +try: + import dns + import dns.resolver + import dns.exception + + _DNS = True +except ImportError: + _DNS = False + + +_logger = logging.getLogger(__name__) + +_populated: str = "/var/lib/samba/POPULATED" +_provisioned: str = "/etc/samba/smb.conf" + +dccommands = CommandBuilder() + + +@dccommands.command(name="summary") +def summary(ctx: Context) -> None: + print("Hello", ctx) + + +_setup_choices = ["init-all", "provision", "populate", "wait-domain", "join"] + + +def _dosetup(ctx: Context, step_name: str) -> bool: + setup = ctx.cli.setup or [] + return ("init-all" in setup) or (step_name in setup) + + +def _run_container_args(parser): + parser.add_argument( + "--setup", + action="append", + choices=_setup_choices, + help=( + "Specify one or more setup step names to preconfigure the" + " container environment before the server process is started." + " The special 'init-all' name will perform all known setup steps." + ), + ) + parser.add_argument( + "--name", + help="Specify a custom name for the dc, overriding the config file.", + ) + + +def _prep_provision(ctx: Context) -> None: + if os.path.exists(_provisioned): + _logger.info("Domain already provisioned") + return + domconfig = ctx.instance_config.domain() + _logger.info(f"Provisioning domain: {domconfig.realm}") + + dcname = ctx.cli.name or domconfig.dcname + addc.provision( + realm=domconfig.realm, + domain=domconfig.short_domain, + dcname=dcname, + admin_password=domconfig.admin_password, + ) + + +def _prep_join(ctx: Context) -> None: + if os.path.exists(_provisioned): + _logger.info("Already configured. Not joining") + return + domconfig = ctx.instance_config.domain() + _logger.info(f"Provisioning domain: {domconfig.realm}") + + dcname = ctx.cli.name or domconfig.dcname + addc.join( + realm=domconfig.realm, + domain=domconfig.short_domain, + dcname=dcname, + admin_password=domconfig.admin_password, + ) + + +def _prep_wait_on_domain(ctx: Context) -> None: + if not _DNS: + _logger.info("Can not query domain. Exiting.") + raise Fail("no dns support available (missing dnsypthon)") + + realm = ctx.instance_config.domain().realm + waiter = best_waiter(max_timeout=30) + while True: + _logger.info(f"checking for AD domain in dns: {realm}") + try: + dns.resolver.query(f"_ldap._tcp.{realm}.", "SRV") + return + except dns.exception.DNSException: + _logger.info(f"dns record for {realm} not found") + waiter.wait() + + +def _prep_populate(ctx: Context) -> None: + if os.path.exists(_populated): + _logger.info("populated marker exists") + return + _logger.info("Populating domain with default entries") + + for dgroup in ctx.instance_config.domain_groups(): + addc.create_group(dgroup.groupname) + + for duser in ctx.instance_config.domain_users(): + addc.create_user( + name=duser.username, + password=duser.plaintext_passwd, + surname=duser.surname, + given_name=duser.given_name, + ) + # TODO: probably should improve this to avoid extra calls / loops + for gname in duser.member_of: + addc.add_group_members(group_name=gname, members=[duser.username]) + + # "touch" the populated marker + with open(_populated, "w"): + pass + + +def _prep_krb5_conf(ctx: Context) -> None: + shutil.copy("/var/lib/samba/private/krb5.conf", "/etc/krb5.conf") + + +@dccommands.command(name="run", arg_func=_run_container_args) +def run(ctx: Context) -> None: + _logger.info("Running AD DC container") + if _dosetup(ctx, "wait-domain"): + _prep_wait_on_domain(ctx) + if _dosetup(ctx, "join"): + _prep_join(ctx) + if _dosetup(ctx, "provision"): + _prep_provision(ctx) + if _dosetup(ctx, "populate"): + _prep_populate(ctx) + + _prep_krb5_conf(ctx) + _logger.info("Starting samba server") + samba_cmds.execute(samba_cmds.samba_dc_foreground()) diff --git a/sambacc/commands/dcmain.py b/sambacc/commands/dcmain.py new file mode 100644 index 0000000..28ef218 --- /dev/null +++ b/sambacc/commands/dcmain.py @@ -0,0 +1,52 @@ +# +# sambacc: a samba container configuration tool +# Copyright (C) 2021 John Mulligan +# +# 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 +# + +from . import addc +from .cli import Fail +from .main import ( + CommandContext, + action_filter, + enable_logging, + env_to_cli, + global_args, + pre_action, +) + + +default_cfunc = addc.summary + + +def main(args=None) -> None: + cli = addc.dccommands.assemble(arg_func=global_args).parse_args(args) + env_to_cli(cli) + enable_logging(cli) + if not cli.identity: + raise Fail("missing container identity") + + pre_action(cli) + skip = action_filter(cli) + if skip: + print(f"Action skipped: {skip}") + return + cfunc = getattr(cli, "cfunc", default_cfunc) + cfunc(CommandContext(cli)) + return + + +if __name__ == "__main__": + main() diff --git a/sambacc/config.py b/sambacc/config.py index b6263a5..9e30c07 100644 --- a/sambacc/config.py +++ b/sambacc/config.py @@ -35,8 +35,9 @@ # the standard location for samba's smb.conf SMB_CONF = "/etc/samba/smb.conf" -CTDB = "ctdb" -FEATURES = "instance_features" +CTDB: typing.Final[str] = "ctdb" +ADDC: typing.Final[str] = "addc" +FEATURES: typing.Final[str] = "instance_features" def read_config_files(fnames) -> GlobalConfig: @@ -149,9 +150,13 @@ def groups(self) -> typing.Iterable[GroupEntry]: yield uentry.vgroup() @property - def with_ctdb(self): + def with_ctdb(self) -> bool: return CTDB in self.iconfig.get(FEATURES, []) + @property + def with_addc(self) -> bool: + return ADDC in self.iconfig.get(FEATURES, []) + def ctdb_smb_config(self) -> CTDBSambaConfig: if not self.with_ctdb: raise ValueError("ctdb not supported in configuration") @@ -170,6 +175,33 @@ def ctdb_config(self) -> typing.Dict[str, str]: ctdb.setdefault("realtime_scheduling", "false") return ctdb + def domain(self) -> DomainConfig: + """Return the general domain settings for this DC instance.""" + if not self.with_addc: + raise ValueError("ad dc not supported by configuration") + domains = self.gconfig.data.get("domain_settings", {}) + instance_name: str = self.iconfig.get("instance_name", "") + return DomainConfig( + drec=domains[self.iconfig["domain_settings"]], + instance_name=instance_name, + ) + + def domain_users(self) -> typing.Iterable[DomainUserEntry]: + if not self.with_addc: + raise ValueError("ad dc not supported by configuration") + ds_name: str = self.iconfig["domain_settings"] + dusers = self.gconfig.data.get("domain_users", {}).get(ds_name, []) + for n, entry in enumerate(dusers): + yield DomainUserEntry(self, entry, n) + + def domain_groups(self) -> typing.Iterable[DomainGroupEntry]: + if not self.with_addc: + raise ValueError("ad dc not supported by configuration") + ds_name: str = self.iconfig["domain_settings"] + dgroups = self.gconfig.data.get("domain_groups", {}).get(ds_name, []) + for n, entry in enumerate(dgroups): + yield DomainGroupEntry(self, entry, n) + class CTDBSambaConfig: def global_options(self) -> typing.Iterable[typing.Tuple[str, str]]: @@ -279,3 +311,25 @@ def gid(self) -> int: def group_fields(self) -> GroupEntryTuple: # fields: name, passwd, gid, members(comma separated) return (self.groupname, "x", str(self.gid), "") + + +class DomainConfig: + def __init__(self, drec: dict, instance_name: str): + self.realm = drec["realm"] + self.short_domain = drec.get("short_domain", "") + self.admin_password = drec.get("admin_password", "") + self.dcname = instance_name + + +class DomainUserEntry(UserEntry): + def __init__(self, iconf: InstanceConfig, urec: dict, num: int): + super().__init__(iconf, urec, num) + self.surname = urec.get("surname") + self.given_name = urec.get("given_name") + self.member_of = urec.get("member_of", []) + if not isinstance(self.member_of, list): + raise ValueError("member_of should contain a list of group names") + + +class DomainGroupEntry(GroupEntry): + pass diff --git a/sambacc/samba_cmds.py b/sambacc/samba_cmds.py index fc5fbca..1a7415f 100644 --- a/sambacc/samba_cmds.py +++ b/sambacc/samba_cmds.py @@ -140,6 +140,8 @@ def __repr__(self) -> str: winbindd = SambaCommand("/usr/sbin/winbindd") +samba_dc = SambaCommand("/usr/sbin/samba") + def smbd_foreground(): return smbd[ @@ -153,6 +155,10 @@ def winbindd_foreground(): ] +def samba_dc_foreground(): + return samba_dc["--foreground"] + + ctdbd = SambaCommand("/usr/sbin/ctdbd") ctdbd_foreground = ctdbd["--interactive"] @@ -161,6 +167,8 @@ def winbindd_foreground(): ctdb = SambaCommand("ctdb") +sambatool = SambaCommand("samba-tool") + def encode(value: typing.Union[str, bytes, None]) -> bytes: if value is None: diff --git a/setup.cfg b/setup.cfg index d8c513b..6eb8e47 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,9 +19,11 @@ include_package_data = True [options.entry_points] console_scripts = samba-container = sambacc.commands.main:main + samba-dc-container = sambacc.commands.dcmain:main [options.data_files] share/sambacc/examples = examples/ctdb.json examples/example1.json examples/minimal.json + examples/addc.json diff --git a/tests/test_config.py b/tests/test_config.py index c65f014..a77e0f5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -204,6 +204,50 @@ } """ +addc_config1 = """ +{ + "samba-container-config": "v0", + "configs": { + "demo": { + "instance_features": ["addc"], + "domain_settings": "sink", + "instance_name": "dc1" + } + }, + "domain_settings": { + "sink": { + "realm": "DOMAIN1.SINK.TEST", + "short_domain": "DOMAIN1", + "admin_password": "Passw0rd" + } + }, + "domain_groups": { + "sink": [ + {"name": "friends"}, + {"name": "gothamites"} + ] + }, + "domain_users": { + "sink": [ + { + "name": "bwayne", + "password": "1115Rose.", + "given_name": "Bruce", + "surname": "Wayne", + "member_of": ["friends", "gothamites"] + }, + { + "name": "ckent", + "password": "1115Rose.", + "given_name": "Clark", + "surname": "Kent", + "member_of": ["friends"] + } + ] + } +} +""" + class TestConfig(unittest.TestCase): def test_non_json(self): @@ -430,3 +474,85 @@ def test_instance_ctdb_config(): assert "nodes_json" in cfg assert "nodes_path" in cfg assert "log_level" in cfg + + +def test_ad_dc_config_demo(): + c1 = sambacc.config.GlobalConfig(io.StringIO(addc_config1)) + i1 = c1.get("demo") + assert i1.with_addc + + domcfg = i1.domain() + assert domcfg.realm == "DOMAIN1.SINK.TEST" + assert domcfg.short_domain == "DOMAIN1" + assert domcfg.dcname == "dc1" + + dgroups = sorted(i1.domain_groups(), key=lambda v: v.groupname) + assert len(dgroups) == 2 + assert dgroups[0].groupname == "friends" + + dusers = sorted(i1.domain_users(), key=lambda v: v.username) + assert len(dusers) == 2 + assert dusers[0].username == "bwayne" + + +def test_ad_dc_invalid(): + c1 = sambacc.config.GlobalConfig(io.StringIO(config1)) + i1 = c1.get("foobar") + assert not i1.with_addc + + with pytest.raises(ValueError): + i1.domain() + + with pytest.raises(ValueError): + list(i1.domain_users()) + + with pytest.raises(ValueError): + list(i1.domain_groups()) + + +def test_ad_dc_bad_memeber_of(): + jdata = """ +{ + "samba-container-config": "v0", + "configs": { + "demo": { + "instance_features": ["addc"], + "domain_settings": "sink", + "instance_name": "dc1" + } + }, + "domain_settings": { + "sink": { + "realm": "DOMAIN1.SINK.TEST", + "short_domain": "DOMAIN1", + "admin_password": "Passw0rd" + } + }, + "domain_groups": { + "sink": [ + {"name": "friends"} + ] + }, + "domain_users": { + "sink": [ + { + "name": "ckent", + "password": "1115Rose.", + "given_name": "Clark", + "surname": "Kent", + "member_of": "friends" + } + ] + } +} + """ + c1 = sambacc.config.GlobalConfig(io.StringIO(jdata)) + i1 = c1.get("demo") + assert i1.with_addc + + dgroups = sorted(i1.domain_groups(), key=lambda v: v.groupname) + assert len(dgroups) == 1 + assert dgroups[0].groupname == "friends" + + with pytest.raises(ValueError): + list(i1.domain_users()) diff --git a/tox.ini b/tox.ini index 87b68eb..9174445 100644 --- a/tox.ini +++ b/tox.ini @@ -15,6 +15,7 @@ deps = mypy inotify_simple black>=21.8b0 + dnspython commands = flake8 sambacc tests black --check -v .