# Copyright 2021 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 import logging import os import shlex import sys import time import jsonschema import yaml 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 from subiquity.common.api.server import bind, controller_for_request from subiquity.common.apidef import API from subiquity.common.errorreport import ErrorReporter from subiquity.common.serialize import to_json from subiquity.common.types import (ApplicationState, ErrorReportKind, ErrorReportRef, PasswordKind) from subiquity.server.controller import SubiquityController from subiquity.server.errors import ErrorController from subiquity.server.server import (MetaController, get_installer_password_from_cloudinit_log) from subiquitycore.async_helpers import run_in_thread from subiquitycore.context import with_context from subiquitycore.core import Application from subiquitycore.ssh import user_key_fingerprints from subiquitycore.utils import arun_command, run_command from system_setup.models.system_server import SystemSetupModel log = logging.getLogger('subiquity.server.server') NOPROBERARG = "NOPROBER" class SystemSetupServer(Application): ''' Server for the System Setup. No longer using old method because it keep inheriting incompatible classes subiquity.server, which is not what we want for System Setup. ''' snapd_socket_path = '/run/snapd.socket' base_schema = { 'type': 'object', 'properties': { 'version': { 'type': 'integer', 'minimum': 1, 'maximum': 1, }, }, 'required': ['version'], 'additionalProperties': True, } project = "subiquity" from system_setup.server import controllers as controllers_mod controllers = [ "Reporting", "Error", "Userdata", "Locale", "Identity", "WSLConfiguration1", "WSLConfiguration2", ] def __init__(self, opts, block_log_dir): super().__init__(opts) self.block_log_dir = block_log_dir self.cloud = None self.cloud_init_ok = None self._state = ApplicationState.STARTING_UP self.state_event = asyncio.Event() self.interactive = None 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()) self.log_syslog_id = 'subiquity_log.{}'.format(os.getpid()) self.error_reporter = ErrorReporter( self.context.child("ErrorReporter"), self.opts.dry_run, self.root) self.prober = None self.kernel_cmdline = shlex.split(opts.kernel_cmdline) self.controllers.remove("Refresh") self.controllers.remove("SnapList") self.snapd = None self.note_data_for_apport("SnapUpdated", str(self.updated)) self.event_listeners = [] self.autoinstall_config = None self.hub.subscribe('network-up', self._network_change) self.hub.subscribe('network-proxy-set', self._proxy_set) self.geoip = None self.is_reconfig = opts.reconfigure if self.is_reconfig and not opts.dry_run: self.controllers = [ "Reporting", "Error", "Locale", "WSLConfiguration2", ] super().__init__(opts, block_log_dir) def make_model(self): root = '/' if self.opts.dry_run: root = os.path.abspath('.subiquity') return SystemSetupModel(root, self.is_reconfig) def load_serialized_state(self): for controller in self.controllers.instances: controller.load_state() def add_event_listener(self, listener): self.event_listeners.append(listener) def _maybe_push_to_journal(self, event_type, context, description): pass def report_start_event(self, context, description): for listener in self.event_listeners: listener.report_start_event(context, description) self._maybe_push_to_journal('start', context, description) def report_finish_event(self, context, description, status): for listener in self.event_listeners: listener.report_finish_event(context, description, status) self._maybe_push_to_journal('finish', context, description) @property def state(self): return self._state def update_state(self, state): self._state = state self.state_event.set() self.state_event.clear() def note_file_for_apport(self, key, path): self.error_reporter.note_file_for_apport(key, path) def note_data_for_apport(self, key, value): self.error_reporter.note_data_for_apport(key, value) def make_apport_report(self, kind, thing, *, wait=False, **kw): return self.error_reporter.make_apport_report( kind, thing, wait=wait, **kw) async def _run_error_cmds(self, report): await report._info_task Error = getattr(self.controllers, "Error", None) if Error is not None and Error.cmds: try: await Error.run() except Exception: log.exception("running error-commands failed") if not self.interactive: self.update_state(ApplicationState.ERROR) def _exception_handler(self, loop, context): exc = context.get('exception') if exc is None: super()._exception_handler(loop, context) return report = self.error_reporter.report_for_exc(exc) log.error("top level error", exc_info=exc) if not report: report = self.make_apport_report( ErrorReportKind.UNKNOWN, "unknown error", exc=exc) self.fatal_error = report if self.interactive: self.update_state(ApplicationState.ERROR) if not self.running_error_commands: self.running_error_commands = True self.aio_loop.create_task(self._run_error_cmds(report)) @web.middleware async def middleware(self, request, handler): override_status = None controller = await controller_for_request(request) if isinstance(controller, SubiquityController): if not controller.interactive(): override_status = 'skip' elif self.state == ApplicationState.NEEDS_CONFIRMATION: if self.base_model.is_postinstall_only(controller.model_name): override_status = 'confirm' if override_status is not None: resp = web.Response(headers={'x-status': override_status}) else: resp = await handler(request) if self.updated: resp.headers['x-updated'] = 'yes' else: resp.headers['x-updated'] = 'no' if resp.get('exception'): exc = resp['exception'] log.debug( 'request to {} crashed'.format(request.raw_path), exc_info=exc) report = self.make_apport_report( ErrorReportKind.SERVER_REQUEST_FAIL, "request to {}".format(request.raw_path), exc=exc) resp.headers['x-error-report'] = to_json( ErrorReportRef, report.ref()) return resp @with_context() async def apply_autoinstall_config(self, context): for controller in self.controllers.instances: if controller.interactive(): log.debug( "apply_autoinstall_config: skipping %s as interactive", controller.name) continue await controller.apply_autoinstall_config() controller.configured() def load_autoinstall_config(self, *, only_early): log.debug("load_autoinstall_config only_early %s", only_early) if self.opts.autoinstall is None: return with open(self.opts.autoinstall) as fp: self.autoinstall_config = yaml.safe_load(fp) if only_early: self.controllers.Reporting.setup_autoinstall() self.controllers.Reporting.start() self.controllers.Error.setup_autoinstall() with self.context.child("core_validation", level="INFO"): jsonschema.validate(self.autoinstall_config, self.base_schema) self.controllers.Early.setup_autoinstall() else: for controller in self.controllers.instances: controller.setup_autoinstall() async def start_api_server(self): app = web.Application(middlewares=[self.middleware]) bind(app.router, API.meta, MetaController(self)) bind(app.router, API.errors, ErrorController(self)) if self.opts.dry_run: from subiquity.server.dryrun import DryRunController bind(app.router, API.dry_run, DryRunController(self)) for controller in self.controllers.instances: controller.add_routes(app) runner = web.AppRunner(app, keepalive_timeout=0xffffffff) await runner.setup() site = web.UnixSite(runner, self.opts.socket) await site.start() # It is intended that a non-root client can connect. os.chmod(self.opts.socket, 0o666) async def wait_for_cloudinit(self): if self.opts.dry_run: self.cloud_init_ok = True return ci_start = time.time() status_coro = arun_command(["cloud-init", "status", "--wait"]) try: status_cp = await asyncio.wait_for(status_coro, 600) except asyncio.CancelledError: status_txt = '' self.cloud_init_ok = False else: status_txt = status_cp.stdout self.cloud_init_ok = True log.debug("waited %ss for cloud-init", time.time() - ci_start) if "status: done" in status_txt: log.debug("loading cloud config") init = stages.Init() init.read_cfg() init.fetch(existing="trust") self.cloud = init.cloudify() autoinstall_path = '/autoinstall.yaml' if 'autoinstall' in self.cloud.cfg: if not os.path.exists(autoinstall_path): atomic_helper.write_file( autoinstall_path, safeyaml.dumps( self.cloud.cfg['autoinstall']).encode('utf-8'), mode=0o600) if os.path.exists(autoinstall_path): self.opts.autoinstall = autoinstall_path else: log.debug( "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): if self.cloud is None: return 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") if not os.path.exists(stamp_file): self.update_state(ApplicationState.EARLY_COMMANDS) # Just wait a second for any clients to get ready to print # output. await asyncio.sleep(1) await self.controllers.Early.run() open(stamp_file, 'w').close() await asyncio.sleep(1) self.load_autoinstall_config(only_early=False) if self.autoinstall_config: self.interactive = bool( self.autoinstall_config.get('interactive-sections')) else: self.interactive = True if not self.interactive and not self.opts.dry_run: open('/run/casper-no-prompt', 'w').close() self.load_serialized_state() self.update_state(ApplicationState.WAITING) await super().start() await self.apply_autoinstall_config() def _network_change(self): if not self.snapd: return self.hub.broadcast('snapd-network-change') async def _proxy_set(self): if not self.snapd: return await run_in_thread( self.snapd.connection.configure_proxy, self.base_model.proxy) self.hub.broadcast('snapd-network-change') def restart(self): if not self.snapd: return cmdline = ['snap', 'run', 'subiquity.subiquity-server'] if self.opts.dry_run: cmdline = [ sys.executable, '-m', 'subiquity.cmd.server', ] + sys.argv[1:] os.execvp(cmdline[0], cmdline) def make_autoinstall(self): config = {'version': 1} for controller in self.controllers.instances: controller_conf = controller.make_autoinstall() if controller_conf: config[controller.autoinstall_key] = controller_conf return config