diff --git a/subiquity/client/client.py b/subiquity/client/client.py index 9646343b..d19386f0 100644 --- a/subiquity/client/client.py +++ b/subiquity/client/client.py @@ -50,7 +50,7 @@ from subiquity.common.types import ( from subiquity.journald import journald_listen from subiquity.ui.frame import SubiquityUI from subiquity.ui.views.error import ErrorReportStretchy -from subiquity.ui.views.help import HelpMenu +from subiquity.ui.views.help import HelpMenu, ssh_help_texts from subiquity.ui.views.installprogress import ( InstallConfirmation, ) @@ -283,12 +283,11 @@ class SubiquityClient(TuiApplication): self.interactive = status.interactive if self.interactive: if self.opts.ssh: - from subiquity.ui.views.help import ( - ssh_help_texts, get_installer_password) - from subiquitycore.ssh import get_ips_standalone - texts = ssh_help_texts( - get_ips_standalone(), - get_installer_password(self.opts.dry_run)) + ssh_info = self.client.meta.ssh_info.GET() + if ssh_info is None: + print("no ssh?") + return + texts = ssh_help_texts(ssh_info) for line in texts: if hasattr(line, 'text'): if line.text.startswith('installer@'): diff --git a/subiquity/client/controllers/welcome.py b/subiquity/client/controllers/welcome.py index 7f0d5cb7..5992461c 100644 --- a/subiquity/client/controllers/welcome.py +++ b/subiquity/client/controllers/welcome.py @@ -31,10 +31,10 @@ class WelcomeController(SubiquityTuiController): i18n.switch_language(language) serial = self.app.opts.run_on_serial if serial: - ips = await self.app.client.network.global_addresses.GET() + ssh_info = await self.app.client.meta.ssh_info.GET() else: - ips = None - return WelcomeView(self, language, serial, ips) + ssh_info = None + return WelcomeView(self, language, serial, ssh_info) def run_answers(self): if 'lang' in self.answers: diff --git a/subiquity/common/apidef.py b/subiquity/common/apidef.py index 5964cc98..b1fedab7 100644 --- a/subiquity/common/apidef.py +++ b/subiquity/common/apidef.py @@ -36,6 +36,7 @@ from subiquity.common.types import ( SnapListResponse, SnapSelection, SSHData, + LiveSessionSSHInfo, StorageResponse, ZdevInfo, ) @@ -67,6 +68,10 @@ class API: class restart: def POST() -> None: """Restart the server process.""" + + class ssh_info: + def GET() -> Optional[LiveSessionSSHInfo]: ... + class errors: class wait: def GET(error_ref: ErrorReportRef) -> ErrorReportRef: diff --git a/subiquity/common/types.py b/subiquity/common/types.py index 5bb6e535..55eb0cb1 100644 --- a/subiquity/common/types.py +++ b/subiquity/common/types.py @@ -79,6 +79,28 @@ class ApplicationStatus: event_syslog_id: str +class PasswordKind(enum.Enum): + NONE = enum.auto() + KNOWN = enum.auto() + UNKNOWN = enum.auto() + + +@attr.s(auto_attribs=True) +class KeyFingerprint: + keytype: str + fingerprint: str + + +@attr.s(auto_attribs=True) +class LiveSessionSSHInfo: + username: str + password_kind: PasswordKind + password: Optional[str] + authorized_key_fingerprints: List[KeyFingerprint] + ips: List[str] + host_key_fingerprints: List[KeyFingerprint] + + class RefreshCheckState(enum.Enum): UNKNOWN = enum.auto() AVAILABLE = enum.auto() diff --git a/subiquity/server/server.py b/subiquity/server/server.py index 2fd054f3..f14789f6 100644 --- a/subiquity/server/server.py +++ b/subiquity/server/server.py @@ -24,6 +24,8 @@ from typing import List, Optional from aiohttp import web from cloudinit import atomic_helper, safeyaml, stages +from cloudinit.config.cc_set_passwords import rand_user_password +from cloudinit.distros import ug_util import jsonschema @@ -35,7 +37,11 @@ from subiquitycore.async_helpers import run_in_thread, schedule_task from subiquitycore.context import with_context from subiquitycore.core import Application from subiquitycore.prober import Prober -from subiquitycore.utils import arun_command +from subiquitycore.ssh import ( + host_key_fingerprints, + user_key_fingerprints, + ) +from subiquitycore.utils import arun_command, run_command from subiquity.common.api.server import ( bind, @@ -51,6 +57,9 @@ from subiquity.common.types import ( ApplicationState, ApplicationStatus, ErrorReportRef, + KeyFingerprint, + LiveSessionSSHInfo, + PasswordKind, ) from subiquity.server.controller import SubiquityController from subiquity.models.subiquity import SubiquityModel @@ -98,6 +107,48 @@ class MetaController: if controller.endpoint in endpoints: controller.configured() + async def ssh_info_GET(self) -> Optional[LiveSessionSSHInfo]: + ips = [] + for dev in self.app.base_model.network.get_all_netdevs(): + ips.extend(map(str, dev.actual_global_ip_addresses)) + if not ips: + return None + username = self.app.installer_user_name + if username is None: + return None + user_fingerprints = [ + KeyFingerprint(keytype, fingerprint) + for keytype, fingerprint in user_key_fingerprints(username) + ] + if self.app.installer_user_passwd_kind == PasswordKind.NONE: + if not user_key_fingerprints: + return None + host_fingerprints = [ + KeyFingerprint(keytype, fingerprint) + for keytype, fingerprint in host_key_fingerprints() + ] + return LiveSessionSSHInfo( + username=username, + password_kind=self.app.installer_user_passwd_kind, + password=self.app.installer_user_passwd, + authorized_key_fingerprints=user_fingerprints, + ips=ips, + host_key_fingerprints=host_fingerprints) + + +def get_installer_password_from_cloudinit_log(): + try: + fp = open("/var/log/cloud-init-output.log") + except FileNotFoundError: + return None + + with fp: + for line in fp: + if line.startswith("installer:"): + return line[len("installer:"):].strip() + + return None + class SubiquityServer(Application): @@ -157,6 +208,9 @@ class SubiquityServer(Application): self.confirming_tty = '' self.fatal_error = None self.running_error_commands = False + self.installer_user_name = None + self.installer_user_passwd_kind = PasswordKind.NONE + self.installer_user_passwd = None self.echo_syslog_id = 'subiquity_echo.{}'.format(os.getpid()) self.event_syslog_id = 'subiquity_event.{}'.format(os.getpid()) @@ -366,14 +420,14 @@ class SubiquityServer(Application): init = stages.Init() init.read_cfg() init.fetch(existing="trust") - cloud = init.cloudify() + self.cloud = init.cloudify() autoinstall_path = '/autoinstall.yaml' - if 'autoinstall' in cloud.cfg: + if 'autoinstall' in self.cloud.cfg: if not os.path.exists(autoinstall_path): atomic_helper.write_file( autoinstall_path, safeyaml.dumps( - cloud.cfg['autoinstall']).encode('utf-8'), + self.cloud.cfg['autoinstall']).encode('utf-8'), mode=0o600) if os.path.exists(autoinstall_path): self.opts.autoinstall = autoinstall_path @@ -382,11 +436,67 @@ class SubiquityServer(Application): "cloud-init status: %r, assumed disabled", status_txt) + def _user_has_password(self, username): + with open('/etc/shadow') as fp: + for line in fp: + if line.startswith(username + ":$"): + return True + return False + + def set_installer_password(self): + passfile = self.state_path("installer-user-passwd") + + if os.path.exists(passfile): + with open(passfile) as fp: + contents = fp.read() + self.installer_user_passwd_kind = PasswordKind.KNOWN + self.installer_user_name, self.installer_user_passwd = \ + contents.split(':', 1) + return + + def use_passwd(passwd): + self.installer_user_passwd = passwd + self.installer_user_passwd_kind = PasswordKind.KNOWN + with open(passfile, 'w') as fp: + fp.write(self.installer_user_name + ':' + passwd) + + if self.opts.dry_run: + self.installer_user_name = os.environ['USER'] + use_passwd(rand_user_password()) + return + + (users, _groups) = ug_util.normalize_users_groups( + self.cloud.cfg, self.cloud.distro) + (username, _user_config) = ug_util.extract_default(users) + + self.installer_user_name = username + + if self._user_has_password(username): + # Was the password set to a random password by a version of + # cloud-init that records the username in the log? (This is the + # case we hit on upgrading the subiquity snap) + passwd = get_installer_password_from_cloudinit_log() + if passwd: + use_passwd(passwd) + else: + self.installer_user_passwd_kind = PasswordKind.UNKNOWN + elif not user_key_fingerprints(username): + passwd = rand_user_password() + cp = run_command('chpasswd', input=username + ':'+passwd+'\n') + if cp.returncode == 0: + use_passwd(passwd) + else: + log.info("setting installer password failed %s", cp) + self.installer_user_passwd_kind = PasswordKind.NONE + else: + self.installer_user_passwd_kind = PasswordKind.NONE + async def start(self): self.controllers.load_all() await self.start_api_server() self.update_state(ApplicationState.CLOUD_INIT_WAIT) await self.wait_for_cloudinit() + self.set_installer_password() self.load_autoinstall_config(only_early=True) if self.autoinstall_config and self.controllers.Early.cmds: stamp_file = self.state_path("early-commands") diff --git a/subiquity/ui/views/help.py b/subiquity/ui/views/help.py index 207361c4..233002f8 100644 --- a/subiquity/ui/views/help.py +++ b/subiquity/ui/views/help.py @@ -15,7 +15,6 @@ import logging import os -import io from urwid import ( connect_signal, @@ -25,9 +24,8 @@ from urwid import ( Text, ) -from subiquitycore.async_helpers import schedule_task from subiquitycore.lsb_release import lsb_release -from subiquitycore.ssh import host_key_info +from subiquitycore.ssh import summarize_host_keys from subiquitycore.ui.buttons import ( header_btn, other_btn, @@ -57,6 +55,7 @@ from subiquitycore.ui.width import ( widget_width, ) +from subiquity.common.types import PasswordKind from subiquity.ui.views.error import ErrorReportListStretchy log = logging.getLogger('subiquity.ui.help') @@ -110,13 +109,30 @@ To connect, SSH to any of these addresses: """) SSH_HELP_ONE_ADDRESSES = _(""" -To connect, SSH to installer@{ip}. +To connect, SSH to {username}@{ip}.""") + +SSH_HELP_EPILOGUE_KNOWN_PASS_NO_KEYS = _("""\ +The password you should use is "{password}".""") + +SSH_HELP_EPILOGUE_UNKNOWN_PASS_NO_KEYS = _("""\ +You should use the preconfigured password passed to cloud-init.""") + +SSH_HELP_EPILOGUE_ONE_KEY = _("""\ +You can log in with the {keytype} key with fingerprint: + + {fingerprint} """) -SSH_HELP_EPILOGUE = _(""" -The password you should use is "{password}". +SSH_HELP_EPILOGUE_MULTIPLE_KEYS = _("""\ +You can log in with one of the following keys: """) +SSH_HELP_EPILOGUE_KNOWN_PASS_KEYS = _(""" +Or you can use the password "{password}".""") + +SSH_HELP_EPILOGUE_UNKNOWN_PASS_KEYS = _(""" +Or you can use the preconfigured password passed to cloud-init.""") + SSH_HELP_NO_ADDRESSES = _(""" Unfortunately this system seems to have no global IP addresses at this time. @@ -128,26 +144,52 @@ been set. """) -def ssh_help_texts(ips, password): +def ssh_help_texts(ssh_info): texts = [_(SSH_HELP_PROLOGUE), ""] - if len(ips) > 0: - if len(ips) > 1: - texts.append(rewrap(_(SSH_HELP_MULTIPLE_ADDRESSES))) + if len(ssh_info.ips) > 0: + if len(ssh_info.ips) > 1: + texts.append(_(SSH_HELP_MULTIPLE_ADDRESSES)) texts.append("") - for ip in ips: + for ip in ssh_info.ips: texts.append(Text( - "installer@" + str(ip), align='center')) + "{}@{}".format(ssh_info.username, ip), align='center')) else: texts.append(_(SSH_HELP_ONE_ADDRESSES).format( - ip=str(ips[0]))) + username=ssh_info.username, ip=str(ssh_info.ips[0]))) texts.append("") - texts.append( - rewrap(_(SSH_HELP_EPILOGUE).format( - password=password))) + if ssh_info.authorized_key_fingerprints: + if len(ssh_info.authorized_key_fingerprints) == 1: + key = ssh_info.authorized_key_fingerprints[0] + texts.append(Text(_(SSH_HELP_EPILOGUE_ONE_KEY).format( + keytype=key.keytype, fingerprint=key.fingerprint))) + else: + texts.append(_(SSH_HELP_EPILOGUE_MULTIPLE_KEYS)) + texts.append("") + rows = [] + for key in ssh_info.authorized_key_fingerprints: + rows.append( + TableRow([Text(key.keytype), Text(key.fingerprint)])) + texts.append(TablePile(rows)) + if ssh_info.password_kind == PasswordKind.KNOWN: + texts.append("") + texts.append(SSH_HELP_EPILOGUE_KNOWN_PASS_KEYS.format( + password=ssh_info.password)) + elif ssh_info.password_kind == PasswordKind.UNKNOWN: + texts.append("") + texts.append(SSH_HELP_EPILOGUE_UNKNOWN_PASS_KEYS) + else: + if ssh_info.password_kind == PasswordKind.KNOWN: + texts.append(SSH_HELP_EPILOGUE_KNOWN_PASS_NO_KEYS.format( + password=ssh_info.password)) + elif ssh_info.password_kind == PasswordKind.UNKNOWN: + texts.append(SSH_HELP_EPILOGUE_UNKNOWN_PASS_NO_KEYS) texts.append("") - texts.append(Text(host_key_info())) + texts.append(Text(summarize_host_keys([ + (key.keytype, key.fingerprint) + for key in ssh_info.host_key_fingerprints + ]))) else: texts.append("") texts.append(_(SSH_HELP_NO_ADDRESSES)) @@ -241,30 +283,6 @@ def menu_item(text, on_press=None): return Color.frame_button(icon) -async def get_global_addresses(app): - return await app.wait_with_text_dialog( - app.client.network.global_addresses.GET(), - _("Getting network info"), - can_cancel=True) - - -def get_installer_password(dry_run=False): - if dry_run: - fp = io.StringIO('installer:rAnd0Mpass') - else: - try: - fp = open("/var/log/cloud-init-output.log") - except FileNotFoundError: - fp = io.StringIO('') - - with fp: - for line in fp: - if line.startswith("installer:"): - return line[len("installer:"):].strip() - - return None - - class OpenHelpMenu(WidgetWrap): def __init__(self, parent): @@ -282,7 +300,7 @@ class OpenHelpMenu(WidgetWrap): drop_to_shell, keys, } - if self.parent.ssh_password is not None: + if self.parent.ssh_info is not None: ssh_help = menu_item( _("Help on SSH access"), on_press=self.parent.ssh_help) buttons.add(ssh_help) @@ -324,7 +342,7 @@ class OpenHelpMenu(WidgetWrap): about, ] - if self.parent.ssh_password is not None: + if self.parent.ssh_info is not None: entries.append(ssh_help) if self.parent.app.opts.run_on_serial: @@ -379,17 +397,20 @@ class HelpMenu(PopUpLauncher): def __init__(self, app): self.app = app self.btn = header_btn(_("Help"), on_press=self._open) - self.ssh_password = None + self.ssh_info = None self.current_help = None super().__init__(self.btn) - def _open(self, sender): - log.debug("open help menu") + async def _get_ssh_info(self): + self.ssh_info = await self.app.wait_with_text_dialog( + self.app.client.meta.ssh_info.GET(), "Getting SSH info") self.open_pop_up() + def _open(self, sender): + log.debug("open help menu") + self.app.aio_loop.create_task(self._get_ssh_info()) + def create_pop_up(self): - if self.ssh_password is None: - self.ssh_password = get_installer_password(self.app.opts.dry_run) self._menu = OpenHelpMenu(self) return self._menu @@ -435,10 +456,8 @@ class HelpMenu(PopUpLauncher): _("About the installer"), template.format(**info))) - async def _ssh_help(self): - texts = ssh_help_texts( - await get_global_addresses(self.app), - self.ssh_password) + def ssh_help(self, sender=None): + texts = ssh_help_texts(self.ssh_info) self._show_overlay( SimpleTextStretchy( @@ -447,9 +466,6 @@ class HelpMenu(PopUpLauncher): *texts, )) - def ssh_help(self, sender=None): - schedule_task(self._ssh_help()) - def show_local(self, local_title, local_doc): def cb(sender=None): diff --git a/subiquity/ui/views/welcome.py b/subiquity/ui/views/welcome.py index b5fb055c..4b68dc56 100644 --- a/subiquity/ui/views/welcome.py +++ b/subiquity/ui/views/welcome.py @@ -30,10 +30,6 @@ from subiquitycore.ui.utils import button_pile, rewrap, screen from subiquitycore.screen import is_linux_tty from subiquitycore.view import BaseView -from subiquity.ui.views.help import ( - get_installer_password, - ) - log = logging.getLogger("subiquity.views.welcome") @@ -85,11 +81,11 @@ def get_languages(): class WelcomeView(BaseView): title = "Willkommen! Bienvenue! Welcome! Добро пожаловать! Welkom!" - def __init__(self, controller, cur_lang, serial, ips): + def __init__(self, controller, cur_lang, serial, ssh_info): self.controller = controller self.cur_lang = cur_lang if serial and not controller.app.rich_mode: - s = self.make_serial_choices(ips) + s = self.make_serial_choices(ssh_info) self.title = "Welcome!" else: s = self.make_language_choices() @@ -119,8 +115,7 @@ class WelcomeView(BaseView): lb, buttons=None, narrow_rows=True, excerpt=_("Use UP, DOWN and ENTER keys to select your language.")) - def make_serial_choices(self, ips): - ssh_password = get_installer_password(self.controller.opts.dry_run) + def make_serial_choices(self, ssh_info): btns = [ other_btn( label="Switch to rich mode", @@ -135,13 +130,13 @@ class WelcomeView(BaseView): Text(rewrap(SERIAL_TEXT)), Text(""), ] - if ssh_password and ips: + if ssh_info: widgets.append(Text(rewrap(SSH_TEXT))) widgets.append(Text("")) btns.insert(1, other_btn( label="View SSH instructions", on_press=self.ssh_help, - user_arg=ssh_password)) + user_arg=ssh_info)) widgets.extend([ button_pile(btns), ]) @@ -154,9 +149,9 @@ class WelcomeView(BaseView): self.controller.ui.set_header(self.title) self._w = self.make_language_choices() - def ssh_help(self, sender, password): + def ssh_help(self, sender, ssh_info): menu = self.controller.app.help_menu - menu.ssh_password = password + menu.ssh_info = ssh_info menu.ssh_help() def choose_language(self, sender, code): diff --git a/subiquitycore/ssh.py b/subiquitycore/ssh.py index fa3a5eab..eedd2453 100644 --- a/subiquitycore/ssh.py +++ b/subiquitycore/ssh.py @@ -14,6 +14,8 @@ # along with this program. If not, see . import logging +import os +import pwd from subiquitycore.utils import run_command @@ -36,13 +38,20 @@ def host_key_fingerprints(): keyfiles.append(line.split(None, 1)[1]) info = [] for keyfile in keyfiles: - cp = run_command(['ssh-keygen', '-lf', keyfile]) - if cp.returncode != 0: - log.debug("ssh-keygen -lf %s failed %r", keyfile, cp.stderr) - continue - parts = cp.stdout.strip().split() - length, fingerprint, host, keytype = parts - keytype = keytype.strip('()') + info.extend(fingerprints(keyfile)) + return info + + +def fingerprints(keyfile): + info = [] + cp = run_command(['ssh-keygen', '-lf', keyfile]) + if cp.returncode != 0: + log.debug("ssh-keygen -lf %s failed %r", keyfile, cp.stderr) + return info + for line in cp.stdout.splitlines(): + parts = line.strip().replace('\r', '').split() + fingerprint = parts[1] + keytype = parts[-1].strip('()') info.append((keytype, fingerprint)) return info @@ -55,13 +64,16 @@ host_key_tmpl = """ {keytype:{width}} {fingerprint}""" single_host_key_tmpl = _("""\ -The {keytype} host key fingerprints is: +The {keytype} host key fingerprint is: {fingerprint} """) def host_key_info(): - fingerprints = host_key_fingerprints() + return summarize_host_keys(host_key_fingerprints()) + + +def summarize_host_keys(fingerprints): if len(fingerprints) == 0: return '' if len(fingerprints) == 1: @@ -77,6 +89,19 @@ def host_key_info(): return "".join(lines) +def user_key_fingerprints(username): + try: + user_info = pwd.getpwnam(username) + except KeyError: + log.exception("getpwnam(%s) failed", username) + return [] + user_key_file = '{}/.ssh/authorized_keys'.format(user_info.pw_dir) + if os.path.exists(user_key_file): + return fingerprints(user_key_file) + else: + return [] + + def get_ips_standalone(): from probert.prober import Prober from subiquitycore.models.network import NETDEV_IGNORED_IFACE_TYPES