Merge pull request #1572 from CarlosNihelton/ad-result-deeng-577

Active Directory - Implement the actual join
This commit is contained in:
Carlos Nihelton 2023-03-01 13:11:25 -03:00 committed by GitHub
commit 65aabe527f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 275 additions and 4 deletions

View File

@ -461,6 +461,8 @@ class SubiquityClient(TuiApplication):
"marking additional endpoints as configured: %s", "marking additional endpoints as configured: %s",
needed) needed)
await self.client.meta.mark_configured.POST(list(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) await self.client.meta.confirm.POST(self.our_tty)
def add_global_overlay(self, overlay): def add_global_overlay(self, overlay):

View File

@ -28,6 +28,7 @@ from subiquity.common.types import (
ADConnectionInfo, ADConnectionInfo,
AdAdminNameValidation, AdAdminNameValidation,
AdDomainNameValidation, AdDomainNameValidation,
AdJoinResult,
AdPasswordValidation, AdPasswordValidation,
AddPartitionV2, AddPartitionV2,
AnyStep, AnyStep,
@ -434,6 +435,9 @@ class API:
class check_password: class check_password:
def POST(password: Payload[str]) -> AdPasswordValidation: ... def POST(password: Payload[str]) -> AdPasswordValidation: ...
class join_result:
def GET(wait: bool = True) -> AdJoinResult: ...
class LinkAction(enum.Enum): class LinkAction(enum.Enum):
NEW = enum.auto() NEW = enum.auto()

View File

@ -801,3 +801,11 @@ class AdDomainNameValidation(enum.Enum):
class AdPasswordValidation(enum.Enum): class AdPasswordValidation(enum.Enum):
OK = 'OK' OK = 'OK'
EMPTY = 'Empty' 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"

View File

@ -27,6 +27,10 @@ class ADModel:
self.do_join = False self.do_join = False
self.conn_info: Optional[ADConnectionInfo] = None 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): def set_domain(self, domain: str):
if not domain: if not domain:
return return

View File

@ -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 <http://www.gnu.org/licenses/>.
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

View File

@ -25,8 +25,10 @@ from subiquity.common.types import (
ADConnectionInfo, ADConnectionInfo,
AdAdminNameValidation, AdAdminNameValidation,
AdDomainNameValidation, AdDomainNameValidation,
AdJoinResult,
AdPasswordValidation AdPasswordValidation
) )
from subiquity.server.ad_joiner import AdJoiner
from subiquity.server.controller import SubiquityController from subiquity.server.controller import SubiquityController
log = logging.getLogger('subiquity.server.controllers.ad') log = logging.getLogger('subiquity.server.controllers.ad')
@ -95,6 +97,8 @@ class ADController(SubiquityController):
def __init__(self, app): def __init__(self, app):
super().__init__(app) super().__init__(app)
self.ad_joiner = AdJoiner(self.app)
self.join_result = AdJoinResult.UNKNOWN
if self.app.opts.dry_run: if self.app.opts.dry_run:
self.ping_strgy = StubDcPingStrategy() self.ping_strgy = StubDcPingStrategy()
else: else:
@ -116,7 +120,7 @@ class ADController(SubiquityController):
async def POST(self, data: ADConnectionInfo) -> None: async def POST(self, data: ADConnectionInfo) -> None:
""" Configures this controller with the supplied info. """ Configures this controller with the supplied info.
Clients are required to validate the info before POST'ing """ Clients are required to validate the info before POST'ing """
self.model.conn_info = data self.model.set(data)
await self.configured() await self.configured()
async def check_admin_name_POST(self, admin_name: str) \ 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.""" to configure AD are present in the live system."""
return self.ping_strgy.has_support() 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. # Helper out-of-class functions grouped.
class AdValidators: class AdValidators:

View File

@ -406,6 +406,13 @@ class InstallController(SubiquityController):
policy = self.model.updates.updates policy = self.model.updates.updates
await self.run_unattended_upgrades(context=context, policy=policy) await self.run_unattended_upgrades(context=context, policy=policy)
await self.restore_apt_config(context=context) 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") @with_context(description="configuring cloud-init")
async def configure_cloud_init(self, context): async def configure_cloud_init(self, context):

View File

@ -13,13 +13,23 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from unittest import TestCase from unittest import (
TestCase,
IsolatedAsyncioTestCase,
)
from subiquity.common.types import ( from subiquity.common.types import (
AdAdminNameValidation, AdAdminNameValidation,
ADConnectionInfo,
AdDomainNameValidation, AdDomainNameValidation,
AdJoinResult,
AdPasswordValidation, 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): class TestADValidation(TestCase):
@ -119,3 +129,51 @@ class TestADValidation(TestCase):
admin = r'$Ubuntu{}' admin = r'$Ubuntu{}'
result = AdValidators.admin_user_name(admin) result = AdValidators.admin_user_name(admin)
self.assertEqual(AdAdminNameValidation.OK, result) 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)

View File

@ -215,7 +215,7 @@ POSTINSTALL_MODEL_NAMES = ModelNames({
"ubuntu_pro", "ubuntu_pro",
"userdata", "userdata",
}, },
desktop={"timezone", "codecs"}) desktop={"timezone", "codecs", "ad"})
class SubiquityServer(Application): class SubiquityServer(Application):

View File

@ -1696,3 +1696,21 @@ class TestActiveDirectory(TestAPI):
result = await instance.post(endpoint + '/check_admin_name', result = await instance.post(endpoint + '/check_admin_name',
data='$Ubuntu') data='$Ubuntu')
self.assertEqual('OK', result) 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)