Merge pull request #1572 from CarlosNihelton/ad-result-deeng-577
Active Directory - Implement the actual join
This commit is contained in:
commit
65aabe527f
|
@ -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):
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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:
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue