diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index 076e5599c..77887b41a 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -203,6 +203,30 @@ user: extra: pattern: *pattern_mailbox_quota + ### ssh_user_enable_ssh() + allow-ssh: + action_help: Allow the user to uses ssh + api: POST /ssh/user/enable-ssh + configuration: + authenticate: all + arguments: + username: + help: Username of the user + extra: + pattern: *pattern_username + + ### ssh_user_disable_ssh() + disallow-ssh: + action_help: Disallow the user to uses ssh + api: POST /ssh/user/disable-ssh + configuration: + authenticate: all + arguments: + username: + help: Username of the user + extra: + pattern: *pattern_username + ### user_info() info: action_help: Get user information @@ -1325,6 +1349,74 @@ dyndns: api: DELETE /dyndns/cron +############################# +# SSH # +############################# +ssh: + category_help: Manage ssh keys and access + actions: {} + subcategories: + authorized-keys: + subcategory_help: Manage user's authorized ssh keys + + actions: + ### ssh_authorized_keys_list() + list: + action_help: Show user's authorized ssh keys + api: GET /ssh/authorized-keys + configuration: + authenticate: all + arguments: + username: + help: Username of the user + extra: + pattern: *pattern_username + + ### ssh_authorized_keys_add() + add: + action_help: Add a new authorized ssh key for this user + api: POST /ssh/authorized-keys + configuration: + authenticate: all + arguments: + username: + help: Username of the user + extra: + pattern: *pattern_username + -u: + full: --public + help: Public key + extra: + required: True + -i: + full: --private + help: Private key + extra: + required: True + -n: + full: --name + help: Key name + extra: + required: True + + ### ssh_authorized_keys_remove() + remove: + action_help: Remove an authorized ssh key for this user + api: DELETE /ssh/authorized-keys + configuration: + authenticate: all + arguments: + username: + help: Username of the user + extra: + pattern: *pattern_username + -k: + full: --key + help: Key as a string + extra: + required: True + + ############################# # Tools # ############################# diff --git a/data/helpers.d/backend b/data/helpers.d/backend index 8fef412cf..c2c626829 100644 --- a/data/helpers.d/backend +++ b/data/helpers.d/backend @@ -64,9 +64,13 @@ ynh_remove_logrotate () { # Create a dedicated systemd config # -# This will use a template in ../conf/systemd.service -# and will replace the following keywords with -# global variables that should be defined before calling +# usage: ynh_add_systemd_config [Service name] [Template name] +# | arg: Service name (optionnal, $app by default) +# | arg: Name of template file (optionnal, this is 'systemd' by default, meaning ./conf/systemd.service will be used as template) +# +# This will use the template ../conf/.service +# to generate a systemd config, by replacing the following keywords +# with global variables that should be defined before calling # this helper : # # __APP__ by $app @@ -74,9 +78,11 @@ ynh_remove_logrotate () { # # usage: ynh_add_systemd_config ynh_add_systemd_config () { - finalsystemdconf="/etc/systemd/system/$app.service" + local service_name="${1:-$app}" + + finalsystemdconf="/etc/systemd/system/$service_name.service" ynh_backup_if_checksum_is_different "$finalsystemdconf" - sudo cp ../conf/systemd.service "$finalsystemdconf" + sudo cp ../conf/${2:-systemd.service} "$finalsystemdconf" # To avoid a break by set -u, use a void substitution ${var:-}. If the variable is not set, it's simply set with an empty variable. # Substitute in a nginx config file only if the variable is not empty @@ -89,19 +95,25 @@ ynh_add_systemd_config () { ynh_store_file_checksum "$finalsystemdconf" sudo chown root: "$finalsystemdconf" - sudo systemctl enable $app + sudo systemctl enable $service_name sudo systemctl daemon-reload } # Remove the dedicated systemd config # +# usage: ynh_remove_systemd_config [Service name] +# | arg: Service name (optionnal, $app by default) +# # usage: ynh_remove_systemd_config ynh_remove_systemd_config () { - local finalsystemdconf="/etc/systemd/system/$app.service" + local service_name="${1:-$app}" + + local finalsystemdconf="/etc/systemd/system/$service_name.service" if [ -e "$finalsystemdconf" ]; then - sudo systemctl stop $app - sudo systemctl disable $app + sudo systemctl stop $service_name + sudo systemctl disable $service_name ynh_secure_remove "$finalsystemdconf" + sudo systemctl daemon-reload fi } @@ -157,7 +169,17 @@ ynh_remove_nginx_config () { # # usage: ynh_add_fpm_config ynh_add_fpm_config () { - finalphpconf="/etc/php5/fpm/pool.d/$app.conf" + # Configure PHP-FPM 7.0 by default + local fpm_config_dir="/etc/php/7.0/fpm" + local fpm_service="php7.0-fpm" + # Configure PHP-FPM 5 on Debian Jessie + if [ "$(ynh_get_debian_release)" == "jessie" ]; then + fpm_config_dir="/etc/php5/fpm" + fpm_service="php5-fpm" + fi + ynh_app_setting_set $app fpm_config_dir "$fpm_config_dir" + ynh_app_setting_set $app fpm_service "$fpm_service" + finalphpconf="$fpm_config_dir/pool.d/$app.conf" ynh_backup_if_checksum_is_different "$finalphpconf" sudo cp ../conf/php-fpm.conf "$finalphpconf" ynh_replace_string "__NAMETOCHANGE__" "$app" "$finalphpconf" @@ -168,21 +190,27 @@ ynh_add_fpm_config () { if [ -e "../conf/php-fpm.ini" ] then - finalphpini="/etc/php5/fpm/conf.d/20-$app.ini" + finalphpini="$fpm_config_dir/conf.d/20-$app.ini" ynh_backup_if_checksum_is_different "$finalphpini" sudo cp ../conf/php-fpm.ini "$finalphpini" sudo chown root: "$finalphpini" ynh_store_file_checksum "$finalphpini" fi - - sudo systemctl reload php5-fpm + sudo systemctl reload $fpm_service } # Remove the dedicated php-fpm config # # usage: ynh_remove_fpm_config ynh_remove_fpm_config () { - ynh_secure_remove "/etc/php5/fpm/pool.d/$app.conf" - ynh_secure_remove "/etc/php5/fpm/conf.d/20-$app.ini" 2>&1 - sudo systemctl reload php5-fpm + local fpm_config_dir=$(ynh_app_setting_get $app fpm_config_dir) + local fpm_service=$(ynh_app_setting_get $app fpm_service) + # Assume php version 5 if not set + if [ -z "$fpm_config_dir" ]; then + fpm_config_dir="/etc/php5/fpm" + fpm_service="php5-fpm" + fi + ynh_secure_remove "$fpm_config_dir/pool.d/$app.conf" + ynh_secure_remove "$fpm_config_dir/conf.d/20-$app.ini" 2>&1 + sudo systemctl reload $fpm_service } diff --git a/data/helpers.d/system b/data/helpers.d/system index 4bb941b7d..f204c836a 100644 --- a/data/helpers.d/system +++ b/data/helpers.d/system @@ -41,3 +41,10 @@ ynh_abort_if_errors () { set -eu # Exit if a command fail, and if a variable is used unset. trap ynh_exit_properly EXIT # Capturing exit signals on shell script } + +# Return the Debian release codename (i.e. jessie, stretch, etc.) +# +# usage: ynh_get_debian_release +ynh_get_debian_release () { + echo $(lsb_release --codename --short) +} \ No newline at end of file diff --git a/data/helpers.d/utils b/data/helpers.d/utils index db51578db..d39bb78f5 100644 --- a/data/helpers.d/utils +++ b/data/helpers.d/utils @@ -37,22 +37,23 @@ ynh_get_plain_key() { ynh_restore_upgradebackup () { echo "Upgrade failed." >&2 local app_bck=${app//_/-} # Replace all '_' by '-' - NO_BACKUP_UPGRADE=${NO_BACKUP_UPGRADE:-0} - if [ "$NO_BACKUP_UPGRADE" -eq 0 ] - then + NO_BACKUP_UPGRADE=${NO_BACKUP_UPGRADE:-0} + + if [ "$NO_BACKUP_UPGRADE" -eq 0 ] + then # Check if an existing backup can be found before removing and restoring the application. if sudo yunohost backup list | grep -q $app_bck-pre-upgrade$backup_number then # Remove the application then restore it sudo yunohost app remove $app # Restore the backup - sudo yunohost backup restore --ignore-system $app_bck-pre-upgrade$backup_number --apps $app --force + sudo yunohost backup restore --ignore-system $app_bck-pre-upgrade$backup_number --apps $app --force --verbose ynh_die "The app was restored to the way it was before the failed upgrade." fi - else - echo "\$NO_BACKUP_UPGRADE is set, that means there's no backup to restore. You have to fix this upgrade by yourself !" >&2 - fi + else + echo "\$NO_BACKUP_UPGRADE is set, that means there's no backup to restore. You have to fix this upgrade by yourself !" >&2 + fi } # Make a backup in case of failed upgrade @@ -86,7 +87,7 @@ ynh_backup_before_upgrade () { fi # Create backup - sudo yunohost backup create --ignore-system --apps $app --name $app_bck-pre-upgrade$backup_number + sudo yunohost backup create --ignore-system --apps $app --name $app_bck-pre-upgrade$backup_number --verbose if [ "$?" -eq 0 ] then # If the backup succeeded, remove the previous backup diff --git a/data/hooks/conf_regen/01-yunohost b/data/hooks/conf_regen/01-yunohost index f8bef0614..e1daa7c3d 100755 --- a/data/hooks/conf_regen/01-yunohost +++ b/data/hooks/conf_regen/01-yunohost @@ -53,6 +53,9 @@ do_pre_regen() { else sudo cp services.yml /etc/yunohost/services.yml fi + + mkdir -p "$pending_dir"/etc/etckeeper/ + cp etckeeper.conf "$pending_dir"/etc/etckeeper/ } _update_services() { diff --git a/data/templates/nginx/plain/global.conf b/data/templates/nginx/plain/global.conf index b3a5f356a..ca8721afb 100644 --- a/data/templates/nginx/plain/global.conf +++ b/data/templates/nginx/plain/global.conf @@ -1 +1,2 @@ server_tokens off; +gzip_types text/css text/javascript application/javascript; diff --git a/data/templates/nginx/plain/yunohost_admin.conf b/data/templates/nginx/plain/yunohost_admin.conf index a9d26d151..156d61bd6 100644 --- a/data/templates/nginx/plain/yunohost_admin.conf +++ b/data/templates/nginx/plain/yunohost_admin.conf @@ -36,8 +36,18 @@ server { # Uncomment the following directive after DH generation # > openssl dhparam -out /etc/ssl/private/dh2048.pem -outform PEM -2 2048 #ssl_dhparam /etc/ssl/private/dh2048.pem; - - add_header Strict-Transport-Security "max-age=31536000;"; + + # Follows the Web Security Directives from the Mozilla Dev Lab and the Mozilla Obervatory + Partners + # https://wiki.mozilla.org/Security/Guidelines/Web_Security + # https://observatory.mozilla.org/ + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"; + add_header 'Referrer-Policy' 'same-origin'; + add_header Content-Security-Policy "upgrade-insecure-requests; object-src 'none'; script-src https: 'unsafe-eval'"; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + add_header X-Download-Options noopen; + add_header X-Permitted-Cross-Domain-Policies none; + add_header X-Frame-Options "SAMEORIGIN"; location / { return 302 https://$http_host/yunohost/admin; diff --git a/data/templates/nginx/plain/yunohost_admin.conf.inc b/data/templates/nginx/plain/yunohost_admin.conf.inc index b0ab4cef6..2ab72293d 100644 --- a/data/templates/nginx/plain/yunohost_admin.conf.inc +++ b/data/templates/nginx/plain/yunohost_admin.conf.inc @@ -1,4 +1,7 @@ -location /yunohost/admin { +# Avoid the nginx path/alias traversal weakness ( #1037 ) +rewrite ^/yunohost/admin$ /yunohost/admin/ permanent; + +location /yunohost/admin/ { alias /usr/share/yunohost/admin/; default_type text/html; index index.html; diff --git a/data/templates/nginx/server.tpl.conf b/data/templates/nginx/server.tpl.conf index 685ae01b8..ac2ff8486 100644 --- a/data/templates/nginx/server.tpl.conf +++ b/data/templates/nginx/server.tpl.conf @@ -42,7 +42,16 @@ server { # > openssl dhparam -out /etc/ssl/private/dh2048.pem -outform PEM -2 2048 #ssl_dhparam /etc/ssl/private/dh2048.pem; - add_header Strict-Transport-Security "max-age=31536000;"; + # Follows the Web Security Directives from the Mozilla Dev Lab and the Mozilla Obervatory + Partners + # https://wiki.mozilla.org/Security/Guidelines/Web_Security + # https://observatory.mozilla.org/ + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"; + add_header Content-Security-Policy "upgrade-insecure-requests; object-src 'none'; script-src https: 'unsafe-eval'"; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + add_header X-Download-Options noopen; + add_header X-Permitted-Cross-Domain-Policies none; + add_header X-Frame-Options "SAMEORIGIN"; access_by_lua_file /usr/share/ssowat/access.lua; diff --git a/data/templates/yunohost/etckeeper.conf b/data/templates/yunohost/etckeeper.conf new file mode 100644 index 000000000..2d11c3dc6 --- /dev/null +++ b/data/templates/yunohost/etckeeper.conf @@ -0,0 +1,43 @@ +# The VCS to use. +#VCS="hg" +VCS="git" +#VCS="bzr" +#VCS="darcs" + +# Options passed to git commit when run by etckeeper. +GIT_COMMIT_OPTIONS="--quiet" + +# Options passed to hg commit when run by etckeeper. +HG_COMMIT_OPTIONS="" + +# Options passed to bzr commit when run by etckeeper. +BZR_COMMIT_OPTIONS="" + +# Options passed to darcs record when run by etckeeper. +DARCS_COMMIT_OPTIONS="-a" + +# Uncomment to avoid etckeeper committing existing changes +# to /etc automatically once per day. +#AVOID_DAILY_AUTOCOMMITS=1 + +# Uncomment the following to avoid special file warning +# (the option is enabled automatically by cronjob regardless). +#AVOID_SPECIAL_FILE_WARNING=1 + +# Uncomment to avoid etckeeper committing existing changes to +# /etc before installation. It will cancel the installation, +# so you can commit the changes by hand. +#AVOID_COMMIT_BEFORE_INSTALL=1 + +# The high-level package manager that's being used. +# (apt, pacman-g2, yum, zypper etc) +HIGHLEVEL_PACKAGE_MANAGER=apt + +# The low-level package manager that's being used. +# (dpkg, rpm, pacman, pacman-g2, etc) +LOWLEVEL_PACKAGE_MANAGER=dpkg + +# To push each commit to a remote, put the name of the remote here. +# (eg, "origin" for git). Space-separated lists of multiple remotes +# also work (eg, "origin gitlab github" for git). +PUSH_REMOTE="" diff --git a/debian/control b/debian/control index dcdd0dd9a..c15c5eec5 100644 --- a/debian/control +++ b/debian/control @@ -18,7 +18,7 @@ Depends: ${python:Depends}, ${misc:Depends} , ca-certificates, netcat-openbsd, iproute , mariadb-server | mysql-server, php5-mysql | php5-mysqlnd , slapd, ldap-utils, sudo-ldap, libnss-ldapd, nscd - , postfix-ldap, postfix-policyd-spf-perl, postfix-pcre, procmail + , postfix-ldap, postfix-policyd-spf-perl, postfix-pcre, procmail, mailutils , dovecot-ldap, dovecot-lmtpd, dovecot-managesieved , dovecot-antispam, fail2ban , nginx-extras (>=1.6.2), php5-fpm, php5-ldap, php5-intl diff --git a/locales/ar.json b/locales/ar.json new file mode 100644 index 000000000..2c63c0851 --- /dev/null +++ b/locales/ar.json @@ -0,0 +1,2 @@ +{ +} diff --git a/locales/en.json b/locales/en.json index f27d9f5f4..66fa93f45 100644 --- a/locales/en.json +++ b/locales/en.json @@ -14,6 +14,7 @@ "app_change_url_identical_domains": "The old and new domain/url_path are identical ('{domain:s}{path:s}'), nothing to do.", "app_change_url_no_script": "This application '{app_name:s}' doesn't support url modification yet. Maybe you should upgrade the application.", "app_change_url_success": "Successfully changed {app:s} url to {domain:s}{path:s}", + "app_checkurl_is_deprecated": "Packagers /!\\ 'app checkurl' is deprecated ! Please use 'app register-url' instead !", "app_extraction_failed": "Unable to extract installation files", "app_id_invalid": "Invalid app id", "app_incompatible": "The app {app} is incompatible with your YunoHost version", diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 9ccc0886d..ac70833f6 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -432,7 +432,7 @@ def app_change_url(auth, app, domain, path): path -- New path at which the application will be move """ - from yunohost.hook import hook_exec + from yunohost.hook import hook_exec, hook_callback installed = _is_installed(app) if not installed: @@ -485,6 +485,12 @@ def app_change_url(auth, app, domain, path): shutil.copytree(os.path.join(APPS_SETTING_PATH, app, "scripts"), os.path.join(APP_TMP_FOLDER, "scripts")) + if os.path.exists(os.path.join(APP_TMP_FOLDER, "conf")): + shutil.rmtree(os.path.join(APP_TMP_FOLDER, "conf")) + + shutil.copytree(os.path.join(APPS_SETTING_PATH, app, "conf"), + os.path.join(APP_TMP_FOLDER, "conf")) + # Execute App change_url script os.system('chown -R admin: %s' % INSTALL_TMP) os.system('chmod +x %s' % os.path.join(os.path.join(APP_TMP_FOLDER, "scripts"))) @@ -521,6 +527,8 @@ def app_change_url(auth, app, domain, path): logger.success(m18n.n("app_change_url_success", app=app, domain=domain, path=path)) + hook_callback('post_app_change_url', args=args_list, env=env_dict) + def app_upgrade(auth, app=[], url=None, file=None): """ @@ -532,7 +540,8 @@ def app_upgrade(auth, app=[], url=None, file=None): url -- Git url to fetch for upgrade """ - from yunohost.hook import hook_add, hook_remove, hook_exec + from yunohost.hook import hook_add, hook_remove, hook_exec, hook_callback + # Retrieve interface is_api = msettings.get('interface') == 'api' @@ -635,6 +644,9 @@ def app_upgrade(auth, app=[], url=None, file=None): upgraded_apps.append(app_instance_name) logger.success(m18n.n('app_upgraded', app=app_instance_name)) + hook_callback('post_app_upgrade', args=args_list, env=env_dict) + + if not upgraded_apps: raise MoulinetteError(errno.ENODATA, m18n.n('app_no_upgrade')) @@ -658,7 +670,7 @@ def app_install(auth, app, label=None, args=None, no_remove_on_failure=False): no_remove_on_failure -- Debug option to avoid removing the app on a failed installation """ - from yunohost.hook import hook_add, hook_remove, hook_exec + from yunohost.hook import hook_add, hook_remove, hook_exec, hook_callback # Fetch or extract sources try: @@ -800,6 +812,8 @@ def app_install(auth, app, label=None, args=None, no_remove_on_failure=False): logger.success(m18n.n('installation_complete')) + hook_callback('post_app_install', args=args_list, env=env_dict) + def app_remove(auth, app): """ @@ -809,7 +823,7 @@ def app_remove(auth, app): app -- App(s) to delete """ - from yunohost.hook import hook_exec, hook_remove + from yunohost.hook import hook_exec, hook_remove, hook_callback if not _is_installed(app): raise MoulinetteError(errno.EINVAL, @@ -838,6 +852,8 @@ def app_remove(auth, app): if hook_exec('/tmp/yunohost_remove/scripts/remove', args=args_list, env=env_dict, user="root") == 0: logger.success(m18n.n('app_removed', app=app)) + hook_callback('post_app_remove', args=args_list, env=env_dict) + if os.path.exists(app_setting_path): shutil.rmtree(app_setting_path) shutil.rmtree('/tmp/yunohost_remove') @@ -1149,6 +1165,9 @@ def app_checkurl(auth, url, app=None): app -- Write domain & path to app settings for further checks """ + + logger.warning(m18n.n("app_checkurl_is_deprecated")) + from yunohost.domain import domain_list if "https://" == url[:8]: diff --git a/src/yunohost/backup.py b/src/yunohost/backup.py index 0c957db7e..15c793802 100644 --- a/src/yunohost/backup.py +++ b/src/yunohost/backup.py @@ -1543,9 +1543,13 @@ class BackupMethod(object): # Can create a hard link only if files are on the same fs # (i.e. we can't if it's on a different fs) if os.stat(src).st_dev == os.stat(dest_dir).st_dev: - os.link(src, dest) - # Success, go to next file to organize - continue + # Don't hardlink /etc/cron.d files to avoid cron bug + # 'NUMBER OF HARD LINKS > 1' see #1043 + cron_path = os.path.abspath('/etc/cron') + '.' + if not os.path.abspath(src).startswith(cron_path): + os.link(src, dest) + # Success, go to next file to organize + continue # If mountbind or hardlink couldnt be created, # prepare a list of files that need to be copied diff --git a/src/yunohost/certificate.py b/src/yunohost/certificate.py index b6fb0e275..310c5d131 100644 --- a/src/yunohost/certificate.py +++ b/src/yunohost/certificate.py @@ -44,6 +44,7 @@ from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger import yunohost.domain +from yunohost.utils.network import get_public_ip from moulinette import m18n from yunohost.app import app_ssowatconf @@ -809,7 +810,7 @@ def _backup_current_cert(domain): def _check_domain_is_ready_for_ACME(domain): - public_ip = yunohost.domain.get_public_ip() + public_ip = get_public_ip() # Check if IP from DNS matches public IP if not _dns_ip_match_public_ip(public_ip, domain): @@ -856,14 +857,9 @@ def _regen_dnsmasq_if_needed(): """ Update the dnsmasq conf if some IPs are not up to date... """ - try: - ipv4 = yunohost.domain.get_public_ip() - except: - ipv4 = None - try: - ipv6 = yunohost.domain.get_public_ip(6) - except: - ipv6 = None + + ipv4 = get_public_ip() + ipv6 = get_public_ip(6) do_regen = False diff --git a/src/yunohost/domain.py b/src/yunohost/domain.py index 727a63df3..026c4da36 100644 --- a/src/yunohost/domain.py +++ b/src/yunohost/domain.py @@ -30,8 +30,6 @@ import yaml import errno import requests -from urllib import urlopen - from moulinette import m18n, msettings from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger @@ -39,6 +37,7 @@ from moulinette.utils.log import getActionLogger import yunohost.certificate from yunohost.service import service_regen_conf +from yunohost.utils.network import get_public_ip logger = getActionLogger('yunohost.domain') @@ -260,42 +259,6 @@ def domain_url_available(auth, domain, path): return available -def get_public_ip(protocol=4): - """Retrieve the public IP address from ip.yunohost.org""" - if protocol == 4: - url = 'https://ip.yunohost.org' - elif protocol == 6: - url = 'https://ip6.yunohost.org' - else: - raise ValueError("invalid protocol version") - - try: - return urlopen(url).read().strip() - except IOError: - logger.debug('cannot retrieve public IPv%d' % protocol, exc_info=1) - raise MoulinetteError(errno.ENETUNREACH, - m18n.n('no_internet_connection')) - -def get_public_ips(): - """ - Retrieve the public IPv4 and v6 from ip. and ip6.yunohost.org - - Returns a 2-tuple (ipv4, ipv6). ipv4 or ipv6 can be None if they were not - found. - """ - - try: - ipv4 = get_public_ip() - except: - ipv4 = None - try: - ipv6 = get_public_ip(6) - except: - ipv6 = None - - return (ipv4, ipv6) - - def _get_maindomain(): with open('/etc/yunohost/current_host', 'r') as f: maindomain = f.readline().rstrip() @@ -356,15 +319,8 @@ def _build_dns_conf(domain, ttl=3600): } """ - try: - ipv4 = get_public_ip() - except: - ipv4 = None - - try: - ipv6 = get_public_ip(6) - except: - ipv6 = None + ipv4 = get_public_ip() + ipv6 = get_public_ip(6) basic = [] diff --git a/src/yunohost/dyndns.py b/src/yunohost/dyndns.py index 851d04f45..ec3bf88c8 100644 --- a/src/yunohost/dyndns.py +++ b/src/yunohost/dyndns.py @@ -39,7 +39,8 @@ from moulinette.utils.log import getActionLogger from moulinette.utils.filesystem import read_file, write_to_file, rm from moulinette.utils.network import download_json -from yunohost.domain import get_public_ips, _get_maindomain, _build_dns_conf +from yunohost.domain import _get_maindomain, _build_dns_conf +from yunohost.utils.network import get_public_ip logger = getActionLogger('yunohost.dyndns') @@ -193,7 +194,8 @@ def dyndns_update(dyn_host="dyndns.yunohost.org", domain=None, key=None, old_ipv6 = read_file(OLD_IPV6_FILE).rstrip() # Get current IPv4 and IPv6 - (ipv4_, ipv6_) = get_public_ips() + ipv4_ = get_public_ip() + ipv6_ = get_public_ip(6) if ipv4 is None: ipv4 = ipv4_ diff --git a/src/yunohost/monitor.py b/src/yunohost/monitor.py index d99ac1688..ed13d532d 100644 --- a/src/yunohost/monitor.py +++ b/src/yunohost/monitor.py @@ -41,7 +41,8 @@ from moulinette import m18n from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger -from yunohost.domain import get_public_ip, _get_maindomain +from yunohost.utils.network import get_public_ip +from yunohost.domain import _get_maindomain logger = getActionLogger('yunohost.monitor') @@ -210,10 +211,7 @@ def monitor_network(units=None, human_readable=False): else: logger.debug('interface name %s was not found', iname) elif u == 'infos': - try: - p_ipv4 = get_public_ip() - except: - p_ipv4 = 'unknown' + p_ipv4 = get_public_ip() or 'unknown' l_ip = 'unknown' for name, addrs in devices.items(): diff --git a/src/yunohost/ssh.py b/src/yunohost/ssh.py new file mode 100644 index 000000000..5f1f33b55 --- /dev/null +++ b/src/yunohost/ssh.py @@ -0,0 +1,102 @@ +# encoding: utf-8 + +import os + +from moulinette.utils.filesystem import read_file, write_to_file, chown, chmod, mkdir + +from yunohost.user import _get_user_for_ssh + + +def ssh_authorized_keys_list(auth, username): + user = _get_user_for_ssh(auth, username, ["homeDirectory"]) + if not user: + raise Exception("User with username '%s' doesn't exists" % username) + + authorized_keys_file = os.path.join(user["homeDirectory"][0], ".ssh", "authorized_keys") + + if not os.path.exists(authorized_keys_file): + return [] + + keys = [] + last_comment = "" + for line in read_file(authorized_keys_file).split("\n"): + # empty line + if not line.strip(): + continue + + if line.lstrip().startswith("#"): + last_comment = line.lstrip().lstrip("#").strip() + continue + + # assuming a key per non empty line + key = line.strip() + keys.append({ + "key": key, + "name": last_comment, + }) + + last_comment = "" + + return {"keys": keys} + + +def ssh_authorized_keys_add(auth, username, key, comment): + user = _get_user_for_ssh(auth, username, ["homeDirectory", "uid"]) + if not user: + raise Exception("User with username '%s' doesn't exists" % username) + + authorized_keys_file = os.path.join(user["homeDirectory"][0], ".ssh", "authorized_keys") + + if not os.path.exists(authorized_keys_file): + # ensure ".ssh" exists + mkdir(os.path.join(user["homeDirectory"][0], ".ssh"), + force=True, parents=True, uid=user["uid"][0]) + + # create empty file to set good permissions + write_to_file(authorized_keys_file, "") + chown(authorized_keys_file, uid=user["uid"][0]) + chmod(authorized_keys_file, 0600) + + authorized_keys_content = read_file(authorized_keys_file) + + authorized_keys_content += "\n" + authorized_keys_content += "\n" + + if comment and comment.strip(): + if not comment.lstrip().startswith("#"): + comment = "# " + comment + authorized_keys_content += comment.replace("\n", " ").strip() + authorized_keys_content += "\n" + + authorized_keys_content += key.strip() + authorized_keys_content += "\n" + + write_to_file(authorized_keys_file, authorized_keys_content) + + +def ssh_authorized_keys_remove(auth, username, key): + user = _get_user(auth, username, ["homeDirectory", "uid"]) + if not user: + raise Exception("User with username '%s' doesn't exists" % username) + + authorized_keys_file = os.path.join(user["homeDirectory"][0], ".ssh", "authorized_keys") + + if not os.path.exists(authorized_keys_file): + raise Exception("this key doesn't exists ({} dosesn't exists)".format(authorized_keys_file)) + + authorized_keys_content = read_file(authorized_keys_file) + + if key not in authorized_keys_content: + raise Exception("Key '{}' is not present in authorized_keys".format(key)) + + # don't delete the previous comment because we can't verify if it's legit + + # this regex approach failed for some reasons and I don't know why :( + # authorized_keys_content = re.sub("{} *\n?".format(key), + # "", + # authorized_keys_content, + # flags=re.MULTILINE) + + authorized_keys_content = authorized_keys_content.replace(key, "") + + write_to_file(authorized_keys_file, authorized_keys_content) diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index b997961be..f98d48fc5 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -33,8 +33,9 @@ import logging import subprocess import pwd import socket -from collections import OrderedDict +from xmlrpclib import Fault from importlib import import_module +from collections import OrderedDict import apt import apt.progress @@ -45,12 +46,13 @@ from moulinette.utils.log import getActionLogger from moulinette.utils.process import check_output from moulinette.utils.filesystem import read_json, write_to_json from yunohost.app import app_fetchlist, app_info, app_upgrade, app_ssowatconf, app_list, _install_appslist_fetch_cron -from yunohost.domain import domain_add, domain_list, get_public_ip, _get_maindomain, _set_maindomain +from yunohost.domain import domain_add, domain_list, _get_maindomain, _set_maindomain from yunohost.dyndns import _dyndns_available, _dyndns_provides from yunohost.firewall import firewall_upnp from yunohost.service import service_status, service_regen_conf, service_log, service_start, service_enable from yunohost.monitor import monitor_disk, monitor_system from yunohost.utils.packages import ynh_packages_version +from yunohost.utils.network import get_public_ip # FIXME this is a duplicate from apps.py APPS_SETTING_PATH = '/etc/yunohost/apps/' @@ -568,7 +570,7 @@ def tools_diagnosis(auth, private=False): diagnosis['system'] = OrderedDict() try: disks = monitor_disk(units=['filesystem'], human_readable=True) - except MoulinetteError as e: + except (MoulinetteError, Fault) as e: logger.warning(m18n.n('diagnosis_monitor_disk_error', error=format(e)), exc_info=1) else: diagnosis['system']['disks'] = {} @@ -621,16 +623,11 @@ def tools_diagnosis(auth, private=False): # Private data if private: diagnosis['private'] = OrderedDict() + # Public IP diagnosis['private']['public_ip'] = {} - try: - diagnosis['private']['public_ip']['IPv4'] = get_public_ip(4) - except MoulinetteError as e: - pass - try: - diagnosis['private']['public_ip']['IPv6'] = get_public_ip(6) - except MoulinetteError as e: - pass + diagnosis['private']['public_ip']['IPv4'] = get_public_ip(4) + diagnosis['private']['public_ip']['IPv6'] = get_public_ip(6) # Domains diagnosis['private']['domains'] = domain_list(auth)['domains'] diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 11f61d807..793ccaf7a 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -25,6 +25,7 @@ """ import os import re +import pwd import json import errno import crypt @@ -35,10 +36,13 @@ import subprocess from moulinette import m18n from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import read_file from yunohost.service import service_status logger = getActionLogger('yunohost.user') +SSHD_CONFIG_PATH = "/etc/ssh/sshd_config" + def user_list(auth, fields=None): """ @@ -56,6 +60,8 @@ def user_list(auth, fields=None): 'cn': 'fullname', 'mail': 'mail', 'maildrop': 'mail-forward', + 'loginShell': 'shell', + 'homeDirectory': 'home_path', 'mailuserquota': 'mailbox-quota' } @@ -71,7 +77,7 @@ def user_list(auth, fields=None): raise MoulinetteError(errno.EINVAL, m18n.n('field_invalid', attr)) else: - attrs = ['uid', 'cn', 'mail', 'mailuserquota'] + attrs = ['uid', 'cn', 'mail', 'mailuserquota', 'loginShell'] result = auth.search('ou=users,dc=yunohost,dc=org', '(&(objectclass=person)(!(uid=root))(!(uid=nobody)))', @@ -81,6 +87,12 @@ def user_list(auth, fields=None): entry = {} for attr, values in user.items(): if values: + if attr == "loginShell": + if values[0].strip() == "/bin/false": + entry["ssh_allowed"] = False + else: + entry["ssh_allowed"] = True + entry[user_attrs[attr]] = values[0] uid = entry[user_attrs['uid']] @@ -435,6 +447,36 @@ def user_info(auth, username): raise MoulinetteError(167, m18n.n('user_info_failed')) +def user_allow_ssh(auth, username): + """ + Allow YunoHost user connect as ssh. + + Keyword argument: + username -- User username + """ + # TODO it would be good to support different kind of shells + + if not _get_user_for_ssh(auth, username): + raise MoulinetteError(errno.EINVAL, m18n.n('user_unknown', user=username)) + + auth.update('uid=%s,ou=users' % username, {'loginShell': '/bin/bash'}) + + +def user_disallow_ssh(auth, username): + """ + Disallow YunoHost user connect as ssh. + + Keyword argument: + username -- User username + """ + # TODO it would be good to support different kind of shells + + if not _get_user_for_ssh(auth, username) : + raise MoulinetteError(errno.EINVAL, m18n.n('user_unknown', user=username)) + + auth.update('uid=%s,ou=users' % username, {'loginShell': '/bin/false'}) + + def _convertSize(num, suffix=''): for unit in ['K', 'M', 'G', 'T', 'P', 'E', 'Z']: if abs(num) < 1024.0: @@ -470,3 +512,56 @@ def _hash_user_password(password): salt = '$6$' + salt + '$' return '{CRYPT}' + crypt.crypt(str(password), salt) + + +def _get_user_for_ssh(auth, username, attrs=None): + def ssh_root_login_status(auth): + # XXX temporary placed here for when the ssh_root commands are integrated + # extracted from https://github.com/YunoHost/yunohost/pull/345 + # XXX should we support all the options? + # this is the content of "man sshd_config" + # PermitRootLogin + # Specifies whether root can log in using ssh(1). The argument must be + # “yes”, “without-password”, “forced-commands-only”, or “no”. The + # default is “yes”. + sshd_config_content = read_file(SSHD_CONFIG_PATH) + + if re.search("^ *PermitRootLogin +(no|forced-commands-only) *$", + sshd_config_content, re.MULTILINE): + return {"PermitRootLogin": False} + + return {"PermitRootLogin": True} + + if username == "root": + root_unix = pwd.getpwnam("root") + return { + 'username': 'root', + 'fullname': '', + 'mail': '', + 'ssh_allowed': ssh_root_login_status(auth)["PermitRootLogin"], + 'shell': root_unix.pw_shell, + 'home_path': root_unix.pw_dir, + } + + if username == "admin": + admin_unix = pwd.getpwnam("admin") + return { + 'username': 'admin', + 'fullname': '', + 'mail': '', + 'ssh_allowed': admin_unix.pw_shell.strip() != "/bin/false", + 'shell': admin_unix.pw_shell, + 'home_path': admin_unix.pw_dir, + } + + # TODO escape input using https://www.python-ldap.org/doc/html/ldap-filter.html + user = auth.search('ou=users,dc=yunohost,dc=org', + '(&(objectclass=person)(uid=%s))' % username, + attrs) + + assert len(user) in (0, 1) + + if not user: + return None + + return user[0] diff --git a/src/yunohost/utils/network.py b/src/yunohost/utils/network.py new file mode 100644 index 000000000..e22d1644d --- /dev/null +++ b/src/yunohost/utils/network.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2017 YUNOHOST.ORG + + 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 logging +from urllib import urlopen + +logger = logging.getLogger('yunohost.utils.network') + +def get_public_ip(protocol=4): + """Retrieve the public IP address from ip.yunohost.org""" + + if protocol == 4: + url = 'https://ip.yunohost.org' + elif protocol == 6: + url = 'https://ip6.yunohost.org' + else: + raise ValueError("invalid protocol version") + + try: + return urlopen(url).read().strip() + except IOError: + return None