diff --git a/bin/yunoprompt b/bin/yunoprompt index 2705dbcdc..2b2a6cfb2 100755 --- a/bin/yunoprompt +++ b/bin/yunoprompt @@ -5,7 +5,7 @@ ip=$(hostname --all-ip-address) # Fetch SSH fingerprints i=0 -for key in /etc/ssh/ssh_host_*_key.pub ; do +for key in $(ls /etc/ssh/ssh_host_{ed25519,rsa,ecdsa}_key.pub 2> /dev/null) ; do output=$(ssh-keygen -l -f $key) fingerprint[$i]=" - $(echo $output | cut -d' ' -f2) $(echo $output| cut -d' ' -f4)" i=$(($i + 1)) diff --git a/data/helpers.d/utils b/data/helpers.d/utils index eef9f2a8e..b280c3b21 100644 --- a/data/helpers.d/utils +++ b/data/helpers.d/utils @@ -272,6 +272,7 @@ ynh_local_curl () { ynh_render_template() { local template_path=$1 local output_path=$2 + mkdir -p "$(dirname $output_path)" # Taken from https://stackoverflow.com/a/35009576 python2.7 -c 'import os, sys, jinja2; sys.stdout.write( jinja2.Template(sys.stdin.read() diff --git a/data/hooks/conf_regen/03-ssh b/data/hooks/conf_regen/03-ssh index a469b7a66..34cb441b4 100755 --- a/data/hooks/conf_regen/03-ssh +++ b/data/hooks/conf_regen/03-ssh @@ -2,28 +2,53 @@ set -e +. /usr/share/yunohost/helpers.d/utils + do_pre_regen() { - pending_dir=$1 + pending_dir=$1 - cd /usr/share/yunohost/templates/ssh + # If the (legacy) 'from_script' flag is here, + # we won't touch anything in the ssh config. + [[ ! -f /etc/yunohost/from_script ]] || return 0 - # only overwrite SSH configuration on an ISO installation - if [[ ! -f /etc/yunohost/from_script ]]; then - # do not listen to IPv6 if unavailable - [[ -f /proc/net/if_inet6 ]] \ - || sed -i "s/ListenAddress ::/#ListenAddress ::/g" sshd_config + cd /usr/share/yunohost/templates/ssh + + # do not listen to IPv6 if unavailable + [[ -f /proc/net/if_inet6 ]] && ipv6_enabled=true || ipv6_enabled=false - install -D -m 644 sshd_config "${pending_dir}/etc/ssh/sshd_config" - fi + # Support legacy setting (this setting might be disabled by a user during a migration) + ssh_keys=$(ls /etc/ssh/ssh_host_{ed25519,rsa,ecdsa}_key 2>/dev/null) + if [[ "$(yunohost settings get 'service.ssh.allow_deprecated_dsa_hostkey')" == "True" ]]; then + ssh_keys="$ssh_keys $(ls /etc/ssh/ssh_host_dsa_key 2>/dev/null)" + fi + + ssh_keys=$(ls /etc/ssh/ssh_host_{ed25519,rsa,ecdsa}_key 2>/dev/null) + + # Support legacy setting (this setting might be disabled by a user during a migration) + if [[ "$(yunohost settings get 'service.ssh.allow_deprecated_dsa_hostkey')" == "True" ]]; then + ssh_keys="$ssh_keys $(ls /etc/ssh/ssh_host_dsa_key 2>/dev/null)" + fi + + export ssh_keys + export ipv6_enabled + ynh_render_template "sshd_config" "${pending_dir}/etc/ssh/sshd_config" } do_post_regen() { - regen_conf_files=$1 + regen_conf_files=$1 - if [[ ! -f /etc/yunohost/from_script ]]; then - [[ -z "$regen_conf_files" ]] \ - || sudo service ssh restart - fi + # If the (legacy) 'from_script' flag is here, + # we won't touch anything in the ssh config. + [[ ! -f /etc/yunohost/from_script ]] || return 0 + + # If no file changed, there's nothing to do + [[ -n "$regen_conf_files" ]] || return 0 + + # Enforce permissions for /etc/ssh/sshd_config + chown root:root "/etc/ssh/sshd_config" + chmod 644 "/etc/ssh/sshd_config" + + systemctl restart ssh } FORCE=${2:-0} diff --git a/data/templates/ssh/sshd_config b/data/templates/ssh/sshd_config index 8c5a7fb95..ed870e5dc 100644 --- a/data/templates/ssh/sshd_config +++ b/data/templates/ssh/sshd_config @@ -1,96 +1,78 @@ -# Package generated configuration file -# See the sshd_config(5) manpage for details +# This configuration has been automatically generated +# by YunoHost -# What ports, IPs and protocols we listen for -Port 22 -# Use these options to restrict which interfaces/protocols sshd will bind to -ListenAddress :: -ListenAddress 0.0.0.0 Protocol 2 -# HostKeys for protocol version 2 -HostKey /etc/ssh/ssh_host_rsa_key -HostKey /etc/ssh/ssh_host_dsa_key -#Privilege Separation is turned on for security -UsePrivilegeSeparation yes +Port 22 -# Lifetime and size of ephemeral version 1 server key -KeyRegenerationInterval 3600 -ServerKeyBits 768 +{% if ipv6_enabled == "true" %}ListenAddress ::{% endif %} +ListenAddress 0.0.0.0 -# Logging +{% for key in ssh_keys.split() %} +HostKey {{ key }}{% endfor %} + +# ############################################## +# Stuff recommended by Mozilla "modern" compat' +# https://infosec.mozilla.org/guidelines/openssh +# ############################################## + +# Keys, ciphers and MACS +KexAlgorithms curve25519-sha256@libssh.org,ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256 +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr +MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-512,hmac-sha2-256,umac-128@openssh.com + +# Use kernel sandbox mechanisms where possible in unprivileged processes +UsePrivilegeSeparation sandbox + +# LogLevel VERBOSE logs user's key fingerprint on login. +# Needed to have a clear audit track of which key was using to log in. SyslogFacility AUTH -LogLevel INFO +LogLevel VERBOSE + +# ####################### +# Authentication settings +# ####################### + +# Comment from Mozilla about the motivation behind disabling root login +# +# Root login is not allowed for auditing reasons. This is because it's difficult to track which process belongs to which root user: +# +# On Linux, user sessions are tracking using a kernel-side session id, however, this session id is not recorded by OpenSSH. +# Additionally, only tools such as systemd and auditd record the process session id. +# On other OSes, the user session id is not necessarily recorded at all kernel-side. +# Using regular users in combination with /bin/su or /usr/bin/sudo ensure a clear audit track. -# Authentication: LoginGraceTime 120 PermitRootLogin no StrictModes yes - -RSAAuthentication yes PubkeyAuthentication yes -#AuthorizedKeysFile %h/.ssh/authorized_keys - -# Don't read the user's ~/.rhosts and ~/.shosts files -IgnoreRhosts yes -# For this to work you will also need host keys in /etc/ssh_known_hosts -RhostsRSAAuthentication no -# similar for protocol version 2 -HostbasedAuthentication no -# Uncomment if you don't trust ~/.ssh/known_hosts for RhostsRSAAuthentication -#IgnoreUserKnownHosts yes - -# To enable empty passwords, change to yes (NOT RECOMMENDED) PermitEmptyPasswords no - -# Change to yes to enable challenge-response passwords (beware issues with -# some PAM modules and threads) ChallengeResponseAuthentication no - -# Change to no to disable tunnelled clear text passwords -#PasswordAuthentication yes - -# Kerberos options -#KerberosAuthentication no -#KerberosGetAFSToken no -#KerberosOrLocalPasswd yes -#KerberosTicketCleanup yes - -# GSSAPI options -#GSSAPIAuthentication no -#GSSAPICleanupCredentials yes - -X11Forwarding yes -X11DisplayOffset 10 -PrintMotd no -PrintLastLog yes -TCPKeepAlive yes -#UseLogin no - -# keep ssh sessions fresh -ClientAliveInterval 60 - -#MaxStartups 10:30:60 -Banner /etc/issue.net - -# Allow client to pass locale environment variables -AcceptEnv LANG LC_* - -Subsystem sftp internal-sftp - -# Set this to 'yes' to enable PAM authentication, account processing, -# and session processing. If this is enabled, PAM authentication will -# be allowed through the ChallengeResponseAuthentication and -# PasswordAuthentication. Depending on your PAM configuration, -# PAM authentication via ChallengeResponseAuthentication may bypass -# the setting of "PermitRootLogin without-password". -# If you just want the PAM account and session checks to run without -# PAM authentication, then enable this but set PasswordAuthentication -# and ChallengeResponseAuthentication to 'no'. UsePAM yes +# Change to no to disable tunnelled clear text passwords +# (i.e. everybody will need to authenticate using ssh keys) +#PasswordAuthentication yes + +# Post-login stuff +Banner /etc/issue.net +PrintMotd no +PrintLastLog yes +ClientAliveInterval 60 +AcceptEnv LANG LC_* + +# SFTP stuff +Subsystem sftp internal-sftp Match User sftpusers ForceCommand internal-sftp ChrootDirectory /home/%u AllowTcpForwarding no GatewayPorts no X11Forwarding no + +# root login is allowed on local networks +# It's meant to be a backup solution in case LDAP is down and +# user admin can't be used... +# If the server is a VPS, it's expected that the owner of the +# server has access to a web console through which to log in. +Match Address 192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,169.254.0.0/16,fe80::/10,fd00::/8 + PermitRootLogin yes diff --git a/locales/en.json b/locales/en.json index bc90c93a6..78df35c33 100644 --- a/locales/en.json +++ b/locales/en.json @@ -274,6 +274,8 @@ "migration_description_0004_php5_to_php7_pools": "Reconfigure the PHP pools to use PHP 7 instead of 5", "migration_description_0005_postgresql_9p4_to_9p6": "Migrate databases from postgresql 9.4 to 9.6", "migration_description_0006_sync_admin_and_root_passwords": "Synchronize admin and root passwords", + "migration_description_0007_ssh_conf_managed_by_yunohost_step1": "Let the SSH configuration be managed by YunoHost (step 1, automatic)", + "migration_description_0008_ssh_conf_managed_by_yunohost_step2": "Let the SSH configuration be managed by YunoHost (step 2, manual)", "migration_0003_backward_impossible": "The stretch migration cannot be reverted.", "migration_0003_start": "Starting migration to Stretch. The logs will be available in {logfile}.", "migration_0003_patching_sources_list": "Patching the sources.lists ...", @@ -291,6 +293,14 @@ "migration_0005_postgresql_96_not_installed": "Postgresql 9.4 has been found to be installed, but not postgresql 9.6 !? Something weird might have happened on your system :( ...", "migration_0005_not_enough_space": "Not enough space is available in {path} to run the migration right now :(.", "migration_0006_disclaimer": "Yunohost now expects admin and root passwords to be synchronized. By running this migration, your root password is going to be replaced by the admin password.", + "migration_0007_cancelled": "YunoHost has failed to improve the way your SSH conf is managed.", + "migration_0007_cannot_restart": "SSH can't be restarted after trying to cancel migration number 6.", + "migration_0008_general_disclaimer": "To improve the security of your server, it is recommended to let YunoHost manage the SSH configuration. Your current SSH configuration differs from the recommended configuration. If you let YunoHost reconfigure it, the way you connect to your server through SSH will change in the following way:", + "migration_0008_port": " - you will have to connect using port 22 instead of your current custom SSH port. Feel free to reconfigure it ;", + "migration_0008_root": " - you will not be able to connect as root through SSH. Instead you should use the admin user ;", + "migration_0008_dsa": " - the DSA key will be disabled. Hence, you might need to invalidate a spooky warning from your SSH client, and recheck the fingerprint of your server ;", + "migration_0008_warning": "If you understand those warnings and agree to let YunoHost override your current configuration, run the migration. Otherwise, you can also skip the migration - though it is not recommended.", + "migration_0008_no_warning": "No major risk has been indentified about overriding your SSH configuration - but we can't be absolutely sure ;) ! If you agree to let YunoHost override your current configuration, run the migration. Otherwise, you can also skip the migration - though it is not recommended.", "migrations_backward": "Migrating backward.", "migrations_bad_value_for_target": "Invalid number for target argument, available migrations numbers are 0 or {}", "migrations_cant_reach_migration_file": "Can't access migrations files at path %s", diff --git a/src/yunohost/data_migrations/0007_ssh_conf_managed_by_yunohost_step1.py b/src/yunohost/data_migrations/0007_ssh_conf_managed_by_yunohost_step1.py new file mode 100644 index 000000000..73cb162b6 --- /dev/null +++ b/src/yunohost/data_migrations/0007_ssh_conf_managed_by_yunohost_step1.py @@ -0,0 +1,77 @@ +import os +import re + +from shutil import copyfile + +from moulinette import m18n +from moulinette.core import MoulinetteError +from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import mkdir, rm + +from yunohost.tools import Migration +from yunohost.service import service_regen_conf, _get_conf_hashes, \ + _calculate_hash, _run_service_command +from yunohost.settings import settings_set + +logger = getActionLogger('yunohost.migration') + +SSHD_CONF = '/etc/ssh/sshd_config' + + +class MyMigration(Migration): + """ + This is the first step of a couple of migrations that ensure SSH conf is + managed by YunoHost (even if the "from_script" flag is present, which was + previously preventing it from being managed by YunoHost) + + The goal of this first (automatic) migration is to make sure that the + sshd_config is managed by the regen-conf mechanism. + + If the from_script flag exists, then we keep the current SSH conf such that it + will appear as "manually modified" to the regenconf. + + In step 2 (manual), the admin will be able to choose wether or not to actually + use the recommended configuration, with an appropriate disclaimer. + """ + + def migrate(self): + + # Check if deprecated DSA Host Key is in config + dsa_rgx = r'^[ \t]*HostKey[ \t]+/etc/ssh/ssh_host_dsa_key[ \t]*(?:#.*)?$' + dsa = False + for line in open(SSHD_CONF): + if re.match(dsa_rgx, line) is not None: + dsa = True + break + if dsa: + settings_set("service.ssh.allow_deprecated_dsa_hostkey", True) + + # Create sshd_config.d dir + if not os.path.exists(SSHD_CONF + '.d'): + mkdir(SSHD_CONF + '.d', 0755, uid='root', gid='root') + + # Here, we make it so that /etc/ssh/sshd_config is managed + # by the regen conf (in particular in the case where the + # from_script flag is present - in which case it was *not* + # managed by the regenconf) + # But because we can't be sure the user wants to use the + # recommended conf, we backup then restore the /etc/ssh/sshd_config + # right after the regenconf, such that it will appear as + # "manually modified". + if os.path.exists('/etc/yunohost/from_script'): + rm('/etc/yunohost/from_script') + copyfile(SSHD_CONF, '/etc/ssh/sshd_config.bkp') + service_regen_conf(names=['ssh'], force=True) + copyfile('/etc/ssh/sshd_config.bkp', SSHD_CONF) + + # Restart ssh and backward if it fail + if not _run_service_command('restart', 'ssh'): + self.backward() + raise MoulinetteError(m18n.n("migration_0007_cancel")) + + def backward(self): + + # We don't backward completely but it should be enough + copyfile('/etc/ssh/sshd_config.bkp', SSHD_CONF) + if not _run_service_command('restart', 'ssh'): + raise MoulinetteError(m18n.n("migration_0007_cannot_restart")) diff --git a/src/yunohost/data_migrations/0008_ssh_conf_managed_by_yunohost_step2.py b/src/yunohost/data_migrations/0008_ssh_conf_managed_by_yunohost_step2.py new file mode 100644 index 000000000..c53154192 --- /dev/null +++ b/src/yunohost/data_migrations/0008_ssh_conf_managed_by_yunohost_step2.py @@ -0,0 +1,96 @@ +import re + +from moulinette import m18n +from moulinette.core import MoulinetteError +from moulinette.utils.log import getActionLogger + +from yunohost.tools import Migration +from yunohost.service import service_regen_conf, _get_conf_hashes, \ + _calculate_hash +from yunohost.settings import settings_set, settings_get + +logger = getActionLogger('yunohost.migration') + +SSHD_CONF = '/etc/ssh/sshd_config' + +class MyMigration(Migration): + """ + In this second step, the admin is asked if it's okay to use + the recommended SSH configuration - which also implies + disabling deprecated DSA key. + + This has important implications in the way the user may connect + to its server (key change, and a spooky warning might be given + by SSH later) + + A disclaimer explaining the various things to be aware of is + shown - and the user may also choose to skip this migration. + """ + + def migrate(self): + settings_set("service.ssh.allow_deprecated_dsa_hostkey", False) + service_regen_conf(names=['ssh'], force=True) + + def backward(self): + + raise MoulinetteError(m18n.n("migration_0008_backward_impossible")) + + @property + def mode(self): + + # If the conf is already up to date + # and no DSA key is used, then we're good to go + # and the migration can be done automatically + # (basically nothing shall change) + ynh_hash = _get_conf_hashes('ssh').get(SSHD_CONF, None) + current_hash = _calculate_hash(SSHD_CONF) + dsa = settings_get("service.ssh.allow_deprecated_dsa_hostkey") + if ynh_hash == current_hash and not dsa: + return "auto" + + return "manual" + + @property + def disclaimer(self): + + if self.mode == "auto": + return None + + # Detect key things to be aware of before enabling the + # recommended configuration + dsa_key_enabled = False + ports = [] + root_login = [] + port_rgx = r'^[ \t]*Port[ \t]+(\d+)[ \t]*(?:#.*)?$' + root_rgx = r'^[ \t]*PermitRootLogin[ \t]([^# \t]*)[ \t]*(?:#.*)?$' + dsa_rgx = r'^[ \t]*HostKey[ \t]+/etc/ssh/ssh_host_dsa_key[ \t]*(?:#.*)?$' + for line in open(SSHD_CONF): + + ports = ports + re.findall(port_rgx, line) + + root_login = root_login + re.findall(root_rgx, line) + + if not dsa_key_enabled and re.match(dsa_rgx, line) is not None: + dsa_key_enabled = True + + custom_port = ports != ['22'] and ports != [] + root_login_enabled = root_login and root_login[-1] != 'no' + + # Build message + message = m18n.n("migration_0008_general_disclaimer") + + if custom_port: + message += "\n\n" + m18n.n("migration_0008_port") + + if root_login_enabled: + message += "\n\n" + m18n.n("migration_0008_root") + + if dsa_key_enabled: + message += "\n\n" + m18n.n("migration_0008_dsa") + + if custom_port or root_login_enabled or dsa_key_enabled: + message += "\n\n" + m18n.n("migration_0008_warning") + else: + message += "\n\n" + m18n.n("migration_0008_no_warning") + + return message diff --git a/src/yunohost/service.py b/src/yunohost/service.py index 5b7680a80..9ab301933 100644 --- a/src/yunohost/service.py +++ b/src/yunohost/service.py @@ -39,8 +39,8 @@ from moulinette import m18n from moulinette.core import MoulinetteError from moulinette.utils import log, filesystem -from yunohost.hook import hook_callback from yunohost.log import is_unit_operation +from yunohost.hook import hook_callback, hook_list BASE_CONF_PATH = '/home/yunohost.conf' BACKUP_CONF_DIR = os.path.join(BASE_CONF_PATH, 'backup') @@ -422,6 +422,12 @@ def service_regen_conf(operation_logger, names=[], with_diff=False, force=False, # return the arguments to pass to the script return pre_args + [service_pending_path, ] + # Don't regen SSH if not specifically specified + if not names: + names = hook_list('conf_regen', list_by='name', + show_info=False)['hooks'] + names.remove('ssh') + pre_result = hook_callback('conf_regen', names, pre_callback=_pre_call) # Update the services name diff --git a/src/yunohost/settings.py b/src/yunohost/settings.py index d2526316e..391893b4e 100644 --- a/src/yunohost/settings.py +++ b/src/yunohost/settings.py @@ -39,6 +39,7 @@ DEFAULTS = OrderedDict([ # -1 disabled, 0 alert if listed, 1 8-letter, 2 normal, 3 strong, 4 strongest ("security.password.admin.strength", {"type": "int", "default": 1}), ("security.password.user.strength", {"type": "int", "default": 1}), + ("service.ssh.allow_deprecated_dsa_hostkey", {"type": "bool", "default": False}), ]) diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index 78e641189..397e51eb2 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -443,6 +443,24 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, service_start("yunohost-firewall") service_regen_conf(force=True) + + # Restore original ssh conf, as chosen by the + # admin during the initial install + # + # c.f. the install script and in particular + # https://github.com/YunoHost/install_script/pull/50 + # The user can now choose during the install to keep + # the initial, existing sshd configuration + # instead of YunoHost's recommended conf + # + original_sshd_conf = '/etc/ssh/sshd_config.before_yunohost' + if os.path.exists(original_sshd_conf): + os.rename(original_sshd_conf, '/etc/ssh/sshd_config') + else: + # We need to explicitly ask the regen conf to regen ssh + # (by default, i.e. first argument = None, it won't because it's too touchy) + service_regen_conf(names=["ssh"], force=True) + logger.success(m18n.n('yunohost_configured')) logger.warning(m18n.n('recommend_to_add_first_user'))