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",
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):

View File

@ -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()

View File

@ -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"

View File

@ -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

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,
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:

View File

@ -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):

View File

@ -13,13 +13,23 @@
# 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/>.
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)

View File

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

View File

@ -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)