diff --git a/subiquity/client/client.py b/subiquity/client/client.py index f177a986..a3f5a0a6 100644 --- a/subiquity/client/client.py +++ b/subiquity/client/client.py @@ -461,6 +461,8 @@ class SubiquityClient(TuiApplication): "marking additional endpoints as configured: %s", needed) await self.client.meta.mark_configured.POST(list(needed)) + # TODO: remove this when TUI gets an Active Directory screen: + await self.client.meta.mark_configured.POST(['active_directory']) await self.client.meta.confirm.POST(self.our_tty) def add_global_overlay(self, overlay): diff --git a/subiquity/common/apidef.py b/subiquity/common/apidef.py index e75da3c4..f0f0d0ab 100644 --- a/subiquity/common/apidef.py +++ b/subiquity/common/apidef.py @@ -28,6 +28,7 @@ from subiquity.common.types import ( ADConnectionInfo, AdAdminNameValidation, AdDomainNameValidation, + AdJoinResult, AdPasswordValidation, AddPartitionV2, AnyStep, @@ -434,6 +435,9 @@ class API: class check_password: def POST(password: Payload[str]) -> AdPasswordValidation: ... + class join_result: + def GET(wait: bool = True) -> AdJoinResult: ... + class LinkAction(enum.Enum): NEW = enum.auto() diff --git a/subiquity/common/types.py b/subiquity/common/types.py index 732408b1..4875f97b 100644 --- a/subiquity/common/types.py +++ b/subiquity/common/types.py @@ -801,3 +801,11 @@ class AdDomainNameValidation(enum.Enum): class AdPasswordValidation(enum.Enum): OK = 'OK' EMPTY = 'Empty' + + +class AdJoinResult(enum.Enum): + OK = 'OK' + JOIN_ERROR = 'Failed to join' + EMPTY_HOSTNAME = 'Target hostname cannot be empty' + PAM_ERROR = 'Failed to update pam-auth' + UNKNOWN = "Didn't attempt to join yet" diff --git a/subiquity/models/ad.py b/subiquity/models/ad.py index 9cf8c24b..1816b8a4 100644 --- a/subiquity/models/ad.py +++ b/subiquity/models/ad.py @@ -27,6 +27,10 @@ class ADModel: self.do_join = False self.conn_info: Optional[ADConnectionInfo] = None + def set(self, info: ADConnectionInfo): + self.conn_info = info + self.do_join = True + def set_domain(self, domain: str): if not domain: return diff --git a/subiquity/server/ad_joiner.py b/subiquity/server/ad_joiner.py new file mode 100644 index 00000000..22ad2ac7 --- /dev/null +++ b/subiquity/server/ad_joiner.py @@ -0,0 +1,150 @@ +# Copyright 2023 Canonical, Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import asyncio +from contextlib import contextmanager +import logging +from socket import gethostname +from subprocess import CalledProcessError +from subiquitycore.utils import arun_command, run_command +from subiquity.server.curtin import run_curtin_command +from subiquity.common.types import ( + ADConnectionInfo, + AdJoinResult, +) + +log = logging.getLogger('subiquity.server.ad_joiner') + + +@contextmanager +def hostname_context(hostname: str): + """ Temporarily adjusts the host name to [hostname] and restores it + back in the end of the caller scope. """ + hostname_current = gethostname() + hostname_process = run_command(['hostname', hostname]) + try: + yield hostname_process + finally: + # Restoring the live session hostname. + hostname_process = run_command(['hostname', hostname_current]) + if hostname_process.returncode: + log.info("Failed to restore live session hostname") + + +class AdJoinStrategy(): + realm = "/usr/sbin/realm" + pam = "/usr/sbin/pam-auth-update" + + def __init__(self, app): + self.app = app + + async def do_join(self, info: ADConnectionInfo, hostname: str, context) \ + -> AdJoinResult: + """ This method changes the hostname and perform a real AD join, thus + should only run in a live session. """ + # Set hostname for AD to determine FQDN (no FQDN option in realm join, + # only adcli, which only understands the live system, but not chroot) + with hostname_context(hostname) as host_process: + if host_process.returncode: + log.info("Failed to set live session hostname for adcli") + return AdJoinResult.JOIN_ERROR + + root_dir = self.app.root + cp = await arun_command([self.realm, "join", "--install", root_dir, + "--user", info.admin_name, + "--computer-name", hostname, + "--unattended", info.domain_name], + input=info.password) + + if cp.returncode: + # Try again without the computer name. Lab tests shown more + # success in this setup, but I'm still not sure if we should + # drop the computer name attempt, since that's the way Ubiquity + # has been doing for ages. + log.debug("Joining operation failed:") + log.debug(cp.stderr) + log.debug(cp.stdout) + log.debug("Trying again without overriding the computer name:") + cp = await arun_command([self.realm, "join", "--install", + root_dir, "--user", info.admin_name, + "--unattended", info.domain_name], + input=info.password) + + if cp.returncode: + log.debug("Joining operation failed:") + log.debug(cp.stderr) + log.debug(cp.stdout) + return AdJoinResult.JOIN_ERROR + + # Enable pam_mkhomedir + try: + # The function raises if the process fail. + await run_curtin_command(self.app, context, + "in-target", "-t", root_dir, + "--", self.pam, "--package", + "--enable", "mkhomedir", + private_mounts=False) + + return AdJoinResult.OK + except CalledProcessError: + # The app command runner doesn't give us output in case of + # failure in the wait() method, which is called by + # run_curtin_command + log.info("Failed to update pam-auth") + return AdJoinResult.PAM_ERROR + + return AdJoinResult.JOIN_ERROR + + +class StubStrategy(AdJoinStrategy): + async def do_join(self, info: ADConnectionInfo, hostname: str, context) \ + -> AdJoinResult: + """ Enables testing without real join. The result depends on the + domain name initial character, such that if it is: + - p or P: returns PAM_ERROR. + - j or J: returns JOIN_ERROR. + - returns OK otherwise. """ + initial = info.domain_name[0] + if initial in ('j', 'J'): + return AdJoinResult.JOIN_ERROR + + if initial in ('p', 'P'): + return AdJoinResult.PAM_ERROR + + return AdJoinResult.OK + + +class AdJoiner(): + def __init__(self, app): + self._result = AdJoinResult.UNKNOWN + self._completion_event = asyncio.Event() + if app.opts.dry_run: + self.strategy = StubStrategy(app) + else: + self.strategy = AdJoinStrategy(app) + + async def join_domain(self, info: ADConnectionInfo, hostname: str, + context) -> AdJoinResult: + if hostname: + self._result = await self.strategy.do_join(info, hostname, context) + else: + self._result = AdJoinResult.EMPTY_HOSTNAME + + self._completion_event.set() + return self._result + + async def join_result(self): + await self._completion_event.wait() + return self._result diff --git a/subiquity/server/controllers/ad.py b/subiquity/server/controllers/ad.py index 346f6210..605c825f 100644 --- a/subiquity/server/controllers/ad.py +++ b/subiquity/server/controllers/ad.py @@ -25,8 +25,10 @@ from subiquity.common.types import ( ADConnectionInfo, AdAdminNameValidation, AdDomainNameValidation, + AdJoinResult, AdPasswordValidation ) +from subiquity.server.ad_joiner import AdJoiner from subiquity.server.controller import SubiquityController log = logging.getLogger('subiquity.server.controllers.ad') @@ -95,6 +97,8 @@ class ADController(SubiquityController): def __init__(self, app): super().__init__(app) + self.ad_joiner = AdJoiner(self.app) + self.join_result = AdJoinResult.UNKNOWN if self.app.opts.dry_run: self.ping_strgy = StubDcPingStrategy() else: @@ -116,7 +120,7 @@ class ADController(SubiquityController): async def POST(self, data: ADConnectionInfo) -> None: """ Configures this controller with the supplied info. Clients are required to validate the info before POST'ing """ - self.model.conn_info = data + self.model.set(data) await self.configured() async def check_admin_name_POST(self, admin_name: str) \ @@ -141,6 +145,22 @@ class ADController(SubiquityController): to configure AD are present in the live system.""" return self.ping_strgy.has_support() + async def join_result_GET(self, wait: bool = True) -> AdJoinResult: + """ If [wait] is True and the model is set for joining, this method + blocks until an attempt to join a domain completes. + Otherwise returns the current known state. + Most likely it will be AdJoinResult.UNKNOWN. """ + if wait and self.model.do_join: + self.join_result = await self.ad_joiner.join_result() + + return self.join_result + + async def join_domain(self, hostname: str, context) -> None: + """ To be called from the install controller if the user requested + joining an AD domain """ + await self.ad_joiner.join_domain(self.model.conn_info, hostname, + context) + # Helper out-of-class functions grouped. class AdValidators: diff --git a/subiquity/server/controllers/install.py b/subiquity/server/controllers/install.py index 8f99322f..bc37ca0a 100644 --- a/subiquity/server/controllers/install.py +++ b/subiquity/server/controllers/install.py @@ -406,6 +406,13 @@ class InstallController(SubiquityController): policy = self.model.updates.updates await self.run_unattended_upgrades(context=context, policy=policy) await self.restore_apt_config(context=context) + if self.model.ad.do_join: + hostname = self.model.identity.hostname + if not hostname: + with open(self.tpath('etc/hostname'), 'r') as f: + hostname = f.read().strip() + + await self.app.controllers.AD.join_domain(hostname, context) @with_context(description="configuring cloud-init") async def configure_cloud_init(self, context): diff --git a/subiquity/server/controllers/tests/test_ad.py b/subiquity/server/controllers/tests/test_ad.py index bd6c95e9..3c70c6f7 100644 --- a/subiquity/server/controllers/tests/test_ad.py +++ b/subiquity/server/controllers/tests/test_ad.py @@ -13,13 +13,23 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from unittest import TestCase +from unittest import ( + TestCase, + IsolatedAsyncioTestCase, +) from subiquity.common.types import ( AdAdminNameValidation, + ADConnectionInfo, AdDomainNameValidation, + AdJoinResult, AdPasswordValidation, ) -from subiquity.server.controllers.ad import AdValidators +from subiquity.server.controllers.ad import ( + ADController, + AdValidators, +) +from subiquity.models.ad import ADModel +from subiquitycore.tests.mocks import make_app class TestADValidation(TestCase): @@ -119,3 +129,51 @@ class TestADValidation(TestCase): admin = r'$Ubuntu{}' result = AdValidators.admin_user_name(admin) self.assertEqual(AdAdminNameValidation.OK, result) + + +class TestAdJoin(IsolatedAsyncioTestCase): + def setUp(self): + self.app = make_app() + self.controller = ADController(self.app) + self.controller.model = ADModel() + + async def test_never_join(self): + # Calling join_result_GET has no effect if the model is not set. + result = await self.controller.join_result_GET(wait=True) + self.assertEqual(result, AdJoinResult.UNKNOWN) + + async def test_join_Unknown(self): + # Result remains UNKNOWN while ADController.join_domain is not called. + self.controller.model.set(ADConnectionInfo(domain_name='ubuntu.com', + admin_name='Helper', + password='1234')) + + result = await self.controller.join_result_GET(wait=False) + self.assertEqual(result, AdJoinResult.UNKNOWN) + + async def test_join_OK(self): + # The equivalent of a successful POST + self.controller.model.set(ADConnectionInfo(domain_name='ubuntu.com', + admin_name='Helper', + password='1234')) + # Mimics a client requesting the join result. Blocking by default. + result = self.controller.join_result_GET() + # Mimics a calling from the install controller. + await self.controller.join_domain('this', 'AD Join') + self.assertEqual(await result, AdJoinResult.OK) + + async def test_join_Join_Error(self): + self.controller.model.set(ADConnectionInfo(domain_name='jubuntu.com', + admin_name='Helper', + password='1234')) + await self.controller.join_domain('this', 'AD Join') + result = await self.controller.join_result_GET(wait=True) + self.assertEqual(result, AdJoinResult.JOIN_ERROR) + + async def test_join_Pam_Error(self): + self.controller.model.set(ADConnectionInfo(domain_name='pubuntu.com', + admin_name='Helper', + password='1234')) + await self.controller.join_domain('this', 'AD Join') + result = await self.controller.join_result_GET(wait=True) + self.assertEqual(result, AdJoinResult.PAM_ERROR) diff --git a/subiquity/server/server.py b/subiquity/server/server.py index 9dd2de00..07b8c545 100644 --- a/subiquity/server/server.py +++ b/subiquity/server/server.py @@ -215,7 +215,7 @@ POSTINSTALL_MODEL_NAMES = ModelNames({ "ubuntu_pro", "userdata", }, - desktop={"timezone", "codecs"}) + desktop={"timezone", "codecs", "ad"}) class SubiquityServer(Application): diff --git a/subiquity/tests/api/test_api.py b/subiquity/tests/api/test_api.py index 77c2d4a0..294c3e48 100644 --- a/subiquity/tests/api/test_api.py +++ b/subiquity/tests/api/test_api.py @@ -1696,3 +1696,21 @@ class TestActiveDirectory(TestAPI): result = await instance.post(endpoint + '/check_admin_name', data='$Ubuntu') self.assertEqual('OK', result) + # Attempts to join with the info supplied above. + ad_dict = { + 'admin_name': 'Ubuntu', + 'domain_name': 'jubuntu.com', + 'password': 'u', + } + result = await instance.post(endpoint, ad_dict) + self.assertIsNone(result) + join_result_ep = endpoint + '/join_result' + # Without wait this shouldn't block but the result is unknown until + # the install controller runs. + join_result = await instance.get(join_result_ep, wait=False) + self.assertEqual('UNKNOWN', join_result) + # And without the installer controller running, a blocking call + # should timeout since joining never happens. + with self.assertRaises(asyncio.exceptions.TimeoutError): + join_result = instance.get(join_result_ep, wait=True) + await asyncio.wait_for(join_result, timeout=1.5)