diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..ed13dfa68 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[report] +omit=src/yunohost/tests/*,src/yunohost/vendor/*,/usr/lib/moulinette/yunohost/* diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..c3b460087 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +custom: https://donate.yunohost.org +liberapay: YunoHost diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9642e92f6..953e2940f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,10 +13,3 @@ ## How to test ... - -## Validation - -- [ ] Principle agreement 0/2 : -- [ ] Quick review 0/1 : -- [ ] Simple test 0/1 : -- [ ] Deep review 0/1 : diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 000000000..d1cb36b73 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,30 @@ +--- +stages: + - build + - install + - tests + - lint + - doc + - translation + +default: + tags: + - yunohost-ci + # All jobs are interruptible by default + interruptible: true + +# see: https://docs.gitlab.com/ee/ci/yaml/#switch-between-branch-pipelines-and-merge-request-pipelines +workflow: + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" # If we move to gitlab one day + - if: $CI_PIPELINE_SOURCE == "external_pull_request_event" # For github PR + - if: $CI_COMMIT_TAG # For tags + - if: $CI_COMMIT_REF_NAME != $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push" # If it's not the default branch and if it's a push, then do not trigger a build + when: never + - when: always + +variables: + YNH_BUILD_DIR: "ynh-build" + +include: + - local: .gitlab/ci/*.gitlab-ci.yml diff --git a/.gitlab/ci/build.gitlab-ci.yml b/.gitlab/ci/build.gitlab-ci.yml new file mode 100644 index 000000000..717a5ee73 --- /dev/null +++ b/.gitlab/ci/build.gitlab-ci.yml @@ -0,0 +1,54 @@ +.build-stage: + stage: build + image: "before-install" + variables: + YNH_SOURCE: "https://github.com/yunohost" + before_script: + - mkdir -p $YNH_BUILD_DIR + artifacts: + paths: + - $YNH_BUILD_DIR/*.deb + +.build_script: &build_script + - cd $YNH_BUILD_DIR/$PACKAGE + - VERSION=$(dpkg-parsechangelog -S Version 2>/dev/null) + - VERSION_NIGHTLY="${VERSION}+$(date +%Y%m%d%H%M)" + - dch --package "${PACKAGE}" --force-bad-version -v "${VERSION_NIGHTLY}" -D "unstable" --force-distribution "Daily build." + - debuild --no-lintian -us -uc + +######################################## +# BUILD DEB +######################################## + +build-yunohost: + extends: .build-stage + variables: + PACKAGE: "yunohost" + script: + - git ls-files | xargs tar -czf archive.tar.gz + - mkdir -p $YNH_BUILD_DIR/$PACKAGE + - cat archive.tar.gz | tar -xz -C $YNH_BUILD_DIR/$PACKAGE + - rm archive.tar.gz + - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $(pwd)/$YNH_BUILD_DIR/$PACKAGE + - *build_script + + +build-ssowat: + extends: .build-stage + variables: + PACKAGE: "ssowat" + script: + - DEBIAN_DEPENDS=$(cat debian/control | tr "," "\n" | grep -Po "ssowat \([>,=,<]+ .*\)" | grep -Po "[0-9\.]+") + - git clone $YNH_SOURCE/$PACKAGE -b $CI_COMMIT_REF_NAME $YNH_BUILD_DIR/$PACKAGE --depth 1 || git clone $YNH_SOURCE/$PACKAGE -b $DEBIAN_DEPENDS $YNH_BUILD_DIR/$PACKAGE --depth 1 || git clone $YNH_SOURCE/$PACKAGE $YNH_BUILD_DIR/$PACKAGE --depth 1 + - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $(pwd)/$YNH_BUILD_DIR/$PACKAGE + - *build_script + +build-moulinette: + extends: .build-stage + variables: + PACKAGE: "moulinette" + script: + - DEBIAN_DEPENDS=$(cat debian/control | tr "," "\n" | grep -Po "moulinette \([>,=,<]+ .*\)" | grep -Po "[0-9\.]+") + - git clone $YNH_SOURCE/$PACKAGE -b $CI_COMMIT_REF_NAME $YNH_BUILD_DIR/$PACKAGE --depth 1 || git clone $YNH_SOURCE/$PACKAGE -b $DEBIAN_DEPENDS $YNH_BUILD_DIR/$PACKAGE --depth 1 || git clone $YNH_SOURCE/$PACKAGE $YNH_BUILD_DIR/$PACKAGE --depth 1 + - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $(pwd)/$YNH_BUILD_DIR/$PACKAGE + - *build_script diff --git a/.gitlab/ci/doc.gitlab-ci.yml b/.gitlab/ci/doc.gitlab-ci.yml new file mode 100644 index 000000000..59179f7a7 --- /dev/null +++ b/.gitlab/ci/doc.gitlab-ci.yml @@ -0,0 +1,27 @@ +######################################## +# DOC +######################################## + +generate-helpers-doc: + stage: doc + image: "before-install" + needs: [] + before_script: + - apt-get update -y && apt-get install git hub -y + - git config --global user.email "yunohost@yunohost.org" + - git config --global user.name "$GITHUB_USER" + script: + - cd doc + - python3 generate_helper_doc.py + - hub clone https://$GITHUB_TOKEN:x-oauth-basic@github.com/YunoHost/doc.git doc_repo + - cp helpers.md doc_repo/pages/04.contribute/04.packaging_apps/11.helpers/packaging_apps_helpers.md + - cd doc_repo + # replace ${CI_COMMIT_REF_NAME} with ${CI_COMMIT_TAG} ? + - hub checkout -b "${CI_COMMIT_REF_NAME}" + - hub commit -am "[CI] Helper for ${CI_COMMIT_REF_NAME}" + - hub pull-request -m "[CI] Helper for ${CI_COMMIT_REF_NAME}" -p # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd + artifacts: + paths: + - doc/helpers.md + only: + - tags diff --git a/.gitlab/ci/install.gitlab-ci.yml b/.gitlab/ci/install.gitlab-ci.yml new file mode 100644 index 000000000..e2662e9e2 --- /dev/null +++ b/.gitlab/ci/install.gitlab-ci.yml @@ -0,0 +1,29 @@ +.install-stage: + stage: install + needs: + - job: build-yunohost + artifacts: true + - job: build-ssowat + artifacts: true + - job: build-moulinette + artifacts: true + +######################################## +# INSTALL DEB +######################################## + +upgrade: + extends: .install-stage + image: "after-install" + script: + - apt-get update -o Acquire::Retries=3 + - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ./$YNH_BUILD_DIR/*.deb + + +install-postinstall: + extends: .install-stage + image: "before-install" + script: + - apt-get update -o Acquire::Retries=3 + - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ./$YNH_BUILD_DIR/*.deb + - yunohost tools postinstall -d domain.tld -p the_password --ignore-dyndns --force-diskspace diff --git a/.gitlab/ci/lint.gitlab-ci.yml b/.gitlab/ci/lint.gitlab-ci.yml new file mode 100644 index 000000000..aaddb5a0a --- /dev/null +++ b/.gitlab/ci/lint.gitlab-ci.yml @@ -0,0 +1,57 @@ +######################################## +# LINTER +######################################## +# later we must fix lint and format-check jobs and remove "allow_failure" + +--- +lint37: + stage: lint + image: "before-install" + needs: [] + allow_failure: true + script: + - tox -e py37-lint + +invalidcode37: + stage: lint + image: "before-install" + needs: [] + script: + - tox -e py37-invalidcode + +mypy: + stage: lint + image: "before-install" + needs: [] + script: + - tox -e py37-mypy + +format-check: + stage: lint + image: "before-install" + allow_failure: true + needs: [] + script: + - tox -e py37-black-check + +format-run: + stage: lint + image: "before-install" + needs: [] + before_script: + - apt-get update -y && apt-get install git hub -y + - git config --global user.email "yunohost@yunohost.org" + - git config --global user.name "$GITHUB_USER" + - hub clone --branch ${CI_COMMIT_REF_NAME} "https://$GITHUB_TOKEN:x-oauth-basic@github.com/YunoHost/yunohost.git" github_repo + - cd github_repo + script: + # create a local branch that will overwrite distant one + - git checkout -b "ci-format-${CI_COMMIT_REF_NAME}" --no-track + - tox -e py37-black-run + - '[ $(git diff | wc -l) != 0 ] || exit 0' # stop if there is nothing to commit + - git commit -am "[CI] Format code" || true + - git push -f origin "ci-format-${CI_COMMIT_REF_NAME}":"ci-format-${CI_COMMIT_REF_NAME}" + - hub pull-request -m "[CI] Format code" -b Yunohost:dev -p || true # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd + only: + refs: + - dev diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml new file mode 100644 index 000000000..b3aea606f --- /dev/null +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -0,0 +1,208 @@ +.install_debs: &install_debs + - apt-get update -o Acquire::Retries=3 + - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ./$YNH_BUILD_DIR/*.deb + +.test-stage: + stage: tests + image: "after-install" + variables: + PYTEST_ADDOPTS: "--color=yes" + before_script: + - *install_debs + cache: + paths: + - src/yunohost/tests/apps + key: "$CI_JOB_STAGE-$CI_COMMIT_REF_SLUG" + needs: + - job: build-yunohost + artifacts: true + - job: build-ssowat + artifacts: true + - job: build-moulinette + artifacts: true + - job: upgrade + + +######################################## +# TESTS +######################################## + +full-tests: + stage: tests + image: "before-install" + variables: + PYTEST_ADDOPTS: "--color=yes" + before_script: + - *install_debs + - yunohost tools postinstall -d domain.tld -p the_password --ignore-dyndns --force-diskspace + script: + - python3 -m pytest --cov=yunohost tests/ src/yunohost/tests/ --junitxml=report.xml + - cd tests + - bash test_helpers.sh + needs: + - job: build-yunohost + artifacts: true + - job: build-ssowat + artifacts: true + - job: build-moulinette + artifacts: true + artifacts: + reports: + junit: report.xml + +test-i18n-keys: + extends: .test-stage + script: + - python3 -m pytest tests/test_i18n_keys.py + only: + changes: + - locales/en.json + - src/yunohost/*.py + - data/hooks/diagnosis/*.py + +test-translation-format-consistency: + extends: .test-stage + script: + - python3 -m pytest tests/test_translation_format_consistency.py + only: + changes: + - locales/* + +test-actionmap: + extends: .test-stage + script: + - python3 -m pytest tests/test_actionmap.py + only: + changes: + - data/actionsmap/*.yml + +test-helpers: + extends: .test-stage + script: + - cd tests + - bash test_helpers.sh + only: + changes: + - data/helpers.d/* + +test-domains: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_domains.py + only: + changes: + - src/yunohost/domain.py + +test-dns: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_dns.py + only: + changes: + - src/yunohost/dns.py + - src/yunohost/utils/dns.py + +test-apps: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_apps.py + only: + changes: + - src/yunohost/app.py + +test-appscatalog: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_appscatalog.py + only: + changes: + - src/yunohost/app.py + +test-appurl: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_appurl.py + only: + changes: + - src/yunohost/app.py + +test-questions: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_questions.py + only: + changes: + - src/yunohost/utils/config.py + +test-app-config: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_app_config.py + only: + changes: + - src/yunohost/app.py + - src/yunohost/utils/config.py + +test-changeurl: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_changeurl.py + only: + changes: + - src/yunohost/app.py + +test-backuprestore: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_backuprestore.py + only: + changes: + - src/yunohost/backup.py + +test-permission: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_permission.py + only: + changes: + - src/yunohost/permission.py + +test-settings: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_settings.py + only: + changes: + - src/yunohost/settings.py + +test-user-group: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_user-group.py + only: + changes: + - src/yunohost/user.py + +test-regenconf: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_regenconf.py + only: + changes: + - src/yunohost/regenconf.py + +test-service: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_service.py + only: + changes: + - src/yunohost/service.py + +test-ldapauth: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_ldapauth.py + only: + changes: + - src/yunohost/authenticators/*.py diff --git a/.gitlab/ci/translation.gitlab-ci.yml b/.gitlab/ci/translation.gitlab-ci.yml new file mode 100644 index 000000000..41e8c82d2 --- /dev/null +++ b/.gitlab/ci/translation.gitlab-ci.yml @@ -0,0 +1,29 @@ +######################################## +# TRANSLATION +######################################## + +autofix-translated-strings: + stage: translation + image: "before-install" + needs: [] + before_script: + - apt-get update -y && apt-get install git hub -y + - git config --global user.email "yunohost@yunohost.org" + - git config --global user.name "$GITHUB_USER" + - git remote set-url origin https://$GITHUB_TOKEN:x-oauth-basic@github.com/YunoHost/yunohost.git + script: + - cd tests # Maybe move this script location to another folder? + # create a local branch that will overwrite distant one + - git checkout -b "ci-autofix-translated-strings-${CI_COMMIT_REF_NAME}" --no-track + - python3 remove_stale_translated_strings.py + - python3 autofix_locale_format.py + - python3 reformat_locales.py + - '[ $(git diff -w | wc -l) != 0 ] || exit 0' # stop if there is nothing to commit + - git commit -am "[CI] Reformat / remove stale translated strings" || true + - git push -f origin "HEAD":"ci-remove-stale-translated-strings-${CI_COMMIT_REF_NAME}" + - hub pull-request -m "[CI] Reformat / remove stale translated strings" -b Yunohost:dev -p || true # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd + only: + variables: + - $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH + changes: + - locales/* diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 25fe0e5fc..000000000 --- a/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -language: python -install: "pip install pytest pyyaml" -python: - - "2.7" -script: "py.test tests" diff --git a/README.md b/README.md index 4bd070bea..9fc93740d 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,43 @@ -# YunoHost core +

+ YunoHost +

-- [YunoHost project website](https://yunohost.org) +

YunoHost

-This repository is the core of YunoHost code. +
- -Translation status - +[![Build status](https://shields.io/gitlab/pipeline/yunohost/yunohost/dev)](https://gitlab.com/yunohost/yunohost/-/pipelines) +[![GitHub license](https://img.shields.io/github/license/YunoHost/yunohost)](https://github.com/YunoHost/yunohost/blob/dev/LICENSE) +[![Mastodon Follow](https://img.shields.io/mastodon/follow/28084)](https://mastodon.social/@yunohost) -## Issues -- [Please report issues on YunoHost bugtracker](https://github.com/YunoHost/issues). +
-## Contribute -- You can develop on this repository using [ynh-dev tool](https://github.com/YunoHost/ynh-dev) with `use-git` sub-command. -- On this repository we are [following this workflow](https://yunohost.org/#/build_system_en): `stable <— testing <— branch`. -- Note: if you modify python scripts, you will have to modifiy the actions map. +YunoHost is an operating system aiming to simplify as much as possible the administration of a server. -## Repository content -- [YunoHost core Python 2.7 scripts](https://github.com/YunoHost/yunohost/tree/stable/src/yunohost). -- [An actionsmap](https://github.com/YunoHost/yunohost/blob/stable/data/actionsmap/yunohost.yml) used by moulinette. -- [Services configuration templates](https://github.com/YunoHost/yunohost/tree/stable/data/templates). -- [Hooks](https://github.com/YunoHost/yunohost/tree/stable/data/hooks). -- [Locales](https://github.com/YunoHost/yunohost/tree/stable/locales) for translations of `yunohost` command. -- [Shell helpers](https://github.com/YunoHost/yunohost/tree/stable/data/helpers.d) for [application packaging](https://yunohost.org/#/packaging_apps_helpers_en). -- [Modules for the XMPP server Metronome](https://github.com/YunoHost/yunohost/tree/stable/lib/metronome/modules). -- [Debian files](https://github.com/YunoHost/yunohost/tree/stable/debian) for package creation. +This repository corresponds to the core code of YunoHost, mainly written in Python and Bash. -## How does it work? -- Python core scripts are accessible through two interfaces thanks to the [moulinette framework](https://github.com/YunoHost/moulinette): - - [CLI](https://en.wikipedia.org/wiki/Command-line_interface) for `yunohost` command. - - [API](https://en.wikipedia.org/wiki/Application_programming_interface) for [web administration module](https://github.com/YunoHost/yunohost-admin) (other modules could be implemented). -- You can find more details about how YunoHost works on this [documentation (in french)](https://yunohost.org/#/package_list_fr). +- [Project features](https://yunohost.org/#/whatsyunohost) +- [Project website](https://yunohost.org) +- [Install documentation](https://yunohost.org/install) +- [Issue tracker](https://github.com/YunoHost/issues) -## Dependencies -- [Python 2.7](https://www.python.org/download/releases/2.7) -- [Moulinette](https://github.com/YunoHost/moulinette) -- [Bash](https://www.gnu.org/software/bash/bash.html) -- [Debian Jessie](https://www.debian.org/releases/jessie) +# Screenshots + +Webadmin ([Yunohost-Admin](https://github.com/YunoHost/yunohost-admin)) | Single sign-on user portal ([SSOwat](https://github.com/YunoHost/ssowat)) +--- | --- +![](https://raw.githubusercontent.com/YunoHost/doc/master/images/webadmin.png) | ![](https://raw.githubusercontent.com/YunoHost/doc/master/images/user_panel.png) + + +## Contributing + +- You can learn how to get started with developing on YunoHost by reading [this piece of documentation](https://yunohost.org/dev). +- Come chat with us on the [dev chatroom](https://yunohost.org/#/chat_rooms) ! +- You can help translate YunoHost on our [translation platform](https://translate.yunohost.org/engage/yunohost/?utm_source=widget) + +

+Translation status +

## License -As [other components of YunoHost core code](https://yunohost.org/#/faq_en), this repository is under GNU AGPL v.3 license. + +As [other components of YunoHost](https://yunohost.org/#/faq_en), this repository is licensed under GNU AGPL v3. diff --git a/bin/yunohost b/bin/yunohost index 10a21a9da..0220c5f09 100755 --- a/bin/yunohost +++ b/bin/yunohost @@ -1,74 +1,32 @@ -#! /usr/bin/python +#! /usr/bin/python3 # -*- coding: utf-8 -*- import os import sys import argparse -# Either we are in a development environment or not -IN_DEVEL = False +sys.path.insert(0, "/usr/lib/moulinette/") +import yunohost -# Level for which loggers will log -LOGGERS_LEVEL = 'DEBUG' -TTY_LOG_LEVEL = 'INFO' - -# Handlers that will be used by loggers -# - file: log to the file LOG_DIR/LOG_FILE -# - tty: log to current tty -LOGGERS_HANDLERS = ['file', 'tty'] - -# Directory and file to be used by logging -LOG_DIR = '/var/log/yunohost' -LOG_FILE = 'yunohost-cli.log' - -# Check and load - as needed - development environment -if not __file__.startswith('/usr/'): - IN_DEVEL = True -if IN_DEVEL: - basedir = os.path.abspath('%s/../' % os.path.dirname(__file__)) - if os.path.isdir(os.path.join(basedir, 'moulinette')): - sys.path.insert(0, basedir) - LOG_DIR = os.path.join(basedir, 'log') - - -import moulinette -from moulinette.actionsmap import ActionsMap -from moulinette.interfaces.cli import colorize, get_locale - - -# Initialization & helpers functions ----------------------------------- - -def _die(message, title='Error:'): - """Print error message and exit""" - print('%s %s' % (colorize(title, 'red'), message)) - sys.exit(1) def _parse_cli_args(): """Parse additional arguments for the cli""" parser = argparse.ArgumentParser(add_help=False) - parser.add_argument('--no-cache', - action='store_false', default=True, dest='use_cache', - help="Don't use actions map cache", - ) parser.add_argument('--output-as', choices=['json', 'plain', 'none'], default=None, - help="Output result in another format", + help="Output result in another format" ) parser.add_argument('--debug', action='store_true', default=False, - help="Log and print debug messages", + help="Log and print debug messages" ) parser.add_argument('--quiet', action='store_true', default=False, - help="Don't produce any output", + help="Don't produce any output" ) parser.add_argument('--timeout', type=int, default=None, - help="Number of seconds before this command will timeout because it can't acquire the lock (meaning that another command is currently running), by default there is no timeout and the command will wait until it can get the lock", - ) - parser.add_argument('--admin-password', - default=None, dest='password', metavar='PASSWORD', - help="The admin password to use to authenticate", + help="Number of seconds before this command will timeout because it can't acquire the lock (meaning that another command is currently running), by default there is no timeout and the command will wait until it can get the lock" ) # deprecated arguments parser.add_argument('--plain', @@ -88,129 +46,28 @@ def _parse_cli_args(): return (parser, opts, args) -def _init_moulinette(debug=False, quiet=False): - """Configure logging and initialize the moulinette""" - # Define loggers handlers - handlers = set(LOGGERS_HANDLERS) - if quiet and 'tty' in handlers: - handlers.remove('tty') - elif 'tty' not in handlers: - handlers.append('tty') - - root_handlers = set(handlers) - if not debug and 'tty' in root_handlers: - root_handlers.remove('tty') - - # Define loggers level - level = LOGGERS_LEVEL - tty_level = TTY_LOG_LEVEL - if debug: - tty_level = 'DEBUG' - - # Custom logging configuration - logging = { - 'version': 1, - 'disable_existing_loggers': True, - 'formatters': { - 'tty-debug': { - 'format': '%(relativeCreated)-4d %(fmessage)s' - }, - 'precise': { - 'format': '%(asctime)-15s %(levelname)-8s %(name)s %(funcName)s - %(fmessage)s' - }, - }, - 'filters': { - 'action': { - '()': 'moulinette.utils.log.ActionFilter', - }, - }, - 'handlers': { - 'tty': { - 'level': tty_level, - 'class': 'moulinette.interfaces.cli.TTYHandler', - 'formatter': 'tty-debug' if debug else '', - }, - 'file': { - 'class': 'logging.FileHandler', - 'formatter': 'precise', - 'filename': '%s/%s' % (LOG_DIR, LOG_FILE), - 'filters': ['action'], - }, - }, - 'loggers': { - 'yunohost': { - 'level': level, - 'handlers': handlers, - 'propagate': False, - }, - 'moulinette': { - 'level': level, - 'handlers': [], - 'propagate': True, - }, - 'moulinette.interface': { - 'level': level, - 'handlers': handlers, - 'propagate': False, - }, - }, - 'root': { - 'level': level, - 'handlers': root_handlers, - }, - } - - # Create log directory - if not os.path.isdir(LOG_DIR): - try: - os.makedirs(LOG_DIR, 0750) - except os.error as e: - _die(str(e)) - - # Initialize moulinette - moulinette.init(logging_config=logging, _from_source=IN_DEVEL) - -def _retrieve_namespaces(): - """Return the list of namespaces to load""" - ret = ['yunohost'] - for n in ActionsMap.get_namespaces(): - # Append YunoHost modules - if n.startswith('ynh_'): - ret.append(n) - return ret +# Stupid PATH management because sometimes (e.g. some cron job) PATH is only /usr/bin:/bin ... +default_path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +if os.environ["PATH"] != default_path: + os.environ["PATH"] = default_path + ":" + os.environ["PATH"] # Main action ---------------------------------------------------------- if __name__ == '__main__': if os.geteuid() != 0: - # since moulinette isn't initialized, we can't use m18n here - sys.stderr.write("\033[1;31mError:\033[0m yunohost command must be " \ + sys.stderr.write("\033[1;31mError:\033[0m yunohost command must be " "run as root or with sudo.\n") sys.exit(1) parser, opts, args = _parse_cli_args() - _init_moulinette(opts.debug, opts.quiet) - - # Check that YunoHost is installed - if not os.path.isfile('/etc/yunohost/installed') and \ - (len(args) < 2 or (args[0] +' '+ args[1] != 'tools postinstall' and \ - args[0] +' '+ args[1] != 'backup restore' and \ - args[0] +' '+ args[1] != 'log display')): - - from moulinette import m18n - # Init i18n - m18n.load_namespace('yunohost') - m18n.set_locale(get_locale()) - - # Print error and exit - _die(m18n.n('yunohost_not_installed'), m18n.g('error')) # Execute the action - ret = moulinette.cli( - _retrieve_namespaces(), args, - use_cache=opts.use_cache, output_as=opts.output_as, - password=opts.password, parser_kwargs={'top_parser': parser}, + yunohost.cli( + debug=opts.debug, + quiet=opts.quiet, + output_as=opts.output_as, timeout=opts.timeout, + args=args, + parser=parser ) - sys.exit(ret) diff --git a/bin/yunohost-api b/bin/yunohost-api index e518c34b0..b3ed3a817 100755 --- a/bin/yunohost-api +++ b/bin/yunohost-api @@ -1,52 +1,16 @@ -#! /usr/bin/python +#! /usr/bin/python3 # -*- coding: utf-8 -*- -import os import sys import argparse -# Either we are in a development environment or not -IN_DEVEL = False +sys.path.insert(0, "/usr/lib/moulinette/") +import yunohost # Default server configuration DEFAULT_HOST = 'localhost' DEFAULT_PORT = 6787 -# Level for which loggers will log -LOGGERS_LEVEL = 'DEBUG' -API_LOGGER_LEVEL = 'INFO' - -# Handlers that will be used by loggers -# - file: log to the file LOG_DIR/LOG_FILE -# - api: serve logs through the api -# - console: log to stderr -LOGGERS_HANDLERS = ['file', 'api'] - -# Directory and file to be used by logging -LOG_DIR = '/var/log/yunohost' -LOG_FILE = 'yunohost-api.log' - -# Check and load - as needed - development environment -if not __file__.startswith('/usr/'): - IN_DEVEL = True -if IN_DEVEL: - basedir = os.path.abspath('%s/../' % os.path.dirname(__file__)) - if os.path.isdir(os.path.join(basedir, 'moulinette')): - sys.path.insert(0, basedir) - LOG_DIR = os.path.join(basedir, 'log') - - -import moulinette -from moulinette.actionsmap import ActionsMap -from moulinette.interfaces.cli import colorize - - -# Initialization & helpers functions ----------------------------------- - -def _die(message, title='Error:'): - """Print error message and exit""" - print('%s %s' % (colorize(title, 'red'), message)) - sys.exit(1) def _parse_api_args(): """Parse main arguments for the api""" @@ -62,149 +26,19 @@ def _parse_api_args(): action='store', default=DEFAULT_PORT, type=int, help="Port to listen on (default: %d)" % DEFAULT_PORT, ) - srv_group.add_argument('--no-websocket', - action='store_true', default=True, dest='use_websocket', - help="Serve without WebSocket support, used to handle " - "asynchronous responses such as the messages", - ) glob_group = parser.add_argument_group('global arguments') - glob_group.add_argument('--no-cache', - action='store_false', default=True, dest='use_cache', - help="Don't use actions map cache", - ) glob_group.add_argument('--debug', action='store_true', default=False, help="Set log level to DEBUG", ) - glob_group.add_argument('--verbose', - action='store_true', default=False, - help="Be verbose in the output", - ) glob_group.add_argument('--help', action='help', help="Show this help message and exit", ) return parser.parse_args() -def _init_moulinette(use_websocket=True, debug=False, verbose=False): - """Configure logging and initialize the moulinette""" - # Define loggers handlers - handlers = set(LOGGERS_HANDLERS) - if not use_websocket and 'api' in handlers: - handlers.remove('api') - if verbose and 'console' not in handlers: - handlers.add('console') - root_handlers = handlers - set(['api']) - - # Define loggers level - level = LOGGERS_LEVEL - api_level = API_LOGGER_LEVEL - if debug: - level = 'DEBUG' - api_level = 'DEBUG' - - # Custom logging configuration - logging = { - 'version': 1, - 'disable_existing_loggers': True, - 'formatters': { - 'console': { - 'format': '%(relativeCreated)-5d %(levelname)-8s %(name)s %(funcName)s - %(fmessage)s' - }, - 'precise': { - 'format': '%(asctime)-15s %(levelname)-8s %(name)s %(funcName)s - %(fmessage)s' - }, - }, - 'filters': { - 'action': { - '()': 'moulinette.utils.log.ActionFilter', - }, - }, - 'handlers': { - 'api': { - 'level': api_level, - 'class': 'moulinette.interfaces.api.APIQueueHandler', - }, - 'file': { - 'class': 'logging.handlers.WatchedFileHandler', - 'formatter': 'precise', - 'filename': '%s/%s' % (LOG_DIR, LOG_FILE), - 'filters': ['action'], - }, - 'console': { - 'class': 'logging.StreamHandler', - 'formatter': 'console', - 'stream': 'ext://sys.stdout', - 'filters': ['action'], - }, - }, - 'loggers': { - 'yunohost': { - 'level': level, - 'handlers': handlers, - 'propagate': False, - }, - 'moulinette': { - 'level': level, - 'handlers': [], - 'propagate': True, - }, - 'gnupg': { - 'level': 'INFO', - 'handlers': [], - 'propagate': False, - }, - }, - 'root': { - 'level': level, - 'handlers': root_handlers, - }, - } - - # Create log directory - if not os.path.isdir(LOG_DIR): - try: - os.makedirs(LOG_DIR, 0750) - except os.error as e: - _die(str(e)) - - # Initialize moulinette - moulinette.init(logging_config=logging, _from_source=IN_DEVEL) - -def _retrieve_namespaces(): - """Return the list of namespaces to load""" - ret = ['yunohost'] - for n in ActionsMap.get_namespaces(): - # Append YunoHost modules - if n.startswith('ynh_'): - ret.append(n) - return ret - - -# Callbacks for additional routes -------------------------------------- - -def is_installed(): - """ - Check whether YunoHost is installed or not - - """ - installed = False - if os.path.isfile('/etc/yunohost/installed'): - installed = True - return { 'installed': installed } - - -# Main action ---------------------------------------------------------- if __name__ == '__main__': opts = _parse_api_args() - _init_moulinette(opts.use_websocket, opts.debug, opts.verbose) - # Run the server - ret = moulinette.api( - _retrieve_namespaces(), - host=opts.host, port=opts.port, routes={ - ('GET', '/installed'): is_installed, - }, use_cache=opts.use_cache, use_websocket=opts.use_websocket - ) - sys.exit(ret) + yunohost.api(debug=opts.debug, host=opts.host, port=opts.port) diff --git a/bin/yunomdns b/bin/yunomdns new file mode 100755 index 000000000..862a1f477 --- /dev/null +++ b/bin/yunomdns @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 + +""" +Pythonic declaration of mDNS .local domains for YunoHost +""" + +import subprocess +import re +import sys +import yaml + +import socket +from time import sleep +from typing import List, Dict + +from zeroconf import Zeroconf, ServiceInfo + +# Helper command taken from Moulinette +def check_output(args, stderr=subprocess.STDOUT, shell=True, **kwargs): + """Run command with arguments and return its output as a byte string + Overwrite some of the arguments to capture standard error in the result + and use shell by default before calling subprocess.check_output. + """ + return ( + subprocess.check_output(args, stderr=stderr, shell=shell, **kwargs) + .decode("utf-8") + .strip() + ) + +# Helper command taken from Moulinette +def _extract_inet(string, skip_netmask=False, skip_loopback=True): + """ + Extract IP addresses (v4 and/or v6) from a string limited to one + address by protocol + + Keyword argument: + string -- String to search in + skip_netmask -- True to skip subnet mask extraction + skip_loopback -- False to include addresses reserved for the + loopback interface + + Returns: + A dict of {protocol: address} with protocol one of 'ipv4' or 'ipv6' + + """ + ip4_pattern = ( + r"((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}" + ) + ip6_pattern = r"(((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::?((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)" + ip4_pattern += r"/[0-9]{1,2})" if not skip_netmask else ")" + ip6_pattern += r"/[0-9]{1,3})" if not skip_netmask else ")" + result = {} + + for m in re.finditer(ip4_pattern, string): + addr = m.group(1) + if skip_loopback and addr.startswith("127."): + continue + + # Limit to only one result + result["ipv4"] = addr + break + + for m in re.finditer(ip6_pattern, string): + addr = m.group(1) + if skip_loopback and addr == "::1": + continue + + # Limit to only one result + result["ipv6"] = addr + break + + return result + +# Helper command taken from Moulinette +def get_network_interfaces(): + + # Get network devices and their addresses (raw infos from 'ip addr') + devices_raw = {} + output = check_output("ip --brief a").split("\n") + for line in output: + line = line.split() + iname = line[0] + ips = ' '.join(line[2:]) + + devices_raw[iname] = ips + + # Parse relevant informations for each of them + devices = { + name: _extract_inet(addrs) + for name, addrs in devices_raw.items() + if name != "lo" + } + + return devices + +if __name__ == '__main__': + + ### + # CONFIG + ### + + with open('/etc/yunohost/mdns.yml', 'r') as f: + config = yaml.safe_load(f) or {} + updated = False + + required_fields = ["interfaces", "domains"] + missing_fields = [field for field in required_fields if field not in config] + + if missing_fields: + print("The fields %s are required" % ', '.join(missing_fields)) + + if config['interfaces'] is None: + print('No interface listed for broadcast.') + sys.exit(0) + + if 'yunohost.local' not in config['domains']: + config['domains'].append('yunohost.local') + + zcs = {} + interfaces = get_network_interfaces() + for interface in config['interfaces']: + infos = [] # List of ServiceInfo objects, to feed Zeroconf + ips = [] # Human-readable IPs + b_ips = [] # Binary-convered IPs + + ipv4 = interfaces[interface]['ipv4'].split('/')[0] + if ipv4: + ips.append(ipv4) + b_ips.append(socket.inet_pton(socket.AF_INET, ipv4)) + + ipv6 = interfaces[interface]['ipv6'].split('/')[0] + if ipv6: + ips.append(ipv6) + b_ips.append(socket.inet_pton(socket.AF_INET6, ipv6)) + + # If at least one IP is listed + if ips: + # Create a Zeroconf object, and store the ServiceInfos + zc = Zeroconf(interfaces=ips) + zcs[zc]=[] + for d in config['domains']: + d_domain=d.replace('.local','') + if '.' in d_domain: + print(d_domain+'.local: subdomains are not supported.') + else: + # Create a ServiceInfo object for each .local domain + zcs[zc].append(ServiceInfo( + type_='_device-info._tcp.local.', + name=interface+': '+d_domain+'._device-info._tcp.local.', + addresses=b_ips, + port=80, + server=d+'.', + )) + print('Adding '+d+' with addresses '+str(ips)+' on interface '+interface) + + # Run registration + print("Registering...") + for zc, infos in zcs.items(): + for info in infos: + zc.register_service(info) + + try: + print("Registered. Press Ctrl+C or stop service to stop.") + while True: + sleep(1) + except KeyboardInterrupt: + pass + finally: + print("Unregistering...") + for zc, infos in zcs.items(): + for info in infos: + zc.unregister_service(info) + zc.close() diff --git a/bin/yunopaste b/bin/yunopaste index d52199eba..679f13544 100755 --- a/bin/yunopaste +++ b/bin/yunopaste @@ -34,7 +34,7 @@ Haste server. For example, to paste the output of the YunoHost diagnosis, you can simply execute the following: - yunohost tools diagnosis | ${0} + yunohost diagnosis show | ${0} It will return the URL where you can access the pasted data. diff --git a/bin/yunoprompt b/bin/yunoprompt index 09400639b..8062ab06e 100755 --- a/bin/yunoprompt +++ b/bin/yunoprompt @@ -1,8 +1,12 @@ #!/bin/bash +# Fetch x509 fingerprint +x509_fingerprint=$(openssl x509 -in /etc/yunohost/certs/yunohost.org/crt.pem -noout -fingerprint -sha256 | cut -d= -f2) + + # Fetch SSH fingerprints i=0 -for key in $(ls /etc/ssh/ssh_host_{ed25519,rsa,ecdsa}_key.pub 2> /dev/null) ; 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)) @@ -39,21 +43,21 @@ LOGO_AND_FINGERPRINTS=$(cat << EOF $LOGO - IP: ${local_ip} + Local IP: ${local_ip:-(no ip detected?)} + Local SSL CA X509 fingerprint: + ${x509_fingerprint} SSH fingerprints: ${fingerprint[0]} ${fingerprint[1]} ${fingerprint[2]} - ${fingerprint[3]} - ${fingerprint[4]} EOF ) -if [[ -f /etc/yunohost/installed ]] +echo "$LOGO_AND_FINGERPRINTS" > /etc/issue + +if [[ ! -f /etc/yunohost/installed ]] then - echo "$LOGO_AND_FINGERPRINTS" > /etc/issue -else chvt 2 # Formatting @@ -62,19 +66,19 @@ else echo "$LOGO_AND_FINGERPRINTS" cat << EOF =============================================================================== -You should now proceed with Yunohost post-installation. This is where you will -be asked for : - - the main domain of your server ; +You should now proceed with YunoHost post-installation. This is where you will +be asked for: + - the main domain of your server; - the administration password. -You can perform this step : - - from your web browser, by accessing : ${local_ip} +You can perform this step: + - from your web browser, by accessing: https://yunohost.local/ or ${local_ip} - or in this terminal by answering 'yes' to the following question If this is your first time with YunoHost, it is strongly recommended to take time to read the administator documentation and in particular the sections 'Finalizing your setup' and 'Getting to know YunoHost'. It is available at -the following URL : https://yunohost.org/admindoc +the following URL: https://yunohost.org/admindoc =============================================================================== EOF diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index b5cc4c575..4adf3a07c 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -33,18 +33,10 @@ # Global parameters # ############################# _global: - configuration: - authenticate: - - api - authenticator: - default: - vendor: ldap - help: admin_password - parameters: - uri: ldap://localhost:389 - base_dn: dc=yunohost,dc=org - user_rdn: cn=admin,dc=yunohost,dc=org - argument_auth: false + name: yunohost.admin + authentication: + api: ldap_admin + cli: null arguments: -v: full: --version @@ -67,7 +59,7 @@ user: api: GET /users arguments: --fields: - help: fields to fetch + help: fields to fetch (username, fullname, mail, mail-alias, mail-forward, mailbox-quota, groups, shell, home-path) nargs: "+" ### user_create() @@ -87,7 +79,7 @@ user: ask: ask_firstname required: True pattern: &pattern_firstname - - !!str ^([^\W\d_]{2,30}[ ,.'-]{0,3})+$ + - !!str ^([^\W\d_]{1,30}[ ,.'-]{0,3})+$ - "pattern_firstname" -l: full: --lastname @@ -95,17 +87,11 @@ user: ask: ask_lastname required: True pattern: &pattern_lastname - - !!str ^([^\W\d_]{2,30}[ ,.'-]{0,3})+$ + - !!str ^([^\W\d_]{1,30}[ ,.'-]{0,3})+$ - "pattern_lastname" -m: full: --mail - help: Main unique email address - extra: - ask: ask_email - required: True - pattern: &pattern_email - - !!str ^[\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+([^\W\d_]{2,})$ - - "pattern_email" + help: (Deprecated, see --domain) Main unique email address -p: full: --password help: User password @@ -116,6 +102,13 @@ user: - !!str ^.{3,}$ - "pattern_password" comment: good_practices_about_user_password + -d: + full: --domain + help: Domain for the email address and xmpp account + extra: + pattern: &pattern_domain + - !!str ^([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$ + - "pattern_domain" -q: full: --mailbox-quota help: Mailbox size quota @@ -157,19 +150,26 @@ user: -m: full: --mail extra: - pattern: *pattern_email + pattern: &pattern_email + - !!str ^[\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$ + - "pattern_email" -p: full: --change-password help: New password to set metavar: PASSWORD + nargs: "?" + const: 0 extra: pattern: *pattern_password + comment: good_practices_about_user_password --add-mailforward: help: Mailforward addresses to add nargs: "*" metavar: MAIL extra: - pattern: *pattern_email + pattern: &pattern_email_forward + - !!str ^[\w\+.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$ + - "pattern_email_forward" --remove-mailforward: help: Mailforward addresses to remove nargs: "*" @@ -198,27 +198,59 @@ user: arguments: username: help: Username or email to get information + + ### user_export() + export: + action_help: Export users into CSV + api: GET /users/export + + ### user_import() + import: + action_help: Import several users from CSV + api: POST /users/import + arguments: + csvfile: + help: "CSV file with columns username, firstname, lastname, password, mail, mailbox-quota, mail-alias, mail-forward, groups (separated by coma)" + type: open + -u: + full: --update + help: Update all existing users contained in the CSV file (by default existing users are ignored) + action: store_true + -d: + full: --delete + help: Delete all existing users that are not contained in the CSV file (by default existing users are kept) + action: store_true subcategories: group: - subcategory_help: Manage group + subcategory_help: Manage user groups actions: ### user_group_list() list: - action_help: List group + action_help: List existing groups api: GET /users/groups arguments: - --fields: - help: fields to fetch - nargs: "+" + -s: + full: --short + help: List only the names of groups + action: store_true + -f: + full: --full + help: Display all informations known about each groups + action: store_true + -p: + full: --include-primary-groups + help: Also display primary groups (each user has an eponym group that only contains itself) + action: store_true + default: false - ### user_group_add() - add: + ### user_group_create() + create: action_help: Create group api: POST /users/groups arguments: groupname: - help: The unique group name to add + help: Name of the group to be created extra: pattern: &pattern_groupname - !!str ^[a-z0-9_]+$ @@ -230,165 +262,137 @@ user: api: DELETE /users/groups/ arguments: groupname: - help: Username to delete + help: Name of the group to be deleted extra: pattern: *pattern_groupname - ### user_group_update() - update: - action_help: Update group - api: PUT /users/groups/ - arguments: - groupname: - help: Username to update - extra: - pattern: *pattern_groupname - -a: - full: --add-user - help: User to add in group - nargs: "*" - metavar: USERNAME - extra: - pattern: *pattern_username - -r: - full: --remove-user - help: User to remove in group - nargs: "*" - metavar: USERNAME - extra: - pattern: *pattern_username - ### user_group_info() info: - action_help: Get group information + action_help: Get information about a specific group api: GET /users/groups/ arguments: groupname: - help: Groupname to get information + help: Name of the group to fetch info about + extra: + pattern: *pattern_username + + ### user_group_add() + add: + action_help: Add users to group + api: PUT /users/groups//add/ + arguments: + groupname: + help: Name of the group to add user(s) to + extra: + pattern: *pattern_groupname + usernames: + help: User(s) to add in the group + nargs: "*" + metavar: USERNAME + extra: + pattern: *pattern_username + + ### user_group_remove() + remove: + action_help: Remove users from group + api: PUT /users/groups//remove/ + arguments: + groupname: + help: Name of the group to remove user(s) from + extra: + pattern: *pattern_groupname + usernames: + help: User(s) to remove from the group + nargs: "*" + metavar: USERNAME extra: pattern: *pattern_username permission: - subcategory_help: Manage user permission + subcategory_help: Manage permissions actions: + ### user_permission_list() list: - action_help: List access to user and group - api: GET /users/permission/ + action_help: List permissions and corresponding accesses + api: GET /users/permissions arguments: - -a: - full: --app - help: Application to manage the permission + apps: + help: Apps to list permission for (all by default) nargs: "*" - metavar: APP - -p: - full: --permission - help: Name of permission (main by default) - nargs: "*" - metavar: PERMISSION - -u: - full: --username - help: Username - nargs: "*" - metavar: USER - -g: - full: --group - help: Group name - nargs: "*" - metavar: GROUP + -s: + full: --short + help: Only list permission names + action: store_true + -f: + full: --full + help: Display all info known about each permission, including the full user list of each group it is granted to. + action: store_true - ### user_permission_add() + ### user_permission_info() + info: + action_help: Get information about a specific permission + api: GET /users/permissions/ + arguments: + permission: + help: Name of the permission to fetch info about (use "yunohost user permission list" and "yunohost user permission -f" to see all the current permissions) + + ### user_permission_update() + update: + action_help: Manage group or user permissions + api: PUT /users/permissions/ + arguments: + permission: + help: Permission to manage (e.g. mail or nextcloud or wordpress.editors) (use "yunohost user permission list" and "yunohost user permission -f" to see all the current permissions) + -l: + full: --label + help: Label for this permission. This label will be shown on the SSO and in the admin + -s: + full: --show_tile + help: Define if a tile will be shown in the SSO + choices: + - 'True' + - 'False' + + ## user_permission_add() add: - action_help: Grant access right to users and group - api: POST /users/permission/ + action_help: Grant permission to group or user + api: PUT /users/permissions//add/ arguments: - app: - help: Application to manage the permission - nargs: "+" - -p: - full: --permission - help: Name of permission (main by default) + permission: + help: Permission to manage (e.g. mail or nextcloud or wordpress.editors) (use "yunohost user permission list" and "yunohost user permission -f" to see all the current permissions) + names: + help: Group or usernames to grant this permission to nargs: "*" - metavar: PERMISSION - -u: - full: --username - help: Username - nargs: "*" - metavar: USER - extra: - pattern: *pattern_username - -g: - full: --group - help: Group name - nargs: "*" - metavar: GROUP + metavar: GROUP_OR_USER extra: pattern: *pattern_username - ### user_permission_remove() + ## user_permission_remove() remove: - action_help: Revoke access right to users and group - api: PUT /users/permission/ + action_help: Revoke permission to group or user + api: PUT /users/permissions//remove/ arguments: - app: - help: Application to manage the permission - nargs: "+" - -p: - full: --permission - help: Name of permission (main by default) + permission: + help: Permission to manage (e.g. mail or nextcloud or wordpress.editors) (use "yunohost user permission list" and "yunohost user permission -f" to see all the current permissions) + names: + help: Group or usernames to revoke this permission to nargs: "*" - metavar: PERMISSION - -u: - full: --username - help: Username - nargs: "*" - metavar: USER - extra: - pattern: *pattern_username - -g: - full: --group - help: Group name - nargs: "*" - metavar: GROUP + metavar: GROUP_OR_USER extra: pattern: *pattern_username - ## user_permission_clear() - clear: - action_help: Reset access rights for the app - api: DELETE /users/permission/ + ## user_permission_reset() + reset: + action_help: Reset allowed groups to the default (all_users) for a given permission + api: DELETE /users/permissions/ arguments: - app: - help: Application to manage the permission - nargs: "+" - -p: - full: --permission - help: Name of permission (main by default) - nargs: "*" - metavar: PERMISSION + permission: + help: Permission to manage (e.g. mail or nextcloud or wordpress.editors) (use "yunohost user permission list" and "yunohost user permission -f" to see all the current permissions) ssh: subcategory_help: Manage ssh access actions: - ### user_ssh_enable() - allow: - action_help: Allow the user to uses ssh - api: POST /users/ssh/enable - arguments: - username: - help: Username of the user - extra: - pattern: *pattern_username - - ### user_ssh_disable() - disallow: - action_help: Disallow the user to uses ssh - api: POST /users/ssh/disable - arguments: - username: - help: Username of the user - extra: - pattern: *pattern_username ### user_ssh_keys_list() list-keys: @@ -413,7 +417,7 @@ user: help: The key to be added -c: full: --comment - help: Optionnal comment about the key + help: Optional comment about the key ### user_ssh_keys_remove() remove-key: @@ -427,7 +431,6 @@ user: key: help: The key to be removed - ############################# # Domain # ############################# @@ -439,6 +442,10 @@ domain: list: action_help: List domains api: GET /domains + arguments: + --exclude-subdomains: + help: Filter out domains that are obviously subdomains of other declared domains + action: store_true ### domain_add() add: @@ -448,9 +455,7 @@ domain: domain: help: Domain name to add extra: - pattern: &pattern_domain - - !!str ^([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+([^\W\d_]{2,})$ - - "pattern_domain" + pattern: *pattern_domain -d: full: --dyndns help: Subscribe to the DynDNS service @@ -465,26 +470,45 @@ domain: help: Domain to delete extra: pattern: *pattern_domain + -r: + full: --remove-apps + help: Remove apps installed on the domain + action: store_true + -f: + full: --force + help: Do not ask confirmation to remove apps + action: store_true + ### domain_dns_conf() dns-conf: - action_help: Generate DNS configuration for a domain - api: GET /domains//dns + deprecated: true + action_help: Generate sample DNS configuration for a domain arguments: domain: help: Target domain - -t: - full: --ttl - help: Time To Live (TTL) in second before DNS servers update. Default is 3600 seconds (i.e. 1 hour). extra: - pattern: - - !!str ^[0-9]+$ - - "pattern_positive_number" + pattern: *pattern_domain + + ### domain_maindomain() + main-domain: + action_help: Check the current main domain, or change it + deprecated_alias: + - maindomain + api: + - GET /domains/main + - PUT /domains//main + arguments: + -n: + full: --new-main-domain + help: Change the current main domain + extra: + pattern: *pattern_domain ### certificate_status() cert-status: + deprecated: true action_help: List status of current certificates (all by default). - api: GET /domains/cert-status/ arguments: domain_list: help: Domains to check @@ -495,8 +519,8 @@ domain: ### certificate_install() cert-install: + deprecated: true action_help: Install Let's Encrypt certificates for given domains (all by default). - api: POST /domains/cert-install/ arguments: domain_list: help: Domains for which to install the certificates @@ -516,8 +540,8 @@ domain: ### certificate_renew() cert-renew: + deprecated: true action_help: Renew the Let's Encrypt certificates for given domains (all by default). - api: POST /domains/cert-renew/ arguments: domain_list: help: Domains for which to renew the certificates @@ -538,7 +562,7 @@ domain: ### domain_url_available() url-available: action_help: Check availability of a web path - api: GET /domain/urlavailable + api: GET /domain//urlavailable arguments: domain: help: The domain for the web path (e.g. your.domain.tld) @@ -547,18 +571,139 @@ domain: path: help: The path to check (e.g. /coffee) + subcategories: - ### domain_info() -# info: -# action_help: Get domain informations -# api: GET /domains/ -# arguments: -# domain: -# help: "" -# extra: -# pattern: -# - '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' -# - "Must be a valid domain name (e.g. my-domain.org)" + config: + subcategory_help: Domain settings + actions: + + ### domain_config_get() + get: + action_help: Display a domain configuration + api: GET /domains//config + arguments: + domain: + help: Domain name + key: + help: A specific panel, section or a question identifier + nargs: '?' + -f: + full: --full + help: Display all details (meant to be used by the API) + action: store_true + -e: + full: --export + help: Only export key/values, meant to be reimported using "config set --args-file" + action: store_true + + ### domain_config_set() + set: + action_help: Apply a new configuration + api: PUT /domains//config + arguments: + domain: + help: Domain name + key: + help: The question or form key + nargs: '?' + -v: + full: --value + help: new value + -a: + full: --args + help: Serialized arguments for new configuration (i.e. "mail_in=0&mail_out=0") + + dns: + subcategory_help: Manage domains DNS + actions: + ### domain_dns_conf() + suggest: + action_help: Generate sample DNS configuration for a domain + api: + - GET /domains//dns + - GET /domains//dns/suggest + arguments: + domain: + help: Target domain + extra: + pattern: *pattern_domain + + ### domain_dns_push() + push: + action_help: Push DNS records to registrar + api: POST /domains//dns/push + arguments: + domain: + help: Domain name to push DNS conf for + extra: + pattern: *pattern_domain + -d: + full: --dry-run + help: Only display what's to be pushed + action: store_true + --force: + help: Also update/remove records which were not originally set by Yunohost, or which have been manually modified + action: store_true + --purge: + help: Delete all records + action: store_true + + cert: + subcategory_help: Manage domain certificates + actions: + ### certificate_status() + status: + action_help: List status of current certificates (all by default). + api: GET /domains//cert + arguments: + domain_list: + help: Domains to check + nargs: "*" + --full: + help: Show more details + action: store_true + + ### certificate_install() + install: + action_help: Install Let's Encrypt certificates for given domains (all by default). + api: PUT /domains//cert + arguments: + domain_list: + help: Domains for which to install the certificates + nargs: "*" + --force: + help: Install even if current certificate is not self-signed + action: store_true + --no-checks: + help: Does not perform any check that your domain seems correctly configured (DNS, reachability) before attempting to install. (Not recommended) + action: store_true + --self-signed: + help: Install self-signed certificate instead of Let's Encrypt + action: store_true + --staging: + help: Use the fake/staging Let's Encrypt certification authority. The new certificate won't actually be enabled - it is only intended to test the main steps of the procedure. + action: store_true + + ### certificate_renew() + renew: + action_help: Renew the Let's Encrypt certificates for given domains (all by default). + api: PUT /domains//cert/renew + arguments: + domain_list: + help: Domains for which to renew the certificates + nargs: "*" + --force: + help: Ignore the validity threshold (30 days) + action: store_true + --email: + help: Send an email to root with logs if some renewing fails + action: store_true + --no-checks: + help: Does not perform any check that your domain seems correctly configured (DNS, reachability) before attempting to renew. (Not recommended) + action: store_true + --staging: + help: Use the fake/staging Let's Encrypt certification authority. The new certificate won't actually be enabled - it is only intended to test the main steps of the procedure. + action: store_true ############################# @@ -568,79 +713,69 @@ app: category_help: Manage apps actions: - ### app_fetchlist() + catalog: + action_help: Show the catalog of installable application + api: GET /apps/catalog + arguments: + -f: + full: --full + help: Display all details, including the app manifest and various other infos + action: store_true + -c: + full: --with-categories + help: Also return a list of app categories + action: store_true + + ### app_search() + search: + action_help: Search installable apps + arguments: + string: + help: Return matching app name or description with "string" + + ### app_manifest() + manifest: + action_help: Return the manifest of a given app from the catalog, or from a remote git repo + api: GET /apps/manifest + arguments: + app: + help: Name, local path or git URL of the app to fetch the manifest of + fetchlist: - action_help: Fetch application lists from app servers, or register a new one. - api: PUT /appslists - arguments: - -n: - full: --name - help: Name of the list to fetch (fetches all registered lists if empty) - extra: - pattern: &pattern_listname - - !!str ^[a-z0-9_]+$ - - "pattern_listname" - -u: - full: --url - help: URL of a new application list to register. To be specified with -n. - - ### app_listlists() - listlists: - action_help: List registered application lists - api: GET /appslists - - ### app_removelist() - removelist: - action_help: Remove and forget about a given application list - api: DELETE /appslists - arguments: - name: - help: Name of the list to remove - extra: - ask: ask_list_to_remove - pattern: *pattern_listname + deprecated: true ### app_list() list: - action_help: List apps + action_help: List installed apps api: GET /apps arguments: -f: - full: --filter - help: Name filter of app_id or app_name - -r: - full: --raw - help: Return the full app_dict + full: --full + help: Display all details, including the app manifest and various other infos action: store_true -i: full: --installed - help: Return only installed apps - action: store_true - -b: - full: --with-backup - help: Return only apps with backup feature (force --installed filter) + help: Dummy argument, does nothing anymore (still there only for backward compatibility) action: store_true + filter: + nargs: '?' ### app_info() info: - action_help: Get information about an installed app + action_help: Show infos about a specific installed app api: GET /apps/ arguments: app: help: Specific app ID - -s: - full: --show-status - help: Show app installation status - action: store_true - -r: - full: --raw - help: Return the full app_dict + -f: + full: --full + help: Display all details, including the app manifest and various other infos action: store_true ### app_map() map: - action_help: List apps by domain - api: GET /appsmap + action_help: Show the mapping between urls and apps + api: GET /apps/map arguments: -a: full: --app @@ -677,18 +812,22 @@ app: help: Do not ask confirmation if the app is not safe to use (low quality, experimental or 3rd party) action: store_true - ### app_remove() TODO: Write help + ### app_remove() remove: action_help: Remove app api: DELETE /apps/ arguments: app: - help: App(s) to delete + help: App to remove + -p: + full: --purge + help: Also remove all application data + action: store_true ### app_upgrade() upgrade: action_help: Upgrade app - api: PUT /upgrade/apps + api: PUT /apps//upgrade arguments: app: help: App(s) to upgrade (default all) @@ -699,6 +838,14 @@ app: -f: full: --file help: Folder or tarball for upgrade + -F: + full: --force + help: Force the update, even though the app is up to date + action: store_true + -b: + full: --no-safety-backup + help: Disable the safety backup during upgrade + action: store_true ### app_change_url() change-url: @@ -738,35 +885,10 @@ app: help: Delete the key action: store_true - ### app_checkport() - checkport: - action_help: Check availability of a local port - api: GET /tools/checkport - deprecated: true - arguments: - port: - help: Port to check - extra: - pattern: &pattern_port - - !!str ^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$ - - "pattern_port" - - ### app_checkurl() - checkurl: - action_help: Check availability of a web path - api: GET /tools/checkurl - deprecated: True - arguments: - url: - help: Url to check - -a: - full: --app - help: Write domain & path to app settings for further checks ### app_register_url() register-url: action_help: Book/register a web path for a given app - api: PUT /tools/registerurl arguments: app: help: App which will use the web path @@ -776,32 +898,6 @@ app: help: The path to be registered (e.g. /coffee) - ### app_initdb() - initdb: - action_help: Create database and initialize it with optionnal attached script - api: POST /tools/initdb - deprecated: true - arguments: - user: - help: Name of the DB user - -p: - full: --password - help: Password of the DB (generated unless set) - -d: - full: --db - help: DB name (user unless set) - -s: - full: --sql - help: Initial SQL file - - ### app_debug() - debug: - action_help: Display all debug informations for an application - api: GET /apps//debug - arguments: - app: - help: App name - ### app_makedefault() makedefault: action_help: Redirect domain root to an app @@ -816,7 +912,6 @@ app: ### app_ssowatconf() ssowatconf: action_help: Regenerate SSOwat configuration file - api: PUT /ssowatconf ### app_change_label() change-label: @@ -831,7 +926,7 @@ app: ### app_addaccess() TODO: Write help addaccess: action_help: Grant access right to users (everyone by default) - api: PUT /access + deprecated: true arguments: apps: nargs: "+" @@ -842,7 +937,7 @@ app: ### app_removeaccess() TODO: Write help removeaccess: action_help: Revoke access right to users (everyone by default) - api: DELETE /access + deprecated: true arguments: apps: nargs: "+" @@ -853,7 +948,7 @@ app: ### app_clearaccess() clearaccess: action_help: Reset access rights for the app - api: POST /access + deprecated: true arguments: apps: nargs: "+" @@ -889,24 +984,45 @@ app: subcategory_help: Applications configuration panel actions: - ### app_config_show_panel() - show-panel: - action_help: show config panel for the application + ### app_config_get() + get: + action_help: Display an app configuration api: GET /apps//config-panel arguments: - app: - help: App name + app: + help: App name + key: + help: A specific panel, section or a question identifier + nargs: '?' + -f: + full: --full + help: Display all details (meant to be used by the API) + action: store_true + -e: + full: --export + help: Only export key/values, meant to be reimported using "config set --args-file" + action: store_true - ### app_config_apply() - apply: - action_help: apply the new configuration - api: POST /apps//config + ### app_config_set() + set: + action_help: Apply a new configuration + api: PUT /apps//config arguments: - app: - help: App name - -a: - full: --args - help: Serialized arguments for new configuration (i.e. "domain=domain.tld&path=/path") + app: + help: App name + key: + help: The question or panel key + nargs: '?' + -v: + full: --value + help: new value + -a: + full: --args + help: Serialized arguments for new configuration (i.e. "domain=domain.tld&path=/path") + -f: + full: --args-file + help: YAML or JSON file with key/value couples + type: open ############################# # Backup # @@ -918,7 +1034,7 @@ backup: ### backup_create() create: action_help: Create a backup local archive. If neither --apps or --system are given, this will backup all apps and all system parts. If only --apps if given, this will only backup apps and no system parts. Similarly, if only --system is given, this will only backup system parts and no apps. - api: POST /backup + api: POST /backups arguments: -n: full: --name @@ -933,10 +1049,6 @@ backup: -o: full: --output-directory help: Output directory for the backup - -r: - full: --no-compress - help: Do not create an archive file - action: store_true --methods: help: List of backup methods to apply (copy or tar by default) nargs: "*" @@ -946,11 +1058,14 @@ backup: --apps: help: List of application names to backup (or all if none given) nargs: "*" + --dry-run: + help: "'Simulate' the backup and return the size details per item to backup" + action: store_true ### backup_restore() restore: action_help: Restore from a local backup archive. If neither --apps or --system are given, this will restore all apps and all system parts in the archive. If only --apps if given, this will only restore apps and no system parts. Similarly, if only --system is given, this will only restore system parts and no apps. - api: POST /backup/restore/ + api: PUT /backups//restore arguments: name: help: Name of the local backup archive @@ -967,7 +1082,7 @@ backup: ### backup_list() list: action_help: List available local backup archives - api: GET /backup/archives + api: GET /backups arguments: -r: full: --repos @@ -985,7 +1100,7 @@ backup: ### backup_info() info: action_help: Show info about a local backup archive - api: GET /backup/archives/ + api: GET /backups/ arguments: name: help: Name of the local backup archive @@ -998,10 +1113,18 @@ backup: help: Print sizes in human readable format action: store_true + ### backup_download() + download: + action_help: (API only) Request to download the file + api: GET /backups//download + arguments: + name: + help: Name of the local backup archive + ### backup_delete() delete: action_help: Delete a backup archive - api: DELETE /backup/archives/ + api: DELETE /backups/ arguments: name: help: Name of the archive to delete @@ -1101,147 +1224,6 @@ backup: help: Remove all archives and data inside repository action: store_false -############################# -# Monitor # -############################# -monitor: - category_help: Monitor the server - actions: - - ### monitor_disk() - disk: - action_help: Monitor disk space and usage - api: GET /monitor/disk - arguments: - -f: - full: --filesystem - help: Show filesystem disk space - action: append_const - const: filesystem - dest: units - -t: - full: --io - help: Show I/O throughput - action: append_const - const: io - dest: units - -m: - full: --mountpoint - help: Monitor only the device mounted on MOUNTPOINT - action: store - -H: - full: --human-readable - help: Print sizes in human readable format - action: store_true - - ### monitor_network() - network: - action_help: Monitor network interfaces - api: GET /monitor/network - arguments: - -u: - full: --usage - help: Show interfaces bit rates - action: append_const - const: usage - dest: units - -i: - full: --infos - help: Show network informations - action: append_const - const: infos - dest: units - -c: - full: --check - help: Check network configuration - action: append_const - const: check - dest: units - -H: - full: --human-readable - help: Print sizes in human readable format - action: store_true - - ### monitor_system() - system: - action_help: Monitor system informations and usage - api: GET /monitor/system - arguments: - -m: - full: --memory - help: Show memory usage - action: append_const - const: memory - dest: units - -c: - full: --cpu - help: Show CPU usage and load - action: append_const - const: cpu - dest: units - -p: - full: --process - help: Show processes summary - action: append_const - const: process - dest: units - -u: - full: --uptime - help: Show the system uptime - action: append_const - const: uptime - dest: units - -i: - full: --infos - help: Show system informations - action: append_const - const: infos - dest: units - -H: - full: --human-readable - help: Print sizes in human readable format - action: store_true - - ### monitor_updatestats() - update-stats: - action_help: Update monitoring statistics - api: POST /monitor/stats - arguments: - period: - help: Time period to update - choices: - - day - - week - - month - - ### monitor_showstats() - show-stats: - action_help: Show monitoring statistics - api: GET /monitor/stats - arguments: - period: - help: Time period to show - choices: - - day - - week - - month - - ### monitor_enable() - enable: - action_help: Enable server monitoring - api: PUT /monitor - arguments: - -s: - full: --with-stats - help: Enable monitoring statistics - action: store_true - - ### monitor_disable() - disable: - api: DELETE /monitor - action_help: Disable server monitoring - - ############################# # Settings # ############################# @@ -1301,28 +1283,16 @@ service: ### service_add() add: action_help: Add a service - # api: POST /services arguments: name: help: Service name to add - -s: - full: --status - help: Custom status command + -d: + full: --description + help: Description of the service -l: full: --log help: Absolute path to log file to display nargs: "+" - -r: - full: --runlevel - help: Runlevel priority of the service - type: int - -n: - full: --need_lock - help: Use this option to prevent deadlocks if the service does invoke yunohost commands. - action: store_true - -d: - full: --description - help: Description of the service -t: full: --log_type help: Type of the log (file or systemd) @@ -1330,12 +1300,26 @@ service: choices: - file - systemd - default: file + --test_status: + help: Specify a custom bash command to check the status of the service. Note that it only makes sense to specify this if the corresponding systemd service does not return the proper information already. + --test_conf: + help: Specify a custom bash command to check if the configuration of the service is valid or broken, similar to nginx -t. + --needs_exposed_ports: + help: A list of ports that needs to be publicly exposed for the service to work as intended. + nargs: "+" + type: int + metavar: PORT + -n: + full: --need_lock + help: Use this option to prevent deadlocks if the service does invoke yunohost commands. + action: store_true + -s: + full: --status + help: Deprecated, old option. Does nothing anymore. Possibly check the --test_status option. ### service_remove() remove: action_help: Remove a service - # api: DELETE /services arguments: name: help: Service name to remove @@ -1343,7 +1327,7 @@ service: ### service_start() start: action_help: Start one or more services - api: PUT /services/ + api: PUT /services//start arguments: names: help: Service name to start @@ -1353,7 +1337,7 @@ service: ### service_stop() stop: action_help: Stop one or more services - api: DELETE /services/ + api: PUT /services//stop arguments: names: help: Service name to stop @@ -1372,6 +1356,7 @@ service: ### service_restart() restart: action_help: Restart one or more services. If the services are not running yet, they will be started. + api: PUT /services//restart arguments: names: help: Service name to restart @@ -1400,7 +1385,7 @@ service: ### service_disable() disable: action_help: Disable one or more services - api: DELETE /services//enable + api: PUT /services//disable arguments: names: help: Service name to disable @@ -1435,7 +1420,6 @@ service: ### service_regen_conf() regen-conf: action_help: Regenerate the configuration file(s) for a service - api: PUT /services/regenconf deprecated_alias: - regenconf arguments: @@ -1487,19 +1471,10 @@ firewall: help: List forwarded ports with UPnP action: store_true - ### firewall_reload() - reload: - action_help: Reload all firewall rules - api: PUT /firewall - arguments: - --skip-upnp: - help: Do not refresh port forwarding using UPnP - action: store_true - ### firewall_allow() allow: action_help: Allow connections on a port - api: POST /firewall/port + api: PUT /firewall//allow/ arguments: protocol: help: "Protocol type to allow (TCP/UDP/Both)" @@ -1529,11 +1504,10 @@ firewall: help: Do not reload firewall rules action: store_true - ### firewall_disallow() disallow: action_help: Disallow connections on a port - api: DELETE /firewall/port + api: PUT /firewall//disallow/ arguments: protocol: help: "Protocol type to allow (TCP/UDP/Both)" @@ -1561,11 +1535,10 @@ firewall: help: Do not reload firewall rules action: store_true - ### firewall_upnp() upnp: action_help: Manage port forwarding using UPnP - api: GET /firewall/upnp + api: PUT /firewall/upnp/ arguments: action: choices: @@ -1579,10 +1552,19 @@ firewall: help: Do not refresh port forwarding action: store_true + + ### firewall_reload() + reload: + action_help: Reload all firewall rules + arguments: + --skip-upnp: + help: Do not refresh port forwarding using UPnP + action: store_true + ### firewall_stop() stop: action_help: Stop iptables and ip6tables - api: DELETE /firewall + @@ -1596,7 +1578,6 @@ dyndns: ### dyndns_subscribe() subscribe: action_help: Subscribe to a DynDNS service - api: POST /dyndns arguments: --subscribe-host: help: Dynette HTTP API to subscribe to @@ -1613,7 +1594,6 @@ dyndns: ### dyndns_update() update: action_help: Update IP on DynDNS platform - api: PUT /dyndns arguments: --dyn-host: help: Dynette DNS server to inform @@ -1629,19 +1609,25 @@ dyndns: -i: full: --ipv4 help: IP address to send + -f: + full: --force + help: Force the update (for debugging only) + action: store_true + -D: + full: --dry-run + help: Only display the generated zone + action: store_true -6: full: --ipv6 help: IPv6 address to send ### dyndns_installcron() installcron: - action_help: Install IP update cron - api: POST /dyndns/cron + deprecated: true ### dyndns_removecron() removecron: - action_help: Remove IP update cron - api: DELETE /dyndns/cron + deprecated: true ############################# @@ -1667,12 +1653,9 @@ tools: ### tools_maindomain() maindomain: action_help: Check the current main domain, or change it - api: - - GET /domains/main - - PUT /domains/main arguments: -n: - full: --new-domain + full: --new-main-domain help: Change the current main domain extra: pattern: *pattern_domain @@ -1681,9 +1664,9 @@ tools: postinstall: action_help: YunoHost post-install api: POST /postinstall - configuration: + authentication: # We need to be able to run the postinstall without being authenticated, otherwise we can't run the postinstall - authenticate: false + api: null arguments: -d: full: --domain @@ -1706,51 +1689,50 @@ tools: --force-password: help: Use this if you really want to set a weak password action: store_true + --force-diskspace: + help: Use this if you really want to install YunoHost on a setup with less than 10 GB on the root filesystem + action: store_true + ### tools_update() update: action_help: YunoHost update - api: PUT /update + api: PUT /update/ arguments: + target: + help: What to update, "apps" (application catalog) or "system" (fetch available package upgrades, equivalent to apt update), "all" for both + choices: + - apps + - system + - all + nargs: "?" + metavar: TARGET + default: all --apps: - help: Fetch the application list to check which apps can be upgraded + help: (Deprecated, see first positional arg) Fetch the application list to check which apps can be upgraded action: store_true --system: - help: Fetch available system packages upgrades (equivalent to apt update) + help: (Deprecated, see first positional arg) Fetch available system packages upgrades (equivalent to apt update) action: store_true ### tools_upgrade() upgrade: action_help: YunoHost upgrade - api: PUT /upgrade + api: PUT /upgrade/ arguments: + target: + help: What to upgrade, either "apps" (all apps) or "system" (all system packages) + choices: + - apps + - system + nargs: "?" --apps: - help: List of apps to upgrade (all by default) - nargs: "*" + help: (Deprecated, see first positional arg) Upgrade all applications + action: store_true --system: - help: Upgrade only the system packages + help: (Deprecated, see first positional arg) Upgrade only the system packages action: store_true - ### tools_diagnosis() - diagnosis: - action_help: YunoHost diagnosis - api: GET /diagnosis - arguments: - -p: - full: --private - help: Show private data (domain, IP) - action: store_true - - ### tools_port_available() - port-available: - action_help: Check availability of a local port - api: GET /tools/portavailable - arguments: - port: - help: Port to check - extra: - pattern: *pattern_port - ### tools_shell() shell: action_help: Launch a development shell @@ -1782,7 +1764,9 @@ tools: ### tools_regen_conf() regen-conf: action_help: Regenerate the configuration file(s) - api: PUT /tools/regenconf + api: + - PUT /regenconf + - PUT /regenconf/ arguments: names: help: Categories to regenerate configuration of (all by default) @@ -1805,6 +1789,11 @@ tools: help: List pending configuration files and exit action: store_true + ### tools_versions() + versions: + action_help: Display YunoHost's packages versions + api: GET /versions + subcategories: migrations: @@ -1823,30 +1812,34 @@ tools: help: list only migrations already performed action: store_true - ### tools_migrations_migrate() - migrate: - action_help: Perform migrations - api: POST /migrations/migrate + ### tools_migrations_run() + run: + action_help: Run migrations + api: + - PUT /migrations + - PUT /migrations/ + deprecated_alias: + - migrate arguments: - -t: - help: target migration number (or 0), latest one by default - type: int - full: --target - -s: - help: skip the migration(s), use it only if you know what you are doing - full: --skip + targets: + help: Migrations to run (all pendings by default) + nargs: "*" + --skip: + help: Skip specified migrations (to be used only if you know what you are doing) + action: store_true + --force-rerun: + help: Re-run already-ran specified migration (to be used only if you know what you are doing) action: store_true --auto: - help: automatic mode, won't run manual migrations, use it only if you know what you are doing + help: Automatic mode, won't run manual migrations (to be used only if you know what you are doing) action: store_true --accept-disclaimer: - help: accept disclaimers of migration (please read them before using this option) + help: Accept disclaimers of migrations (please read them before using this option) action: store_true ### tools_migrations_state() state: action_help: Show current migrations state - api: GET /migrations/state ############################# @@ -1875,7 +1868,6 @@ hook: ### hook_info() info: action_help: Get information about a given hook - api: GET /hooks// arguments: action: help: Action name @@ -1905,7 +1897,6 @@ hook: ### hook_callback() callback: action_help: Execute all scripts binded to an action - api: POST /hooks/ arguments: action: help: Action name @@ -1958,22 +1949,26 @@ log: action_help: List logs api: GET /logs arguments: - category: - help: Log category to display (default operations), could be operation, history, package, system, access, service or app - nargs: "*" -l: full: --limit - help: Maximum number of logs + help: Maximum number of operations to list (default to 50) type: int + default: 50 -d: full: --with-details help: Show additional infos (e.g. operation success) but may significantly increase command time. Consider using --limit in combination with this. action: store_true + -s: + full: --with-suboperations + help: Include metadata about operations that are not the main operation but are sub-operations triggered by another ongoing operation... (e.g. initializing groups/permissions when installing an app) + action: store_true - ### log_display() - display: + ### log_show() + show: action_help: Display a log content - api: GET /logs/display + api: GET /logs/ + deprecated_alias: + - display arguments: path: help: Log file which to display the content @@ -1983,5 +1978,102 @@ log: default: 50 type: int --share: - help: Share the full log using yunopaste + help: (Deprecated, see yunohost log share) Share the full log using yunopaste action: store_true + -i: + full: --filter-irrelevant + help: Do not show some lines deemed not relevant (like set +x or helper argument parsing) + action: store_true + -s: + full: --with-suboperations + help: Include metadata about sub-operations of this operation... (e.g. initializing groups/permissions when installing an app) + action: store_true + + ### log_share() + share: + action_help: Share the full log on yunopaste (alias to show --share) + api: GET /logs//share + arguments: + path: + help: Log file to share + + +############################# +# Diagnosis # +############################# +diagnosis: + category_help: Look for possible issues on the server + actions: + + list: + action_help: List diagnosis categories + api: GET /diagnosis/categories + + show: + action_help: Show most recents diagnosis results + api: GET /diagnosis + arguments: + categories: + help: Diagnosis categories to display (all by default) + nargs: "*" + --full: + help: Display additional information + action: store_true + --issues: + help: Only display issues + action: store_true + --share: + help: Share the logs using yunopaste + action: store_true + --human-readable: + help: Show a human-readable output + action: store_true + + get: + action_help: Low-level command to fetch raw data and status about a specific diagnosis test + api: GET /diagnosis/ + arguments: + category: + help: Diagnosis category to fetch results from + item: + help: "List of criteria describing the test. Must correspond exactly to the 'meta' infos in 'yunohost diagnosis show'" + metavar: CRITERIA + nargs: "*" + + run: + action_help: Run diagnosis + api: PUT /diagnosis/run + arguments: + categories: + help: Diagnosis categories to run (all by default) + nargs: "*" + --force: + help: Ignore the cached report even if it is still 'fresh' + action: store_true + --except-if-never-ran-yet: + help: Don't run anything if diagnosis never ran yet ... (this is meant to be used by the webadmin) + action: store_true + --email: + help: Send an email to root with issues found (this is meant to be used by cron job) + action: store_true + + ignore: + action_help: Configure some diagnosis results to be ignored and therefore not considered as actual issues + api: PUT /diagnosis/ignore + arguments: + --filter: + help: "Add a filter. The first element should be a diagnosis category, and other criterias can be provided using the infos from the 'meta' sections in 'yunohost diagnosis show'. For example: 'dnsrecords domain=yolo.test category=xmpp'" + nargs: "*" + metavar: CRITERIA + --list: + help: List active ignore filters + action: store_true + + unignore: + action_help: Configure some diagnosis results to be unignored and therefore considered as actual issues + api: PUT /diagnosis/unignore + arguments: + --filter: + help: Remove a filter (it should be an existing filter as listed with --list) + nargs: "*" + metavar: CRITERIA diff --git a/data/actionsmap/yunohost_completion.py b/data/actionsmap/yunohost_completion.py index a4c17c4d6..c801e2f3c 100644 --- a/data/actionsmap/yunohost_completion.py +++ b/data/actionsmap/yunohost_completion.py @@ -3,7 +3,7 @@ Simple automated generation of a bash_completion file for yunohost command from the actionsmap. Generates a bash completion file assuming the structure -`yunohost domain action` +`yunohost category action` adds `--help` at the end if one presses [tab] again. author: Christophe Vuillot @@ -12,75 +12,148 @@ import os import yaml THIS_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -ACTIONSMAP_FILE = THIS_SCRIPT_DIR + '/yunohost.yml' -BASH_COMPLETION_FILE = THIS_SCRIPT_DIR + '/../bash-completion.d/yunohost' +ACTIONSMAP_FILE = THIS_SCRIPT_DIR + "/yunohost.yml" +os.system(f"mkdir {THIS_SCRIPT_DIR}/../bash-completion.d") +BASH_COMPLETION_FILE = THIS_SCRIPT_DIR + "/../bash-completion.d/yunohost" -with open(ACTIONSMAP_FILE, 'r') as stream: - # Getting the dictionary containning what actions are possible per domain - OPTION_TREE = yaml.load(stream) - DOMAINS = [str for str in OPTION_TREE.keys() if not str.startswith('_')] - DOMAINS_STR = '"{}"'.format(' '.join(DOMAINS)) +def get_dict_actions(OPTION_SUBTREE, category): + ACTIONS = [ + action + for action in OPTION_SUBTREE[category]["actions"].keys() + if not action.startswith("_") + ] + ACTIONS_STR = "{}".format(" ".join(ACTIONS)) + + DICT = {"actions_str": ACTIONS_STR} + + return DICT + + +with open(ACTIONSMAP_FILE, "r") as stream: + + # Getting the dictionary containning what actions are possible per category + OPTION_TREE = yaml.safe_load(stream) + + CATEGORY = [ + category for category in OPTION_TREE.keys() if not category.startswith("_") + ] + + CATEGORY_STR = "{}".format(" ".join(CATEGORY)) ACTIONS_DICT = {} - for domain in DOMAINS: - ACTIONS = [str for str in OPTION_TREE[domain]['actions'].keys() - if not str.startswith('_')] - ACTIONS_STR = '"{}"'.format(' '.join(ACTIONS)) - ACTIONS_DICT[domain] = ACTIONS_STR + for category in CATEGORY: + ACTIONS_DICT[category] = get_dict_actions(OPTION_TREE, category) - with open(BASH_COMPLETION_FILE, 'w') as generated_file: + ACTIONS_DICT[category]["subcategories"] = {} + ACTIONS_DICT[category]["subcategories_str"] = "" + + if "subcategories" in OPTION_TREE[category].keys(): + SUBCATEGORIES = [ + subcategory + for subcategory in OPTION_TREE[category]["subcategories"].keys() + ] + + SUBCATEGORIES_STR = "{}".format(" ".join(SUBCATEGORIES)) + + ACTIONS_DICT[category]["subcategories_str"] = SUBCATEGORIES_STR + + for subcategory in SUBCATEGORIES: + ACTIONS_DICT[category]["subcategories"][subcategory] = get_dict_actions( + OPTION_TREE[category]["subcategories"], subcategory + ) + + with open(BASH_COMPLETION_FILE, "w") as generated_file: # header of the file - generated_file.write('#\n') - generated_file.write('# completion for yunohost\n') - generated_file.write('# automatically generated from the actionsmap\n') - generated_file.write('#\n\n') + generated_file.write("#\n") + generated_file.write("# completion for yunohost\n") + generated_file.write("# automatically generated from the actionsmap\n") + generated_file.write("#\n\n") # Start of the completion function - generated_file.write('_yunohost()\n') - generated_file.write('{\n') + generated_file.write("_yunohost()\n") + generated_file.write("{\n") # Defining local variable for previously and currently typed words - generated_file.write('\tlocal cur prev opts narg\n') - generated_file.write('\tCOMPREPLY=()\n\n') - generated_file.write('\t# the number of words already typed\n') - generated_file.write('\tnarg=${#COMP_WORDS[@]}\n\n') - generated_file.write('\t# the current word being typed\n') + generated_file.write("\tlocal cur prev opts narg\n") + generated_file.write("\tCOMPREPLY=()\n\n") + generated_file.write("\t# the number of words already typed\n") + generated_file.write("\tnarg=${#COMP_WORDS[@]}\n\n") + generated_file.write("\t# the current word being typed\n") generated_file.write('\tcur="${COMP_WORDS[COMP_CWORD]}"\n\n') - generated_file.write('\t# the last typed word\n') - generated_file.write('\tprev="${COMP_WORDS[COMP_CWORD-1]}"\n\n') - # If one is currently typing a domain then match with the domain list - generated_file.write('\t# If one is currently typing a domain,\n') - generated_file.write('\t# match with domains\n') - generated_file.write('\tif [[ $narg == 2 ]]; then\n') - generated_file.write('\t\topts={}\n'.format(DOMAINS_STR)) - generated_file.write('\tfi\n\n') + # If one is currently typing a category then match with the category list + generated_file.write("\t# If one is currently typing a category,\n") + generated_file.write("\t# match with categorys\n") + generated_file.write("\tif [[ $narg == 2 ]]; then\n") + generated_file.write('\t\topts="{}"\n'.format(CATEGORY_STR)) + generated_file.write("\tfi\n\n") # If one is currently typing an action then match with the action list - # of the previously typed domain - generated_file.write('\t# If one already typed a domain,\n') - generated_file.write('\t# match the actions of that domain\n') - generated_file.write('\tif [[ $narg == 3 ]]; then\n') - for domain in DOMAINS: - generated_file.write('\t\tif [[ $prev == "{}" ]]; then\n'.format(domain)) - generated_file.write('\t\t\topts={}\n'.format(ACTIONS_DICT[domain])) - generated_file.write('\t\tfi\n') - generated_file.write('\tfi\n\n') + # of the previously typed category + generated_file.write("\t# If one already typed a category,\n") + generated_file.write( + "\t# match the actions or the subcategories of that category\n" + ) + generated_file.write("\tif [[ $narg == 3 ]]; then\n") + generated_file.write("\t\t# the category typed\n") + generated_file.write('\t\tcategory="${COMP_WORDS[1]}"\n\n') + for category in CATEGORY: + generated_file.write( + '\t\tif [[ $category == "{}" ]]; then\n'.format(category) + ) + generated_file.write( + '\t\t\topts="{} {}"\n'.format( + ACTIONS_DICT[category]["actions_str"], + ACTIONS_DICT[category]["subcategories_str"], + ) + ) + generated_file.write("\t\tfi\n") + generated_file.write("\tfi\n\n") - # If both domain and action have been typed or the domain + generated_file.write("\t# If one already typed an action or a subcategory,\n") + generated_file.write("\t# match the actions of that subcategory\n") + generated_file.write("\tif [[ $narg == 4 ]]; then\n") + generated_file.write("\t\t# the category typed\n") + generated_file.write('\t\tcategory="${COMP_WORDS[1]}"\n\n') + generated_file.write("\t\t# the action or the subcategory typed\n") + generated_file.write('\t\taction_or_subcategory="${COMP_WORDS[2]}"\n\n') + for category in CATEGORY: + if len(ACTIONS_DICT[category]["subcategories"]): + generated_file.write( + '\t\tif [[ $category == "{}" ]]; then\n'.format(category) + ) + for subcategory in ACTIONS_DICT[category]["subcategories"]: + generated_file.write( + '\t\t\tif [[ $action_or_subcategory == "{}" ]]; then\n'.format( + subcategory + ) + ) + generated_file.write( + '\t\t\t\topts="{}"\n'.format( + ACTIONS_DICT[category]["subcategories"][subcategory][ + "actions_str" + ] + ) + ) + generated_file.write("\t\t\tfi\n") + generated_file.write("\t\tfi\n") + generated_file.write("\tfi\n\n") + + # If both category and action have been typed or the category # was not recognized propose --help (only once) - generated_file.write('\t# If no options were found propose --help\n') + generated_file.write("\t# If no options were found propose --help\n") generated_file.write('\tif [ -z "$opts" ]; then\n') + generated_file.write('\t\tprev="${COMP_WORDS[COMP_CWORD-1]}"\n\n') generated_file.write('\t\tif [[ $prev != "--help" ]]; then\n') - generated_file.write('\t\t\topts=( --help )\n') - generated_file.write('\t\tfi\n') - generated_file.write('\tfi\n') + generated_file.write("\t\t\topts=( --help )\n") + generated_file.write("\t\tfi\n") + generated_file.write("\tfi\n") # generate the completion list from the possible options generated_file.write('\tCOMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )\n') - generated_file.write('\treturn 0\n') - generated_file.write('}\n\n') + generated_file.write("\treturn 0\n") + generated_file.write("}\n\n") # Add the function to bash completion - generated_file.write('complete -F _yunohost yunohost') + generated_file.write("complete -F _yunohost yunohost") diff --git a/data/bash-completion.d/yunohost b/data/bash-completion.d/yunohost deleted file mode 100644 index 2572a391d..000000000 --- a/data/bash-completion.d/yunohost +++ /dev/null @@ -1,3 +0,0 @@ -# This file is automatically generated -# during Debian's package build by the script -# data/actionsmap/yunohost_completion.py diff --git a/data/helpers b/data/helpers index a56a6a57a..04f7b538c 100644 --- a/data/helpers +++ b/data/helpers @@ -1,7 +1,8 @@ # -*- shell-script -*- -# TODO : use --regex to validate against a namespace +readonly XTRACE_ENABLE=$(set +o | grep xtrace) # This is a trick to later only restore set -x if it was set when calling this script +set +x for helper in $(run-parts --list /usr/share/yunohost/helpers.d 2>/dev/null) ; do [ -r $helper ] && . $helper || true done - +eval "$XTRACE_ENABLE" diff --git a/data/helpers.d/apt b/data/helpers.d/apt index b4bf60c1f..c3439a583 100644 --- a/data/helpers.d/apt +++ b/data/helpers.d/apt @@ -5,15 +5,17 @@ # [internal] # # usage: ynh_wait_dpkg_free +# | exit: Return 1 if dpkg is broken # # Requires YunoHost version 3.3.1 or higher. ynh_wait_dpkg_free() { local try + set +o xtrace # set +x # With seq 1 17, timeout will be almost 30 minutes for try in `seq 1 17` do # Check if /var/lib/dpkg/lock is used by another process - if sudo lsof /var/lib/dpkg/lock > /dev/null + if lsof /var/lib/dpkg/lock > /dev/null then echo "apt is already in use..." # Sleep an exponential time at each round @@ -27,38 +29,42 @@ ynh_wait_dpkg_free() { while read dpkg_file <&9 do # Check if the name of this file contains only numbers. - if echo "$dpkg_file" | grep -Pq "^[[:digit:]]+$" + if echo "$dpkg_file" | grep --perl-regexp --quiet "^[[:digit:]]+$" then # If so, that a remaining of dpkg. - ynh_print_err "E: dpkg was interrupted, you must manually run 'sudo dpkg --configure -a' to correct the problem." + ynh_print_err "dpkg was interrupted, you must manually run 'sudo dpkg --configure -a' to correct the problem." + set -o xtrace # set -x return 1 fi done 9<<< "$(ls -1 $dpkg_dir)" + set -o xtrace # set -x return 0 fi done echo "apt still used, but timeout reached !" + set -o xtrace # set -x } # Check either a package is installed or not # -# example: ynh_package_is_installed --package=yunohost && echo "ok" +# example: ynh_package_is_installed --package=yunohost && echo "installed" # # usage: ynh_package_is_installed --package=name -# | arg: -p, --package - the package name to check +# | arg: -p, --package= - the package name to check +# | ret: 0 if the package is installed, 1 else. # # Requires YunoHost version 2.2.4 or higher. ynh_package_is_installed() { # Declare an array to define the options of this helper. local legacy_args=p - declare -Ar args_array=( [p]=package= ) + local -A args_array=( [p]=package= ) local package # Manage arguments with getopts ynh_handle_getopts_args "$@" ynh_wait_dpkg_free - dpkg-query -W -f '${Status}' "$package" 2>/dev/null \ - | grep -c "ok installed" &>/dev/null + dpkg-query --show --showformat='${Status}' "$package" 2>/dev/null \ + | grep --count "ok installed" &>/dev/null } # Get the version of an installed package @@ -66,20 +72,21 @@ ynh_package_is_installed() { # example: version=$(ynh_package_version --package=yunohost) # # usage: ynh_package_version --package=name -# | arg: -p, --package - the package name to get version +# | arg: -p, --package= - the package name to get version # | ret: the version or an empty string # # Requires YunoHost version 2.2.4 or higher. ynh_package_version() { # Declare an array to define the options of this helper. local legacy_args=p - declare -Ar args_array=( [p]=package= ) + local -A args_array=( [p]=package= ) local package # Manage arguments with getopts ynh_handle_getopts_args "$@" - if ynh_package_is_installed "$package"; then - dpkg-query -W -f '${Version}' "$package" 2>/dev/null + if ynh_package_is_installed "$package" + then + dpkg-query --show --showformat='${Version}' "$package" 2>/dev/null else echo '' fi @@ -94,7 +101,7 @@ ynh_package_version() { # Requires YunoHost version 2.4.0.3 or higher. ynh_apt() { ynh_wait_dpkg_free - DEBIAN_FRONTEND=noninteractive apt-get -y $@ + LC_ALL=C DEBIAN_FRONTEND=noninteractive apt-get --assume-yes --quiet -o=Acquire::Retries=3 -o=Dpkg::Use-Pty=0 $@ } # Update package index files @@ -113,8 +120,8 @@ ynh_package_update() { # # Requires YunoHost version 2.2.4 or higher. ynh_package_install() { - ynh_apt --no-remove -o Dpkg::Options::=--force-confdef \ - -o Dpkg::Options::=--force-confold install $@ + ynh_apt --no-remove --option Dpkg::Options::=--force-confdef \ + --option Dpkg::Options::=--force-confold install $@ } # Remove package(s) @@ -163,8 +170,8 @@ ynh_package_install_from_equivs () { local controlfile=$1 # retrieve package information - local pkgname=$(grep '^Package: ' $controlfile | cut -d' ' -f 2) # Retrieve the name of the debian package - local pkgversion=$(grep '^Version: ' $controlfile | cut -d' ' -f 2) # And its version number + local pkgname=$(grep '^Package: ' $controlfile | cut --delimiter=' ' --fields=2) # Retrieve the name of the debian package + local pkgversion=$(grep '^Version: ' $controlfile | cut --delimiter=' ' --fields=2) # And its version number [[ -z "$pkgname" || -z "$pkgversion" ]] \ && ynh_die --message="Invalid control file" # Check if this 2 variables aren't empty. @@ -172,9 +179,9 @@ ynh_package_install_from_equivs () { ynh_package_update # Build and install the package - local TMPDIR=$(mktemp -d) + local TMPDIR=$(mktemp --directory) - # Force the compatibility level at 10, levels below are deprecated + # Force the compatibility level at 10, levels below are deprecated echo 10 > /usr/share/equivs/template/debian/compat # Note that the cd executes into a sub shell @@ -184,10 +191,20 @@ ynh_package_install_from_equivs () { ynh_wait_dpkg_free cp "$controlfile" "${TMPDIR}/control" (cd "$TMPDIR" - equivs-build ./control 1> /dev/null - dpkg --force-depends -i "./${pkgname}_${pkgversion}_all.deb" 2>&1) - ynh_package_install -f || ynh_die --message="Unable to install dependencies" - [[ -n "$TMPDIR" ]] && rm -rf $TMPDIR # Remove the temp dir. + LC_ALL=C equivs-build ./control 1> /dev/null + LC_ALL=C dpkg --force-depends --install "./${pkgname}_${pkgversion}_all.deb" 2>&1 | tee ./dpkg_log) + + ynh_package_install --fix-broken || \ + { # If the installation failed + # (the following is ran inside { } to not start a subshell otherwise ynh_die wouldnt exit the original process) + # Parse the list of problematic dependencies from dpkg's log ... + # (relevant lines look like: "foo-ynh-deps depends on bar; however:") + local problematic_dependencies="$(cat $TMPDIR/dpkg_log | grep -oP '(?<=-ynh-deps depends on ).*(?=; however)' | tr '\n' ' ')" + # Fake an install of those dependencies to see the errors + # The sed command here is, Print only from 'Reading state info' to the end. + [[ -n "$problematic_dependencies" ]] && ynh_package_install $problematic_dependencies --dry-run 2>&1 | sed --quiet '/Reading state info/,$p' | grep -v "fix-broken\|Reading state info" >&2 + ynh_die --message="Unable to install dependencies"; } + [[ -n "$TMPDIR" ]] && rm --recursive --force $TMPDIR # Remove the temp dir. # check if the package is actually installed ynh_package_is_installed "$pkgname" @@ -200,24 +217,57 @@ ynh_package_install_from_equivs () { # example : ynh_install_app_dependencies dep1 dep2 "dep3|dep4|dep5" # # usage: ynh_install_app_dependencies dep [dep [...]] -# | arg: dep - the package name to install in dependence. Writing "dep3|dep4|dep5" can be used to specify alternatives. For example : dep1 dep2 "dep3|dep4|dep5" will require to install dep1 and dep 2 and (dep3 or dep4 or dep5). +# | arg: dep - the package name to install in dependence. +# | arg: "dep1|dep2|…" - You can specify alternatives. It will require to install (dep1 or dep2, etc). # # Requires YunoHost version 2.6.4 or higher. ynh_install_app_dependencies () { local dependencies=$@ - local dependencies=${dependencies// /, } + # Add a comma for each space between packages. But not add a comma if the space separate a version specification. (See below) + dependencies="$(echo "$dependencies" | sed 's/\([^\<=\>]\)\ \([^(]\)/\1, \2/g')" local dependencies=${dependencies//|/ | } - local manifest_path="../manifest.json" - if [ ! -e "$manifest_path" ]; then - manifest_path="../settings/manifest.json" # Into the restore script, the manifest is not at the same place - fi + local manifest_path="$YNH_APP_BASEDIR/manifest.json" - local version=$(grep '\"version\": ' "$manifest_path" | cut -d '"' -f 4) # Retrieve the version number in the manifest file. - if [ ${#version} -eq 0 ]; then + local version=$(jq -r '.version' "$manifest_path") + if [ -z "${version}" ] || [ "$version" == "null" ]; then version="1.0" fi local dep_app=${app//_/-} # Replace all '_' by '-' + # Handle specific versions + if [[ "$dependencies" =~ [\<=\>] ]] + then + # Replace version specifications by relationships syntax + # https://www.debian.org/doc/debian-policy/ch-relationships.html + # Sed clarification + # [^(\<=\>] ignore if it begins by ( or < = >. To not apply twice. + # [\<=\>] matches < = or > + # \+ matches one or more occurence of the previous characters, for >= or >>. + # [^,]\+ matches all characters except ',' + # Ex: 'package>=1.0' will be replaced by 'package (>= 1.0)' + dependencies="$(echo "$dependencies" | sed 's/\([^(\<=\>]\)\([\<=\>]\+\)\([^,]\+\)/\1 (\2 \3)/g')" + fi + + # + # Epic ugly hack to fix the goddamn dependency nightmare of sury + # Sponsored by the "Djeezusse Fokin Kraiste Why Do Adminsys Has To Be So Fucking Complicated I Should Go Grow Potatoes Instead Of This Shit" collective + # https://github.com/YunoHost/issues/issues/1407 + # + # If we require to install php dependency + if echo $dependencies | grep --quiet 'php' + then + # And we have packages from sury installed (7.0.33-10+weirdshiftafter instead of 7.0.33-0 on debian) + if dpkg --list | grep "php7.0" | grep --quiet --invert-match "7.0.33-0+deb9" + then + # And sury ain't already in sources.lists + if ! grep --recursive --quiet "^ *deb.*sury" /etc/apt/sources.list* + then + # Re-add sury + ynh_install_extra_repo --repo="https://packages.sury.org/php/ $(ynh_get_debian_release) main" --key="https://packages.sury.org/php/apt.gpg" --name=extra_php_version --priority=600 + fi + fi + fi + cat > /tmp/${dep_app}-ynh-deps.control << EOF # Make a control file for equivs-build Section: misc Priority: optional @@ -234,6 +284,38 @@ EOF ynh_app_setting_set --app=$app --key=apt_dependencies --value="$dependencies" } +# Add dependencies to install with ynh_install_app_dependencies +# +# usage: ynh_add_app_dependencies --package=phpversion [--replace] +# | arg: -p, --package= - Packages to add as dependencies for the app. +# | arg: -r, --replace - Replace dependencies instead of adding to existing ones. +# +# Requires YunoHost version 3.8.1 or higher. +ynh_add_app_dependencies () { + # Declare an array to define the options of this helper. + local legacy_args=pr + local -A args_array=( [p]=package= [r]=replace) + local package + local replace + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + replace=${replace:-0} + + local current_dependencies="" + if [ $replace -eq 0 ] + then + local dep_app=${app//_/-} # Replace all '_' by '-' + if ynh_package_is_installed --package="${dep_app}-ynh-deps" + then + current_dependencies="$(dpkg-query --show --showformat='${Depends}' ${dep_app}-ynh-deps) " + fi + + current_dependencies=${current_dependencies// | /|} + fi + + ynh_install_app_dependencies "${current_dependencies}${package}" +} + # Remove fake package and its dependencies # # Dependencies will removed only if no other package need them. @@ -245,3 +327,234 @@ ynh_remove_app_dependencies () { local dep_app=${app//_/-} # Replace all '_' by '-' ynh_package_autopurge ${dep_app}-ynh-deps # Remove the fake package and its dependencies if they not still used. } + +# Install packages from an extra repository properly. +# +# usage: ynh_install_extra_app_dependencies --repo="repo" --package="dep1 dep2" [--key=key_url] [--name=name] +# | arg: -r, --repo= - Complete url of the extra repository. +# | arg: -p, --package= - The packages to install from this extra repository +# | arg: -k, --key= - url to get the public key. +# | arg: -n, --name= - Name for the files for this repo, $app as default value. +# +# Requires YunoHost version 3.8.1 or higher. +ynh_install_extra_app_dependencies () { + # Declare an array to define the options of this helper. + local legacy_args=rpkn + local -A args_array=( [r]=repo= [p]=package= [k]=key= [n]=name= ) + local repo + local package + local key + local name + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + name="${name:-$app}" + key=${key:-} + + # Set a key only if asked + if [ -n "$key" ] + then + key="--key=$key" + fi + # Add an extra repository for those packages + ynh_install_extra_repo --repo="$repo" $key --priority=995 --name=$name + + # Install requested dependencies from this extra repository. + ynh_add_app_dependencies --package="$package" + + # Remove this extra repository after packages are installed + ynh_remove_extra_repo --name=$app +} + +# Add an extra repository correctly, pin it and get the key. +# +# [internal] +# +# usage: ynh_install_extra_repo --repo="repo" [--key=key_url] [--priority=priority_value] [--name=name] [--append] +# | arg: -r, --repo= - Complete url of the extra repository. +# | arg: -k, --key= - url to get the public key. +# | arg: -p, --priority= - Priority for the pin +# | arg: -n, --name= - Name for the files for this repo, $app as default value. +# | arg: -a, --append - Do not overwrite existing files. +# +# Requires YunoHost version 3.8.1 or higher. +ynh_install_extra_repo () { + # Declare an array to define the options of this helper. + local legacy_args=rkpna + local -A args_array=( [r]=repo= [k]=key= [p]=priority= [n]=name= [a]=append ) + local repo + local key + local priority + local name + local append + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + name="${name:-$app}" + append=${append:-0} + key=${key:-} + priority=${priority:-} + + if [ $append -eq 1 ] + then + append="--append" + wget_append="tee --append" + else + append="" + wget_append="tee" + fi + + # Split the repository into uri, suite and components. + # Remove "deb " at the beginning of the repo. + repo="${repo#deb }" + + # Get the uri + local uri="$(echo "$repo" | awk '{ print $1 }')" + + # Get the suite + local suite="$(echo "$repo" | awk '{ print $2 }')" + + # Get the components + local component="${repo##$uri $suite }" + + # Add the repository into sources.list.d + ynh_add_repo --uri="$uri" --suite="$suite" --component="$component" --name="$name" $append + + # Pin the new repo with the default priority, so it won't be used for upgrades. + # Build $pin from the uri without http and any sub path + local pin="${uri#*://}" + pin="${pin%%/*}" + # Set a priority only if asked + if [ -n "$priority" ] + then + priority="--priority=$priority" + fi + ynh_pin_repo --package="*" --pin="origin \"$pin\"" $priority --name="$name" $append + + # Get the public key for the repo + if [ -n "$key" ] + then + mkdir --parents "/etc/apt/trusted.gpg.d" + # Timeout option is here to enforce the timeout on dns query and tcp connect (c.f. man wget) + wget --timeout 900 --quiet "$key" --output-document=- | gpg --dearmor | $wget_append /etc/apt/trusted.gpg.d/$name.gpg > /dev/null + fi + + # Update the list of package with the new repo + ynh_package_update +} + +# Remove an extra repository and the assiociated configuration. +# +# [internal] +# +# usage: ynh_remove_extra_repo [--name=name] +# | arg: -n, --name= - Name for the files for this repo, $app as default value. +# +# Requires YunoHost version 3.8.1 or higher. +ynh_remove_extra_repo () { + # Declare an array to define the options of this helper. + local legacy_args=n + local -A args_array=( [n]=name= ) + local name + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + name="${name:-$app}" + + ynh_secure_remove --file="/etc/apt/sources.list.d/$name.list" + # Sury pinning is managed by the regenconf in the core... + [[ "$name" == "extra_php_version" ]] || ynh_secure_remove "/etc/apt/preferences.d/$name" + ynh_secure_remove --file="/etc/apt/trusted.gpg.d/$name.gpg" > /dev/null + ynh_secure_remove --file="/etc/apt/trusted.gpg.d/$name.asc" > /dev/null + + # Update the list of package to exclude the old repo + ynh_package_update +} + +# Add a repository. +# +# [internal] +# +# usage: ynh_add_repo --uri=uri --suite=suite --component=component [--name=name] [--append] +# | arg: -u, --uri= - Uri of the repository. +# | arg: -s, --suite= - Suite of the repository. +# | arg: -c, --component= - Component of the repository. +# | arg: -n, --name= - Name for the files for this repo, $app as default value. +# | arg: -a, --append - Do not overwrite existing files. +# +# Example for a repo like deb http://forge.yunohost.org/debian/ stretch stable +# uri suite component +# ynh_add_repo --uri=http://forge.yunohost.org/debian/ --suite=stretch --component=stable +# +# Requires YunoHost version 3.8.1 or higher. +ynh_add_repo () { + # Declare an array to define the options of this helper. + local legacy_args=uscna + local -A args_array=( [u]=uri= [s]=suite= [c]=component= [n]=name= [a]=append ) + local uri + local suite + local component + local name + local append + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + name="${name:-$app}" + append=${append:-0} + + if [ $append -eq 1 ] + then + append="tee --append" + else + append="tee" + fi + + mkdir --parents "/etc/apt/sources.list.d" + # Add the new repo in sources.list.d + echo "deb $uri $suite $component" \ + | $append "/etc/apt/sources.list.d/$name.list" +} + +# Pin a repository. +# +# [internal] +# +# usage: ynh_pin_repo --package=packages --pin=pin_filter [--priority=priority_value] [--name=name] [--append] +# | arg: -p, --package= - Packages concerned by the pin. Or all, *. +# | arg: -i, --pin= - Filter for the pin. +# | arg: -p, --priority= - Priority for the pin +# | arg: -n, --name= - Name for the files for this repo, $app as default value. +# | arg: -a, --append - Do not overwrite existing files. +# +# See https://manpages.debian.org/stretch/apt/apt_preferences.5.en.html#How_APT_Interprets_Priorities for information about pinning. +# +# Requires YunoHost version 3.8.1 or higher. +ynh_pin_repo () { + # Declare an array to define the options of this helper. + local legacy_args=pirna + local -A args_array=( [p]=package= [i]=pin= [r]=priority= [n]=name= [a]=append ) + local package + local pin + local priority + local name + local append + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + package="${package:-*}" + priority=${priority:-50} + name="${name:-$app}" + append=${append:-0} + + if [ $append -eq 1 ] + then + append="tee --append" + else + append="tee" + fi + + # Sury pinning is managed by the regenconf in the core... + [[ "$name" != "extra_php_version" ]] || return 0 + + mkdir --parents "/etc/apt/preferences.d" + echo "Package: $package +Pin: $pin +Pin-Priority: $priority +" \ + | $append "/etc/apt/preferences.d/$name" +} diff --git a/data/helpers.d/backup b/data/helpers.d/backup index dcf306085..21ca2d7f0 100644 --- a/data/helpers.d/backup +++ b/data/helpers.d/backup @@ -4,22 +4,22 @@ CAN_BIND=${CAN_BIND:-1} # Add a file or a directory to the list of paths to backup # -# This helper can be used both in a system backup hook, and in an app backup script -# -# Details: ynh_backup writes SRC and the relative DEST into a CSV file. And it -# creates the parent destination directory -# -# If DEST is ended by a slash it complete this path with the basename of SRC. -# # usage: ynh_backup --src_path=src_path [--dest_path=dest_path] [--is_big] [--not_mandatory] -# | arg: -s, --src_path - file or directory to bind or symlink or copy. it shouldn't be in the backup dir. -# | arg: -d, --dest_path - destination file or directory inside the backup dir -# | arg: -b, --is_big - Indicate data are big (mail, video, image ...) -# | arg: -m, --not_mandatory - Indicate that if the file is missing, the backup can ignore it. +# | arg: -s, --src_path= - file or directory to bind or symlink or copy. it shouldn't be in the backup dir. +# | arg: -d, --dest_path= - destination file or directory inside the backup dir +# | arg: -b, --is_big - Indicate data are big (mail, video, image ...) +# | arg: -m, --not_mandatory - Indicate that if the file is missing, the backup can ignore it. # | arg: arg - Deprecated arg # -# Example in the context of a wordpress app +# This helper can be used both in a system backup hook, and in an app backup script # +# `ynh_backup` writes `src_path` and the relative `dest_path` into a CSV file, and it +# creates the parent destination directory +# +# If `dest_path` is ended by a slash it complete this path with the basename of `src_path`. +# +# Example in the context of a wordpress app : +# ``` # ynh_backup "/etc/nginx/conf.d/$domain.d/$app.conf" # # => This line will be added into CSV file # # "/etc/nginx/conf.d/$domain.d/$app.conf","apps/wordpress/etc/nginx/conf.d/$domain.d/$app.conf" @@ -40,34 +40,56 @@ CAN_BIND=${CAN_BIND:-1} # ynh_backup "/etc/nginx/conf.d/$domain.d/$app.conf" "/conf/" # # => "/etc/nginx/conf.d/$domain.d/$app.conf","apps/wordpress/conf/$app.conf" # +# ``` +# +# How to use `--is_big`: +# +# `--is_big` is used to specify that this part of the backup can be quite huge. +# So, you don't want that your package does backup that part during ynh_backup_before_upgrade. +# In the same way, an user may doesn't want to backup this big part of the app for +# each of his backup. And so handle that part differently. +# +# As this part of your backup may not be done, your restore script has to handle it. +# In your restore script, use `--not_mandatory` with `ynh_restore_file` +# As well in your remove script, you should not remove those data ! Or an user may end up with +# a failed upgrade restoring an app without data anymore ! +# +# To have the benefit of `--is_big` while doing a backup, you can whether set the environement +# variable `BACKUP_CORE_ONLY` to 1 (`BACKUP_CORE_ONLY=1`) before the backup command. It will affect +# only that backup command. +# Or set the config `do_not_backup_data` to 1 into the `settings.yml` of the app. This will affect +# all backups for this app until the setting is removed. +# # Requires YunoHost version 2.4.0 or higher. +# Requires YunoHost version 3.5.0 or higher for the argument `--not_mandatory` ynh_backup() { # TODO find a way to avoid injection by file strange naming ! # Declare an array to define the options of this helper. local legacy_args=sdbm - declare -Ar args_array=( [s]=src_path= [d]=dest_path= [b]=is_big [m]=not_mandatory ) + local -A args_array=( [s]=src_path= [d]=dest_path= [b]=is_big [m]=not_mandatory ) local src_path local dest_path local is_big local not_mandatory # Manage arguments with getopts ynh_handle_getopts_args "$@" - local dest_path="${dest_path:-}" - local is_big="${is_big:-0}" - local not_mandatory="${not_mandatory:-0}" + dest_path="${dest_path:-}" + is_big="${is_big:-0}" + not_mandatory="${not_mandatory:-0}" BACKUP_CORE_ONLY=${BACKUP_CORE_ONLY:-0} test -n "${app:-}" && do_not_backup_data=$(ynh_app_setting_get --app=$app --key=do_not_backup_data) # If backing up core only (used by ynh_backup_before_upgrade), # don't backup big data items - if [ $is_big -eq 1 ] && ( [ ${do_not_backup_data:-0} -eq 1 ] || [ $BACKUP_CORE_ONLY -eq 1 ] ) + if [ $is_big -eq 1 ] && ( [ ${do_not_backup_data:-0} -eq 1 ] || [ $BACKUP_CORE_ONLY -eq 1 ] ) then - if [ $BACKUP_CORE_ONLY -eq 1 ]; then - ynh_print_warn --message="$src_path will not be saved, because 'BACKUP_CORE_ONLY' is set." + if [ $BACKUP_CORE_ONLY -eq 1 ] + then + ynh_print_info --message="$src_path will not be saved, because 'BACKUP_CORE_ONLY' is set." else - ynh_print_warn --message="$src_path will not be saved, because 'do_not_backup_data' is set." + ynh_print_info --message="$src_path will not be saved, because 'do_not_backup_data' is set." fi return 0 fi @@ -76,22 +98,23 @@ ynh_backup() { # Format correctly source and destination paths # ============================================================================== # Be sure the source path is not empty - [[ -e "${src_path}" ]] || { + if [ ! -e "$src_path" ] + then ynh_print_warn --message="Source path '${src_path}' does not exist" if [ "$not_mandatory" == "0" ] then - # This is a temporary fix for fail2ban config files missing after the migration to stretch. - if echo "${src_path}" | grep --quiet "/etc/fail2ban" - then - touch "${src_path}" - ynh_print_info --message="The missing file will be replaced by a dummy one for the backup !!!" - else - return 1 - fi + # This is a temporary fix for fail2ban config files missing after the migration to stretch. + if echo "${src_path}" | grep --quiet "/etc/fail2ban" + then + touch "${src_path}" + ynh_print_info --message="The missing file will be replaced by a dummy one for the backup !!!" + else + return 1 + fi else - return 0 + return 0 fi - } + fi # Transform the source path as an absolute path # If it's a dir remove the ending / @@ -100,12 +123,13 @@ ynh_backup() { # If there is no destination path, initialize it with the source path # relative to "/". # eg: src_path=/etc/yunohost -> dest_path=etc/yunohost - if [[ -z "$dest_path" ]]; then - + if [[ -z "$dest_path" ]] + then dest_path="${src_path#/}" else - if [[ "${dest_path:0:1}" == "/" ]]; then + if [[ "${dest_path:0:1}" == "/" ]] + then # If the destination path is an absolute path, transform it as a path # relative to the current working directory ($YNH_CWD) @@ -117,20 +141,23 @@ ynh_backup() { dest_path="${dest_path#$YNH_CWD/}" # Case where $2 is an absolute dir but doesn't begin with $YNH_CWD - [[ "${dest_path:0:1}" == "/" ]] \ - && dest_path="${dest_path#/}" + if [[ "${dest_path:0:1}" == "/" ]]; then + dest_path="${dest_path#/}" + fi fi # Complete dest_path if ended by a / - [[ "${dest_path: -1}" == "/" ]] \ - && dest_path="${dest_path}/$(basename $src_path)" + if [[ "${dest_path: -1}" == "/" ]]; then + dest_path="${dest_path}/$(basename $src_path)" + fi fi # Check if dest_path already exists in tmp archive - [[ ! -e "${dest_path}" ]] || { + if [[ -e "${dest_path}" ]] + then ynh_print_err --message="Destination path '${dest_path}' already exist" return 1 - } + fi # Add the relative current working directory to the destination path local rel_dir="${YNH_CWD#$YNH_BACKUP_DIR}" @@ -142,15 +169,15 @@ ynh_backup() { # ============================================================================== # Write file to backup into backup_list # ============================================================================== - local src=$(echo "${src_path}" | sed -r 's/"/\"\"/g') - local dest=$(echo "${dest_path}" | sed -r 's/"/\"\"/g') + local src=$(echo "${src_path}" | sed --regexp-extended 's/"/\"\"/g') + local dest=$(echo "${dest_path}" | sed --regexp-extended 's/"/\"\"/g') echo "\"${src}\",\"${dest}\"" >> "${YNH_BACKUP_CSV}" # ============================================================================== # Create the parent dir of the destination path # It's for retro compatibility, some script consider ynh_backup creates this dir - mkdir -p $(dirname "$YNH_BACKUP_DIR/${dest_path}") + mkdir --parents $(dirname "$YNH_BACKUP_DIR/${dest_path}") } # Restore all files that were previously backuped in a core backup script or app backup script @@ -164,10 +191,11 @@ ynh_restore () { REL_DIR="${REL_DIR%/}/" # For each destination path begining by $REL_DIR - cat ${YNH_BACKUP_CSV} | tr -d $'\r' | grep -ohP "^\".*\",\"$REL_DIR.*\"$" | \ - while read line; do - local ORIGIN_PATH=$(echo "$line" | grep -ohP "^\"\K.*(?=\",\".*\"$)") - local ARCHIVE_PATH=$(echo "$line" | grep -ohP "^\".*\",\"$REL_DIR\K.*(?=\"$)") + cat ${YNH_BACKUP_CSV} | tr --delete $'\r' | grep --only-matching --no-filename --perl-regexp "^\".*\",\"$REL_DIR.*\"$" | \ + while read line + do + local ORIGIN_PATH=$(echo "$line" | grep --only-matching --no-filename --perl-regexp "^\"\K.*(?=\",\".*\"$)") + local ARCHIVE_PATH=$(echo "$line" | grep --only-matching --no-filename --perl-regexp "^\".*\",\"$REL_DIR\K.*(?=\"$)") ynh_restore_file --origin_path="$ARCHIVE_PATH" --dest_path="$ORIGIN_PATH" done } @@ -179,14 +207,14 @@ ynh_restore () { # usage: _get_archive_path ORIGIN_PATH _get_archive_path () { # For security reasons we use csv python library to read the CSV - sudo python -c " + python3 -c " import sys import csv with open(sys.argv[1], 'r') as backup_file: backup_csv = csv.DictReader(backup_file, fieldnames=['source', 'dest']) for row in backup_csv: if row['source']==sys.argv[2].strip('\"'): - print row['dest'] + print(row['dest']) sys.exit(0) raise Exception('Original path for %s not found' % sys.argv[2]) " "${YNH_BACKUP_CSV}" "$1" @@ -195,46 +223,46 @@ with open(sys.argv[1], 'r') as backup_file: # Restore a file or a directory # -# Use the registered path in backup_list by ynh_backup to restore the file at -# the right place. -# # usage: ynh_restore_file --origin_path=origin_path [--dest_path=dest_path] [--not_mandatory] -# | arg: -o, --origin_path - Path where was located the file or the directory before to be backuped or relative path to $YNH_CWD where it is located in the backup archive -# | arg: -d, --dest_path - Path where restore the file or the dir, if unspecified, the destination will be ORIGIN_PATH or if the ORIGIN_PATH doesn't exist in the archive, the destination will be searched into backup.csv -# | arg: -m, --not_mandatory - Indicate that if the file is missing, the restore process can ignore it. +# | arg: -o, --origin_path= - Path where was located the file or the directory before to be backuped or relative path to $YNH_CWD where it is located in the backup archive +# | arg: -d, --dest_path= - Path where restore the file or the dir. If unspecified, the destination will be `ORIGIN_PATH` or if the `ORIGIN_PATH` doesn't exist in the archive, the destination will be searched into `backup.csv` +# | arg: -m, --not_mandatory - Indicate that if the file is missing, the restore process can ignore it. +# +# Use the registered path in backup_list by ynh_backup to restore the file at the right place. # # examples: -# ynh_restore_file "/etc/nginx/conf.d/$domain.d/$app.conf" +# ynh_restore_file -o "/etc/nginx/conf.d/$domain.d/$app.conf" # # You can also use relative paths: -# ynh_restore_file "conf/nginx.conf" +# ynh_restore_file -o "conf/nginx.conf" # -# If DEST_PATH already exists and is lighter than 500 Mo, a backup will be made in -# /home/yunohost.conf/backup/. Otherwise, the existing file is removed. +# If `DEST_PATH` already exists and is lighter than 500 Mo, a backup will be made in +# `/home/yunohost.conf/backup/`. Otherwise, the existing file is removed. # -# if apps/wordpress/etc/nginx/conf.d/$domain.d/$app.conf exists, restore it into -# /etc/nginx/conf.d/$domain.d/$app.conf +# if `apps/$app/etc/nginx/conf.d/$domain.d/$app.conf` exists, restore it into +# `/etc/nginx/conf.d/$domain.d/$app.conf` # if no, search for a match in the csv (eg: conf/nginx.conf) and restore it into -# /etc/nginx/conf.d/$domain.d/$app.conf +# `/etc/nginx/conf.d/$domain.d/$app.conf` # # Requires YunoHost version 2.6.4 or higher. +# Requires YunoHost version 3.5.0 or higher for the argument --not_mandatory ynh_restore_file () { # Declare an array to define the options of this helper. local legacy_args=odm - declare -Ar args_array=( [o]=origin_path= [d]=dest_path= [m]=not_mandatory ) + local -A args_array=( [o]=origin_path= [d]=dest_path= [m]=not_mandatory ) local origin_path - local archive_path local dest_path local not_mandatory # Manage arguments with getopts ynh_handle_getopts_args "$@" - local origin_path="/${origin_path#/}" - local archive_path="$YNH_CWD${origin_path}" + origin_path="/${origin_path#/}" # Default value for dest_path = /$origin_path - local dest_path="${dest_path:-$origin_path}" - local not_mandatory="${not_mandatory:-0}" + dest_path="${dest_path:-$origin_path}" + not_mandatory="${not_mandatory:-0}" + local archive_path="$YNH_CWD${origin_path}" # If archive_path doesn't exist, search for a corresponding path in CSV - if [ ! -d "$archive_path" ] && [ ! -f "$archive_path" ] && [ ! -L "$archive_path" ]; then + if [ ! -d "$archive_path" ] && [ ! -f "$archive_path" ] && [ ! -L "$archive_path" ] + then if [ "$not_mandatory" == "0" ] then archive_path="$YNH_BACKUP_DIR/$(_get_archive_path \"$origin_path\")" @@ -247,10 +275,10 @@ ynh_restore_file () { if [[ -e "${dest_path}" ]] then # Check if the file/dir size is less than 500 Mo - if [[ $(du -sb ${dest_path} | cut -d"/" -f1) -le "500000000" ]] + if [[ $(du --summarize --bytes ${dest_path} | cut --delimiter="/" --fields=1) -le "500000000" ]] then local backup_file="/home/yunohost.conf/backup/${dest_path}.backup.$(date '+%Y%m%d.%H%M%S')" - mkdir -p "$(dirname "$backup_file")" + mkdir --parents "$(dirname "$backup_file")" mv "${dest_path}" "$backup_file" # Move the current file or directory else ynh_secure_remove --file=${dest_path} @@ -258,15 +286,17 @@ ynh_restore_file () { fi # Restore origin_path into dest_path - mkdir -p $(dirname "$dest_path") + mkdir --parents $(dirname "$dest_path") # Do a copy if it's just a mounting point - if mountpoint -q $YNH_BACKUP_DIR; then - if [[ -d "${archive_path}" ]]; then + if mountpoint --quiet $YNH_BACKUP_DIR + then + if [[ -d "${archive_path}" ]] + then archive_path="${archive_path}/." - mkdir -p "$dest_path" + mkdir --parents "$dest_path" fi - cp -a "$archive_path" "${dest_path}" + cp --archive "$archive_path" "${dest_path}" # Do a move if YNH_BACKUP_DIR is already a copy else mv "$archive_path" "${dest_path}" @@ -287,22 +317,35 @@ ynh_bind_or_cp() { # Calculate and store a file checksum into the app settings # -# $app should be defined when calling this helper -# # usage: ynh_store_file_checksum --file=file -# | arg: -f, --file - The file on which the checksum will performed, then stored. +# | arg: -f, --file= - The file on which the checksum will performed, then stored. +# +# $app should be defined when calling this helper # # Requires YunoHost version 2.6.4 or higher. ynh_store_file_checksum () { # Declare an array to define the options of this helper. local legacy_args=f - declare -Ar args_array=( [f]=file= ) + local -A args_array=( [f]=file= [u]=update_only ) local file + local update_only + update_only="${update_only:-0}" + # Manage arguments with getopts ynh_handle_getopts_args "$@" local checksum_setting_name=checksum_${file//[\/ ]/_} # Replace all '/' and ' ' by '_' - ynh_app_setting_set --app=$app --key=$checksum_setting_name --value=$(sudo md5sum "$file" | cut -d' ' -f1) + + # If update only, we don't save the new checksum if no old checksum exist + if [ $update_only -eq 1 ] ; then + local checksum_value=$(ynh_app_setting_get --app=$app --key=$checksum_setting_name) + if [ -z "${checksum_value}" ] ; then + unset backup_file_checksum + return 0 + fi + fi + + ynh_app_setting_set --app=$app --key=$checksum_setting_name --value=$(md5sum "$file" | cut --delimiter=' ' --fields=1) # If backup_file_checksum isn't empty, ynh_backup_if_checksum_is_different has made a backup if [ -n "${backup_file_checksum-}" ] @@ -316,19 +359,19 @@ ynh_store_file_checksum () { } # Verify the checksum and backup the file if it's different -# -# This helper is primarily meant to allow to easily backup personalised/manually -# modified config files. # # usage: ynh_backup_if_checksum_is_different --file=file -# | arg: -f, --file - The file on which the checksum test will be perfomed. +# | arg: -f, --file= - The file on which the checksum test will be perfomed. # | ret: the name of a backup file, or nothing # +# This helper is primarily meant to allow to easily backup personalised/manually +# modified config files. +# # Requires YunoHost version 2.6.4 or higher. ynh_backup_if_checksum_is_different () { # Declare an array to define the options of this helper. local legacy_args=f - declare -Ar args_array=( [f]=file= ) + local -A args_array=( [f]=file= ) local file # Manage arguments with getopts ynh_handle_getopts_args "$@" @@ -339,11 +382,11 @@ ynh_backup_if_checksum_is_different () { backup_file_checksum="" if [ -n "$checksum_value" ] then # Proceed only if a value was stored into the app settings - if ! echo "$checksum_value $file" | sudo md5sum -c --status + if [ -e $file ] && ! echo "$checksum_value $file" | md5sum --check --status then # If the checksum is now different backup_file_checksum="/home/yunohost.conf/backup/$file.backup.$(date '+%Y%m%d.%H%M%S')" - sudo mkdir -p "$(dirname "$backup_file_checksum")" - sudo cp -a "$file" "$backup_file_checksum" # Backup the current file + mkdir --parents "$(dirname "$backup_file_checksum")" + cp --archive "$file" "$backup_file_checksum" # Backup the current file ynh_print_warn "File $file has been manually modified since the installation or last upgrade. So it has been duplicated in $backup_file_checksum" echo "$backup_file_checksum" # Return the name of the backup file fi @@ -352,16 +395,16 @@ ynh_backup_if_checksum_is_different () { # Delete a file checksum from the app settings # -# $app should be defined when calling this helper +# usage: ynh_delete_file_checksum --file=file +# | arg: -f, --file= - The file for which the checksum will be deleted # -# usage: ynh_remove_file_checksum file -# | arg: -f, --file= - The file for which the checksum will be deleted +# $app should be defined when calling this helper # # Requires YunoHost version 3.3.1 or higher. ynh_delete_file_checksum () { # Declare an array to define the options of this helper. local legacy_args=f - declare -Ar args_array=( [f]=file= ) + local -A args_array=( [f]=file= ) local file # Manage arguments with getopts ynh_handle_getopts_args "$@" @@ -370,14 +413,27 @@ ynh_delete_file_checksum () { ynh_app_setting_delete --app=$app --key=$checksum_setting_name } +# Checks a backup archive exists +# +# [internal] +# +ynh_backup_archive_exists () { + yunohost backup list --output-as json --quiet \ + | jq -e --arg archive "$1" '.archives | index($archive)' >/dev/null +} + # Make a backup in case of failed upgrade # -# usage: -# ynh_backup_before_upgrade -# ynh_clean_setup () { -# ynh_restore_upgradebackup -# } -# ynh_abort_if_errors +# usage: ynh_backup_before_upgrade +# +# Usage in a package script: +# ``` +# ynh_backup_before_upgrade +# ynh_clean_setup () { +# ynh_restore_upgradebackup +# } +# ynh_abort_if_errors +# ``` # # Requires YunoHost version 2.7.2 or higher. ynh_backup_before_upgrade () { @@ -394,7 +450,7 @@ ynh_backup_before_upgrade () { if [ "$NO_BACKUP_UPGRADE" -eq 0 ] then # Check if a backup already exists with the prefix 1 - if sudo yunohost backup list | grep -q $app_bck-pre-upgrade1 + if ynh_backup_archive_exists "$app_bck-pre-upgrade1" then # Prefix becomes 2 to preserve the previous backup backup_number=2 @@ -402,14 +458,14 @@ ynh_backup_before_upgrade () { fi # Create backup - sudo BACKUP_CORE_ONLY=1 yunohost backup create --apps $app --name $app_bck-pre-upgrade$backup_number --debug + BACKUP_CORE_ONLY=1 yunohost backup create --apps $app --name $app_bck-pre-upgrade$backup_number --debug if [ "$?" -eq 0 ] then # If the backup succeeded, remove the previous backup - if sudo yunohost backup list | grep -q $app_bck-pre-upgrade$old_backup_number + if ynh_backup_archive_exists "$app_bck-pre-upgrade$old_backup_number" then # Remove the previous backup only if it exists - sudo yunohost backup delete $app_bck-pre-upgrade$old_backup_number > /dev/null + yunohost backup delete $app_bck-pre-upgrade$old_backup_number > /dev/null fi else ynh_die --message="Backup failed, the upgrade process was aborted." @@ -421,12 +477,16 @@ ynh_backup_before_upgrade () { # Restore a previous backup if the upgrade process failed # -# usage: -# ynh_backup_before_upgrade -# ynh_clean_setup () { -# ynh_restore_upgradebackup -# } -# ynh_abort_if_errors +# usage: ynh_restore_upgradebackup +# +# Usage in a package script: +# ``` +# ynh_backup_before_upgrade +# ynh_clean_setup () { +# ynh_restore_upgradebackup +# } +# ynh_abort_if_errors +# ``` # # Requires YunoHost version 2.7.2 or higher. ynh_restore_upgradebackup () { @@ -438,12 +498,12 @@ ynh_restore_upgradebackup () { 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 + if ynh_backup_archive_exists "$app_bck-pre-upgrade$backup_number" then # Remove the application then restore it - sudo yunohost app remove $app + yunohost app remove $app # Restore the backup - sudo yunohost backup restore $app_bck-pre-upgrade$backup_number --apps $app --force --debug + yunohost backup restore $app_bck-pre-upgrade$backup_number --apps $app --force --debug ynh_die --message="The app was restored to the way it was before the failed upgrade." fi else diff --git a/data/helpers.d/config b/data/helpers.d/config new file mode 100644 index 000000000..d12316996 --- /dev/null +++ b/data/helpers.d/config @@ -0,0 +1,357 @@ +#!/bin/bash + + +_ynh_app_config_get_one() { + local short_setting="$1" + local type="$2" + local bind="$3" + local getter="get__${short_setting}" + # Get value from getter if exists + if type -t $getter 2>/dev/null | grep -q '^function$' 2>/dev/null; + then + old[$short_setting]="$($getter)" + formats[${short_setting}]="yaml" + + elif [[ "$bind" == *"("* ]] && type -t "get__${bind%%(*}" 2>/dev/null | grep -q '^function$' 2>/dev/null; + then + old[$short_setting]="$("get__${bind%%(*}" $short_setting $type $bind)" + formats[${short_setting}]="yaml" + + elif [[ "$bind" == "null" ]] + then + old[$short_setting]="YNH_NULL" + + # Get value from app settings or from another file + elif [[ "$type" == "file" ]] + then + if [[ "$bind" == "settings" ]] + then + ynh_die --message="File '${short_setting}' can't be stored in settings" + fi + old[$short_setting]="$(ls "$(echo $bind | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" 2> /dev/null || echo YNH_NULL)" + file_hash[$short_setting]="true" + + # Get multiline text from settings or from a full file + elif [[ "$type" == "text" ]] + then + if [[ "$bind" == "settings" ]] + then + old[$short_setting]="$(ynh_app_setting_get $app $short_setting)" + elif [[ "$bind" == *":"* ]] + then + ynh_die --message="For technical reasons, multiline text '${short_setting}' can't be stored automatically in a variable file, you have to create custom getter/setter" + else + old[$short_setting]="$(cat $(echo $bind | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/) 2> /dev/null || echo YNH_NULL)" + fi + + # Get value from a kind of key/value file + else + local bind_after="" + if [[ "$bind" == "settings" ]] + then + bind=":/etc/yunohost/apps/$app/settings.yml" + fi + local bind_key="$(echo "$bind" | cut -d: -f1)" + bind_key=${bind_key:-$short_setting} + if [[ "$bind_key" == *">"* ]]; + then + bind_after="$(echo "${bind_key}" | cut -d'>' -f1)" + bind_key="$(echo "${bind_key}" | cut -d'>' -f2)" + fi + local bind_file="$(echo "$bind" | cut -d: -f2 | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" + old[$short_setting]="$(ynh_read_var_in_file --file="${bind_file}" --key="${bind_key}" --after="${bind_after}")" + + fi +} +_ynh_app_config_apply_one() { + local short_setting="$1" + local setter="set__${short_setting}" + local bind="${binds[$short_setting]}" + local type="${types[$short_setting]}" + if [ "${changed[$short_setting]}" == "true" ] + then + # Apply setter if exists + if type -t $setter 2>/dev/null | grep -q '^function$' 2>/dev/null; + then + $setter + + elif [[ "$bind" == *"("* ]] && type -t "set__${bind%%(*}" 2>/dev/null | grep -q '^function$' 2>/dev/null; + then + "set__${bind%%(*}" $short_setting $type $bind + + elif [[ "$bind" == "null" ]] + then + continue + + # Save in a file + elif [[ "$type" == "file" ]] + then + if [[ "$bind" == "settings" ]] + then + ynh_die --message="File '${short_setting}' can't be stored in settings" + fi + local bind_file="$(echo "$bind" | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" + if [[ "${!short_setting}" == "" ]] + then + ynh_backup_if_checksum_is_different --file="$bind_file" + ynh_secure_remove --file="$bind_file" + ynh_delete_file_checksum --file="$bind_file" --update_only + ynh_print_info --message="File '$bind_file' removed" + else + ynh_backup_if_checksum_is_different --file="$bind_file" + if [[ "${!short_setting}" != "$bind_file" ]] + then + cp "${!short_setting}" "$bind_file" + fi + ynh_store_file_checksum --file="$bind_file" --update_only + ynh_print_info --message="File '$bind_file' overwrited with ${!short_setting}" + fi + + # Save value in app settings + elif [[ "$bind" == "settings" ]] + then + ynh_app_setting_set --app=$app --key=$short_setting --value="${!short_setting}" + ynh_print_info --message="Configuration key '$short_setting' edited in app settings" + + # Save multiline text in a file + elif [[ "$type" == "text" ]] + then + if [[ "$bind" == *":"* ]] + then + ynh_die --message="For technical reasons, multiline text '${short_setting}' can't be stored automatically in a variable file, you have to create custom getter/setter" + fi + local bind_file="$(echo "$bind" | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" + ynh_backup_if_checksum_is_different --file="$bind_file" + echo "${!short_setting}" > "$bind_file" + ynh_store_file_checksum --file="$bind_file" --update_only + ynh_print_info --message="File '$bind_file' overwrited with the content you provieded in '${short_setting}' question" + + # Set value into a kind of key/value file + else + local bind_after="" + local bind_key="$(echo "$bind" | cut -d: -f1)" + bind_key=${bind_key:-$short_setting} + if [[ "$bind_key" == *">"* ]]; + then + bind_after="$(echo "${bind_key}" | cut -d'>' -f1)" + bind_key="$(echo "${bind_key}" | cut -d'>' -f2)" + fi + local bind_file="$(echo "$bind" | cut -d: -f2 | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" + + ynh_backup_if_checksum_is_different --file="$bind_file" + ynh_write_var_in_file --file="${bind_file}" --key="${bind_key}" --value="${!short_setting}" --after="${bind_after}" + ynh_store_file_checksum --file="$bind_file" --update_only + + # We stored the info in settings in order to be able to upgrade the app + ynh_app_setting_set --app=$app --key=$short_setting --value="${!short_setting}" + ynh_print_info --message="Configuration key '$bind_key' edited into $bind_file" + + fi + fi +} +_ynh_app_config_get() { + # From settings + local lines + lines=$(python3 << EOL +import toml +from collections import OrderedDict +with open("../config_panel.toml", "r") as f: + file_content = f.read() +loaded_toml = toml.loads(file_content, _dict=OrderedDict) + +for panel_name, panel in loaded_toml.items(): + if not isinstance(panel, dict): continue + for section_name, section in panel.items(): + if not isinstance(section, dict): continue + for name, param in section.items(): + if not isinstance(param, dict): + continue + print(';'.join([ + name, + param.get('type', 'string'), + param.get('bind', 'settings' if param.get('type', 'string') != 'file' else 'null') + ])) +EOL +) + for line in $lines + do + # Split line into short_setting, type and bind + IFS=';' read short_setting type bind <<< "$line" + binds[${short_setting}]="$bind" + types[${short_setting}]="$type" + file_hash[${short_setting}]="" + formats[${short_setting}]="" + ynh_app_config_get_one $short_setting $type $bind + done + + +} + +_ynh_app_config_apply() { + for short_setting in "${!old[@]}" + do + ynh_app_config_apply_one $short_setting + done +} + +_ynh_app_config_show() { + for short_setting in "${!old[@]}" + do + if [[ "${old[$short_setting]}" != YNH_NULL ]] + then + if [[ "${formats[$short_setting]}" == "yaml" ]] + then + ynh_return "${short_setting}:" + ynh_return "$(echo "${old[$short_setting]}" | sed 's/^/ /g')" + else + ynh_return "${short_setting}: "'"'"$(echo "${old[$short_setting]}" | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\n\n/g')"'"' + + fi + fi + done +} + +_ynh_app_config_validate() { + # Change detection + ynh_script_progression --message="Checking what changed in the new configuration..." --weight=1 + local nothing_changed=true + local changes_validated=true + for short_setting in "${!old[@]}" + do + changed[$short_setting]=false + if [ -z ${!short_setting+x} ] + then + # Assign the var with the old value in order to allows multiple + # args validation + declare "$short_setting"="${old[$short_setting]}" + continue + fi + if [ ! -z "${file_hash[${short_setting}]}" ] + then + file_hash[old__$short_setting]="" + file_hash[new__$short_setting]="" + if [ -f "${old[$short_setting]}" ] + then + file_hash[old__$short_setting]=$(sha256sum "${old[$short_setting]}" | cut -d' ' -f1) + if [ -z "${!short_setting}" ] + then + changed[$short_setting]=true + nothing_changed=false + fi + fi + if [ -f "${!short_setting}" ] + then + file_hash[new__$short_setting]=$(sha256sum "${!short_setting}" | cut -d' ' -f1) + if [[ "${file_hash[old__$short_setting]}" != "${file_hash[new__$short_setting]}" ]] + then + changed[$short_setting]=true + nothing_changed=false + fi + fi + else + if [[ "${!short_setting}" != "${old[$short_setting]}" ]] + then + changed[$short_setting]=true + nothing_changed=false + fi + fi + done + if [[ "$nothing_changed" == "true" ]] + then + ynh_print_info --message="Nothing has changed" + exit 0 + fi + + # Run validation if something is changed + ynh_script_progression --message="Validating the new configuration..." --weight=1 + + for short_setting in "${!old[@]}" + do + [[ "${changed[$short_setting]}" == "false" ]] && continue + local result="" + if type -t validate__$short_setting | grep -q '^function$' 2>/dev/null; + then + result="$(validate__$short_setting)" + elif [[ "$bind" == *"("* ]] && type -t "validate__${bind%%(*}" 2>/dev/null | grep -q '^function$' 2>/dev/null; + then + "validate__${bind%%(*}" $short_setting + fi + if [ -n "$result" ] + then + # + # Return a yaml such as: + # + # validation_errors: + # some_key: "An error message" + # some_other_key: "Another error message" + # + # We use changes_validated to know if this is + # the first validation error + if [[ "$changes_validated" == true ]] + then + ynh_return "validation_errors:" + fi + ynh_return " ${short_setting}: \"$result\"" + changes_validated=false + fi + done + + # If validation failed, exit the script right now (instead of going into apply) + # Yunohost core will pick up the errors returned via ynh_return previously + if [[ "$changes_validated" == "false" ]] + then + exit 0 + fi + +} + +ynh_app_config_get_one() { + _ynh_app_config_get_one $1 $2 $3 +} + +ynh_app_config_get() { + _ynh_app_config_get +} + +ynh_app_config_show() { + _ynh_app_config_show +} + +ynh_app_config_validate() { + _ynh_app_config_validate +} + +ynh_app_config_apply_one() { + _ynh_app_config_apply_one $1 +} +ynh_app_config_apply() { + _ynh_app_config_apply +} + +ynh_app_config_run() { + declare -Ag old=() + declare -Ag changed=() + declare -Ag file_hash=() + declare -Ag binds=() + declare -Ag types=() + declare -Ag formats=() + + case $1 in + show) + ynh_app_config_get + ynh_app_config_show + ;; + apply) + max_progression=4 + ynh_script_progression --message="Reading config panel description and current configuration..." + ynh_app_config_get + + ynh_app_config_validate + + ynh_script_progression --message="Applying the new configuration..." + ynh_app_config_apply + ynh_script_progression --message="Configuration of $app completed" --last + ;; + esac +} + diff --git a/data/helpers.d/fail2ban b/data/helpers.d/fail2ban index 58af9ec0b..26c899d93 100644 --- a/data/helpers.d/fail2ban +++ b/data/helpers.d/fail2ban @@ -12,18 +12,14 @@ # # usage 2: ynh_add_fail2ban_config --use_template [--others_var="list of others variables to replace"] # | arg: -t, --use_template - Use this helper in template mode -# | arg: -v, --others_var= - List of others variables to replace separeted by a space -# | for example : 'var_1 var_2 ...' +# | arg: -v, --others_var= - List of others variables to replace separeted by a space for example : 'var_1 var_2 ...' # -# This will use a template in ../conf/f2b_jail.conf and ../conf/f2b_filter.conf -# __APP__ by $app -# -# You can dynamically replace others variables by example : -# __VAR_1__ by $var_1 -# __VAR_2__ by $var_2 +# This will use a template in `../conf/f2b_jail.conf` and `../conf/f2b_filter.conf` +# See the documentation of `ynh_add_config` for a description of the template +# format and how placeholders are replaced with actual variables. # # Generally your template will look like that by example (for synapse): -# +# ``` # f2b_jail.conf: # [__APP__] # enabled = true @@ -31,7 +27,8 @@ # filter = __APP__ # logpath = /var/log/__APP__/logfile.log # maxretry = 3 -# +# ``` +# ``` # f2b_filter.conf: # [INCLUDES] # before = common.conf @@ -44,99 +41,81 @@ # failregex = ^%(__synapse_start_line)s INFO \- POST\-(\d+)\- \- \d+ \- Received request\: POST /_matrix/client/r0/login\??%(__synapse_start_line)s INFO \- POST\-\1\- Got login request with identifier: \{u'type': u'm.id.user', u'user'\: u'(.+?)'\}, medium\: None, address: None, user\: u'\5'%(__synapse_start_line)s WARNING \- \- (Attempted to login as @\5\:.+ but they do not exist|Failed password login for user @\5\:.+)$ # # ignoreregex = +# ``` # # ----------------------------------------------------------------------------- # # Note about the "failregex" option: -# regex to match the password failure messages in the logfile. The -# host must be matched by a group named "host". The tag "" can -# be used for standard IP/hostname matching and is only an alias for -# (?:::f{4,6}:)?(?P[\w\-.^_]+) # -# You can find some more explainations about how to make a regex here : -# https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Filters +# regex to match the password failure messages in the logfile. The host must be +# matched by a group named "`host`". The tag "``" can be used for standard +# IP/hostname matching and is only an alias for `(?:::f{4,6}:)?(?P[\w\-.^_]+)` +# +# You can find some more explainations about how to make a regex here : +# https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Filters # # Note that the logfile need to exist before to call this helper !! # # To validate your regex you can test with this command: +# ``` # fail2ban-regex /var/log/YOUR_LOG_FILE_PATH /etc/fail2ban/filter.d/YOUR_APP.conf +# ``` # -# Requires YunoHost version 3.5.0 or higher. +# Requires YunoHost version 4.1.0 or higher. ynh_add_fail2ban_config () { - # Declare an array to define the options of this helper. - local legacy_args=lrmptv - declare -Ar args_array=( [l]=logpath= [r]=failregex= [m]=max_retry= [p]=ports= [t]=use_template [v]=others_var=) - local logpath - local failregex - local max_retry - local ports - local others_var - local use_template - # Manage arguments with getopts - ynh_handle_getopts_args "$@" - use_template="${use_template:-0}" - max_retry=${max_retry:-3} - ports=${ports:-http,https} + # Declare an array to define the options of this helper. + local legacy_args=lrmptv + local -A args_array=( [l]=logpath= [r]=failregex= [m]=max_retry= [p]=ports= [t]=use_template [v]=others_var=) + local logpath + local failregex + local max_retry + local ports + local others_var + local use_template + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + max_retry=${max_retry:-3} + ports=${ports:-http,https} + others_var="${others_var:-}" + use_template="${use_template:-0}" - finalfail2banjailconf="/etc/fail2ban/jail.d/$app.conf" - finalfail2banfilterconf="/etc/fail2ban/filter.d/$app.conf" - ynh_backup_if_checksum_is_different "$finalfail2banjailconf" - ynh_backup_if_checksum_is_different "$finalfail2banfilterconf" + [[ -z "$others_var" ]] || ynh_print_warn --message="Packagers: using --others_var is unecessary since YunoHost 4.2" - if [ $use_template -eq 1 ] - then - # Usage 2, templates - cp ../conf/f2b_jail.conf $finalfail2banjailconf - cp ../conf/f2b_filter.conf $finalfail2banfilterconf - - if [ -n "${app:-}" ] + if [ $use_template -ne 1 ] then - ynh_replace_string "__APP__" "$app" "$finalfail2banjailconf" - ynh_replace_string "__APP__" "$app" "$finalfail2banfilterconf" - fi + # Usage 1, no template. Build a config file from scratch. + test -n "$logpath" || ynh_die --message="ynh_add_fail2ban_config expects a logfile path as first argument and received nothing." + test -n "$failregex" || ynh_die --message="ynh_add_fail2ban_config expects a failure regex as second argument and received nothing." - # Replace all other variable given as arguments - for var_to_replace in ${others_var:-}; do - # ${var_to_replace^^} make the content of the variable on upper-cases - # ${!var_to_replace} get the content of the variable named $var_to_replace - ynh_replace_string --match_string="__${var_to_replace^^}__" --replace_string="${!var_to_replace}" --target_file="$finalfail2banjailconf" - ynh_replace_string --match_string="__${var_to_replace^^}__" --replace_string="${!var_to_replace}" --target_file="$finalfail2banfilterconf" - done - - else - # Usage 1, no template. Build a config file from scratch. - test -n "$logpath" || ynh_die "ynh_add_fail2ban_config expects a logfile path as first argument and received nothing." - test -n "$failregex" || ynh_die "ynh_add_fail2ban_config expects a failure regex as second argument and received nothing." - - tee $finalfail2banjailconf < $YNH_APP_BASEDIR/conf/f2b_jail.conf - tee $finalfail2banfilterconf < $YNH_APP_BASEDIR/conf/f2b_filter.conf + fi - # Common to usage 1 and 2. - ynh_store_file_checksum "$finalfail2banjailconf" - ynh_store_file_checksum "$finalfail2banfilterconf" + ynh_add_config --template="$YNH_APP_BASEDIR/conf/f2b_jail.conf" --destination="/etc/fail2ban/jail.d/$app.conf" + ynh_add_config --template="$YNH_APP_BASEDIR/conf/f2b_filter.conf" --destination="/etc/fail2ban/filter.d/$app.conf" - ynh_systemd_action --service_name=fail2ban --action=reload + ynh_systemd_action --service_name=fail2ban --action=reload --line_match="(Started|Reloaded) Fail2Ban Service" --log_path=systemd - local fail2ban_error="$(journalctl -u fail2ban | tail -n50 | grep "WARNING.*$app.*")" - if [[ -n "$fail2ban_error" ]]; then - ynh_print_err --message="Fail2ban failed to load the jail for $app" - ynh_print_warn --message="${fail2ban_error#*WARNING}" - fi + local fail2ban_error="$(journalctl --no-hostname --unit=fail2ban | tail --lines=50 | grep "WARNING.*$app.*")" + if [[ -n "$fail2ban_error" ]] + then + ynh_print_err --message="Fail2ban failed to load the jail for $app" + ynh_print_warn --message="${fail2ban_error#*WARNING}" + fi } # Remove the dedicated fail2ban config (jail and filter conf files) @@ -145,7 +124,7 @@ EOF # # Requires YunoHost version 3.5.0 or higher. ynh_remove_fail2ban_config () { - ynh_secure_remove "/etc/fail2ban/jail.d/$app.conf" - ynh_secure_remove "/etc/fail2ban/filter.d/$app.conf" - ynh_systemd_action --service_name=fail2ban --action=reload + ynh_secure_remove --file="/etc/fail2ban/jail.d/$app.conf" + ynh_secure_remove --file="/etc/fail2ban/filter.d/$app.conf" + ynh_systemd_action --service_name=fail2ban --action=reload } diff --git a/data/helpers.d/getopts b/data/helpers.d/getopts index c8045fa25..8d9e55826 100644 --- a/data/helpers.d/getopts +++ b/data/helpers.d/getopts @@ -6,7 +6,7 @@ # # example: function my_helper() # { -# declare -Ar args_array=( [a]=arg1= [b]=arg2= [c]=arg3 ) +# local -A args_array=( [a]=arg1= [b]=arg2= [c]=arg3 ) # local arg1 # local arg2 # local arg3 @@ -22,13 +22,13 @@ # This helper need an array, named "args_array" with all the arguments used by the helper # that want to use ynh_handle_getopts_args # Be carreful, this array has to be an associative array, as the following example: -# declare -Ar args_array=( [a]=arg1 [b]=arg2= [c]=arg3 ) +# local -A args_array=( [a]=arg1 [b]=arg2= [c]=arg3 ) # Let's explain this array: # a, b and c are short options, -a, -b and -c # arg1, arg2 and arg3 are the long options associated to the previous short ones. --arg1, --arg2 and --arg3 # For each option, a short and long version has to be defined. # Let's see something more significant -# declare -Ar args_array=( [u]=user [f]=finalpath= [d]=database ) +# local -A args_array=( [u]=user [f]=finalpath= [d]=database ) # # NB: Because we're using 'declare' without -g, the array will be declared as a local variable. # @@ -46,173 +46,185 @@ # # Requires YunoHost version 3.2.2 or higher. ynh_handle_getopts_args () { - # Manage arguments only if there's some provided - set +x - if [ $# -ne 0 ] - then - # Store arguments in an array to keep each argument separated - local arguments=("$@") + # Manage arguments only if there's some provided + set +o xtrace # set +x + if [ $# -ne 0 ] + then + # Store arguments in an array to keep each argument separated + local arguments=("$@") - # For each option in the array, reduce to short options for getopts (e.g. for [u]=user, --user will be -u) - # And built parameters string for getopts - # ${!args_array[@]} is the list of all option_flags in the array (An option_flag is 'u' in [u]=user, user is a value) - local getopts_parameters="" - local option_flag="" - for option_flag in "${!args_array[@]}" - do - # Concatenate each option_flags of the array to build the string of arguments for getopts - # Will looks like 'abcd' for -a -b -c -d - # If the value of an option_flag finish by =, it's an option with additionnal values. (e.g. --user bob or -u bob) - # Check the last character of the value associate to the option_flag - if [ "${args_array[$option_flag]: -1}" = "=" ] - then - # For an option with additionnal values, add a ':' after the letter for getopts. - getopts_parameters="${getopts_parameters}${option_flag}:" - else - getopts_parameters="${getopts_parameters}${option_flag}" - fi - # Check each argument given to the function - local arg="" - # ${#arguments[@]} is the size of the array - for arg in `seq 0 $(( ${#arguments[@]} - 1 ))` - do - # Escape options' values starting with -. Otherwise the - will be considered as another option. - arguments[arg]="${arguments[arg]//--${args_array[$option_flag]}-/--${args_array[$option_flag]}\\TOBEREMOVED\\-}" - # And replace long option (value of the option_flag) by the short option, the option_flag itself - # (e.g. for [u]=user, --user will be -u) - # Replace long option with = - arguments[arg]="${arguments[arg]//--${args_array[$option_flag]}/-${option_flag} }" - # And long option without = - arguments[arg]="${arguments[arg]//--${args_array[$option_flag]%=}/-${option_flag}}" - done - done + # For each option in the array, reduce to short options for getopts (e.g. for [u]=user, --user will be -u) + # And built parameters string for getopts + # ${!args_array[@]} is the list of all option_flags in the array (An option_flag is 'u' in [u]=user, user is a value) + local getopts_parameters="" + local option_flag="" + for option_flag in "${!args_array[@]}" + do + # Concatenate each option_flags of the array to build the string of arguments for getopts + # Will looks like 'abcd' for -a -b -c -d + # If the value of an option_flag finish by =, it's an option with additionnal values. (e.g. --user bob or -u bob) + # Check the last character of the value associate to the option_flag + if [ "${args_array[$option_flag]: -1}" = "=" ] + then + # For an option with additionnal values, add a ':' after the letter for getopts. + getopts_parameters="${getopts_parameters}${option_flag}:" + else + getopts_parameters="${getopts_parameters}${option_flag}" + fi + # Check each argument given to the function + local arg="" + # ${#arguments[@]} is the size of the array + for arg in `seq 0 $(( ${#arguments[@]} - 1 ))` + do + # Escape options' values starting with -. Otherwise the - will be considered as another option. + arguments[arg]="${arguments[arg]//--${args_array[$option_flag]}-/--${args_array[$option_flag]}\\TOBEREMOVED\\-}" + # And replace long option (value of the option_flag) by the short option, the option_flag itself + # (e.g. for [u]=user, --user will be -u) + # Replace long option with = (match the beginning of the argument) + arguments[arg]="$(echo "${arguments[arg]}" | sed "s/^--${args_array[$option_flag]}/-${option_flag} /")" + # And long option without = (match the whole line) + arguments[arg]="$(echo "${arguments[arg]}" | sed "s/^--${args_array[$option_flag]%=}$/-${option_flag} /")" + done + done - # Read and parse all the arguments - # Use a function here, to use standart arguments $@ and be able to use shift. - parse_arg () { - # Read all arguments, until no arguments are left - while [ $# -ne 0 ] - do - # Initialize the index of getopts - OPTIND=1 - # Parse with getopts only if the argument begin by -, that means the argument is an option - # getopts will fill $parameter with the letter of the option it has read. - local parameter="" - getopts ":$getopts_parameters" parameter || true + # Read and parse all the arguments + # Use a function here, to use standart arguments $@ and be able to use shift. + parse_arg () { + # Read all arguments, until no arguments are left + while [ $# -ne 0 ] + do + # Initialize the index of getopts + OPTIND=1 + # Parse with getopts only if the argument begin by -, that means the argument is an option + # getopts will fill $parameter with the letter of the option it has read. + local parameter="" + getopts ":$getopts_parameters" parameter || true - if [ "$parameter" = "?" ] - then - ynh_die --message="Invalid argument: -${OPTARG:-}" - elif [ "$parameter" = ":" ] - then - ynh_die --message="-$OPTARG parameter requires an argument." - else - local shift_value=1 - # Use the long option, corresponding to the short option read by getopts, as a variable - # (e.g. for [u]=user, 'user' will be used as a variable) - # Also, remove '=' at the end of the long option - # The variable name will be stored in 'option_var' - local option_var="${args_array[$parameter]%=}" - # If this option doesn't take values - # if there's a '=' at the end of the long option name, this option takes values - if [ "${args_array[$parameter]: -1}" != "=" ] - then - # 'eval ${option_var}' will use the content of 'option_var' - eval ${option_var}=1 - else - # Read all other arguments to find multiple value for this option. - # Load args in a array - local all_args=("$@") + if [ "$parameter" = "?" ] + then + ynh_die --message="Invalid argument: -${OPTARG:-}" + elif [ "$parameter" = ":" ] + then + ynh_die --message="-$OPTARG parameter requires an argument." + else + local shift_value=1 + # Use the long option, corresponding to the short option read by getopts, as a variable + # (e.g. for [u]=user, 'user' will be used as a variable) + # Also, remove '=' at the end of the long option + # The variable name will be stored in 'option_var' + local option_var="${args_array[$parameter]%=}" + # If this option doesn't take values + # if there's a '=' at the end of the long option name, this option takes values + if [ "${args_array[$parameter]: -1}" != "=" ] + then + # 'eval ${option_var}' will use the content of 'option_var' + eval ${option_var}=1 + else + # Read all other arguments to find multiple value for this option. + # Load args in a array + local all_args=("$@") - # If the first argument is longer than 2 characters, - # There's a value attached to the option, in the same array cell - if [ ${#all_args[0]} -gt 2 ]; then - # Remove the option and the space, so keep only the value itself. - all_args[0]="${all_args[0]#-${parameter} }" - # Reduce the value of shift, because the option has been removed manually - shift_value=$(( shift_value - 1 )) - fi + # If the first argument is longer than 2 characters, + # There's a value attached to the option, in the same array cell + if [ ${#all_args[0]} -gt 2 ] + then + # Remove the option and the space, so keep only the value itself. + all_args[0]="${all_args[0]#-${parameter} }" - # Declare the content of option_var as a variable. - eval ${option_var}="" - # Then read the array value per value - local i - for i in `seq 0 $(( ${#all_args[@]} - 1 ))` - do - # If this argument is an option, end here. - if [ "${all_args[$i]:0:1}" == "-" ] - then - # Ignore the first value of the array, which is the option itself - if [ "$i" -ne 0 ]; then - break - fi - else - # Else, add this value to this option - # Each value will be separated by ';' - if [ -n "${!option_var}" ] - then - # If there's already another value for this option, add a ; before adding the new value - eval ${option_var}+="\;" - fi + # At this point, if all_args[0] start with "-", then the argument is not well formed + if [ "${all_args[0]:0:1}" == "-" ] + then + ynh_die --message="Argument \"${all_args[0]}\" not valid! Did you use a single \"-\" instead of two?" + fi + # Reduce the value of shift, because the option has been removed manually + shift_value=$(( shift_value - 1 )) + fi - # Remove the \ that escape - at beginning of values. - all_args[i]="${all_args[i]//\\TOBEREMOVED\\/}" + # Declare the content of option_var as a variable. + eval ${option_var}="" + # Then read the array value per value + local i + for i in `seq 0 $(( ${#all_args[@]} - 1 ))` + do + # If this argument is an option, end here. + if [ "${all_args[$i]:0:1}" == "-" ] + then + # Ignore the first value of the array, which is the option itself + if [ "$i" -ne 0 ]; then + break + fi + else + # Ignore empty parameters + if [ -n "${all_args[$i]}" ] + then + # Else, add this value to this option + # Each value will be separated by ';' + if [ -n "${!option_var}" ] + then + # If there's already another value for this option, add a ; before adding the new value + eval ${option_var}+="\;" + fi - # For the record. - # We're using eval here to get the content of the variable stored itself as simple text in $option_var... - # Other ways to get that content would be to use either ${!option_var} or declare -g ${option_var} - # But... ${!option_var} can't be used as left part of an assignation. - # declare -g ${option_var} will create a local variable (despite -g !) and will not be available for the helper itself. - # So... Stop fucking arguing each time that eval is evil... Go find an other working solution if you can find one! + # Remove the \ that escape - at beginning of values. + all_args[i]="${all_args[i]//\\TOBEREMOVED\\/}" - eval ${option_var}+='"${all_args[$i]}"' - shift_value=$(( shift_value + 1 )) - fi - done - fi - fi + # For the record. + # We're using eval here to get the content of the variable stored itself as simple text in $option_var... + # Other ways to get that content would be to use either ${!option_var} or declare -g ${option_var} + # But... ${!option_var} can't be used as left part of an assignation. + # declare -g ${option_var} will create a local variable (despite -g !) and will not be available for the helper itself. + # So... Stop fucking arguing each time that eval is evil... Go find an other working solution if you can find one! - # Shift the parameter and its argument(s) - shift $shift_value - done - } + eval ${option_var}+='"${all_args[$i]}"' + fi + shift_value=$(( shift_value + 1 )) + fi + done + fi + fi - # LEGACY MODE - # Check if there's getopts arguments - if [ "${arguments[0]:0:1}" != "-" ] - then - # If not, enter in legacy mode and manage the arguments as positionnal ones.. - # Dot not echo, to prevent to go through a helper output. But print only in the log. - set -x; echo "! Helper used in legacy mode !" > /dev/null; set +x - local i - for i in `seq 0 $(( ${#arguments[@]} -1 ))` - do - # Try to use legacy_args as a list of option_flag of the array args_array - # Otherwise, fallback to getopts_parameters to get the option_flag. But an associative arrays isn't always sorted in the correct order... - # Remove all ':' in getopts_parameters - getopts_parameters=${legacy_args:-${getopts_parameters//:}} - # Get the option_flag from getopts_parameters, by using the option_flag according to the position of the argument. - option_flag=${getopts_parameters:$i:1} - if [ -z "$option_flag" ]; then - ynh_print_warn --message="Too many arguments ! \"${arguments[$i]}\" will be ignored." - continue - fi - # Use the long option, corresponding to the option_flag, as a variable - # (e.g. for [u]=user, 'user' will be used as a variable) - # Also, remove '=' at the end of the long option - # The variable name will be stored in 'option_var' - local option_var="${args_array[$option_flag]%=}" + # Shift the parameter and its argument(s) + shift $shift_value + done + } - # Store each value given as argument in the corresponding variable - # The values will be stored in the same order than $args_array - eval ${option_var}+='"${arguments[$i]}"' - done - unset legacy_args - else - # END LEGACY MODE - # Call parse_arg and pass the modified list of args as an array of arguments. - parse_arg "${arguments[@]}" - fi - fi - set -x + # LEGACY MODE + # Check if there's getopts arguments + if [ "${arguments[0]:0:1}" != "-" ] + then + # If not, enter in legacy mode and manage the arguments as positionnal ones.. + # Dot not echo, to prevent to go through a helper output. But print only in the log. + set -x; echo "! Helper used in legacy mode !" > /dev/null; set +x + local i + for i in `seq 0 $(( ${#arguments[@]} -1 ))` + do + # Try to use legacy_args as a list of option_flag of the array args_array + # Otherwise, fallback to getopts_parameters to get the option_flag. But an associative arrays isn't always sorted in the correct order... + # Remove all ':' in getopts_parameters + getopts_parameters=${legacy_args:-${getopts_parameters//:}} + # Get the option_flag from getopts_parameters, by using the option_flag according to the position of the argument. + option_flag=${getopts_parameters:$i:1} + if [ -z "$option_flag" ] + then + ynh_print_warn --message="Too many arguments ! \"${arguments[$i]}\" will be ignored." + continue + fi + # Use the long option, corresponding to the option_flag, as a variable + # (e.g. for [u]=user, 'user' will be used as a variable) + # Also, remove '=' at the end of the long option + # The variable name will be stored in 'option_var' + local option_var="${args_array[$option_flag]%=}" + + # Store each value given as argument in the corresponding variable + # The values will be stored in the same order than $args_array + eval ${option_var}+='"${arguments[$i]}"' + done + unset legacy_args + else + # END LEGACY MODE + # Call parse_arg and pass the modified list of args as an array of arguments. + parse_arg "${arguments[@]}" + fi + fi + set -o xtrace # set -x } diff --git a/data/helpers.d/hardware b/data/helpers.d/hardware new file mode 100644 index 000000000..6d1c314fa --- /dev/null +++ b/data/helpers.d/hardware @@ -0,0 +1,109 @@ +#!/bin/bash + +# Get the total or free amount of RAM+swap on the system +# +# usage: ynh_get_ram [--free|--total] [--ignore_swap|--only_swap] +# | arg: -f, --free - Count free RAM+swap +# | arg: -t, --total - Count total RAM+swap +# | arg: -s, --ignore_swap - Ignore swap, consider only real RAM +# | arg: -o, --only_swap - Ignore real RAM, consider only swap +# | ret: the amount of free ram, in MB (MegaBytes) +# +# Requires YunoHost version 3.8.1 or higher. +ynh_get_ram () { + # Declare an array to define the options of this helper. + local legacy_args=ftso + local -A args_array=( [f]=free [t]=total [s]=ignore_swap [o]=only_swap ) + local free + local total + local ignore_swap + local only_swap + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + ignore_swap=${ignore_swap:-0} + only_swap=${only_swap:-0} + free=${free:-0} + total=${total:-0} + + if [ $free -eq $total ] + then + ynh_print_warn --message="You have to choose --free or --total when using ynh_get_ram" + ram=0 + # Use the total amount of ram + elif [ $free -eq 1 ] + then + local free_ram=$(vmstat --stats --unit M | grep "free memory" | awk '{print $1}') + local free_swap=$(vmstat --stats --unit M | grep "free swap" | awk '{print $1}') + local free_ram_swap=$(( free_ram + free_swap )) + + # Use the total amount of free ram + local ram=$free_ram_swap + if [ $ignore_swap -eq 1 ] + then + # Use only the amount of free ram + ram=$free_ram + elif [ $only_swap -eq 1 ] + then + # Use only the amount of free swap + ram=$free_swap + fi + elif [ $total -eq 1 ] + then + local total_ram=$(vmstat --stats --unit M | grep "total memory" | awk '{print $1}') + local total_swap=$(vmstat --stats --unit M | grep "total swap" | awk '{print $1}') + local total_ram_swap=$(( total_ram + total_swap )) + + local ram=$total_ram_swap + if [ $ignore_swap -eq 1 ] + then + # Use only the amount of free ram + ram=$total_ram + elif [ $only_swap -eq 1 ] + then + # Use only the amount of free swap + ram=$total_swap + fi + fi + + echo $ram +} + +# Return 0 or 1 depending if the system has a given amount of RAM+swap free or total +# +# usage: ynh_require_ram --required=RAM [--free|--total] [--ignore_swap|--only_swap] +# | arg: -r, --required= - The amount to require, in MB +# | arg: -f, --free - Count free RAM+swap +# | arg: -t, --total - Count total RAM+swap +# | arg: -s, --ignore_swap - Ignore swap, consider only real RAM +# | arg: -o, --only_swap - Ignore real RAM, consider only swap +# | ret: 1 if the ram is under the requirement, 0 otherwise. +# +# Requires YunoHost version 3.8.1 or higher. +ynh_require_ram () { + # Declare an array to define the options of this helper. + local legacy_args=rftso + local -A args_array=( [r]=required= [f]=free [t]=total [s]=ignore_swap [o]=only_swap ) + local required + local free + local total + local ignore_swap + local only_swap + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + # Dunno if that's the right way to do, but that's some black magic to be able to + # forward the bool args to ynh_get_ram easily? + # If the variable $free is not empty, set it to '--free' + free=${free:+--free} + total=${total:+--total} + ignore_swap=${ignore_swap:+--ignore_swap} + only_swap=${only_swap:+--only_swap} + + local ram=$(ynh_get_ram $free $total $ignore_swap $only_swap) + + if [ $ram -lt $required ] + then + return 1 + else + return 0 + fi +} diff --git a/data/helpers.d/logging b/data/helpers.d/logging index be33b75a5..71998763e 100644 --- a/data/helpers.d/logging +++ b/data/helpers.d/logging @@ -3,35 +3,39 @@ # Print a message to stderr and exit # # usage: ynh_die --message=MSG [--ret_code=RETCODE] +# | arg: -m, --message= - Message to display +# | arg: -c, --ret_code= - Exit code to exit with # # Requires YunoHost version 2.4.0 or higher. ynh_die() { - # Declare an array to define the options of this helper. - local legacy_args=mc - declare -Ar args_array=( [m]=message= [c]=ret_code= ) - local message - local ret_code - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=mc + local -A args_array=( [m]=message= [c]=ret_code= ) + local message + local ret_code + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + ret_code=${ret_code:-1} - echo "$message" 1>&2 - exit "${ret_code:-1}" + echo "$message" 1>&2 + exit "$ret_code" } # Display a message in the 'INFO' logging category # # usage: ynh_print_info --message="Some message" +# | arg: -m, --message= - Message to display # # Requires YunoHost version 3.2.0 or higher. ynh_print_info() { # Declare an array to define the options of this helper. local legacy_args=m - declare -Ar args_array=( [m]=message= ) + local -A args_array=( [m]=message= ) local message # Manage arguments with getopts ynh_handle_getopts_args "$@" - echo "$message" >> "$YNH_STDINFO" + echo "$message" >&$YNH_STDINFO } # Ignore the yunohost-cli log to prevent errors with conditional commands @@ -45,12 +49,12 @@ ynh_print_info() { # # Requires YunoHost version 2.6.4 or higher. ynh_no_log() { - local ynh_cli_log=/var/log/yunohost/yunohost-cli.log - sudo cp -a ${ynh_cli_log} ${ynh_cli_log}-move - eval $@ - local exit_code=$? - sudo mv ${ynh_cli_log}-move ${ynh_cli_log} - return $? + local ynh_cli_log=/var/log/yunohost/yunohost-cli.log + cp --archive ${ynh_cli_log} ${ynh_cli_log}-move + eval $@ + local exit_code=$? + mv ${ynh_cli_log}-move ${ynh_cli_log} + return $exit_code } # Main printer, just in case in the future we have to change anything about that. @@ -59,121 +63,111 @@ ynh_no_log() { # # Requires YunoHost version 3.2.0 or higher. ynh_print_log () { - echo -e "${1}" + echo -e "${1}" } # Print a warning on stderr # # usage: ynh_print_warn --message="Text to print" -# | arg: -m, --message - The text to print +# | arg: -m, --message= - The text to print # # Requires YunoHost version 3.2.0 or higher. ynh_print_warn () { - # Declare an array to define the options of this helper. - local legacy_args=m - declare -Ar args_array=( [m]=message= ) - local message - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=m + local -A args_array=( [m]=message= ) + local message + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - ynh_print_log "\e[93m\e[1m[WARN]\e[0m ${message}" >&2 + ynh_print_log "${message}" >&2 } # Print an error on stderr # # usage: ynh_print_err --message="Text to print" -# | arg: -m, --message - The text to print +# | arg: -m, --message= - The text to print # # Requires YunoHost version 3.2.0 or higher. ynh_print_err () { - # Declare an array to define the options of this helper. - local legacy_args=m - declare -Ar args_array=( [m]=message= ) - local message - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=m + local -A args_array=( [m]=message= ) + local message + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - ynh_print_log "\e[91m\e[1m[ERR]\e[0m ${message}" >&2 + ynh_print_log "[Error] ${message}" >&2 } # Execute a command and print the result as an error # -# usage: ynh_exec_err your_command -# usage: ynh_exec_err "your_command | other_command" +# usage: ynh_exec_err "your_command [ | other_command ]" +# | arg: command - command to execute # # When using pipes, double quotes are required - otherwise, this helper will run the first command, and the whole output will be sent through the next pipe. # # If the command to execute uses double quotes, they have to be escaped or they will be interpreted and removed. # -# | arg: command - command to execute -# # Requires YunoHost version 3.2.0 or higher. ynh_exec_err () { - ynh_print_err "$(eval $@)" + ynh_print_err "$(eval $@)" } # Execute a command and print the result as a warning # -# usage: ynh_exec_warn your_command -# usage: ynh_exec_warn "your_command | other_command" +# usage: ynh_exec_warn "your_command [ | other_command ]" +# | arg: command - command to execute # # When using pipes, double quotes are required - otherwise, this helper will run the first command, and the whole output will be sent through the next pipe. # # If the command to execute uses double quotes, they have to be escaped or they will be interpreted and removed. # -# | arg: command - command to execute -# # Requires YunoHost version 3.2.0 or higher. ynh_exec_warn () { - ynh_print_warn "$(eval $@)" + ynh_print_warn "$(eval $@)" } # Execute a command and force the result to be printed on stdout # -# usage: ynh_exec_warn_less your_command -# usage: ynh_exec_warn_less "your_command | other_command" +# usage: ynh_exec_warn_less "your_command [ | other_command ]" +# | arg: command - command to execute # # When using pipes, double quotes are required - otherwise, this helper will run the first command, and the whole output will be sent through the next pipe. # # If the command to execute uses double quotes, they have to be escaped or they will be interpreted and removed. # -# | arg: command - command to execute -# # Requires YunoHost version 3.2.0 or higher. ynh_exec_warn_less () { - eval $@ 2>&1 + eval $@ 2>&1 } # Execute a command and redirect stdout in /dev/null # -# usage: ynh_exec_quiet your_command -# usage: ynh_exec_quiet "your_command | other_command" +# usage: ynh_exec_quiet "your_command [ | other_command ]" +# | arg: command - command to execute # # When using pipes, double quotes are required - otherwise, this helper will run the first command, and the whole output will be sent through the next pipe. # # If the command to execute uses double quotes, they have to be escaped or they will be interpreted and removed. # -# | arg: command - command to execute -# # Requires YunoHost version 3.2.0 or higher. ynh_exec_quiet () { - eval $@ > /dev/null + eval $@ > /dev/null } # Execute a command and redirect stdout and stderr in /dev/null # -# usage: ynh_exec_fully_quiet your_command -# usage: ynh_exec_fully_quiet "your_command | other_command" +# usage: ynh_exec_fully_quiet "your_command [ | other_command ]" +# | arg: command - command to execute # # When using pipes, double quotes are required - otherwise, this helper will run the first command, and the whole output will be sent through the next pipe. # # If the command to execute uses double quotes, they have to be escaped or they will be interpreted and removed. # -# | arg: command - command to execute -# # Requires YunoHost version 3.2.0 or higher. ynh_exec_fully_quiet () { - eval $@ > /dev/null 2>&1 + eval $@ > /dev/null 2>&1 } # Remove any logs for all the following commands. @@ -184,7 +178,7 @@ ynh_exec_fully_quiet () { # # Requires YunoHost version 3.2.0 or higher. ynh_print_OFF () { - exec {BASH_XTRACEFD}>/dev/null + exec {BASH_XTRACEFD}>/dev/null } # Restore the logging after ynh_print_OFF @@ -193,9 +187,9 @@ ynh_print_OFF () { # # Requires YunoHost version 3.2.0 or higher. ynh_print_ON () { - exec {BASH_XTRACEFD}>&1 - # Print an echo only for the log, to be able to know that ynh_print_ON has been called. - echo ynh_print_ON > /dev/null + exec {BASH_XTRACEFD}>&1 + # Print an echo only for the log, to be able to know that ynh_print_ON has been called. + echo ynh_print_ON > /dev/null } # Initial definitions for ynh_script_progression @@ -216,89 +210,90 @@ base_time=$(date +%s) # usage: ynh_script_progression --message=message [--weight=weight] [--time] # | arg: -m, --message= - The text to print # | arg: -w, --weight= - The weight for this progression. This value is 1 by default. Use a bigger value for a longer part of the script. -# | arg: -t, --time= - Print the execution time since the last call to this helper. Especially usefull to define weights. The execution time is given for the duration since the previous call. So the weight should be applied to this previous call. -# | arg: -l, --last= - Use for the last call of the helper, to fill te progression bar. +# | arg: -t, --time - Print the execution time since the last call to this helper. Especially usefull to define weights. The execution time is given for the duration since the previous call. So the weight should be applied to this previous call. +# | arg: -l, --last - Use for the last call of the helper, to fill the progression bar. # # Requires YunoHost version 3.5.0 or higher. ynh_script_progression () { - set +x - # Declare an array to define the options of this helper. - local legacy_args=mwtl - declare -Ar args_array=( [m]=message= [w]=weight= [t]=time [l]=last ) - local message - local weight - local time - local last - # Manage arguments with getopts - ynh_handle_getopts_args "$@" - set +x - weight=${weight:-1} - time=${time:-0} - last=${last:-0} + set +o xtrace # set +x + # Declare an array to define the options of this helper. + local legacy_args=mwtl + local -A args_array=( [m]=message= [w]=weight= [t]=time [l]=last ) + local message + local weight + local time + local last + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + # Re-disable xtrace, ynh_handle_getopts_args set it back + set +o xtrace # set +x + weight=${weight:-1} + time=${time:-0} + last=${last:-0} - # Get execution time since the last $base_time - local exec_time=$(( $(date +%s) - $base_time )) - base_time=$(date +%s) + # Get execution time since the last $base_time + local exec_time=$(( $(date +%s) - $base_time )) + base_time=$(date +%s) - # Compute $max_progression (if we didn't already) - if [ "$max_progression" = -1 ] - then - # Get the number of occurrences of 'ynh_script_progression' in the script. Except those are commented. - local helper_calls="$(grep --count "^[^#]*ynh_script_progression" $0)" - # Get the number of call with a weight value - local weight_calls=$(grep --perl-regexp --count "^[^#]*ynh_script_progression.*(--weight|-w )" $0) + # Compute $max_progression (if we didn't already) + if [ "$max_progression" = -1 ] + then + # Get the number of occurrences of 'ynh_script_progression' in the script. Except those are commented. + local helper_calls="$(grep --count "^[^#]*ynh_script_progression" $0)" + # Get the number of call with a weight value + local weight_calls=$(grep --perl-regexp --count "^[^#]*ynh_script_progression.*(--weight|-w )" $0) - # Get the weight of each occurrences of 'ynh_script_progression' in the script using --weight - local weight_valuesA="$(grep --perl-regexp "^[^#]*ynh_script_progression.*--weight" $0 | sed 's/.*--weight[= ]\([[:digit:]]*\).*/\1/g')" - # Get the weight of each occurrences of 'ynh_script_progression' in the script using -w - local weight_valuesB="$(grep --perl-regexp "^[^#]*ynh_script_progression.*-w " $0 | sed 's/.*-w[= ]\([[:digit:]]*\).*/\1/g')" - # Each value will be on a different line. - # Remove each 'end of line' and replace it by a '+' to sum the values. - local weight_values=$(( $(echo "$weight_valuesA" | tr '\n' '+') + $(echo "$weight_valuesB" | tr '\n' '+') 0 )) + # Get the weight of each occurrences of 'ynh_script_progression' in the script using --weight + local weight_valuesA="$(grep --perl-regexp "^[^#]*ynh_script_progression.*--weight" $0 | sed 's/.*--weight[= ]\([[:digit:]]*\).*/\1/g')" + # Get the weight of each occurrences of 'ynh_script_progression' in the script using -w + local weight_valuesB="$(grep --perl-regexp "^[^#]*ynh_script_progression.*-w " $0 | sed 's/.*-w[= ]\([[:digit:]]*\).*/\1/g')" + # Each value will be on a different line. + # Remove each 'end of line' and replace it by a '+' to sum the values. + local weight_values=$(( $(echo "$weight_valuesA" | tr '\n' '+') + $(echo "$weight_valuesB" | tr '\n' '+') 0 )) - # max_progression is a total number of calls to this helper. - # Less the number of calls with a weight value. - # Plus the total of weight values - max_progression=$(( $helper_calls - $weight_calls + $weight_values )) - fi + # max_progression is a total number of calls to this helper. + # Less the number of calls with a weight value. + # Plus the total of weight values + max_progression=$(( $helper_calls - $weight_calls + $weight_values )) + fi - # Increment each execution of ynh_script_progression in this script by the weight of the previous call. - increment_progression=$(( $increment_progression + $previous_weight )) - # Store the weight of the current call in $previous_weight for next call - previous_weight=$weight + # Increment each execution of ynh_script_progression in this script by the weight of the previous call. + increment_progression=$(( $increment_progression + $previous_weight )) + # Store the weight of the current call in $previous_weight for next call + previous_weight=$weight - # Reduce $increment_progression to the size of the scale - if [ $last -eq 0 ] - then - local effective_progression=$(( $increment_progression * $progress_scale / $max_progression )) - # If last is specified, fill immediately the progression_bar - else - local effective_progression=$progress_scale - fi + # Reduce $increment_progression to the size of the scale + if [ $last -eq 0 ] + then + local effective_progression=$(( $increment_progression * $progress_scale / $max_progression )) + # If last is specified, fill immediately the progression_bar + else + local effective_progression=$progress_scale + fi - # Build $progression_bar from progress_string(0,1,2) according to $effective_progression and the weight of the current task - # expected_progression is the progression expected after the current task - local expected_progression="$(( ( $increment_progression + $weight ) * $progress_scale / $max_progression - $effective_progression ))" - if [ $last -eq 1 ] - then - expected_progression=0 - fi - # left_progression is the progression not yet done - local left_progression="$(( $progress_scale - $effective_progression - $expected_progression ))" - # Build the progression bar with $effective_progression, work done, $expected_progression, current work and $left_progression, work to be done. - local progression_bar="${progress_string2:0:$effective_progression}${progress_string1:0:$expected_progression}${progress_string0:0:$left_progression}" + # Build $progression_bar from progress_string(0,1,2) according to $effective_progression and the weight of the current task + # expected_progression is the progression expected after the current task + local expected_progression="$(( ( $increment_progression + $weight ) * $progress_scale / $max_progression - $effective_progression ))" + if [ $last -eq 1 ] + then + expected_progression=0 + fi + # left_progression is the progression not yet done + local left_progression="$(( $progress_scale - $effective_progression - $expected_progression ))" + # Build the progression bar with $effective_progression, work done, $expected_progression, current work and $left_progression, work to be done. + local progression_bar="${progress_string2:0:$effective_progression}${progress_string1:0:$expected_progression}${progress_string0:0:$left_progression}" - local print_exec_time="" - if [ $time -eq 1 ] - then - print_exec_time=" [$(date +%Hh%Mm,%Ss --date="0 + $exec_time sec")]" - fi + local print_exec_time="" + if [ $time -eq 1 ] + then + print_exec_time=" [$(date +%Hh%Mm,%Ss --date="0 + $exec_time sec")]" + fi - ynh_print_info "[$progression_bar] > ${message}${print_exec_time}" - set -x + ynh_print_info "[$progression_bar] > ${message}${print_exec_time}" + set -o xtrace # set -x } -# Return data to the Yunohost core for later processing +# Return data to the YunoHost core for later processing # (to be used by special hooks like app config panel and core diagnosis) # # usage: ynh_return somedata @@ -316,63 +311,61 @@ ynh_return () { # # Requires YunoHost version 3.5.0 or higher. ynh_debug () { - # Disable set xtrace for the helper itself, to not pollute the debug log - set +x - # Declare an array to define the options of this helper. - local legacy_args=mt - declare -Ar args_array=( [m]=message= [t]=trace= ) - local message - local trace - # Manage arguments with getopts - ynh_handle_getopts_args "$@" - # Redisable xtrace, ynh_handle_getopts_args set it back - set +x - message=${message:-} - trace=${trace:-} + # Disable set xtrace for the helper itself, to not pollute the debug log + set +o xtrace # set +x + # Declare an array to define the options of this helper. + local legacy_args=mt + local -A args_array=( [m]=message= [t]=trace= ) + local message + local trace + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + # Re-disable xtrace, ynh_handle_getopts_args set it back + set +o xtrace # set +x + message=${message:-} + trace=${trace:-} - if [ -n "$message" ] - then - ynh_print_log "\e[34m\e[1m[DEBUG]\e[0m ${message}" >&2 - fi + if [ -n "$message" ] + then + ynh_print_log "[Debug] ${message}" >&2 + fi - if [ "$trace" == "1" ] - then - ynh_debug --message="Enable debugging" - set +x - # Get the current file descriptor of xtrace - old_bash_xtracefd=$BASH_XTRACEFD - # Add the current file name and the line number of any command currently running while tracing. - PS4='$(basename ${BASH_SOURCE[0]})-L${LINENO}: ' - # Force xtrace to stderr - BASH_XTRACEFD=2 - # Force stdout to stderr - exec 1>&2 - fi - if [ "$trace" == "0" ] - then - ynh_debug --message="Disable debugging" - set +x - # Put xtrace back to its original fild descriptor - BASH_XTRACEFD=$old_bash_xtracefd - # Restore stdout - exec 1>&1 - fi - # Renable set xtrace - set -x + if [ "$trace" == "1" ] + then + ynh_debug --message="Enable debugging" + set +o xtrace # set +x + # Get the current file descriptor of xtrace + old_bash_xtracefd=$BASH_XTRACEFD + # Add the current file name and the line number of any command currently running while tracing. + PS4='$(basename ${BASH_SOURCE[0]})-L${LINENO}: ' + # Force xtrace to stderr + BASH_XTRACEFD=2 + # Force stdout to stderr + exec 1>&2 + fi + if [ "$trace" == "0" ] + then + ynh_debug --message="Disable debugging" + set +o xtrace # set +x + # Put xtrace back to its original fild descriptor + BASH_XTRACEFD=$old_bash_xtracefd + # Restore stdout + exec 1>&1 + fi + # Renable set xtrace + set -o xtrace # set -x } # Execute a command and print the result as debug # -# usage: ynh_debug_exec your_command -# usage: ynh_debug_exec "your_command | other_command" +# usage: ynh_debug_exec "your_command [ | other_command ]" +# | arg: command - command to execute # # When using pipes, double quotes are required - otherwise, this helper will run the first command, and the whole output will be sent through the next pipe. # # If the command to execute uses double quotes, they have to be escaped or they will be interpreted and removed. # -# | arg: command - command to execute -# # Requires YunoHost version 3.5.0 or higher. ynh_debug_exec () { - ynh_debug --message="$(eval $@)" + ynh_debug --message="$(eval $@)" } diff --git a/data/helpers.d/logrotate b/data/helpers.d/logrotate index 47ce46cf6..2d9ab6b72 100644 --- a/data/helpers.d/logrotate +++ b/data/helpers.d/logrotate @@ -3,92 +3,99 @@ # Use logrotate to manage the logfile # # usage: ynh_use_logrotate [--logfile=/log/file] [--nonappend] [--specific_user=user/group] -# | arg: -l, --logfile - absolute path of logfile -# | arg: -n, --nonappend - (optional) Replace the config file instead of appending this new config. -# | arg: -u, --specific_user : run logrotate as the specified user and group. If not specified logrotate is runned as root. +# | arg: -l, --logfile= - absolute path of logfile +# | arg: -n, --nonappend - (optional) Replace the config file instead of appending this new config. +# | arg: -u, --specific_user= - run logrotate as the specified user and group. If not specified logrotate is runned as root. # -# If no --logfile is provided, /var/log/${app} will be used as default. -# logfile can be just a directory, or a full path to a logfile : -# /parentdir/logdir -# /parentdir/logdir/logfile.log +# If no `--logfile` is provided, `/var/log/$app` will be used as default. +# `logfile` can point to a directory or a file. # # It's possible to use this helper multiple times, each config will be added to -# the same logrotate config file. Unless you use the option --non-append +# the same logrotate config file. Unless you use the option `--non-append` # # Requires YunoHost version 2.6.4 or higher. +# Requires YunoHost version 3.2.0 or higher for the argument `--specific_user` ynh_use_logrotate () { - # Declare an array to define the options of this helper. - local legacy_args=lnuya - declare -Ar args_array=( [l]=logfile= [n]=nonappend [u]=specific_user= [y]=non [a]=append ) - # [y]=non [a]=append are only for legacy purpose, to not fail on the old option '--non-append' - local logfile - local nonappend - local specific_user - # Manage arguments with getopts - ynh_handle_getopts_args "$@" - local logfile="${logfile:-}" - local nonappend="${nonappend:-0}" - local specific_user="${specific_user:-}" + # Declare an array to define the options of this helper. + local legacy_args=lnuya + local -A args_array=( [l]=logfile= [n]=nonappend [u]=specific_user= [y]=non [a]=append ) + # [y]=non [a]=append are only for legacy purpose, to not fail on the old option '--non-append' + local logfile + local nonappend + local specific_user + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + logfile="${logfile:-}" + nonappend="${nonappend:-0}" + specific_user="${specific_user:-}" - # LEGACY CODE - PRE GETOPTS - if [ $# -gt 0 ] && [ "$1" == "--non-append" ]; then - nonappend=1 - # Destroy this argument for the next command. - shift - elif [ $# -gt 1 ] && [ "$2" == "--non-append" ]; then - nonappend=1 - fi + # LEGACY CODE - PRE GETOPTS + if [ $# -gt 0 ] && [ "$1" == "--non-append" ] + then + nonappend=1 + # Destroy this argument for the next command. + shift + elif [ $# -gt 1 ] && [ "$2" == "--non-append" ] + then + nonappend=1 + fi - if [ $# -gt 0 ] && [ "$(echo ${1:0:1})" != "-" ]; then - if [ "$(echo ${1##*.})" == "log" ]; then # Keep only the extension to check if it's a logfile - local logfile=$1 # In this case, focus logrotate on the logfile - else - local logfile=$1/*.log # Else, uses the directory and all logfile into it. - fi - fi - # LEGACY CODE + if [ $# -gt 0 ] && [ "$(echo ${1:0:1})" != "-" ] + then + # If the given logfile parameter already exists as a file, or if it ends up with ".log", + # we just want to manage a single file + if [ -f "$1" ] || [ "$(echo ${1##*.})" == "log" ] + then + local logfile=$1 + # Otherwise we assume we want to manage a directory and all its .log file inside + else + local logfile=$1/*.log + fi + fi + # LEGACY CODE - local customtee="tee -a" - if [ "$nonappend" -eq 1 ]; then - customtee="tee" - fi - if [ -n "$logfile" ] - then - if [ "$(echo ${logfile##*.})" != "log" ]; then # Keep only the extension to check if it's a logfile - local logfile="$logfile/*.log" # Else, uses the directory and all logfile into it. - fi - else - logfile="/var/log/${app}/*.log" # Without argument, use a defaut directory in /var/log - fi - local su_directive="" - if [[ -n $specific_user ]]; then - su_directive=" # Run logorotate as specific user - group - su ${specific_user%/*} ${specific_user#*/}" - fi + local customtee="tee --append" + if [ "$nonappend" -eq 1 ]; then + customtee="tee" + fi + if [ -n "$logfile" ] + then + if [ ! -f "$1" ] && [ "$(echo ${logfile##*.})" != "log" ]; then # Keep only the extension to check if it's a logfile + local logfile="$logfile/*.log" # Else, uses the directory and all logfile into it. + fi + else + logfile="/var/log/${app}/*.log" # Without argument, use a defaut directory in /var/log + fi + local su_directive="" + if [[ -n $specific_user ]] + then + su_directive=" # Run logorotate as specific user - group + su ${specific_user%/*} ${specific_user#*/}" + fi - cat > ./${app}-logrotate << EOF # Build a config file for logrotate + cat > ./${app}-logrotate << EOF # Build a config file for logrotate $logfile { - # Rotate if the logfile exceeds 100Mo - size 100M - # Keep 12 old log maximum - rotate 12 - # Compress the logs with gzip - compress - # Compress the log at the next cycle. So keep always 2 non compressed logs - delaycompress - # Copy and truncate the log to allow to continue write on it. Instead of move the log. - copytruncate - # Do not do an error if the log is missing - missingok - # Not rotate if the log is empty - notifempty - # Keep old logs in the same dir - noolddir - $su_directive + # Rotate if the logfile exceeds 100Mo + size 100M + # Keep 12 old log maximum + rotate 12 + # Compress the logs with gzip + compress + # Compress the log at the next cycle. So keep always 2 non compressed logs + delaycompress + # Copy and truncate the log to allow to continue write on it. Instead of move the log. + copytruncate + # Do not do an error if the log is missing + missingok + # Not rotate if the log is empty + notifempty + # Keep old logs in the same dir + noolddir + $su_directive } EOF - sudo mkdir -p $(dirname "$logfile") # Create the log directory, if not exist - cat ${app}-logrotate | sudo $customtee /etc/logrotate.d/$app > /dev/null # Append this config to the existing config file, or replace the whole config file (depending on $customtee) + mkdir --parents $(dirname "$logfile") # Create the log directory, if not exist + cat ${app}-logrotate | $customtee /etc/logrotate.d/$app > /dev/null # Append this config to the existing config file, or replace the whole config file (depending on $customtee) } # Remove the app's logrotate config. @@ -97,7 +104,7 @@ EOF # # Requires YunoHost version 2.6.4 or higher. ynh_remove_logrotate () { - if [ -e "/etc/logrotate.d/$app" ]; then - sudo rm "/etc/logrotate.d/$app" - fi + if [ -e "/etc/logrotate.d/$app" ]; then + rm "/etc/logrotate.d/$app" + fi } diff --git a/data/helpers.d/multimedia b/data/helpers.d/multimedia new file mode 100644 index 000000000..552b8c984 --- /dev/null +++ b/data/helpers.d/multimedia @@ -0,0 +1,104 @@ +#!/bin/bash + +readonly MEDIA_GROUP=multimedia +readonly MEDIA_DIRECTORY=/home/yunohost.multimedia + +# Initialize the multimedia directory system +# +# usage: ynh_multimedia_build_main_dir +# +# Requires YunoHost version 4.2 or higher. +ynh_multimedia_build_main_dir() { + + ## Création du groupe multimedia + groupadd -f $MEDIA_GROUP + + ## Création des dossiers génériques + mkdir -p "$MEDIA_DIRECTORY" + mkdir -p "$MEDIA_DIRECTORY/share" + mkdir -p "$MEDIA_DIRECTORY/share/Music" + mkdir -p "$MEDIA_DIRECTORY/share/Picture" + mkdir -p "$MEDIA_DIRECTORY/share/Video" + mkdir -p "$MEDIA_DIRECTORY/share/eBook" + + ## Création des dossiers utilisateurs + for user in $(yunohost user list --output-as json | jq -r '.users | keys[]') + do + mkdir -p "$MEDIA_DIRECTORY/$user" + mkdir -p "$MEDIA_DIRECTORY/$user/Music" + mkdir -p "$MEDIA_DIRECTORY/$user/Picture" + mkdir -p "$MEDIA_DIRECTORY/$user/Video" + mkdir -p "$MEDIA_DIRECTORY/$user/eBook" + ln -sfn "$MEDIA_DIRECTORY/share" "$MEDIA_DIRECTORY/$user/Share" + # Création du lien symbolique dans le home de l'utilisateur. + #link will only be created if the home directory of the user exists and if it's located in '/home' folder + local user_home="$(getent passwd $user | cut -d: -f6 | grep '^/home/')" + if [[ -d "$user_home" ]]; then + ln -sfn "$MEDIA_DIRECTORY/$user" "$user_home/Multimedia" + fi + # Propriétaires des dossiers utilisateurs. + chown -R $user "$MEDIA_DIRECTORY/$user" + done + # Default yunohost hooks for post_user_create,delete will take care + # of creating/deleting corresponding multimedia folders when users + # are created/deleted in the future... + + ## Application des droits étendus sur le dossier multimedia. + # Droit d'écriture pour le groupe et le groupe multimedia en acl et droit de lecture pour other: + setfacl -RnL -m g:$MEDIA_GROUP:rwX,g::rwX,o:r-X "$MEDIA_DIRECTORY" + # Application de la même règle que précédemment, mais par défaut pour les nouveaux fichiers. + setfacl -RnL -m d:g:$MEDIA_GROUP:rwX,g::rwX,o:r-X "$MEDIA_DIRECTORY" + # Réglage du masque par défaut. Qui garantie (en principe...) un droit maximal à rwx. Donc pas de restriction de droits par l'acl. + setfacl -RL -m m::rwx "$MEDIA_DIRECTORY" +} + +# Add a directory in yunohost.multimedia +# +# usage: ynh_multimedia_addfolder --source_dir="source_dir" --dest_dir="dest_dir" +# +# | arg: -s, --source_dir= - Source directory - The real directory which contains your medias. +# | arg: -d, --dest_dir= - Destination directory - The name and the place of the symbolic link, relative to "/home/yunohost.multimedia" +# +# This "directory" will be a symbolic link to a existing directory. +# +# Requires YunoHost version 4.2 or higher. +ynh_multimedia_addfolder() { + + # Declare an array to define the options of this helper. + local legacy_args=sd + local -A args_array=( [s]=source_dir= [d]=dest_dir= ) + local source_dir + local dest_dir + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + + # Ajout d'un lien symbolique vers le dossier à partager + ln -sfn "$source_dir" "$MEDIA_DIRECTORY/$dest_dir" + + ## Application des droits étendus sur le dossier ajouté + # Droit d'écriture pour le groupe et le groupe multimedia en acl et droit de lecture pour other: + setfacl -RnL -m g:$MEDIA_GROUP:rwX,g::rwX,o:r-X "$source_dir" + # Application de la même règle que précédemment, mais par défaut pour les nouveaux fichiers. + setfacl -RnL -m d:g:$MEDIA_GROUP:rwX,g::rwX,o:r-X "$source_dir" + # Réglage du masque par défaut. Qui garantie (en principe...) un droit maximal à rwx. Donc pas de restriction de droits par l'acl. + setfacl -RL -m m::rwx "$source_dir" +} + +# Allow an user to have an write authorisation in multimedia directories +# +# usage: ynh_multimedia_addaccess user_name +# +# | arg: -u, --user_name= - The name of the user which gain this access. +# +# Requires YunoHost version 4.2 or higher. +ynh_multimedia_addaccess () { + # Declare an array to define the options of this helper. + local legacy_args=u + declare -Ar args_array=( [u]=user_name=) + local user_name + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + + groupadd -f multimedia + usermod -a -G multimedia $user_name +} diff --git a/data/helpers.d/mysql b/data/helpers.d/mysql index 372819025..091dfaf40 100644 --- a/data/helpers.d/mysql +++ b/data/helpers.d/mysql @@ -1,22 +1,21 @@ #!/bin/bash -MYSQL_ROOT_PWD_FILE=/etc/yunohost/mysql - # Open a connection as a user # -# example: ynh_mysql_connect_as 'user' 'pass' <<< "UPDATE ...;" -# example: ynh_mysql_connect_as 'user' 'pass' < /path/to/file.sql -# # usage: ynh_mysql_connect_as --user=user --password=password [--database=database] -# | arg: -u, --user - the user name to connect as -# | arg: -p, --password - the user password -# | arg: -d, --database - the database to connect to +# | arg: -u, --user= - the user name to connect as +# | arg: -p, --password= - the user password +# | arg: -d, --database= - the database to connect to +# +# examples: +# ynh_mysql_connect_as --user="user" --password="pass" <<< "UPDATE ...;" +# ynh_mysql_connect_as --user="user" --password="pass" < /path/to/file.sql # # Requires YunoHost version 2.2.4 or higher. ynh_mysql_connect_as() { # Declare an array to define the options of this helper. local legacy_args=upd - declare -Ar args_array=( [u]=user= [p]=password= [d]=database= ) + local -A args_array=( [u]=user= [p]=password= [d]=database= ) local user local password local database @@ -24,49 +23,57 @@ ynh_mysql_connect_as() { ynh_handle_getopts_args "$@" database="${database:-}" - mysql -u "$user" --password="$password" -B "$database" + mysql --user="$user" --password="$password" --batch "$database" } # Execute a command as root user # # usage: ynh_mysql_execute_as_root --sql=sql [--database=database] -# | arg: -s, --sql - the SQL command to execute -# | arg: -d, --database - the database to connect to +# | arg: -s, --sql= - the SQL command to execute +# | arg: -d, --database= - the database to connect to # # Requires YunoHost version 2.2.4 or higher. ynh_mysql_execute_as_root() { # Declare an array to define the options of this helper. local legacy_args=sd - declare -Ar args_array=( [s]=sql= [d]=database= ) + local -A args_array=( [s]=sql= [d]=database= ) local sql local database # Manage arguments with getopts ynh_handle_getopts_args "$@" database="${database:-}" - ynh_mysql_connect_as --user="root" --password="$(sudo cat $MYSQL_ROOT_PWD_FILE)" \ - --database="$database" <<< "$sql" + if [ -n "$database" ] + then + database="--database=$database" + fi + + mysql -B "$database" <<< "$sql" } # Execute a command from a file as root user # # usage: ynh_mysql_execute_file_as_root --file=file [--database=database] -# | arg: -f, --file - the file containing SQL commands -# | arg: -d, --database - the database to connect to +# | arg: -f, --file= - the file containing SQL commands +# | arg: -d, --database= - the database to connect to # # Requires YunoHost version 2.2.4 or higher. ynh_mysql_execute_file_as_root() { # Declare an array to define the options of this helper. local legacy_args=fd - declare -Ar args_array=( [f]=file= [d]=database= ) + local -A args_array=( [f]=file= [d]=database= ) local file local database # Manage arguments with getopts ynh_handle_getopts_args "$@" database="${database:-}" - ynh_mysql_connect_as --user="root" --password="$(sudo cat $MYSQL_ROOT_PWD_FILE)" \ - --database="$database" < "$file" + if [ -n "$database" ] + then + database="--database=$database" + fi + + mysql -B "$database" < "$file" } # Create a database and grant optionnaly privilegies to a user @@ -85,9 +92,12 @@ ynh_mysql_create_db() { local sql="CREATE DATABASE ${db};" # grant all privilegies to user - if [[ $# -gt 1 ]]; then + if [[ $# -gt 1 ]] + then sql+=" GRANT ALL PRIVILEGES ON ${db}.* TO '${2}'@'localhost'" - [[ -n ${3:-} ]] && sql+=" IDENTIFIED BY '${3}'" + if [[ -n ${3:-} ]]; then + sql+=" IDENTIFIED BY '${3}'" + fi sql+=" WITH GRANT OPTION;" fi @@ -111,22 +121,22 @@ ynh_mysql_drop_db() { # Dump a database # -# example: ynh_mysql_dump_db 'roundcube' > ./dump.sql -# # usage: ynh_mysql_dump_db --database=database -# | arg: -d, --database - the database name to dump -# | ret: the mysqldump output +# | arg: -d, --database= - the database name to dump +# | ret: The mysqldump output +# +# example: ynh_mysql_dump_db --database=roundcube > ./dump.sql # # Requires YunoHost version 2.2.4 or higher. ynh_mysql_dump_db() { # Declare an array to define the options of this helper. local legacy_args=d - declare -Ar args_array=( [d]=database= ) + local -A args_array=( [d]=database= ) local database # Manage arguments with getopts ynh_handle_getopts_args "$@" - mysqldump -u "root" -p"$(sudo cat $MYSQL_ROOT_PWD_FILE)" --single-transaction --skip-dump-date "$database" + mysqldump --single-transaction --skip-dump-date "$database" } # Create a user @@ -146,24 +156,25 @@ ynh_mysql_create_user() { # Check if a mysql user exists # # usage: ynh_mysql_user_exists --user=user -# | arg: -u, --user - the user for which to check existence +# | arg: -u, --user= - the user for which to check existence +# | ret: 0 if the user exists, 1 otherwise. # # Requires YunoHost version 2.2.4 or higher. ynh_mysql_user_exists() { - # Declare an array to define the options of this helper. - local legacy_args=u - declare -Ar args_array=( [u]=user= ) - local user - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=u + local -A args_array=( [u]=user= ) + local user + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - if [[ -z $(ynh_mysql_execute_as_root --sql="SELECT User from mysql.user WHERE User = '$user';") ]] - then - return 1 - else - return 0 - fi + if [[ -z $(ynh_mysql_execute_as_root --sql="SELECT User from mysql.user WHERE User = '$user';") ]] + then + return 1 + else + return 0 + fi } # Drop a user @@ -180,59 +191,58 @@ ynh_mysql_drop_user() { # Create a database, an user and its password. Then store the password in the app's config # -# After executing this helper, the password of the created database will be available in $db_pwd -# It will also be stored as "mysqlpwd" into the app settings. -# # usage: ynh_mysql_setup_db --db_user=user --db_name=name [--db_pwd=pwd] -# | arg: -u, --db_user - Owner of the database -# | arg: -n, --db_name - Name of the database -# | arg: -p, --db_pwd - Password of the database. If not provided, a password will be generated +# | arg: -u, --db_user= - Owner of the database +# | arg: -n, --db_name= - Name of the database +# | arg: -p, --db_pwd= - Password of the database. If not provided, a password will be generated +# +# After executing this helper, the password of the created database will be available in `$db_pwd` +# It will also be stored as "`mysqlpwd`" into the app settings. # # Requires YunoHost version 2.6.4 or higher. ynh_mysql_setup_db () { - # Declare an array to define the options of this helper. - local legacy_args=unp - declare -Ar args_array=( [u]=db_user= [n]=db_name= [p]=db_pwd= ) - local db_user - local db_name - db_pwd="" - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=unp + local -A args_array=( [u]=db_user= [n]=db_name= [p]=db_pwd= ) + local db_user + local db_name + db_pwd="" + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - local new_db_pwd=$(ynh_string_random) # Generate a random password - # If $db_pwd is not provided, use new_db_pwd instead for db_pwd - db_pwd="${db_pwd:-$new_db_pwd}" + # Generate a random password + local new_db_pwd=$(ynh_string_random) + # If $db_pwd is not provided, use new_db_pwd instead for db_pwd + db_pwd="${db_pwd:-$new_db_pwd}" - ynh_mysql_create_db "$db_name" "$db_user" "$db_pwd" # Create the database - ynh_app_setting_set --app=$app --key=mysqlpwd --value=$db_pwd # Store the password in the app's config + ynh_mysql_create_db "$db_name" "$db_user" "$db_pwd" + ynh_app_setting_set --app=$app --key=mysqlpwd --value=$db_pwd } # Remove a database if it exists, and the associated user # # usage: ynh_mysql_remove_db --db_user=user --db_name=name -# | arg: -u, --db_user - Owner of the database -# | arg: -n, --db_name - Name of the database +# | arg: -u, --db_user= - Owner of the database +# | arg: -n, --db_name= - Name of the database # # Requires YunoHost version 2.6.4 or higher. ynh_mysql_remove_db () { - # Declare an array to define the options of this helper. - local legacy_args=un - declare -Ar args_array=( [u]=db_user= [n]=db_name= ) - local db_user - local db_name - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=un + local -Ar args_array=( [u]=db_user= [n]=db_name= ) + local db_user + local db_name + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - local mysql_root_password=$(sudo cat $MYSQL_ROOT_PWD_FILE) - if mysqlshow -u root -p$mysql_root_password | grep -q "^| $db_name"; then # Check if the database exists - ynh_mysql_drop_db $db_name # Remove the database - else - ynh_print_warn --message="Database $db_name not found" - fi + if mysqlshow | grep -q "^| $db_name "; then + ynh_mysql_drop_db $db_name + else + ynh_print_warn --message="Database $db_name not found" + fi - # Remove mysql user if it exists - if $(ynh_mysql_user_exists --user=$db_user); then - ynh_mysql_drop_user $db_user - fi + # Remove mysql user if it exists + if ynh_mysql_user_exists --user=$db_user; then + ynh_mysql_drop_user $db_user + fi } - diff --git a/data/helpers.d/network b/data/helpers.d/network index 0f75cb165..4e536a8db 100644 --- a/data/helpers.d/network +++ b/data/helpers.d/network @@ -2,30 +2,64 @@ # Find a free port and return it # -# example: port=$(ynh_find_port --port=8080) -# # usage: ynh_find_port --port=begin_port -# | arg: -p, --port - port to start to search +# | arg: -p, --port= - port to start to search +# | ret: the port number +# +# example: port=$(ynh_find_port --port=8080) # # Requires YunoHost version 2.6.4 or higher. ynh_find_port () { - # Declare an array to define the options of this helper. - local legacy_args=p - declare -Ar args_array=( [p]=port= ) - local port - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=p + local -A args_array=( [p]=port= ) + local port + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - test -n "$port" || ynh_die --message="The argument of ynh_find_port must be a valid port." - while netcat -z 127.0.0.1 $port # Check if the port is free - do - port=$((port+1)) # Else, pass to next port - done - echo $port + test -n "$port" || ynh_die --message="The argument of ynh_find_port must be a valid port." + while ! ynh_port_available --port=$port + do + port=$((port+1)) + done + echo $port } +# Test if a port is available +# +# usage: ynh_find_port --port=XYZ +# | arg: -p, --port= - port to check +# | ret: 0 if the port is available, 1 if it is already used by another process. +# +# example: ynh_port_available --port=1234 || ynh_die --message="Port 1234 is needs to be available for this app" +# +# Requires YunoHost version 3.8.0 or higher. +ynh_port_available () { + # Declare an array to define the options of this helper. + local legacy_args=p + local -A args_array=( [p]=port= ) + local port + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + + # Check if the port is free + if ss --numeric --listening --tcp --udp | awk '{print$5}' | grep --quiet --extended-regexp ":$port$" + then + return 1 + # This is to cover (most) case where an app is using a port yet ain't currently using it for some reason (typically service ain't up) + elif grep -q "port: '$port'" /etc/yunohost/apps/*/settings.yml + then + return 1 + else + return 0 + fi +} + + # Validate an IP address # +# [internal] +# # usage: ynh_validate_ip --family=family --ip_address=ip_address # | ret: 0 for valid ip addresses, 1 otherwise # @@ -34,19 +68,19 @@ ynh_find_port () { # Requires YunoHost version 2.2.4 or higher. ynh_validate_ip() { - # http://stackoverflow.com/questions/319279/how-to-validate-ip-address-in-python#319298 + # http://stackoverflow.com/questions/319279/how-to-validate-ip-address-in-python#319298 - # Declare an array to define the options of this helper. - local legacy_args=fi - declare -Ar args_array=( [f]=family= [i]=ip_address= ) - local family - local ip_address - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=fi + local -A args_array=( [f]=family= [i]=ip_address= ) + local family + local ip_address + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - [ "$family" == "4" ] || [ "$family" == "6" ] || return 1 + [ "$family" == "4" ] || [ "$family" == "6" ] || return 1 - python /dev/stdin << EOF + python3 /dev/stdin << EOF import socket import sys family = { "4" : socket.AF_INET, "6" : socket.AF_INET6 } @@ -60,41 +94,43 @@ EOF # Validate an IPv4 address # -# example: ynh_validate_ip4 111.222.333.444 -# # usage: ynh_validate_ip4 --ip_address=ip_address +# | arg: -i, --ip_address= - the ipv4 address to check # | ret: 0 for valid ipv4 addresses, 1 otherwise # +# example: ynh_validate_ip4 111.222.333.444 +# # Requires YunoHost version 2.2.4 or higher. ynh_validate_ip4() { - # Declare an array to define the options of this helper. - local legacy_args=i - declare -Ar args_array=( [i]=ip_address= ) - local ip_address - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=i + local -A args_array=( [i]=ip_address= ) + local ip_address + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - ynh_validate_ip 4 $ip_address + ynh_validate_ip --family=4 --ip_address=$ip_address } # Validate an IPv6 address # -# example: ynh_validate_ip6 2000:dead:beef::1 -# # usage: ynh_validate_ip6 --ip_address=ip_address +# | arg: -i, --ip_address= - the ipv6 address to check # | ret: 0 for valid ipv6 addresses, 1 otherwise # +# example: ynh_validate_ip6 2000:dead:beef::1 +# # Requires YunoHost version 2.2.4 or higher. ynh_validate_ip6() { - # Declare an array to define the options of this helper. - local legacy_args=i - declare -Ar args_array=( [i]=ip_address= ) - local ip_address - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=i + local -A args_array=( [i]=ip_address= ) + local ip_address + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - ynh_validate_ip 6 $ip_address + ynh_validate_ip --family=6 --ip_address=$ip_address } diff --git a/data/helpers.d/nginx b/data/helpers.d/nginx index ce6b61d3c..dca581d94 100644 --- a/data/helpers.d/nginx +++ b/data/helpers.d/nginx @@ -2,67 +2,35 @@ # Create a dedicated nginx config # -# usage: ynh_add_nginx_config "list of others variables to replace" +# usage: ynh_add_nginx_config # -# | arg: list - (Optional) list of others variables to replace separated by spaces. For example : 'path_2 port_2 ...' +# This will use a template in `../conf/nginx.conf` +# See the documentation of `ynh_add_config` for a description of the template +# format and how placeholders are replaced with actual variables. # -# This will use a template in ../conf/nginx.conf -# __PATH__ by $path_url -# __DOMAIN__ by $domain -# __PORT__ by $port -# __NAME__ by $app -# __FINALPATH__ by $final_path +# Additionally, ynh_add_nginx_config will replace: +# - `#sub_path_only` by empty string if `path_url` is not `'/'` +# - `#root_path_only` by empty string if `path_url` *is* `'/'` # -# And dynamic variables (from the last example) : -# __PATH_2__ by $path_2 -# __PORT_2__ by $port_2 +# This allows to enable/disable specific behaviors dependenging on the install +# location # -# Requires YunoHost version 2.7.2 or higher. +# Requires YunoHost version 4.1.0 or higher. ynh_add_nginx_config () { - finalnginxconf="/etc/nginx/conf.d/$domain.d/$app.conf" - local others_var=${1:-} - ynh_backup_if_checksum_is_different --file="$finalnginxconf" - sudo cp ../conf/nginx.conf "$finalnginxconf" - # 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 - if test -n "${path_url:-}"; then - # path_url_slash_less is path_url, or a blank value if path_url is only '/' - local path_url_slash_less=${path_url%/} - ynh_replace_string --match_string="__PATH__/" --replace_string="$path_url_slash_less/" --target_file="$finalnginxconf" - ynh_replace_string --match_string="__PATH__" --replace_string="$path_url" --target_file="$finalnginxconf" - fi - if test -n "${domain:-}"; then - ynh_replace_string --match_string="__DOMAIN__" --replace_string="$domain" --target_file="$finalnginxconf" - fi - if test -n "${port:-}"; then - ynh_replace_string --match_string="__PORT__" --replace_string="$port" --target_file="$finalnginxconf" - fi - if test -n "${app:-}"; then - ynh_replace_string --match_string="__NAME__" --replace_string="$app" --target_file="$finalnginxconf" - fi - if test -n "${final_path:-}"; then - ynh_replace_string --match_string="__FINALPATH__" --replace_string="$final_path" --target_file="$finalnginxconf" - fi + local finalnginxconf="/etc/nginx/conf.d/$domain.d/$app.conf" - # Replace all other variable given as arguments - for var_to_replace in $others_var - do - # ${var_to_replace^^} make the content of the variable on upper-cases - # ${!var_to_replace} get the content of the variable named $var_to_replace - ynh_replace_string --match_string="__${var_to_replace^^}__" --replace_string="${!var_to_replace}" --target_file="$finalnginxconf" - done - - if [ "${path_url:-}" != "/" ] - then - ynh_replace_string --match_string="^#sub_path_only" --replace_string="" --target_file="$finalnginxconf" - else - ynh_replace_string --match_string="^#root_path_only" --replace_string="" --target_file="$finalnginxconf" - fi + if [ "${path_url:-}" != "/" ] + then + ynh_replace_string --match_string="^#sub_path_only" --replace_string="" --target_file="$YNH_APP_BASEDIR/conf/nginx.conf" + else + ynh_replace_string --match_string="^#root_path_only" --replace_string="" --target_file="$YNH_APP_BASEDIR/conf/nginx.conf" + fi - ynh_store_file_checksum --file="$finalnginxconf" + ynh_add_config --template="$YNH_APP_BASEDIR/conf/nginx.conf" --destination="$finalnginxconf" - ynh_systemd_action --service_name=nginx --action=reload + + ynh_systemd_action --service_name=nginx --action=reload } # Remove the dedicated nginx config @@ -71,6 +39,6 @@ ynh_add_nginx_config () { # # Requires YunoHost version 2.7.2 or higher. ynh_remove_nginx_config () { - ynh_secure_remove --file="/etc/nginx/conf.d/$domain.d/$app.conf" - ynh_systemd_action --service_name=nginx --action=reload + ynh_secure_remove --file="/etc/nginx/conf.d/$domain.d/$app.conf" + ynh_systemd_action --service_name=nginx --action=reload } diff --git a/data/helpers.d/nodejs b/data/helpers.d/nodejs index aabdcb6be..a796b68fd 100644 --- a/data/helpers.d/nodejs +++ b/data/helpers.d/nodejs @@ -1,5 +1,6 @@ #!/bin/bash +n_version=7.3.0 n_install_dir="/opt/node_n" node_version_path="$n_install_dir/n/versions/node" # N_PREFIX is the directory of n, it needs to be loaded as a environment variable. @@ -13,154 +14,202 @@ export N_PREFIX="$n_install_dir" # # Requires YunoHost version 2.7.12 or higher. ynh_install_n () { - ynh_print_info --message="Installation of N - Node.js version management" - # Build an app.src for n - mkdir -p "../conf" - echo "SOURCE_URL=https://github.com/tj/n/archive/v2.1.7.tar.gz -SOURCE_SUM=2ba3c9d4dd3c7e38885b37e02337906a1ee91febe6d5c9159d89a9050f2eea8f" > "../conf/n.src" - # Download and extract n - ynh_setup_source --dest_dir="$n_install_dir/git" --source_id=n - # Install n - (cd "$n_install_dir/git" - PREFIX=$N_PREFIX make install 2>&1) + ynh_print_info --message="Installation of N - Node.js version management" + # Build an app.src for n + echo "SOURCE_URL=https://github.com/tj/n/archive/v${n_version}.tar.gz +SOURCE_SUM=b908b0fc86922ede37e89d1030191285209d7d521507bf136e62895e5797847f" > "$YNH_APP_BASEDIR/conf/n.src" + # Download and extract n + ynh_setup_source --dest_dir="$n_install_dir/git" --source_id=n + # Install n + (cd "$n_install_dir/git" + PREFIX=$N_PREFIX make install 2>&1) } # Load the version of node for an app, and set variables. # -# ynh_use_nodejs has to be used in any app scripts before using node for the first time. -# -# 2 variables are available: -# - $nodejs_path: The absolute path of node for the chosen version. -# - $nodejs_version: Just the version number of node for this app. Stored as 'nodejs_version' in settings.yml. -# And 2 alias stored in variables: -# - $nodejs_use_version: An old variable, not used anymore. Keep here to not break old apps -# NB: $PATH will contain the path to node, it has to be propagated to any other shell which needs to use it. -# That's means it has to be added to any systemd script. -# # usage: ynh_use_nodejs # +# `ynh_use_nodejs` has to be used in any app scripts before using node for the first time. +# This helper will provide alias and variables to use in your scripts. +# +# To use npm or node, use the alias `ynh_npm` and `ynh_node`. +# +# Those alias will use the correct version installed for the app. +# For example: use `ynh_npm install` instead of `npm install` +# +# With `sudo` or `ynh_exec_as`, use instead the fallback variables `$ynh_npm` and `$ynh_node` +# And propagate $PATH to sudo with $ynh_node_load_PATH +# Exemple: `ynh_exec_as $app $ynh_node_load_PATH $ynh_npm install` +# +# $PATH contains the path of the requested version of node. +# However, $PATH is duplicated into $node_PATH to outlast any manipulation of `$PATH` +# You can use the variable `$ynh_node_load_PATH` to quickly load your node version +# in $PATH for an usage into a separate script. +# Exemple: $ynh_node_load_PATH $final_path/script_that_use_npm.sh` +# +# +# Finally, to start a nodejs service with the correct version, 2 solutions +# Either the app is dependent of node or npm, but does not called it directly. +# In such situation, you need to load PATH : +# ``` +# Environment="__NODE_ENV_PATH__" +# ExecStart=__FINALPATH__/my_app +# ``` +# You will replace __NODE_ENV_PATH__ with $ynh_node_load_PATH. +# +# Or node start the app directly, then you don't need to load the PATH variable +# ``` +# ExecStart=__YNH_NODE__ my_app run +# ``` +# You will replace __YNH_NODE__ with $ynh_node +# +# +# 2 other variables are also available +# - $nodejs_path: The absolute path to node binaries for the chosen version. +# - $nodejs_version: Just the version number of node for this app. Stored as 'nodejs_version' in settings.yml. +# # Requires YunoHost version 2.7.12 or higher. ynh_use_nodejs () { - nodejs_version=$(ynh_app_setting_get --app=$app --key=nodejs_version) + nodejs_version=$(ynh_app_setting_get --app=$app --key=nodejs_version) - nodejs_use_version="echo \"Deprecated command, should be removed\"" + # Get the absolute path of this version of node + nodejs_path="$node_version_path/$nodejs_version/bin" - # Get the absolute path of this version of node - nodejs_path="$node_version_path/$nodejs_version/bin" + # Allow alias to be used into bash script + shopt -s expand_aliases - # Load the path of this version of node in $PATH - [[ :$PATH: == *":$nodejs_path"* ]] || PATH="$nodejs_path:$PATH" + # Create an alias for the specific version of node and a variable as fallback + ynh_node="$nodejs_path/node" + alias ynh_node="$ynh_node" + # And npm + ynh_npm="$nodejs_path/npm" + alias ynh_npm="$ynh_npm" + + # Load the path of this version of node in $PATH + if [[ :$PATH: != *":$nodejs_path"* ]]; then + PATH="$nodejs_path:$PATH" + fi + node_PATH="$PATH" + # Create an alias to easily load the PATH + ynh_node_load_PATH="PATH=$node_PATH" + # Same var but in lower case to be compatible with ynh_replace_vars... + ynh_node_load_path="PATH=$node_PATH" } # Install a specific version of nodejs # -# n (Node version management) uses the PATH variable to store the path of the version of node it is going to use. -# That's how it changes the version -# # ynh_install_nodejs will install the version of node provided as argument by using n. # # usage: ynh_install_nodejs --nodejs_version=nodejs_version -# | arg: -n, --nodejs_version - Version of node to install. When possible, your should prefer to use major version number (e.g. 8 instead of 8.10.0). The crontab will then handle the update of minor versions when needed. +# | arg: -n, --nodejs_version= - Version of node to install. When possible, your should prefer to use major version number (e.g. 8 instead of 8.10.0). The crontab will then handle the update of minor versions when needed. +# +# `n` (Node version management) uses the `PATH` variable to store the path of the version of node it is going to use. +# That's how it changes the version +# +# Refer to `ynh_use_nodejs` for more information about available commands and variables # # Requires YunoHost version 2.7.12 or higher. ynh_install_nodejs () { - # Use n, https://github.com/tj/n to manage the nodejs versions + # Use n, https://github.com/tj/n to manage the nodejs versions - # Declare an array to define the options of this helper. - local legacy_args=n - declare -Ar args_array=( [n]=nodejs_version= ) - local nodejs_version - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=n + local -A args_array=( [n]=nodejs_version= ) + local nodejs_version + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - # Create $n_install_dir - mkdir -p "$n_install_dir" + # Create $n_install_dir + mkdir --parents "$n_install_dir" - # Load n path in PATH - CLEAR_PATH="$n_install_dir/bin:$PATH" - # Remove /usr/local/bin in PATH in case of node prior installation - PATH=$(echo $CLEAR_PATH | sed 's@/usr/local/bin:@@') + # Load n path in PATH + CLEAR_PATH="$n_install_dir/bin:$PATH" + # Remove /usr/local/bin in PATH in case of node prior installation + PATH=$(echo $CLEAR_PATH | sed 's@/usr/local/bin:@@') - # Move an existing node binary, to avoid to block n. - test -x /usr/bin/node && mv /usr/bin/node /usr/bin/node_n - test -x /usr/bin/npm && mv /usr/bin/npm /usr/bin/npm_n + # Move an existing node binary, to avoid to block n. + test -x /usr/bin/node && mv /usr/bin/node /usr/bin/node_n + test -x /usr/bin/npm && mv /usr/bin/npm /usr/bin/npm_n - # If n is not previously setup, install it - if ! test $(n --version > /dev/null 2>&1) - then - ynh_install_n - fi + # If n is not previously setup, install it + if ! $n_install_dir/bin/n --version > /dev/null 2>&1 + then + ynh_install_n + elif dpkg --compare-versions "$($n_install_dir/bin/n --version)" lt $n_version + then + ynh_install_n + fi - # Modify the default N_PREFIX in n script - ynh_replace_string --match_string="^N_PREFIX=\${N_PREFIX-.*}$" --replace_string="N_PREFIX=\${N_PREFIX-$N_PREFIX}" --target_file="$n_install_dir/bin/n" + # Modify the default N_PREFIX in n script + ynh_replace_string --match_string="^N_PREFIX=\${N_PREFIX-.*}$" --replace_string="N_PREFIX=\${N_PREFIX-$N_PREFIX}" --target_file="$n_install_dir/bin/n" - # Restore /usr/local/bin in PATH - PATH=$CLEAR_PATH + # Restore /usr/local/bin in PATH + PATH=$CLEAR_PATH - # And replace the old node binary. - test -x /usr/bin/node_n && mv /usr/bin/node_n /usr/bin/node - test -x /usr/bin/npm_n && mv /usr/bin/npm_n /usr/bin/npm + # And replace the old node binary. + test -x /usr/bin/node_n && mv /usr/bin/node_n /usr/bin/node + test -x /usr/bin/npm_n && mv /usr/bin/npm_n /usr/bin/npm - # Install the requested version of nodejs - uname=$(uname -m) - if [[ $uname =~ aarch64 || $uname =~ arm64 ]] - then - n $nodejs_version --arch=arm64 - else - n $nodejs_version - fi + # Install the requested version of nodejs + uname=$(uname --machine) + if [[ $uname =~ aarch64 || $uname =~ arm64 ]] + then + n $nodejs_version --arch=arm64 + else + n $nodejs_version + fi - # Find the last "real" version for this major version of node. - real_nodejs_version=$(find $node_version_path/$nodejs_version* -maxdepth 0 | sort --version-sort | tail --lines=1) - real_nodejs_version=$(basename $real_nodejs_version) + # Find the last "real" version for this major version of node. + real_nodejs_version=$(find $node_version_path/$nodejs_version* -maxdepth 0 | sort --version-sort | tail --lines=1) + real_nodejs_version=$(basename $real_nodejs_version) - # Create a symbolic link for this major version if the file doesn't already exist - if [ ! -e "$node_version_path/$nodejs_version" ] - then - ln --symbolic --force --no-target-directory $node_version_path/$real_nodejs_version $node_version_path/$nodejs_version - fi + # Create a symbolic link for this major version if the file doesn't already exist + if [ ! -e "$node_version_path/$nodejs_version" ] + then + ln --symbolic --force --no-target-directory $node_version_path/$real_nodejs_version $node_version_path/$nodejs_version + fi - # Store the ID of this app and the version of node requested for it - echo "$YNH_APP_INSTANCE_NAME:$nodejs_version" | tee --append "$n_install_dir/ynh_app_version" + # Store the ID of this app and the version of node requested for it + echo "$YNH_APP_INSTANCE_NAME:$nodejs_version" | tee --append "$n_install_dir/ynh_app_version" - # Store nodejs_version into the config of this app - ynh_app_setting_set --app=$app --key=nodejs_version --value=$nodejs_version + # Store nodejs_version into the config of this app + ynh_app_setting_set --app=$app --key=nodejs_version --value=$nodejs_version - # Build the update script and set the cronjob - ynh_cron_upgrade_node + # Build the update script and set the cronjob + ynh_cron_upgrade_node - ynh_use_nodejs + ynh_use_nodejs } # Remove the version of node used by the app. # -# This helper will check if another app uses the same version of node, -# if not, this version of node will be removed. -# If no other app uses node, n will be also removed. -# # usage: ynh_remove_nodejs # +# This helper will check if another app uses the same version of node. +# - If not, this version of node will be removed. +# - If no other app uses node, n will be also removed. +# # Requires YunoHost version 2.7.12 or higher. ynh_remove_nodejs () { - nodejs_version=$(ynh_app_setting_get --app=$app --key=nodejs_version) + nodejs_version=$(ynh_app_setting_get --app=$app --key=nodejs_version) - # Remove the line for this app - sed --in-place "/$YNH_APP_INSTANCE_NAME:$nodejs_version/d" "$n_install_dir/ynh_app_version" + # Remove the line for this app + sed --in-place "/$YNH_APP_INSTANCE_NAME:$nodejs_version/d" "$n_install_dir/ynh_app_version" - # If no other app uses this version of nodejs, remove it. - if ! grep --quiet "$nodejs_version" "$n_install_dir/ynh_app_version" - then - $n_install_dir/bin/n rm $nodejs_version - fi + # If no other app uses this version of nodejs, remove it. + if ! grep --quiet "$nodejs_version" "$n_install_dir/ynh_app_version" + then + $n_install_dir/bin/n rm $nodejs_version + fi - # If no other app uses n, remove n - if [ ! -s "$n_install_dir/ynh_app_version" ] - then - ynh_secure_remove --file="$n_install_dir" - ynh_secure_remove --file="/usr/local/n" - sed --in-place "/N_PREFIX/d" /root/.bashrc - rm -f /etc/cron.daily/node_update - fi + # If no other app uses n, remove n + if [ ! -s "$n_install_dir/ynh_app_version" ] + then + ynh_secure_remove --file="$n_install_dir" + ynh_secure_remove --file="/usr/local/n" + sed --in-place "/N_PREFIX/d" /root/.bashrc + rm --force /etc/cron.daily/node_update + fi } # Set a cron design to update your node versions @@ -173,8 +222,8 @@ ynh_remove_nodejs () { # # Requires YunoHost version 2.7.12 or higher. ynh_cron_upgrade_node () { - # Build the update script - cat > "$n_install_dir/node_update.sh" << EOF + # Build the update script + cat > "$n_install_dir/node_update.sh" << EOF #!/bin/bash version_path="$node_version_path" @@ -195,26 +244,26 @@ all_real_version=\$(echo "\$all_real_version" | sort --unique) # Read each major version while read version do - echo "Update of the version \$version" - sudo \$n_install_dir/bin/n \$version + echo "Update of the version \$version" + sudo \$n_install_dir/bin/n \$version - # Find the last "real" version for this major version of node. - real_nodejs_version=\$(find \$version_path/\$version* -maxdepth 0 | sort --version-sort | tail --lines=1) - real_nodejs_version=\$(basename \$real_nodejs_version) + # Find the last "real" version for this major version of node. + real_nodejs_version=\$(find \$version_path/\$version* -maxdepth 0 | sort --version-sort | tail --lines=1) + real_nodejs_version=\$(basename \$real_nodejs_version) - # Update the symbolic link for this version - sudo ln --symbolic --force --no-target-directory \$version_path/\$real_nodejs_version \$version_path/\$version + # Update the symbolic link for this version + sudo ln --symbolic --force --no-target-directory \$version_path/\$real_nodejs_version \$version_path/\$version done <<< "\$(echo "\$all_real_version")" EOF - chmod +x "$n_install_dir/node_update.sh" + chmod +x "$n_install_dir/node_update.sh" - # Build the cronjob - cat > "/etc/cron.daily/node_update" << EOF + # Build the cronjob + cat > "/etc/cron.daily/node_update" << EOF #!/bin/bash $n_install_dir/node_update.sh >> $n_install_dir/node_update.log EOF - chmod +x "/etc/cron.daily/node_update" + chmod +x "/etc/cron.daily/node_update" } diff --git a/data/helpers.d/permission b/data/helpers.d/permission new file mode 100644 index 000000000..c04b4145b --- /dev/null +++ b/data/helpers.d/permission @@ -0,0 +1,412 @@ +#!/bin/bash + +# Create a new permission for the app +# +# Example 1: `ynh_permission_create --permission=admin --url=/admin --additional_urls=domain.tld/admin /superadmin --allowed=alice bob \ +# --label="My app admin" --show_tile=true` +# +# This example will create a new permission permission with this following effect: +# - A tile named "My app admin" in the SSO will be available for the users alice and bob. This tile will point to the relative url '/admin'. +# - Only the user alice and bob will have the access to theses following url: /admin, domain.tld/admin, /superadmin +# +# +# Example 2: +# +# ynh_permission_create --permission=api --url=domain.tld/api --auth_header=false --allowed=visitors \ +# --label="MyApp API" --protected=true +# +# This example will create a new protected permission. So the admin won't be able to add/remove the visitors group of this permission. +# In case of an API with need to be always public it avoid that the admin break anything. +# With this permission all client will be allowed to access to the url 'domain.tld/api'. +# Note that in this case no tile will be show on the SSO. +# Note that the auth_header parameter is to 'false'. So no authentication header will be passed to the application. +# Generally the API is requested by an application and enabling the auth_header has no advantage and could bring some issues in some case. +# So in this case it's better to disable this option for all API. +# +# +# usage: ynh_permission_create --permission="permission" [--url="url"] [--additional_urls="second-url" [ "third-url" ]] [--auth_header=true|false] +# [--allowed=group1 [ group2 ]] [--label="label"] [--show_tile=true|false] +# [--protected=true|false] +# | arg: -p, --permission= - the name for the permission (by default a permission named "main" already exist) +# | arg: -u, --url= - (optional) URL for which access will be allowed/forbidden. Note that if 'show_tile' is enabled, this URL will be the URL of the tile. +# | arg: -A, --additional_urls= - (optional) List of additional URL for which access will be allowed/forbidden +# | arg: -h, --auth_header= - (optional) Define for the URL of this permission, if SSOwat pass the authentication header to the application. Default is true +# | arg: -a, --allowed= - (optional) A list of group/user to allow for the permission +# | arg: -l, --label= - (optional) Define a name for the permission. This label will be shown on the SSO and in the admin. Default is "APP_LABEL (permission name)". +# | arg: -t, --show_tile= - (optional) Define if a tile will be shown in the SSO. If yes the name of the tile will be the 'label' parameter. Defaults to false for the permission different than 'main'. +# | arg: -P, --protected= - (optional) Define if this permission is protected. If it is protected the administrator won't be able to add or remove the visitors group of this permission. Defaults to 'false'. +# +# If provided, 'url' or 'additional_urls' is assumed to be relative to the app domain/path if they +# start with '/'. For example: +# / -> domain.tld/app +# /admin -> domain.tld/app/admin +# domain.tld/app/api -> domain.tld/app/api +# +# 'url' or 'additional_urls' can be treated as a PCRE (not lua) regex if it starts with "re:". +# For example: +# re:/api/[A-Z]*$ -> domain.tld/app/api/[A-Z]*$ +# re:domain.tld/app/api/[A-Z]*$ -> domain.tld/app/api/[A-Z]*$ +# +# Note that globally the parameter 'url' and 'additional_urls' are same. The only difference is: +# - 'url' is only one url, 'additional_urls' can be a list of urls. There are no limitation of 'additional_urls' +# - 'url' is used for the url of tile in the SSO (if enabled with the 'show_tile' parameter) +# +# +# About the authentication header (auth_header parameter). +# The SSO pass (by default) to the application theses following HTTP header (linked to the authenticated user) to the application: +# - "Auth-User": username +# - "Remote-User": username +# - "Email": user email +# +# Generally this feature is usefull to authenticate automatically the user in the application but in some case the application don't work with theses header and theses header need to be disabled to have the application to work correctly. +# See https://github.com/YunoHost/issues/issues/1420 for more informations +# +# +# Requires YunoHost version 3.7.0 or higher. +ynh_permission_create() { + # Declare an array to define the options of this helper. + local legacy_args=puAhaltP + local -A args_array=( [p]=permission= [u]=url= [A]=additional_urls= [h]=auth_header= [a]=allowed= [l]=label= [t]=show_tile= [P]=protected= ) + local permission + local url + local additional_urls + local auth_header + local allowed + local label + local show_tile + local protected + ynh_handle_getopts_args "$@" + url=${url:-} + additional_urls=${additional_urls:-} + auth_header=${auth_header:-} + allowed=${allowed:-} + label=${label:-} + show_tile=${show_tile:-} + protected=${protected:-} + + if [[ -n $url ]] + then + url=",url='$url'" + fi + + if [[ -n $additional_urls ]] + then + # Convert a list from getopts to python list + # Note that getopts separate the args with ';' + # By example: + # --additional_urls /urlA /urlB + # will be: + # additional_urls=['/urlA', '/urlB'] + additional_urls=",additional_urls=['${additional_urls//;/\',\'}']" + fi + + if [[ -n $auth_header ]] + then + if [ $auth_header == "true" ] + then + auth_header=",auth_header=True" + else + auth_header=",auth_header=False" + fi + fi + + if [[ -n $allowed ]] + then + # Convert a list from getopts to python list + # Note that getopts separate the args with ';' + # By example: + # --allowed alice bob + # will be: + # allowed=['alice', 'bob'] + allowed=",allowed=['${allowed//;/\',\'}']" + fi + + if [[ -n ${label:-} ]]; then + label=",label='$label'" + else + label=",label='$permission'" + fi + + if [[ -n ${show_tile:-} ]] + then + if [ $show_tile == "true" ] + then + show_tile=",show_tile=True" + else + show_tile=",show_tile=False" + fi + fi + + if [[ -n ${protected:-} ]] + then + if [ $protected == "true" ] + then + protected=",protected=True" + else + protected=",protected=False" + fi + fi + + yunohost tools shell -c "from yunohost.permission import permission_create; permission_create('$app.$permission' $url $additional_urls $auth_header $allowed $label $show_tile $protected)" +} + +# Remove a permission for the app (note that when the app is removed all permission is automatically removed) +# +# example: ynh_permission_delete --permission=editors +# +# usage: ynh_permission_delete --permission="permission" +# | arg: -p, --permission= - the name for the permission (by default a permission named "main" is removed automatically when the app is removed) +# +# Requires YunoHost version 3.7.0 or higher. +ynh_permission_delete() { + # Declare an array to define the options of this helper. + local legacy_args=p + local -A args_array=( [p]=permission= ) + local permission + ynh_handle_getopts_args "$@" + + yunohost tools shell -c "from yunohost.permission import permission_delete; permission_delete('$app.$permission')" +} + +# Check if a permission exists +# +# usage: ynh_permission_exists --permission=permission +# | arg: -p, --permission= - the permission to check +# | exit: Return 1 if the permission doesn't exist, 0 otherwise +# +# Requires YunoHost version 3.7.0 or higher. +ynh_permission_exists() { + # Declare an array to define the options of this helper. + local legacy_args=p + local -A args_array=( [p]=permission= ) + local permission + ynh_handle_getopts_args "$@" + + yunohost user permission list "$app" --output-as json --quiet \ + | jq -e --arg perm "$app.$permission" '.permissions[$perm]' >/dev/null +} + +# Redefine the url associated to a permission +# +# usage: ynh_permission_url --permission "permission" [--url="url"] [--add_url="new-url" [ "other-new-url" ]] [--remove_url="old-url" [ "other-old-url" ]] +# [--auth_header=true|false] [--clear_urls] +# | arg: -p, --permission= - the name for the permission (by default a permission named "main" is removed automatically when the app is removed) +# | arg: -u, --url= - (optional) URL for which access will be allowed/forbidden. Note that if you want to remove url you can pass an empty sting as arguments (""). +# | arg: -a, --add_url= - (optional) List of additional url to add for which access will be allowed/forbidden. +# | arg: -r, --remove_url= - (optional) List of additional url to remove for which access will be allowed/forbidden +# | arg: -h, --auth_header= - (optional) Define for the URL of this permission, if SSOwat pass the authentication header to the application +# | arg: -c, --clear_urls - (optional) Clean all urls (url and additional_urls) +# +# Requires YunoHost version 3.7.0 or higher. +ynh_permission_url() { + # Declare an array to define the options of this helper. + local legacy_args=puarhc + local -A args_array=( [p]=permission= [u]=url= [a]=add_url= [r]=remove_url= [h]=auth_header= [c]=clear_urls ) + local permission + local url + local add_url + local remove_url + local auth_header + local clear_urls + ynh_handle_getopts_args "$@" + url=${url:-} + add_url=${add_url:-} + remove_url=${remove_url:-} + auth_header=${auth_header:-} + clear_urls=${clear_urls:-} + + if [[ -n $url ]] + then + url=",url='$url'" + fi + + if [[ -n $add_url ]] + then + # Convert a list from getopts to python list + # Note that getopts separate the args with ';' + # For example: + # --add_url /urlA /urlB + # will be: + # add_url=['/urlA', '/urlB'] + add_url=",add_url=['${add_url//;/\',\'}']" + fi + + if [[ -n $remove_url ]] + then + # Convert a list from getopts to python list + # Note that getopts separate the args with ';' + # For example: + # --remove_url /urlA /urlB + # will be: + # remove_url=['/urlA', '/urlB'] + remove_url=",remove_url=['${remove_url//;/\',\'}']" + fi + + if [[ -n $auth_header ]] + then + if [ $auth_header == "true" ] + then + auth_header=",auth_header=True" + else + auth_header=",auth_header=False" + fi + fi + + if [[ -n $clear_urls ]] && [ $clear_urls -eq 1 ] + then + clear_urls=",clear_urls=True" + fi + + yunohost tools shell -c "from yunohost.permission import permission_url; permission_url('$app.$permission' $url $add_url $remove_url $auth_header $clear_urls)" +} + + +# Update a permission for the app +# +# usage: ynh_permission_update --permission "permission" [--add="group" ["group" ...]] [--remove="group" ["group" ...]] +# [--label="label"] [--show_tile=true|false] [--protected=true|false] +# | arg: -p, --permission= - the name for the permission (by default a permission named "main" already exist) +# | arg: -a, --add= - the list of group or users to enable add to the permission +# | arg: -r, --remove= - the list of group or users to remove from the permission +# | arg: -l, --label= - (optional) Define a name for the permission. This label will be shown on the SSO and in the admin. +# | arg: -t, --show_tile= - (optional) Define if a tile will be shown in the SSO +# | arg: -P, --protected= - (optional) Define if this permission is protected. If it is protected the administrator won't be able to add or remove the visitors group of this permission. +# +# Requires YunoHost version 3.7.0 or higher. +ynh_permission_update() { + # Declare an array to define the options of this helper. + local legacy_args=parltP + local -A args_array=( [p]=permission= [a]=add= [r]=remove= [l]=label= [t]=show_tile= [P]=protected= ) + local permission + local add + local remove + local label + local show_tile + local protected + ynh_handle_getopts_args "$@" + add=${add:-} + remove=${remove:-} + label=${label:-} + show_tile=${show_tile:-} + protected=${protected:-} + + if [[ -n $add ]] + then + # Convert a list from getopts to python list + # Note that getopts separate the args with ';' + # For example: + # --add alice bob + # will be: + # add=['alice', 'bob'] + add=",add=['${add//';'/"','"}']" + fi + if [[ -n $remove ]] + then + # Convert a list from getopts to python list + # Note that getopts separate the args with ';' + # For example: + # --remove alice bob + # will be: + # remove=['alice', 'bob'] + remove=",remove=['${remove//';'/"','"}']" + fi + + if [[ -n $label ]] + then + label=",label='$label'" + fi + + if [[ -n $show_tile ]] + then + if [ $show_tile == "true" ] + then + show_tile=",show_tile=True" + else + show_tile=",show_tile=False" + fi + fi + + if [[ -n $protected ]]; then + if [ $protected == "true" ] + then + protected=",protected=True" + else + protected=",protected=False" + fi + fi + + yunohost tools shell -c "from yunohost.permission import user_permission_update; user_permission_update('$app.$permission' $add $remove $label $show_tile $protected , force=True)" +} + +# Check if a permission has an user +# +# example: ynh_permission_has_user --permission=main --user=visitors +# +# usage: ynh_permission_has_user --permission=permission --user=user +# | arg: -p, --permission= - the permission to check +# | arg: -u, --user= - the user seek in the permission +# | exit: Return 1 if the permission doesn't have that user or doesn't exist, 0 otherwise +# +# Requires YunoHost version 3.7.1 or higher. +ynh_permission_has_user() { + local legacy_args=pu + # Declare an array to define the options of this helper. + local -A args_array=( [p]=permission= [u]=user= ) + local permission + local user + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + + if ! ynh_permission_exists --permission=$permission + then + return 1 + fi + + # Check both allowed and corresponding_users sections in the json + for section in "allowed" "corresponding_users" + do + if yunohost user permission info "$app.$permission" --output-as json --quiet \ + | jq -e --arg user $user --arg section $section '.[$section] | index($user)' >/dev/null + then + return 0 + fi + done + + return 1 +} + +# Check if a legacy permissions exist +# +# usage: ynh_legacy_permissions_exists +# | exit: Return 1 if the permission doesn't exist, 0 otherwise +# +# Requires YunoHost version 4.1.2 or higher. +ynh_legacy_permissions_exists () { + for permission in "skipped" "unprotected" "protected" + do + if ynh_permission_exists --permission="legacy_${permission}_uris"; then + return 0 + fi + done + return 1 +} + +# Remove all legacy permissions +# +# usage: ynh_legacy_permissions_delete_all +# +# example: +# if ynh_legacy_permissions_exists +# then +# ynh_legacy_permissions_delete_all +# # You can recreate the required permissions here with ynh_permission_create +# fi +# Requires YunoHost version 4.1.2 or higher. +ynh_legacy_permissions_delete_all () { + for permission in "skipped" "unprotected" "protected" + do + if ynh_permission_exists --permission="legacy_${permission}_uris"; then + ynh_permission_delete --permission="legacy_${permission}_uris" + fi + done +} diff --git a/data/helpers.d/php b/data/helpers.d/php index c9e3ba9ed..7c91d89d2 100644 --- a/data/helpers.d/php +++ b/data/helpers.d/php @@ -1,67 +1,621 @@ #!/bin/bash -# Create a dedicated php-fpm config +readonly YNH_DEFAULT_PHP_VERSION=7.3 +# Declare the actual PHP version to use. +# A packager willing to use another version of PHP can override the variable into its _common.sh. +YNH_PHP_VERSION=${YNH_PHP_VERSION:-$YNH_DEFAULT_PHP_VERSION} + +# Create a dedicated PHP-FPM config # -# usage: ynh_add_fpm_config [--phpversion=7.X] -# | arg: -v, --phpversion - Version of php to use. +# usage 1: ynh_add_fpm_config [--phpversion=7.X] [--use_template] [--package=packages] [--dedicated_service] +# | arg: -v, --phpversion= - Version of PHP to use. +# | arg: -t, --use_template - Use this helper in template mode. +# | arg: -p, --package= - Additionnal PHP packages to install +# | arg: -d, --dedicated_service - Use a dedicated PHP-FPM service instead of the common one. # -# Requires YunoHost version 2.7.2 or higher. +# ----------------------------------------------------------------------------- +# +# usage 2: ynh_add_fpm_config [--phpversion=7.X] --usage=usage --footprint=footprint [--package=packages] [--dedicated_service] +# | arg: -v, --phpversion= - Version of PHP to use. +# | arg: -f, --footprint= - Memory footprint of the service (low/medium/high). +# low - Less than 20 MB of RAM by pool. +# medium - Between 20 MB and 40 MB of RAM by pool. +# high - More than 40 MB of RAM by pool. +# Or specify exactly the footprint, the load of the service as MB by pool instead of having a standard value. +# To have this value, use the following command and stress the service. +# watch -n0.5 ps -o user,cmd,%cpu,rss -u APP +# +# | arg: -u, --usage= - Expected usage of the service (low/medium/high). +# low - Personal usage, behind the SSO. +# medium - Low usage, few people or/and publicly accessible. +# high - High usage, frequently visited website. +# +# | arg: -p, --package= - Additionnal PHP packages to install for a specific version of PHP +# | arg: -d, --dedicated_service - Use a dedicated PHP-FPM service instead of the common one. +# +# +# The footprint of the service will be used to defined the maximum footprint we can allow, which is half the maximum RAM. +# So it will be used to defined 'pm.max_children' +# A lower value for the footprint will allow more children for 'pm.max_children'. And so for +# 'pm.start_servers', 'pm.min_spare_servers' and 'pm.max_spare_servers' which are defined from the +# value of 'pm.max_children' +# NOTE: 'pm.max_children' can't exceed 4 times the number of processor's cores. +# +# The usage value will defined the way php will handle the children for the pool. +# A value set as 'low' will set the process manager to 'ondemand'. Children will start only if the +# service is used, otherwise no child will stay alive. This config gives the lower footprint when the +# service is idle. But will use more proc since it has to start a child as soon it's used. +# Set as 'medium', the process manager will be at dynamic. If the service is idle, a number of children +# equal to pm.min_spare_servers will stay alive. So the service can be quick to answer to any request. +# The number of children can grow if needed. The footprint can stay low if the service is idle, but +# not null. The impact on the proc is a little bit less than 'ondemand' as there's always a few +# children already available. +# Set as 'high', the process manager will be set at 'static'. There will be always as many children as +# 'pm.max_children', the footprint is important (but will be set as maximum a quarter of the maximum +# RAM) but the impact on the proc is lower. The service will be quick to answer as there's always many +# children ready to answer. +# +# Requires YunoHost version 4.1.0 or higher. ynh_add_fpm_config () { - # Declare an array to define the options of this helper. - local legacy_args=v - declare -Ar args_array=( [v]=phpversion= ) - local phpversion - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=vtufpd + local -A args_array=( [v]=phpversion= [t]=use_template [u]=usage= [f]=footprint= [p]=package= [d]=dedicated_service ) + local phpversion + local use_template + local usage + local footprint + local package + local dedicated_service + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + package=${package:-} - # Configure PHP-FPM 7.0 by default - phpversion="${phpversion:-7.0}" + # The default behaviour is to use the template. + use_template="${use_template:-1}" + usage="${usage:-}" + footprint="${footprint:-}" + if [ -n "$usage" ] || [ -n "$footprint" ]; then + use_template=0 + fi + # Do not use a dedicated service by default + dedicated_service=${dedicated_service:-0} - local fpm_config_dir="/etc/php/$phpversion/fpm" - local fpm_service="php${phpversion}-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=$app --key=fpm_config_dir --value="$fpm_config_dir" - ynh_app_setting_set --app=$app --key=fpm_service --value="$fpm_service" - finalphpconf="$fpm_config_dir/pool.d/$app.conf" - ynh_backup_if_checksum_is_different --file="$finalphpconf" - sudo cp ../conf/php-fpm.conf "$finalphpconf" - ynh_replace_string --match_string="__NAMETOCHANGE__" --replace_string="$app" --target_file="$finalphpconf" - ynh_replace_string --match_string="__FINALPATH__" --replace_string="$final_path" --target_file="$finalphpconf" - ynh_replace_string --match_string="__USER__" --replace_string="$app" --target_file="$finalphpconf" - ynh_replace_string --match_string="__PHPVERSION__" --replace_string="$phpversion" --target_file="$finalphpconf" - sudo chown root: "$finalphpconf" - ynh_store_file_checksum --file="$finalphpconf" + # Set the default PHP-FPM version by default + phpversion="${phpversion:-$YNH_PHP_VERSION}" - if [ -e "../conf/php-fpm.ini" ] - then - echo "Packagers ! Please do not use a separate php ini file, merge your directives in the pool file instead." >&2 - 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 - ynh_systemd_action --service_name=$fpm_service --action=reload + local old_phpversion=$(ynh_app_setting_get --app=$app --key=phpversion) + + # If the PHP version changed, remove the old fpm conf + if [ -n "$old_phpversion" ] && [ "$old_phpversion" != "$phpversion" ] + then + local old_php_fpm_config_dir=$(ynh_app_setting_get --app=$app --key=fpm_config_dir) + local old_php_finalphpconf="$old_php_fpm_config_dir/pool.d/$app.conf" + + ynh_backup_if_checksum_is_different --file="$old_php_finalphpconf" + + ynh_remove_fpm_config + fi + + # If the requested PHP version is not the default version for YunoHost + if [ "$phpversion" != "$YNH_DEFAULT_PHP_VERSION" ] + then + # If the argument --package is used, add the packages to ynh_install_php to install them from sury + if [ -n "$package" ] + then + local additionnal_packages="--package=$package" + else + local additionnal_packages="" + fi + # Install this specific version of PHP. + ynh_install_php --phpversion="$phpversion" "$additionnal_packages" + elif [ -n "$package" ] + then + # Install the additionnal packages from the default repository + ynh_add_app_dependencies --package="$package" + fi + + if [ $dedicated_service -eq 1 ] + then + local fpm_service="${app}-phpfpm" + local fpm_config_dir="/etc/php/$phpversion/dedicated-fpm" + else + local fpm_service="php${phpversion}-fpm" + local fpm_config_dir="/etc/php/$phpversion/fpm" + fi + + # Create the directory for FPM pools + mkdir --parents "$fpm_config_dir/pool.d" + + ynh_app_setting_set --app=$app --key=fpm_config_dir --value="$fpm_config_dir" + ynh_app_setting_set --app=$app --key=fpm_service --value="$fpm_service" + ynh_app_setting_set --app=$app --key=fpm_dedicated_service --value="$dedicated_service" + ynh_app_setting_set --app=$app --key=phpversion --value=$phpversion + + # Migrate from mutual PHP service to dedicated one. + if [ $dedicated_service -eq 1 ] + then + local old_fpm_config_dir="/etc/php/$phpversion/fpm" + # If a config file exist in the common pool, move it. + if [ -e "$old_fpm_config_dir/pool.d/$app.conf" ] + then + ynh_print_info --message="Migrate to a dedicated php-fpm service for $app." + # Create a backup of the old file before migration + ynh_backup_if_checksum_is_different --file="$old_fpm_config_dir/pool.d/$app.conf" + # Remove the old PHP config file + ynh_secure_remove --file="$old_fpm_config_dir/pool.d/$app.conf" + # Reload PHP to release the socket and allow the dedicated service to use it + ynh_systemd_action --service_name=php${phpversion}-fpm --action=reload + fi + fi + + if [ $use_template -eq 1 ] + then + # Usage 1, use the template in conf/php-fpm.conf + local phpfpm_path="$YNH_APP_BASEDIR/conf/php-fpm.conf" + # Make sure now that the template indeed exists + [ -e "$phpfpm_path" ] || ynh_die --message="Unable to find template to configure PHP-FPM." + else + # Usage 2, generate a PHP-FPM config file with ynh_get_scalable_phpfpm + + # Store settings + ynh_app_setting_set --app=$app --key=fpm_footprint --value=$footprint + ynh_app_setting_set --app=$app --key=fpm_usage --value=$usage + + # Define the values to use for the configuration of PHP. + ynh_get_scalable_phpfpm --usage=$usage --footprint=$footprint + + local phpfpm_path="$YNH_APP_BASEDIR/conf/php-fpm.conf" + echo " +[__APP__] + +user = __APP__ +group = __APP__ + +chdir = __FINALPATH__ + +listen = /var/run/php/php__PHPVERSION__-fpm-__APP__.sock +listen.owner = www-data +listen.group = www-data + +pm = __PHP_PM__ +pm.max_children = __PHP_MAX_CHILDREN__ +pm.max_requests = 500 +request_terminate_timeout = 1d +" > $phpfpm_path + + if [ "$php_pm" = "dynamic" ] + then + echo " +pm.start_servers = __PHP_START_SERVERS__ +pm.min_spare_servers = __PHP_MIN_SPARE_SERVERS__ +pm.max_spare_servers = __PHP_MAX_SPARE_SERVERS__ +" >> $phpfpm_path + + elif [ "$php_pm" = "ondemand" ] + then + echo " +pm.process_idle_timeout = 10s +" >> $phpfpm_path + fi + + # Concatene the extra config. + if [ -e $YNH_APP_BASEDIR/conf/extra_php-fpm.conf ]; then + cat $YNH_APP_BASEDIR/conf/extra_php-fpm.conf >> "$phpfpm_path" + fi + fi + + local finalphpconf="$fpm_config_dir/pool.d/$app.conf" + ynh_add_config --template="$phpfpm_path" --destination="$finalphpconf" + + if [ -e "$YNH_APP_BASEDIR/conf/php-fpm.ini" ] + then + ynh_print_warn --message="Packagers ! Please do not use a separate php ini file, merge your directives in the pool file instead." + ynh_add_config --template="$YNH_APP_BASEDIR/conf/php-fpm.ini" --destination="$fpm_config_dir/conf.d/20-$app.ini" + fi + + if [ $dedicated_service -eq 1 ] + then + # Create a dedicated php-fpm.conf for the service + local globalphpconf=$fpm_config_dir/php-fpm-$app.conf + +echo "[global] +pid = /run/php/php__PHPVERSION__-fpm-__APP__.pid +error_log = /var/log/php/fpm-php.__APP__.log +syslog.ident = php-fpm-__APP__ +include = __FINALPHPCONF__ +" > $YNH_APP_BASEDIR/conf/php-fpm-$app.conf + + ynh_add_config --template="$YNH_APP_BASEDIR/conf/php-fpm-$app.conf" --destination="$globalphpconf" + + # Create a config for a dedicated PHP-FPM service for the app + echo "[Unit] +Description=PHP __PHPVERSION__ FastCGI Process Manager for __APP__ +After=network.target + +[Service] +Type=notify +PIDFile=/run/php/php__PHPVERSION__-fpm-__APP__.pid +ExecStart=/usr/sbin/php-fpm__PHPVERSION__ --nodaemonize --fpm-config __GLOBALPHPCONF__ +ExecReload=/bin/kill -USR2 \$MAINPID + +[Install] +WantedBy=multi-user.target +" > $YNH_APP_BASEDIR/conf/$fpm_service + + # Create this dedicated PHP-FPM service + ynh_add_systemd_config --service=$fpm_service --template=$fpm_service + # Integrate the service in YunoHost admin panel + yunohost service add $fpm_service --log /var/log/php/fpm-php.$app.log --description "Php-fpm dedicated to $app" + # Configure log rotate + ynh_use_logrotate --logfile=/var/log/php + # Restart the service, as this service is either stopped or only for this app + ynh_systemd_action --service_name=$fpm_service --action=restart + else + # Validate that the new php conf doesn't break php-fpm entirely + if ! php-fpm${phpversion} --test 2>/dev/null + then + php-fpm${phpversion} --test || true + ynh_secure_remove --file="$finalphpconf" + ynh_die --message="The new configuration broke php-fpm?" + fi + ynh_systemd_action --service_name=$fpm_service --action=reload + fi } -# Remove the dedicated php-fpm config +# Remove the dedicated PHP-FPM config # # usage: ynh_remove_fpm_config # # Requires YunoHost version 2.7.2 or higher. ynh_remove_fpm_config () { - local fpm_config_dir=$(ynh_app_setting_get --app=$app --key=fpm_config_dir) - local fpm_service=$(ynh_app_setting_get --app=$app --key=fpm_service) - # Assume php version 7 if not set - if [ -z "$fpm_config_dir" ]; then - fpm_config_dir="/etc/php/7.0/fpm" - fpm_service="php7.0-fpm" - fi - ynh_secure_remove --file="$fpm_config_dir/pool.d/$app.conf" - ynh_secure_remove --file="$fpm_config_dir/conf.d/20-$app.ini" 2>&1 - ynh_systemd_action --service_name=$fpm_service --action=reload + local fpm_config_dir=$(ynh_app_setting_get --app=$app --key=fpm_config_dir) + local fpm_service=$(ynh_app_setting_get --app=$app --key=fpm_service) + local dedicated_service=$(ynh_app_setting_get --app=$app --key=fpm_dedicated_service) + dedicated_service=${dedicated_service:-0} + # Get the version of PHP used by this app + local phpversion=$(ynh_app_setting_get --app=$app --key=phpversion) + + # Assume default PHP-FPM version by default + phpversion="${phpversion:-$YNH_DEFAULT_PHP_VERSION}" + + # Assume default PHP files if not set + if [ -z "$fpm_config_dir" ] + then + fpm_config_dir="/etc/php/$YNH_DEFAULT_PHP_VERSION/fpm" + fpm_service="php$YNH_DEFAULT_PHP_VERSION-fpm" + fi + + ynh_secure_remove --file="$fpm_config_dir/pool.d/$app.conf" + if [ -e $fpm_config_dir/conf.d/20-$app.ini ] + then + ynh_secure_remove --file="$fpm_config_dir/conf.d/20-$app.ini" + fi + + if [ $dedicated_service -eq 1 ] + then + # Remove the dedicated service PHP-FPM service for the app + ynh_remove_systemd_config --service=$fpm_service + # Remove the global PHP-FPM conf + ynh_secure_remove --file="$fpm_config_dir/php-fpm-$app.conf" + # Remove the service from the list of services known by YunoHost + yunohost service remove $fpm_service + elif ynh_package_is_installed --package="php${phpversion}-fpm"; then + ynh_systemd_action --service_name=$fpm_service --action=reload + fi + + # If the PHP version used is not the default version for YunoHost + if [ "$phpversion" != "$YNH_DEFAULT_PHP_VERSION" ] + then + # Remove this specific version of PHP + ynh_remove_php + fi +} + +# Install another version of PHP. +# +# [internal] +# +# usage: ynh_install_php --phpversion=phpversion [--package=packages] +# | arg: -v, --phpversion= - Version of PHP to install. +# | arg: -p, --package= - Additionnal PHP packages to install +# +# Requires YunoHost version 3.8.1 or higher. +ynh_install_php () { + # Declare an array to define the options of this helper. + local legacy_args=vp + local -A args_array=( [v]=phpversion= [p]=package= ) + local phpversion + local package + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + package=${package:-} + + # Store phpversion into the config of this app + ynh_app_setting_set $app phpversion $phpversion + + if [ "$phpversion" == "$YNH_DEFAULT_PHP_VERSION" ] + then + ynh_die --message="Do not use ynh_install_php to install php$YNH_DEFAULT_PHP_VERSION" + fi + + # Create the file if doesn't exist already + touch /etc/php/ynh_app_version + + # Do not add twice the same line + if ! grep --quiet "$YNH_APP_INSTANCE_NAME:" "/etc/php/ynh_app_version" + then + # Store the ID of this app and the version of PHP requested for it + echo "$YNH_APP_INSTANCE_NAME:$phpversion" | tee --append "/etc/php/ynh_app_version" + fi + + # Add an extra repository for those packages + ynh_install_extra_repo --repo="https://packages.sury.org/php/ $(ynh_get_debian_release) main" --key="https://packages.sury.org/php/apt.gpg" --name=extra_php_version --priority=600 + + # Install requested dependencies from this extra repository. + # Install PHP-FPM first, otherwise PHP will install apache as a dependency. + ynh_add_app_dependencies --package="php${phpversion}-fpm" + ynh_add_app_dependencies --package="php$phpversion php${phpversion}-common $package" + + # Set the default PHP version back as the default version for php-cli. + update-alternatives --set php /usr/bin/php$YNH_DEFAULT_PHP_VERSION + + # Advertise service in admin panel + yunohost service add php${phpversion}-fpm --log "/var/log/php${phpversion}-fpm.log" +} + +# Remove the specific version of PHP used by the app. +# +# [internal] +# +# usage: ynh_install_php +# +# Requires YunoHost version 3.8.1 or higher. +ynh_remove_php () { + # Get the version of PHP used by this app + local phpversion=$(ynh_app_setting_get --app=$app --key=phpversion) + + if [ "$phpversion" == "$YNH_DEFAULT_PHP_VERSION" ] || [ -z "$phpversion" ] + then + if [ "$phpversion" == "$YNH_DEFAULT_PHP_VERSION" ] + then + ynh_print_err "Do not use ynh_remove_php to remove php$YNH_DEFAULT_PHP_VERSION !" + fi + return 0 + fi + + # Create the file if doesn't exist already + touch /etc/php/ynh_app_version + + # Remove the line for this app + sed --in-place "/$YNH_APP_INSTANCE_NAME:$phpversion/d" "/etc/php/ynh_app_version" + + # If no other app uses this version of PHP, remove it. + if ! grep --quiet "$phpversion" "/etc/php/ynh_app_version" + then + # Remove the service from the admin panel + if ynh_package_is_installed --package="php${phpversion}-fpm"; then + yunohost service remove php${phpversion}-fpm + fi + + # Purge PHP dependencies for this version. + ynh_package_autopurge "php$phpversion php${phpversion}-fpm php${phpversion}-common" + fi +} + +# Define the values to configure PHP-FPM +# +# [internal] +# +# usage: ynh_get_scalable_phpfpm --usage=usage --footprint=footprint [--print] +# | arg: -f, --footprint= - Memory footprint of the service (low/medium/high). +# low - Less than 20 MB of RAM by pool. +# medium - Between 20 MB and 40 MB of RAM by pool. +# high - More than 40 MB of RAM by pool. +# Or specify exactly the footprint, the load of the service as MB by pool instead of having a standard value. +# To have this value, use the following command and stress the service. +# watch -n0.5 ps -o user,cmd,%cpu,rss -u APP +# +# | arg: -u, --usage= - Expected usage of the service (low/medium/high). +# low - Personal usage, behind the SSO. +# medium - Low usage, few people or/and publicly accessible. +# high - High usage, frequently visited website. +# +# | arg: -p, --print - Print the result (intended for debug purpose only when packaging the app) +ynh_get_scalable_phpfpm () { + local legacy_args=ufp + # Declare an array to define the options of this helper. + local -A args_array=( [u]=usage= [f]=footprint= [p]=print ) + local usage + local footprint + local print + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + # Set all characters as lowercase + footprint=${footprint,,} + usage=${usage,,} + print=${print:-0} + + if [ "$footprint" = "low" ] + then + footprint=20 + elif [ "$footprint" = "medium" ] + then + footprint=35 + elif [ "$footprint" = "high" ] + then + footprint=50 + fi + + # Define the factor to determine min_spare_servers + # to avoid having too few children ready to start for heavy apps + if [ $footprint -le 20 ] + then + min_spare_servers_factor=8 + elif [ $footprint -le 35 ] + then + min_spare_servers_factor=5 + else + min_spare_servers_factor=3 + fi + + # Define the way the process manager handle child processes. + if [ "$usage" = "low" ] + then + php_pm=ondemand + elif [ "$usage" = "medium" ] + then + php_pm=dynamic + elif [ "$usage" = "high" ] + then + php_pm=static + else + ynh_die --message="Does not recognize '$usage' as an usage value." + fi + + # Get the total of RAM available, except swap. + local max_ram=$(ynh_get_ram --total --ignore_swap) + + at_least_one() { + # Do not allow value below 1 + if [ $1 -le 0 ] + then + echo 1 + else + echo $1 + fi + } + + # Define pm.max_children + # The value of pm.max_children is the total amount of ram divide by 2 and divide again by the footprint of a pool for this app. + # So if PHP-FPM start the maximum of children, it won't exceed half of the ram. + php_max_children=$(( $max_ram / 2 / $footprint )) + # If process manager is set as static, use half less children. + # Used as static, there's always as many children as the value of pm.max_children + if [ "$php_pm" = "static" ] + then + php_max_children=$(( $php_max_children / 2 )) + fi + php_max_children=$(at_least_one $php_max_children) + + # To not overload the proc, limit the number of children to 4 times the number of cores. + local core_number=$(nproc) + local max_proc=$(( $core_number * 4 )) + if [ $php_max_children -gt $max_proc ] + then + php_max_children=$max_proc + fi + + # Get a potential forced value for php_max_children + local php_forced_max_children=$(ynh_app_setting_get --app=$app --key=php_forced_max_children) + if [ -n "$php_forced_max_children" ]; then + php_max_children=$php_forced_max_children + fi + + if [ "$php_pm" = "dynamic" ] + then + # Define pm.start_servers, pm.min_spare_servers and pm.max_spare_servers for a dynamic process manager + php_min_spare_servers=$(( $php_max_children / $min_spare_servers_factor )) + php_min_spare_servers=$(at_least_one $php_min_spare_servers) + + php_max_spare_servers=$(( $php_max_children / 2 )) + php_max_spare_servers=$(at_least_one $php_max_spare_servers) + + php_start_servers=$(( $php_min_spare_servers + ( $php_max_spare_servers - $php_min_spare_servers ) /2 )) + php_start_servers=$(at_least_one $php_start_servers) + else + php_min_spare_servers=0 + php_max_spare_servers=0 + php_start_servers=0 + fi + + if [ $print -eq 1 ] + then + ynh_debug --message="Footprint=${footprint}Mb by pool." + ynh_debug --message="Process manager=$php_pm" + ynh_debug --message="Max RAM=${max_ram}Mb" + if [ "$php_pm" != "static" ] + then + ynh_debug --message="\nMax estimated footprint=$(( $php_max_children * $footprint ))" + ynh_debug --message="Min estimated footprint=$(( $php_min_spare_servers * $footprint ))" + fi + if [ "$php_pm" = "dynamic" ] + then + ynh_debug --message="Estimated average footprint=$(( $php_max_spare_servers * $footprint ))" + elif [ "$php_pm" = "static" ] + then + ynh_debug --message="Estimated footprint=$(( $php_max_children * $footprint ))" + fi + ynh_debug --message="\nRaw php-fpm values:" + ynh_debug --message="pm.max_children = $php_max_children" + if [ "$php_pm" = "dynamic" ] + then + ynh_debug --message="pm.start_servers = $php_start_servers" + ynh_debug --message="pm.min_spare_servers = $php_min_spare_servers" + ynh_debug --message="pm.max_spare_servers = $php_max_spare_servers" + fi + fi +} + +readonly YNH_DEFAULT_COMPOSER_VERSION=1.10.17 +# Declare the actual composer version to use. +# A packager willing to use another version of composer can override the variable into its _common.sh. +YNH_COMPOSER_VERSION=${YNH_COMPOSER_VERSION:-$YNH_DEFAULT_COMPOSER_VERSION} + +# Execute a command with Composer +# +# usage: ynh_composer_exec [--phpversion=phpversion] [--workdir=$final_path] --commands="commands" +# | arg: -v, --phpversion - PHP version to use with composer +# | arg: -w, --workdir - The directory from where the command will be executed. Default $final_path. +# | arg: -c, --commands - Commands to execute. +# +# Requires YunoHost version 4.2 or higher. +ynh_composer_exec () { + # Declare an array to define the options of this helper. + local legacy_args=vwc + declare -Ar args_array=( [v]=phpversion= [w]=workdir= [c]=commands= ) + local phpversion + local workdir + local commands + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + workdir="${workdir:-$final_path}" + phpversion="${phpversion:-$YNH_PHP_VERSION}" + + COMPOSER_HOME="$workdir/.composer" COMPOSER_MEMORY_LIMIT=-1 \ + php${phpversion} "$workdir/composer.phar" $commands \ + -d "$workdir" --quiet --no-interaction +} + +# Install and initialize Composer in the given directory +# +# usage: ynh_install_composer [--phpversion=phpversion] [--workdir=$final_path] [--install_args="--optimize-autoloader"] [--composerversion=composerversion] +# | arg: -v, --phpversion - PHP version to use with composer +# | arg: -w, --workdir - The directory from where the command will be executed. Default $final_path. +# | arg: -a, --install_args - Additional arguments provided to the composer install. Argument --no-dev already include +# | arg: -c, --composerversion - Composer version to install +# +# Requires YunoHost version 4.2 or higher. +ynh_install_composer () { + # Declare an array to define the options of this helper. + local legacy_args=vwac + declare -Ar args_array=( [v]=phpversion= [w]=workdir= [a]=install_args= [c]=composerversion=) + local phpversion + local workdir + local install_args + local composerversion + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + workdir="${workdir:-$final_path}" + phpversion="${phpversion:-$YNH_PHP_VERSION}" + install_args="${install_args:-}" + composerversion="${composerversion:-$YNH_COMPOSER_VERSION}" + + curl -sS https://getcomposer.org/installer \ + | COMPOSER_HOME="$workdir/.composer" \ + php${phpversion} -- --quiet --install-dir="$workdir" --version=$composerversion \ + || ynh_die --message="Unable to install Composer." + + # install dependencies + ynh_composer_exec --phpversion="${phpversion}" --workdir="$workdir" --commands="install --no-dev $install_args" \ + || ynh_die --message="Unable to install core dependencies with Composer." } diff --git a/data/helpers.d/postgresql b/data/helpers.d/postgresql index a76580b11..12738a922 100644 --- a/data/helpers.d/postgresql +++ b/data/helpers.d/postgresql @@ -1,73 +1,84 @@ #!/bin/bash PSQL_ROOT_PWD_FILE=/etc/yunohost/psql +PSQL_VERSION=11 # Open a connection as a user # -# examples: +# usage: ynh_psql_connect_as --user=user --password=password [--database=database] +# | arg: -u, --user= - the user name to connect as +# | arg: -p, --password= - the user password +# | arg: -d, --database= - the database to connect to +# +# examples: # ynh_psql_connect_as 'user' 'pass' <<< "UPDATE ...;" # ynh_psql_connect_as 'user' 'pass' < /path/to/file.sql # -# usage: ynh_psql_connect_as --user=user --password=password [--database=database] -# | arg: -u, --user - the user name to connect as -# | arg: -p, --password - the user password -# | arg: -d, --database - the database to connect to -# # Requires YunoHost version 3.5.0 or higher. ynh_psql_connect_as() { - # Declare an array to define the options of this helper. - local legacy_args=upd - declare -Ar args_array=([u]=user= [p]=password= [d]=database=) - local user - local password - local database - # Manage arguments with getopts - ynh_handle_getopts_args "$@" - database="${database:-}" + # Declare an array to define the options of this helper. + local legacy_args=upd + local -A args_array=([u]=user= [p]=password= [d]=database=) + local user + local password + local database + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + database="${database:-}" - sudo --login --user=postgres PGUSER="$user" PGPASSWORD="$password" psql "$database" + sudo --login --user=postgres PGUSER="$user" PGPASSWORD="$password" psql "$database" } # Execute a command as root user # # usage: ynh_psql_execute_as_root --sql=sql [--database=database] -# | arg: -s, --sql - the SQL command to execute -# | arg: -d, --database - the database to connect to +# | arg: -s, --sql= - the SQL command to execute +# | arg: -d, --database= - the database to connect to # # Requires YunoHost version 3.5.0 or higher. ynh_psql_execute_as_root() { - # Declare an array to define the options of this helper. - local legacy_args=sd - declare -Ar args_array=([s]=sql= [d]=database=) - local sql - local database - # Manage arguments with getopts - ynh_handle_getopts_args "$@" - database="${database:-}" + # Declare an array to define the options of this helper. + local legacy_args=sd + local -A args_array=([s]=sql= [d]=database=) + local sql + local database + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + database="${database:-}" - ynh_psql_connect_as --user="postgres" --password="$(sudo cat $PSQL_ROOT_PWD_FILE)" \ - --database="$database" <<<"$sql" + if [ -n "$database" ] + then + database="--database=$database" + fi + + ynh_psql_connect_as --user="postgres" --password="$(cat $PSQL_ROOT_PWD_FILE)" \ + $database <<<"$sql" } # Execute a command from a file as root user # # usage: ynh_psql_execute_file_as_root --file=file [--database=database] -# | arg: -f, --file - the file containing SQL commands -# | arg: -d, --database - the database to connect to +# | arg: -f, --file= - the file containing SQL commands +# | arg: -d, --database= - the database to connect to # # Requires YunoHost version 3.5.0 or higher. ynh_psql_execute_file_as_root() { - # Declare an array to define the options of this helper. - local legacy_args=fd - declare -Ar args_array=([f]=file= [d]=database=) - local file - local database - # Manage arguments with getopts - ynh_handle_getopts_args "$@" - database="${database:-}" + # Declare an array to define the options of this helper. + local legacy_args=fd + local -A args_array=([f]=file= [d]=database=) + local file + local database + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + database="${database:-}" - ynh_psql_connect_as --user="postgres" --password="$(sudo cat $PSQL_ROOT_PWD_FILE)" \ - --database="$database" <"$file" + if [ -n "$database" ] + then + database="--database=$database" + fi + + ynh_psql_connect_as --user="postgres" --password="$(cat $PSQL_ROOT_PWD_FILE)" \ + $database <"$file" } # Create a database and grant optionnaly privilegies to a user @@ -80,17 +91,18 @@ ynh_psql_execute_file_as_root() { # # Requires YunoHost version 3.5.0 or higher. ynh_psql_create_db() { - local db=$1 - local user=${2:-} + local db=$1 + local user=${2:-} - local sql="CREATE DATABASE ${db};" + local sql="CREATE DATABASE ${db};" - # grant all privilegies to user - if [ -n "$user" ]; then - sql+="GRANT ALL PRIVILEGES ON DATABASE ${db} TO ${user} WITH GRANT OPTION;" - fi + # grant all privilegies to user + if [ -n "$user" ]; then + sql+="ALTER DATABASE ${db} OWNER TO ${user};" + sql+="GRANT ALL PRIVILEGES ON DATABASE ${db} TO ${user} WITH GRANT OPTION;" + fi - ynh_psql_execute_as_root --sql="$sql" + ynh_psql_execute_as_root --sql="$sql" } # Drop a database @@ -105,32 +117,32 @@ ynh_psql_create_db() { # # Requires YunoHost version 3.5.0 or higher. ynh_psql_drop_db() { - local db=$1 - # First, force disconnection of all clients connected to the database - # https://stackoverflow.com/questions/5408156/how-to-drop-a-postgresql-database-if-there-are-active-connections-to-it - # https://dba.stackexchange.com/questions/16426/how-to-drop-all-connections-to-a-specific-database-without-stopping-the-server - ynh_psql_execute_as_root --sql="SELECT pg_terminate_backend (pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '$db';" --database="$db" - sudo --login --user=postgres dropdb $db + local db=$1 + # First, force disconnection of all clients connected to the database + # https://stackoverflow.com/questions/17449420/postgresql-unable-to-drop-database-because-of-some-auto-connections-to-db + ynh_psql_execute_as_root --sql="REVOKE CONNECT ON DATABASE $db FROM public;" --database="$db" + ynh_psql_execute_as_root --sql="SELECT pg_terminate_backend (pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '$db' AND pid <> pg_backend_pid();" --database="$db" + sudo --login --user=postgres dropdb $db } # Dump a database # -# example: ynh_psql_dump_db 'roundcube' > ./dump.sql -# # usage: ynh_psql_dump_db --database=database -# | arg: -d, --database - the database name to dump +# | arg: -d, --database= - the database name to dump # | ret: the psqldump output # +# example: ynh_psql_dump_db 'roundcube' > ./dump.sql +# # Requires YunoHost version 3.5.0 or higher. ynh_psql_dump_db() { - # Declare an array to define the options of this helper. - local legacy_args=d - declare -Ar args_array=([d]=database=) - local database - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=d + local -A args_array=([d]=database=) + local database + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - sudo --login --user=postgres pg_dump "$database" + sudo --login --user=postgres pg_dump "$database" } # Create a user @@ -143,47 +155,55 @@ ynh_psql_dump_db() { # # Requires YunoHost version 3.5.0 or higher. ynh_psql_create_user() { - local user=$1 - local pwd=$2 - ynh_psql_execute_as_root --sql="CREATE USER $user WITH ENCRYPTED PASSWORD '$pwd'" + local user=$1 + local pwd=$2 + ynh_psql_execute_as_root --sql="CREATE USER $user WITH ENCRYPTED PASSWORD '$pwd'" } # Check if a psql user exists # # usage: ynh_psql_user_exists --user=user -# | arg: -u, --user - the user for which to check existence +# | arg: -u, --user= - the user for which to check existence +# | exit: Return 1 if the user doesn't exist, 0 otherwise +# +# Requires YunoHost version 3.5.0 or higher. ynh_psql_user_exists() { - # Declare an array to define the options of this helper. - local legacy_args=u - declare -Ar args_array=([u]=user=) - local user - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=u + local -A args_array=([u]=user=) + local user + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - if ! sudo --login --user=postgres PGUSER="postgres" PGPASSWORD="$(sudo cat $PSQL_ROOT_PWD_FILE)" psql -tAc "SELECT rolname FROM pg_roles WHERE rolname='$user';" | grep --quiet "$user" ; then - return 1 - else - return 0 - fi + if ! sudo --login --user=postgres PGUSER="postgres" PGPASSWORD="$(cat $PSQL_ROOT_PWD_FILE)" psql -tAc "SELECT rolname FROM pg_roles WHERE rolname='$user';" | grep --quiet "$user" + then + return 1 + else + return 0 + fi } # Check if a psql database exists # # usage: ynh_psql_database_exists --database=database -# | arg: -d, --database - the database for which to check existence +# | arg: -d, --database= - the database for which to check existence +# | exit: Return 1 if the database doesn't exist, 0 otherwise +# +# Requires YunoHost version 3.5.0 or higher. ynh_psql_database_exists() { - # Declare an array to define the options of this helper. - local legacy_args=d - declare -Ar args_array=([d]=database=) - local database - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=d + local -A args_array=([d]=database=) + local database + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - if ! sudo --login --user=postgres PGUSER="postgres" PGPASSWORD="$(sudo cat $PSQL_ROOT_PWD_FILE)" psql -tAc "SELECT datname FROM pg_database WHERE datname='$database';" | grep --quiet "$database"; then - return 1 - else - return 0 - fi + if ! sudo --login --user=postgres PGUSER="postgres" PGPASSWORD="$(cat $PSQL_ROOT_PWD_FILE)" psql -tAc "SELECT datname FROM pg_database WHERE datname='$database';" | grep --quiet "$database" + then + return 1 + else + return 0 + fi } # Drop a user @@ -195,104 +215,118 @@ ynh_psql_database_exists() { # # Requires YunoHost version 3.5.0 or higher. ynh_psql_drop_user() { - ynh_psql_execute_as_root --sql="DROP USER ${1};" + ynh_psql_execute_as_root --sql="DROP USER ${1};" } # Create a database, an user and its password. Then store the password in the app's config # +# usage: ynh_psql_setup_db --db_user=user --db_name=name [--db_pwd=pwd] +# | arg: -u, --db_user= - Owner of the database +# | arg: -n, --db_name= - Name of the database +# | arg: -p, --db_pwd= - Password of the database. If not provided, a password will be generated +# # After executing this helper, the password of the created database will be available in $db_pwd # It will also be stored as "psqlpwd" into the app settings. # -# usage: ynh_psql_setup_db --db_user=user --db_name=name [--db_pwd=pwd] -# | arg: -u, --db_user - Owner of the database -# | arg: -n, --db_name - Name of the database -# | arg: -p, --db_pwd - Password of the database. If not given, a password will be generated +# Requires YunoHost version 2.7.13 or higher. ynh_psql_setup_db() { - # Declare an array to define the options of this helper. - local legacy_args=unp - declare -Ar args_array=([u]=db_user= [n]=db_name= [p]=db_pwd=) - local db_user - local db_name - db_pwd="" - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=unp + local -A args_array=([u]=db_user= [n]=db_name= [p]=db_pwd=) + local db_user + local db_name + db_pwd="" + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - local new_db_pwd=$(ynh_string_random) # Generate a random password - # If $db_pwd is not given, use new_db_pwd instead for db_pwd - db_pwd="${db_pwd:-$new_db_pwd}" + if ! ynh_psql_user_exists --user=$db_user; then + local new_db_pwd=$(ynh_string_random) # Generate a random password + # If $db_pwd is not provided, use new_db_pwd instead for db_pwd + db_pwd="${db_pwd:-$new_db_pwd}" - if ! ynh_psql_user_exists --user=$db_user; then - ynh_psql_create_user "$db_user" "$db_pwd" - fi + ynh_psql_create_user "$db_user" "$db_pwd" + elif [ -z $db_pwd ]; then + ynh_die --message="The user $db_user exists, please provide his password" + fi - ynh_psql_create_db "$db_name" "$db_user" # Create the database - ynh_app_setting_set --app=$app --key=psqlpwd --value=$db_pwd # Store the password in the app's config + ynh_psql_create_db "$db_name" "$db_user" # Create the database + ynh_app_setting_set --app=$app --key=psqlpwd --value=$db_pwd # Store the password in the app's config } # Remove a database if it exists, and the associated user # # usage: ynh_psql_remove_db --db_user=user --db_name=name -# | arg: -u, --db_user - Owner of the database -# | arg: -n, --db_name - Name of the database +# | arg: -u, --db_user= - Owner of the database +# | arg: -n, --db_name= - Name of the database +# +# Requires YunoHost version 2.7.13 or higher. ynh_psql_remove_db() { - # Declare an array to define the options of this helper. - local legacy_args=un - declare -Ar args_array=([u]=db_user= [n]=db_name=) - local db_user - local db_name - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=un + local -A args_array=([u]=db_user= [n]=db_name=) + local db_user + local db_name + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - local psql_root_password=$(sudo cat $PSQL_ROOT_PWD_FILE) - if ynh_psql_database_exists --database=$db_name; then # Check if the database exists - ynh_psql_drop_db $db_name # Remove the database - else - ynh_print_warn --message="Database $db_name not found" - fi + if ynh_psql_database_exists --database=$db_name + then # Check if the database exists + ynh_psql_drop_db $db_name # Remove the database + else + ynh_print_warn --message="Database $db_name not found" + fi - # Remove psql user if it exists - if ynh_psql_user_exists --user=$db_user; then - ynh_psql_drop_user $db_user - else - ynh_print_warn --message="User $db_user not found" - fi + # Remove psql user if it exists + if ynh_psql_user_exists --user=$db_user + then + ynh_psql_drop_user $db_user + else + ynh_print_warn --message="User $db_user not found" + fi } # Create a master password and set up global settings -# Please always call this script in install and restore scripts # # usage: ynh_psql_test_if_first_run +# +# It also make sure that postgresql is installed and running +# Please always call this script in install and restore scripts +# +# Requires YunoHost version 2.7.13 or higher. ynh_psql_test_if_first_run() { - if [ -f "$PSQL_ROOT_PWD_FILE" ]; then - echo "PostgreSQL is already installed, no need to create master password" - else - local psql_root_password="$(ynh_string_random)" - echo "$psql_root_password" >$PSQL_ROOT_PWD_FILE - if [ -e /etc/postgresql/9.4/ ]; then - local pg_hba=/etc/postgresql/9.4/main/pg_hba.conf - local logfile=/var/log/postgresql/postgresql-9.4-main.log - elif [ -e /etc/postgresql/9.6/ ]; then - local pg_hba=/etc/postgresql/9.6/main/pg_hba.conf - local logfile=/var/log/postgresql/postgresql-9.6-main.log - else - ynh_die "postgresql shoud be 9.4 or 9.6" - fi + # Make sure postgresql is indeed installed + dpkg --list | grep -q "ii postgresql-$PSQL_VERSION" || ynh_die --message="postgresql-$PSQL_VERSION is not installed !?" - ynh_systemd_action --service_name=postgresql --action=start + # Check for some weird issue where postgresql could be installed but etc folder would not exist ... + [ -e "/etc/postgresql/$PSQL_VERSION" ] || ynh_die --message="It looks like postgresql was not properly configured ? /etc/postgresql/$PSQL_VERSION is missing ... Could be due to a locale issue, c.f.https://serverfault.com/questions/426989/postgresql-etc-postgresql-doesnt-exist" - sudo --login --user=postgres psql -c"ALTER user postgres WITH PASSWORD '$psql_root_password'" postgres + # Make sure postgresql is started and enabled + # (N.B. : to check the active state, we check the cluster state because + # postgresql could be flagged as active even though the cluster is in + # failed state because of how the service is configured..) + systemctl is-active postgresql@$PSQL_VERSION-main -q || ynh_systemd_action --service_name=postgresql --action=restart + systemctl is-enabled postgresql -q || systemctl enable postgresql --quiet - # force all user to connect to local database using passwords - # https://www.postgresql.org/docs/current/static/auth-pg-hba-conf.html#EXAMPLE-PG-HBA.CONF - # Note: we can't use peer since YunoHost create users with nologin - # See: https://github.com/YunoHost/yunohost/blob/unstable/data/helpers.d/user - ynh_replace_string --match_string="local\(\s*\)all\(\s*\)all\(\s*\)peer" --replace_string="local\1all\2all\3password" --target_file="$pg_hba" + # If this is the very first time, we define the root password + # and configure a few things + if [ ! -f "$PSQL_ROOT_PWD_FILE" ] + then + local pg_hba=/etc/postgresql/$PSQL_VERSION/main/pg_hba.conf - # Advertise service in admin panel - yunohost service add postgresql --log "$logfile" + local psql_root_password="$(ynh_string_random)" + echo "$psql_root_password" >$PSQL_ROOT_PWD_FILE + sudo --login --user=postgres psql -c"ALTER user postgres WITH PASSWORD '$psql_root_password'" postgres - systemctl enable postgresql - ynh_systemd_action --service_name=postgresql --action=reload - fi + # force all user to connect to local databases using hashed passwords + # https://www.postgresql.org/docs/current/static/auth-pg-hba-conf.html#EXAMPLE-PG-HBA.CONF + # Note: we can't use peer since YunoHost create users with nologin + # See: https://github.com/YunoHost/yunohost/blob/unstable/data/helpers.d/user + ynh_replace_string --match_string="local\(\s*\)all\(\s*\)all\(\s*\)peer" --replace_string="local\1all\2all\3md5" --target_file="$pg_hba" + + # Integrate postgresql service in yunohost + yunohost service add postgresql --log "/var/log/postgresql/" + + ynh_systemd_action --service_name=postgresql --action=reload + fi } diff --git a/data/helpers.d/setting b/data/helpers.d/setting index da711b4bd..66bce9717 100644 --- a/data/helpers.d/setting +++ b/data/helpers.d/setting @@ -3,152 +3,72 @@ # Get an application setting # # usage: ynh_app_setting_get --app=app --key=key -# | arg: -a, --app - the application id -# | arg: -k, --key - the setting to get +# | arg: -a, --app= - the application id +# | arg: -k, --key= - the setting to get # # Requires YunoHost version 2.2.4 or higher. ynh_app_setting_get() { # Declare an array to define the options of this helper. local legacy_args=ak - declare -Ar args_array=( [a]=app= [k]=key= ) + local -A args_array=( [a]=app= [k]=key= ) local app local key # Manage arguments with getopts ynh_handle_getopts_args "$@" - ynh_app_setting "get" "$app" "$key" + if [[ $key =~ (unprotected|protected|skipped)_ ]]; then + yunohost app setting $app $key + else + ynh_app_setting "get" "$app" "$key" + fi } # Set an application setting # # usage: ynh_app_setting_set --app=app --key=key --value=value -# | arg: -a, --app - the application id -# | arg: -k, --key - the setting name to set -# | arg: -v, --value - the setting value to set +# | arg: -a, --app= - the application id +# | arg: -k, --key= - the setting name to set +# | arg: -v, --value= - the setting value to set # # Requires YunoHost version 2.2.4 or higher. ynh_app_setting_set() { # Declare an array to define the options of this helper. local legacy_args=akv - declare -Ar args_array=( [a]=app= [k]=key= [v]=value= ) + local -A args_array=( [a]=app= [k]=key= [v]=value= ) local app local key local value # Manage arguments with getopts ynh_handle_getopts_args "$@" - ynh_app_setting "set" "$app" "$key" "$value" + if [[ $key =~ (unprotected|protected|skipped)_ ]]; then + yunohost app setting $app $key -v $value + else + ynh_app_setting "set" "$app" "$key" "$value" + fi } # Delete an application setting # # usage: ynh_app_setting_delete --app=app --key=key -# | arg: -a, --app - the application id -# | arg: -k, --key - the setting to delete +# | arg: -a, --app= - the application id +# | arg: -k, --key= - the setting to delete # # Requires YunoHost version 2.2.4 or higher. ynh_app_setting_delete() { # Declare an array to define the options of this helper. local legacy_args=ak - declare -Ar args_array=( [a]=app= [k]=key= ) + local -A args_array=( [a]=app= [k]=key= ) local app local key # Manage arguments with getopts ynh_handle_getopts_args "$@" - ynh_app_setting "delete" "$app" "$key" -} - -# Add skipped_uris urls into the config -# -# usage: ynh_add_skipped_uris [--appid=app] --url=url1,url2 [--regex] -# | arg: -a, --appid - the application id -# | arg: -u, --url - the urls to add to the sso for this app -# | arg: -r, --regex - Use the key 'skipped_regex' instead of 'skipped_uris' -# -# An URL set with 'skipped_uris' key will be totally ignored by the SSO, -# which means that the access will be public and the logged-in user information will not be passed to the app. -# -# Requires YunoHost version 3.6.0 or higher. -ynh_add_skipped_uris() { - # Declare an array to define the options of this helper. - local legacy_args=aur - declare -Ar args_array=( [a]=appid= [u]=url= [r]=regex ) - local appid - local url - local regex - # Manage arguments with getopts - ynh_handle_getopts_args "$@" - appid={appid:-$app} - regex={regex:-0} - - local key=skipped_uris - if [ $regex -eq 1 ]; then - key=skipped_regex + if [[ "$key" =~ (unprotected|skipped|protected)_ ]]; then + yunohost app setting $app $key -d + else + ynh_app_setting "delete" "$app" "$key" fi - - ynh_app_setting_set --app=$appid --key=$key --value="$url" -} - -# Add unprotected_uris urls into the config -# -# usage: ynh_add_unprotected_uris [--appid=app] --url=url1,url2 [--regex] -# | arg: -a, --appid - the application id -# | arg: -u, --url - the urls to add to the sso for this app -# | arg: -r, --regex - Use the key 'unprotected_regex' instead of 'unprotected_uris' -# -# An URL set with unprotected_uris key will be accessible publicly, but if an user is logged in, -# his information will be accessible (through HTTP headers) to the app. -# -# Requires YunoHost version 3.6.0 or higher. -ynh_add_unprotected_uris() { - # Declare an array to define the options of this helper. - local legacy_args=aur - declare -Ar args_array=( [a]=appid= [u]=url= [r]=regex ) - local appid - local url - local regex - # Manage arguments with getopts - ynh_handle_getopts_args "$@" - appid={appid:-$app} - regex={regex:-0} - - local key=unprotected_uris - if [ $regex -eq 1 ]; then - key=unprotected_regex - fi - - ynh_app_setting_set --app=$appid --key=$key --value="$url" -} - -# Add protected_uris urls into the config -# -# usage: ynh_add_protected_uris [--appid=app] --url=url1,url2 [--regex] -# | arg: -a, --appid - the application id -# | arg: -u, --url - the urls to add to the sso for this app -# | arg: -r, --regex - Use the key 'protected_regex' instead of 'protected_uris' -# -# An URL set with protected_uris will be blocked by the SSO and accessible only to authenticated and authorized users. -# -# Requires YunoHost version 3.6.0 or higher. -ynh_add_protected_uris() { - # Declare an array to define the options of this helper. - local legacy_args=aur - declare -Ar args_array=( [a]=appid= [u]=url= [r]=regex ) - local appid - local url - local regex - # Manage arguments with getopts - ynh_handle_getopts_args "$@" - appid={appid:-$app} - regex={regex:-0} - - local key=protected_uris - if [ $regex -eq 1 ]; then - key=protected_regex - fi - - ynh_app_setting_set --app=$appid --key=$key --value="$url" } # Small "hard-coded" interface to avoid calling "yunohost app" directly each @@ -158,14 +78,15 @@ ynh_add_protected_uris() { # ynh_app_setting() { - ACTION="$1" APP="$2" KEY="$3" VALUE="${4:-}" python - < /dev/null \ - | tr -c -d 'A-Za-z0-9' \ - | sed -n 's/\(.\{'"$length"'\}\).*/\1/p' + | tr --complement --delete 'A-Za-z0-9' \ + | sed --quiet 's/\(.\{'"$length"'\}\).*/\1/p' } # Substitute/replace a string (or expression) by another in a file # # usage: ynh_replace_string --match_string=match_string --replace_string=replace_string --target_file=target_file -# | arg: -m, --match_string - String to be searched and replaced in the file -# | arg: -r, --replace_string - String that will replace matches -# | arg: -f, --target_file - File in which the string will be replaced. +# | arg: -m, --match_string= - String to be searched and replaced in the file +# | arg: -r, --replace_string= - String that will replace matches +# | arg: -f, --target_file= - File in which the string will be replaced. # -# As this helper is based on sed command, regular expressions and -# references to sub-expressions can be used -# (see sed manual page for more information) +# As this helper is based on sed command, regular expressions and references to +# sub-expressions can be used (see sed manual page for more information) # # Requires YunoHost version 2.6.4 or higher. ynh_replace_string () { - # Declare an array to define the options of this helper. - local legacy_args=mrf - declare -Ar args_array=( [m]=match_string= [r]=replace_string= [f]=target_file= ) - local match_string - local replace_string - local target_file - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=mrf + local -A args_array=( [m]=match_string= [r]=replace_string= [f]=target_file= ) + local match_string + local replace_string + local target_file + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + set +o xtrace # set +x - local delimit=@ - # Escape the delimiter if it's in the string. - match_string=${match_string//${delimit}/"\\${delimit}"} - replace_string=${replace_string//${delimit}/"\\${delimit}"} + local delimit=@ + # Escape the delimiter if it's in the string. + match_string=${match_string//${delimit}/"\\${delimit}"} + replace_string=${replace_string//${delimit}/"\\${delimit}"} - sudo sed --in-place "s${delimit}${match_string}${delimit}${replace_string}${delimit}g" "$target_file" + set -o xtrace # set -x + sed --in-place "s${delimit}${match_string}${delimit}${replace_string}${delimit}g" "$target_file" } # Substitute/replace a special string by another in a file # # usage: ynh_replace_special_string --match_string=match_string --replace_string=replace_string --target_file=target_file -# | arg: -m, --match_string - String to be searched and replaced in the file -# | arg: -r, --replace_string - String that will replace matches -# | arg: -t, --target_file - File in which the string will be replaced. +# | arg: -m, --match_string= - String to be searched and replaced in the file +# | arg: -r, --replace_string= - String that will replace matches +# | arg: -t, --target_file= - File in which the string will be replaced. # # This helper will use ynh_replace_string, but as you can use special # characters, you can't use some regular expressions and sub-expressions. # # Requires YunoHost version 2.7.7 or higher. ynh_replace_special_string () { - # Declare an array to define the options of this helper. - local legacy_args=mrf - declare -Ar args_array=( [m]=match_string= [r]=replace_string= [f]=target_file= ) - local match_string - local replace_string - local target_file - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=mrf + local -A args_array=( [m]=match_string= [r]=replace_string= [f]=target_file= ) + local match_string + local replace_string + local target_file + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - # Escape any backslash to preserve them as simple backslash. - match_string=${match_string//\\/"\\\\"} - replace_string=${replace_string//\\/"\\\\"} + # Escape any backslash to preserve them as simple backslash. + match_string=${match_string//\\/"\\\\"} + replace_string=${replace_string//\\/"\\\\"} - # Escape the & character, who has a special function in sed. - match_string=${match_string//&/"\&"} - replace_string=${replace_string//&/"\&"} + # Escape the & character, who has a special function in sed. + match_string=${match_string//&/"\&"} + replace_string=${replace_string//&/"\&"} - ynh_replace_string --match_string="$match_string" --replace_string="$replace_string" --target_file="$target_file" + ynh_replace_string --match_string="$match_string" --replace_string="$replace_string" --target_file="$target_file" } # Sanitize a string intended to be the name of a database -# (More specifically : replace - and . by _) +# +# usage: ynh_sanitize_dbid --db_name=name +# | arg: -n, --db_name= - name to correct/sanitize +# | ret: the corrected name # # example: dbname=$(ynh_sanitize_dbid $app) # -# usage: ynh_sanitize_dbid --db_name=name -# | arg: -n, --db_name - name to correct/sanitize -# | ret: the corrected name +# Underscorify the string (replace - and . by _) # # Requires YunoHost version 2.2.4 or higher. ynh_sanitize_dbid () { - # Declare an array to define the options of this helper. - local legacy_args=n - declare -Ar args_array=( [n]=db_name= ) - local db_name - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=n + local -A args_array=( [n]=db_name= ) + local db_name + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - # We should avoid having - and . in the name of databases. They are replaced by _ - echo ${db_name//[-.]/_} + # We should avoid having - and . in the name of databases. They are replaced by _ + echo ${db_name//[-.]/_} } # Normalize the url path syntax # +# [internal] +# # Handle the slash at the beginning of path and its absence at ending # Return a normalized url path # @@ -119,23 +124,23 @@ ynh_sanitize_dbid () { # ynh_normalize_url_path / # -> / # # usage: ynh_normalize_url_path --path_url=path_to_normalize -# | arg: -p, --path_url - URL path to normalize before using it +# | arg: -p, --path_url= - URL path to normalize before using it # # Requires YunoHost version 2.6.4 or higher. ynh_normalize_url_path () { - # Declare an array to define the options of this helper. - local legacy_args=p - declare -Ar args_array=( [p]=path_url= ) - local path_url - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=p + local -A args_array=( [p]=path_url= ) + local path_url + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - test -n "$path_url" || ynh_die --message="ynh_normalize_url_path expect a URL path as first argument and received nothing." - if [ "${path_url:0:1}" != "/" ]; then # If the first character is not a / - path_url="/$path_url" # Add / at begin of path variable - fi - if [ "${path_url:${#path_url}-1}" == "/" ] && [ ${#path_url} -gt 1 ]; then # If the last character is a / and that not the only character. - path_url="${path_url:0:${#path_url}-1}" # Delete the last character - fi - echo $path_url + test -n "$path_url" || ynh_die --message="ynh_normalize_url_path expect a URL path as first argument and received nothing." + if [ "${path_url:0:1}" != "/" ]; then # If the first character is not a / + path_url="/$path_url" # Add / at begin of path variable + fi + if [ "${path_url:${#path_url}-1}" == "/" ] && [ ${#path_url} -gt 1 ]; then # If the last character is a / and that not the only character. + path_url="${path_url:0:${#path_url}-1}" # Delete the last character + fi + echo $path_url } diff --git a/data/helpers.d/systemd b/data/helpers.d/systemd index 4b3b5a289..d0f88b5f7 100644 --- a/data/helpers.d/systemd +++ b/data/helpers.d/systemd @@ -3,114 +3,111 @@ # Create a dedicated systemd config # # usage: ynh_add_systemd_config [--service=service] [--template=template] -# | arg: -s, --service - Service name (optionnal, $app by default) -# | arg: -t, --template - Name of template file (optionnal, this is 'systemd' by default, meaning ./conf/systemd.service will be used as template) +# | arg: -s, --service= - Service name (optionnal, `$app` by default) +# | arg: -t, --template= - 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 : +# This will use the template `../conf/.service`. # -# __APP__ by $app -# __FINALPATH__ by $final_path +# See the documentation of `ynh_add_config` for a description of the template +# format and how placeholders are replaced with actual variables. # -# Requires YunoHost version 2.7.2 or higher. +# Requires YunoHost version 4.1.0 or higher. ynh_add_systemd_config () { - # Declare an array to define the options of this helper. - local legacy_args=st - declare -Ar args_array=( [s]=service= [t]=template= ) - local service - local template - # Manage arguments with getopts - ynh_handle_getopts_args "$@" - local service="${service:-$app}" - local template="${template:-systemd.service}" + # Declare an array to define the options of this helper. + local legacy_args=stv + local -A args_array=( [s]=service= [t]=template= [v]=others_var=) + local service + local template + local others_var + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + service="${service:-$app}" + template="${template:-systemd.service}" + others_var="${others_var:-}" - finalsystemdconf="/etc/systemd/system/$service.service" - ynh_backup_if_checksum_is_different --file="$finalsystemdconf" - sudo cp ../conf/$template "$finalsystemdconf" + [[ -z "$others_var" ]] || ynh_print_warn --message="Packagers: using --others_var is unecessary since YunoHost 4.2" - # 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 - if test -n "${final_path:-}"; then - ynh_replace_string --match_string="__FINALPATH__" --replace_string="$final_path" --target_file="$finalsystemdconf" - fi - if test -n "${app:-}"; then - ynh_replace_string --match_string="__APP__" --replace_string="$app" --target_file="$finalsystemdconf" - fi - ynh_store_file_checksum --file="$finalsystemdconf" + ynh_add_config --template="$YNH_APP_BASEDIR/conf/$template" --destination="/etc/systemd/system/$service.service" - sudo chown root: "$finalsystemdconf" - sudo systemctl enable $service - sudo systemctl daemon-reload + systemctl enable $service --quiet + systemctl daemon-reload } # Remove the dedicated systemd config # # usage: ynh_remove_systemd_config [--service=service] -# | arg: -s, --service - Service name (optionnal, $app by default) +# | arg: -s, --service= - Service name (optionnal, $app by default) # # Requires YunoHost version 2.7.2 or higher. ynh_remove_systemd_config () { - # Declare an array to define the options of this helper. - local legacy_args=s - declare -Ar args_array=( [s]=service= ) - local service - # Manage arguments with getopts - ynh_handle_getopts_args "$@" - local service="${service:-$app}" + # Declare an array to define the options of this helper. + local legacy_args=s + local -A args_array=( [s]=service= ) + local service + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + local service="${service:-$app}" - local finalsystemdconf="/etc/systemd/system/$service.service" - if [ -e "$finalsystemdconf" ]; then - ynh_systemd_action --service_name=$service --action=stop - systemctl disable $service - ynh_secure_remove --file="$finalsystemdconf" - systemctl daemon-reload - fi + local finalsystemdconf="/etc/systemd/system/$service.service" + if [ -e "$finalsystemdconf" ] + then + ynh_systemd_action --service_name=$service --action=stop + systemctl disable $service --quiet + ynh_secure_remove --file="$finalsystemdconf" + systemctl daemon-reload + fi } # Start (or other actions) a service, print a log in case of failure and optionnaly wait until the service is completely started # -# usage: ynh_systemd_action [-n service_name] [-a action] [ [-l "line to match"] [-p log_path] [-t timeout] [-e length] ] -# | arg: -n, --service_name= - Name of the service to start. Default : $app +# usage: ynh_systemd_action [--service_name=service_name] [--action=action] [ [--line_match="line to match"] [--log_path=log_path] [--timeout=300] [--length=20] ] +# | arg: -n, --service_name= - Name of the service to start. Default : `$app` # | arg: -a, --action= - Action to perform with systemctl. Default: start -# | arg: -l, --line_match= - Line to match - The line to find in the log to attest the service have finished to boot. If not defined it don't wait until the service is completely started. WARNING: When using --line_match, you should always add `ynh_clean_check_starting` into your `ynh_clean_setup` at the beginning of the script. Otherwise, tail will not stop in case of failure of the script. The script will then hang forever. -# | arg: -p, --log_path= - Log file - Path to the log file. Default : /var/log/$app/$app.log +# | arg: -l, --line_match= - Line to match - The line to find in the log to attest the service have finished to boot. If not defined it don't wait until the service is completely started. +# | arg: -p, --log_path= - Log file - Path to the log file. Default : `/var/log/$app/$app.log` # | arg: -t, --timeout= - Timeout - The maximum time to wait before ending the watching. Default : 300 seconds. # | arg: -e, --length= - Length of the error log : Default : 20 +# +# Requires YunoHost version 3.5.0 or higher. ynh_systemd_action() { # Declare an array to define the options of this helper. local legacy_args=nalpte - declare -Ar args_array=( [n]=service_name= [a]=action= [l]=line_match= [p]=log_path= [t]=timeout= [e]=length= ) + local -A args_array=( [n]=service_name= [a]=action= [l]=line_match= [p]=log_path= [t]=timeout= [e]=length= ) local service_name local action local line_match local length local log_path local timeout - # Manage arguments with getopts ynh_handle_getopts_args "$@" + service_name="${service_name:-$app}" + action=${action:-start} + line_match=${line_match:-} + length=${length:-20} + log_path="${log_path:-/var/log/$service_name/$service_name.log}" + timeout=${timeout:-300} - local service_name="${service_name:-$app}" - local action=${action:-start} - local log_path="${log_path:-/var/log/$service_name/$service_name.log}" - local length=${length:-20} - local timeout=${timeout:-300} + # Manage case of service already stopped + if [ "$action" == "stop" ] && ! systemctl is-active --quiet $service_name + then + return 0 + fi # Start to read the log - if [[ -n "${line_match:-}" ]] + if [[ -n "$line_match" ]] then local templog="$(mktemp)" # Following the starting of the app in its log - if [ "$log_path" == "systemd" ] ; then + if [ "$log_path" == "systemd" ] + then # Read the systemd journal journalctl --unit=$service_name --follow --since=-0 --quiet > "$templog" & # Get the PID of the journalctl command local pid_tail=$! else # Read the specified log file - tail -F -n0 "$log_path" > "$templog" 2>&1 & + tail --follow=name --retry --lines=0 "$log_path" > "$templog" 2>&1 & # Get the PID of the tail command local pid_tail=$! fi @@ -121,53 +118,70 @@ ynh_systemd_action() { action="reload-or-restart" fi - systemctl $action $service_name \ - || ( journalctl --no-pager --lines=$length -u $service_name >&2 \ - ; test -e "$log_path" && echo "--" >&2 && tail --lines=$length "$log_path" >&2 \ - ; false ) + # If the service fails to perform the action + if ! systemctl $action $service_name + then + # Show syslog for this service + ynh_exec_err journalctl --quiet --no-hostname --no-pager --lines=$length --unit=$service_name + # If a log is specified for this service, show also the content of this log + if [ -e "$log_path" ] + then + ynh_exec_err tail --lines=$length "$log_path" + fi + ynh_clean_check_starting + return 1 + fi # Start the timeout and try to find line_match if [[ -n "${line_match:-}" ]] then + set +x local i=0 for i in $(seq 1 $timeout) do # Read the log until the sentence is found, that means the app finished to start. Or run until the timeout - if grep --quiet "$line_match" "$templog" + if grep --extended-regexp --quiet "$line_match" "$templog" then - ynh_print_info --message="The service $service_name has correctly started." + ynh_print_info --message="The service $service_name has correctly executed the action ${action}." break fi - if [ $i -eq 3 ]; then - echo -n "Please wait, the service $service_name is ${action}ing" >&2 - fi - if [ $i -ge 3 ]; then - echo -n "." >&2 + if [ $i -eq 30 ]; then + echo "(this may take some time)" >&2 fi sleep 1 done + set -x if [ $i -ge 3 ]; then echo "" >&2 fi if [ $i -eq $timeout ] then - ynh_print_warn --message="The service $service_name didn't fully started before the timeout." + ynh_print_warn --message="The service $service_name didn't fully executed the action ${action} before the timeout." ynh_print_warn --message="Please find here an extract of the end of the log of the service $service_name:" - journalctl --no-pager --lines=$length -u $service_name >&2 - test -e "$log_path" && echo "--" >&2 && tail --lines=$length "$log_path" >&2 + ynh_exec_warn journalctl --quiet --no-hostname --no-pager --lines=$length --unit=$service_name + if [ -e "$log_path" ] + then + ynh_print_warn --message="\-\-\-" + ynh_exec_warn tail --lines=$length "$log_path" + fi fi ynh_clean_check_starting fi } # Clean temporary process and file used by ynh_check_starting -# (usually used in ynh_clean_setup scripts) # -# usage: ynh_clean_check_starting +# [internal] +# +# Requires YunoHost version 3.5.0 or higher. ynh_clean_check_starting () { - # Stop the execution of tail. - kill -s 15 $pid_tail 2>&1 - ynh_secure_remove "$templog" 2>&1 + if [ -n "${pid_tail:-}" ] + then + # Stop the execution of tail. + kill -SIGTERM $pid_tail 2>&1 + fi + if [ -n "${templog:-}" ] + then + ynh_secure_remove --file="$templog" 2>&1 + fi } - - diff --git a/data/helpers.d/user b/data/helpers.d/user index e7890ccb2..d5ede9f73 100644 --- a/data/helpers.d/user +++ b/data/helpers.d/user @@ -2,68 +2,69 @@ # Check if a YunoHost user exists # -# example: ynh_user_exists 'toto' || exit 1 -# # usage: ynh_user_exists --username=username -# | arg: -u, --username - the username to check +# | arg: -u, --username= - the username to check +# | ret: 0 if the user exists, 1 otherwise. +# +# example: ynh_user_exists 'toto' || echo "User does not exist" # # Requires YunoHost version 2.2.4 or higher. ynh_user_exists() { # Declare an array to define the options of this helper. local legacy_args=u - declare -Ar args_array=( [u]=username= ) + local -A args_array=( [u]=username= ) local username # Manage arguments with getopts ynh_handle_getopts_args "$@" - sudo yunohost user list --output-as json | grep -q "\"username\": \"${username}\"" + yunohost user list --output-as json --quiet | jq -e ".users.${username}" >/dev/null } # Retrieve a YunoHost user information # -# example: mail=$(ynh_user_get_info 'toto' 'mail') -# # usage: ynh_user_get_info --username=username --key=key -# | arg: -u, --username - the username to retrieve info from -# | arg: -k, --key - the key to retrieve -# | ret: string - the key's value +# | arg: -u, --username= - the username to retrieve info from +# | arg: -k, --key= - the key to retrieve +# | ret: the value associate to that key +# +# example: mail=$(ynh_user_get_info 'toto' 'mail') # # Requires YunoHost version 2.2.4 or higher. ynh_user_get_info() { # Declare an array to define the options of this helper. local legacy_args=uk - declare -Ar args_array=( [u]=username= [k]=key= ) + local -A args_array=( [u]=username= [k]=key= ) local username local key # Manage arguments with getopts ynh_handle_getopts_args "$@" - sudo yunohost user info "$username" --output-as plain | ynh_get_plain_key "$key" + yunohost user info "$username" --output-as json --quiet | jq -r ".$key" } # Get the list of YunoHost users # -# example: for u in $(ynh_user_list); do ... -# # usage: ynh_user_list -# | ret: string - one username per line +# | ret: one username per line as strings +# +# example: for u in $(ynh_user_list); do ... ; done # # Requires YunoHost version 2.4.0 or higher. ynh_user_list() { - sudo yunohost user list --output-as plain --quiet \ - | awk '/^##username$/{getline; print}' + yunohost user list --output-as json --quiet | jq -r ".users | keys[]" } # Check if a user exists on the system # # usage: ynh_system_user_exists --username=username -# | arg: -u, --username - the username to check +# | arg: -u, --username= - the username to check +# | ret: 0 if the user exists, 1 otherwise. # # Requires YunoHost version 2.2.4 or higher. ynh_system_user_exists() { # Declare an array to define the options of this helper. local legacy_args=u - declare -Ar args_array=( [u]=username= ) + local -A args_array=( [u]=username= ) local username # Manage arguments with getopts ynh_handle_getopts_args "$@" @@ -74,11 +75,14 @@ ynh_system_user_exists() { # Check if a group exists on the system # # usage: ynh_system_group_exists --group=group -# | arg: -g, --group - the group to check +# | arg: -g, --group= - the group to check +# | ret: 0 if the group exists, 1 otherwise. +# +# Requires YunoHost version 3.5.0.2 or higher. ynh_system_group_exists() { # Declare an array to define the options of this helper. local legacy_args=g - declare -Ar args_array=( [g]=group= ) + local -A args_array=( [g]=group= ) local group # Manage arguments with getopts ynh_handle_getopts_args "$@" @@ -88,56 +92,71 @@ ynh_system_group_exists() { # Create a system user # -# examples: -# # Create a nextcloud user with no home directory and /usr/sbin/nologin login shell (hence no login capability) -# ynh_system_user_create --username=nextcloud -# # Create a discourse user using /var/www/discourse as home directory and the default login shell -# ynh_system_user_create --username=discourse --home_dir=/var/www/discourse --use_shell +# usage: ynh_system_user_create --username=user_name [--home_dir=home_dir] [--use_shell] [--groups="group1 group2"] +# | arg: -u, --username= - Name of the system user that will be create +# | arg: -h, --home_dir= - Path of the home dir for the user. Usually the final path of the app. If this argument is omitted, the user will be created without home +# | arg: -s, --use_shell - Create a user using the default login shell if present. If this argument is omitted, the user will be created with /usr/sbin/nologin shell +# | arg: -g, --groups - Add the user to system groups. Typically meant to add the user to the ssh.app / sftp.app group (e.g. for borgserver, my_webapp) # -# usage: ynh_system_user_create --username=user_name [--home_dir=home_dir] [--use_shell] -# | arg: -u, --username - Name of the system user that will be create -# | arg: -h, --home_dir - Path of the home dir for the user. Usually the final path of the app. If this argument is omitted, the user will be created without home -# | arg: -s, --use_shell - Create a user using the default login shell if present. If this argument is omitted, the user will be created with /usr/sbin/nologin shell +# Create a nextcloud user with no home directory and /usr/sbin/nologin login shell (hence no login capability) : +# ``` +# ynh_system_user_create --username=nextcloud +# ``` +# Create a discourse user using /var/www/discourse as home directory and the default login shell : +# ``` +# ynh_system_user_create --username=discourse --home_dir=/var/www/discourse --use_shell +# ``` # # Requires YunoHost version 2.6.4 or higher. ynh_system_user_create () { - # Declare an array to define the options of this helper. - local legacy_args=uhs - declare -Ar args_array=( [u]=username= [h]=home_dir= [s]=use_shell ) - local username - local home_dir - local use_shell - # Manage arguments with getopts - ynh_handle_getopts_args "$@" - use_shell="${use_shell:-0}" - home_dir="${home_dir:-}" + # Declare an array to define the options of this helper. + local legacy_args=uhs + local -A args_array=( [u]=username= [h]=home_dir= [s]=use_shell [g]=groups= ) + local username + local home_dir + local use_shell + local groups - if ! ynh_system_user_exists "$username" # Check if the user exists on the system - then # If the user doesn't exist - if [ -n "$home_dir" ]; then # If a home dir is mentioned - local user_home_dir="-d $home_dir" - else - local user_home_dir="--no-create-home" - fi - if [ $use_shell -eq 1 ]; then # If we want a shell for the user - local shell="" # Use default shell - else - local shell="--shell /usr/sbin/nologin" - fi - useradd $user_home_dir --system --user-group $username $shell || ynh_die "Unable to create $username system account" - fi + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + use_shell="${use_shell:-0}" + home_dir="${home_dir:-}" + groups="${groups:-}" + + if ! ynh_system_user_exists "$username" # Check if the user exists on the system + then # If the user doesn't exist + if [ -n "$home_dir" ] + then # If a home dir is mentioned + local user_home_dir="--home-dir $home_dir" + else + local user_home_dir="--no-create-home" + fi + if [ $use_shell -eq 1 ] + then # If we want a shell for the user + local shell="" # Use default shell + else + local shell="--shell /usr/sbin/nologin" + fi + useradd $user_home_dir --system --user-group $username $shell || ynh_die --message="Unable to create $username system account" + fi + + local group + for group in $groups + do + usermod -a -G "$group" "$username" + done } # Delete a system user # # usage: ynh_system_user_delete --username=user_name -# | arg: -u, --username - Name of the system user that will be create +# | arg: -u, --username= - Name of the system user that will be create # # Requires YunoHost version 2.6.4 or higher. ynh_system_user_delete () { # Declare an array to define the options of this helper. local legacy_args=u - declare -Ar args_array=( [u]=username= ) + local -A args_array=( [u]=username= ) local username # Manage arguments with getopts ynh_handle_getopts_args "$@" @@ -145,14 +164,30 @@ ynh_system_user_delete () { # Check if the user exists on the system if ynh_system_user_exists "$username" then - deluser $username - else - ynh_print_warn --message="The user $username was not found" + deluser $username + else + ynh_print_warn --message="The user $username was not found" fi # Check if the group exists on the system if ynh_system_group_exists "$username" then - delgroup $username + delgroup $username + fi +} + +# Execute a command as another user +# +# usage: ynh_exec_as $USER COMMAND [ARG ...] +# +# Requires YunoHost version 4.1.7 or higher. +ynh_exec_as() { + local user=$1 + shift 1 + + if [[ $user = $(whoami) ]]; then + eval "$@" + else + sudo -u "$user" "$@" fi } diff --git a/data/helpers.d/utils b/data/helpers.d/utils index e1feed6b1..061ff324d 100644 --- a/data/helpers.d/utils +++ b/data/helpers.d/utils @@ -1,5 +1,7 @@ #!/bin/bash +YNH_APP_BASEDIR=${YNH_APP_BASEDIR:-$(realpath ..)} + # Handle script crashes / failures # # [internal] @@ -16,60 +18,32 @@ # # It prints a warning to inform that the script was failed, and execute the ynh_clean_setup function if used in the app script # +# Requires YunoHost version 2.6.4 or higher. ynh_exit_properly () { - local exit_code=$? - if [ "$exit_code" -eq 0 ]; then - exit 0 # Exit without error if the script ended correctly - fi + local exit_code=$? - trap '' EXIT # Ignore new exit signals - set +eu # Do not exit anymore if a command fail or if a variable is empty + rm -rf "/var/cache/yunohost/download/" - # Small tempo to avoid the next message being mixed up with other DEBUG messages - sleep 0.5 + if [ "$exit_code" -eq 0 ]; then + exit 0 # Exit without error if the script ended correctly + fi - ynh_print_err --message="!!\n $app's script has encountered an error. Its execution was cancelled.\n!!" + trap '' EXIT # Ignore new exit signals + # Do not exit anymore if a command fail or if a variable is empty + set +o errexit # set +e + set +o nounset # set +u - # If the script is executed from the CLI, dump the end of the log that precedes the crash. - if [ "$YNH_INTERFACE" == "cli" ] - then - # Unset xtrace to not spoil the log - set +x + # Small tempo to avoid the next message being mixed up with other DEBUG messages + sleep 0.5 - local ynh_log="/var/log/yunohost/yunohost-cli.log" + if type -t ynh_clean_setup > /dev/null; then # Check if the function exist in the app script. + ynh_clean_setup # Call the function to do specific cleaning for the app. + fi - # Wait for the log to be fill with the data until the crash. - local timeout=0 - while ! tail --lines=20 "$ynh_log" | grep --quiet "+ ynh_exit_properly" - do - ((timeout++)) - if [ $timeout -eq 500 ]; then - break - fi - done - - echo -e "\e[34m\e[1mPlease find here an extract of the log before the crash:\e[0m" >&2 - # Tail the last 30 lines of log of YunoHost - # But remove all lines after "ynh_exit_properly" - # Remove the timestamp at the beginning of the line - # Remove "yunohost.hook..." - # Add DEBUG and color it at the beginning of each log line. - echo -e "$(tail --lines=30 "$ynh_log" \ - | sed '1,/+ ynh_exit_properly/!d' \ - | sed 's/^[[:digit:]: ,-]*//g' \ - | sed 's/ *yunohost.hook.*\]/ -/g' \ - | sed 's/^WARNING /&/g' \ - | sed 's/^DEBUG /& /g' \ - | sed 's/^INFO /& /g' \ - | sed 's/^/\\e[34m\\e[1m[DEBUG]\\e[0m: /g')" >&2 - set -x - fi - - if type -t ynh_clean_setup > /dev/null; then # Check if the function exist in the app script. - ynh_clean_setup # Call the function to do specific cleaning for the app. - fi - - ynh_die # Exit with error status + # Exit with error status + # We don't call ynh_die basically to avoid unecessary 10-ish + # debug lines about parsing args and stuff just to exit 1.. + exit 1 } # Exits if an error occurs during the execution of the script. @@ -77,88 +51,77 @@ ynh_exit_properly () { # usage: ynh_abort_if_errors # # This configure the rest of the script execution such that, if an error occurs -# or if an empty variable is used, the execution of the script stops -# immediately and a call to `ynh_clean_setup` is triggered if it has been -# defined by your script. +# or if an empty variable is used, the execution of the script stops immediately +# and a call to `ynh_clean_setup` is triggered if it has been defined by your script. # # Requires YunoHost version 2.6.4 or higher. 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 + set -o errexit # set -e; Exit if a command fail + set -o nounset # set -u; And if a variable is used unset + trap ynh_exit_properly EXIT # Capturing exit signals on shell script } # Download, check integrity, uncompress and patch the source from app.src # -# The file conf/app.src need to contains: +# usage: ynh_setup_source --dest_dir=dest_dir [--source_id=source_id] [--keep="file1 file2"] +# | arg: -d, --dest_dir= - Directory where to setup sources +# | arg: -s, --source_id= - Name of the source, defaults to `app` +# | arg: -k, --keep= - Space-separated list of files/folders that will be backup/restored in $dest_dir, such as a config file you don't want to overwrite. For example 'conf.json secrets.json logs/' # +# This helper will read `conf/${source_id}.src`, download and install the sources. +# +# The src file need to contains: +# ``` # SOURCE_URL=Address to download the app archive # SOURCE_SUM=Control sum -# # (Optional) Program to check the integrity (sha256sum, md5sum...) -# # default: sha256 +# # (Optional) Program to check the integrity (sha256sum, md5sum...). Default: sha256 # SOURCE_SUM_PRG=sha256 -# # (Optional) Archive format -# # default: tar.gz +# # (Optional) Archive format. Default: tar.gz # SOURCE_FORMAT=tar.gz -# # (Optional) Put false if sources are directly in the archive root -# # default: true -# # Instead of true, SOURCE_IN_SUBDIR could be the number of sub directories -# # to remove. +# # (Optional) Put false if sources are directly in the archive root. Default: true +# # Instead of true, SOURCE_IN_SUBDIR could be the number of sub directories to remove. # SOURCE_IN_SUBDIR=false -# # (Optionnal) Name of the local archive (offline setup support) -# # default: ${src_id}.${src_format} +# # (Optionnal) Name of the local archive (offline setup support). Default: ${src_id}.${src_format} # SOURCE_FILENAME=example.tar.gz -# # (Optional) If it set as false don't extract the source. +# # (Optional) If it set as false don't extract the source. Default: true # # (Useful to get a debian package or a python wheel.) -# # default: true # SOURCE_EXTRACT=(true|false) +# ``` # -# Details: -# This helper downloads sources from SOURCE_URL if there is no local source -# archive in /opt/yunohost-apps-src/APP_ID/SOURCE_FILENAME -# -# Next, it checks the integrity with "SOURCE_SUM_PRG -c --status" command. -# -# If it's ok, the source archive will be uncompressed in $dest_dir. If the -# SOURCE_IN_SUBDIR is true, the first level directory of the archive will be -# removed. -# If SOURCE_IN_SUBDIR is a numeric value, 2 for example, the 2 first level -# directories will be removed -# -# Finally, patches named sources/patches/${src_id}-*.patch and extra files in -# sources/extra_files/$src_id will be applied to dest_dir -# -# -# usage: ynh_setup_source --dest_dir=dest_dir [--source_id=source_id] -# | arg: -d, --dest_dir - Directory where to setup sources -# | arg: -s, --source_id - Name of the app, if the package contains more than one app +# The helper will: +# - Check if there is a local source archive in `/opt/yunohost-apps-src/$APP_ID/$SOURCE_FILENAME` +# - Download `$SOURCE_URL` if there is no local archive +# - Check the integrity with `$SOURCE_SUM_PRG -c --status` +# - Uncompress the archive to `$dest_dir`. +# - If `$SOURCE_IN_SUBDIR` is true, the first level directory of the archive will be removed. +# - If `$SOURCE_IN_SUBDIR` is a numeric value, the N first level directories will be removed. +# - Patches named `sources/patches/${src_id}-*.patch` will be applied to `$dest_dir` +# - Extra files in `sources/extra_files/$src_id` will be copied to dest_dir # # Requires YunoHost version 2.6.4 or higher. ynh_setup_source () { # Declare an array to define the options of this helper. - local legacy_args=ds - declare -Ar args_array=( [d]=dest_dir= [s]=source_id= ) + local legacy_args=dsk + local -A args_array=( [d]=dest_dir= [s]=source_id= [k]=keep= ) local dest_dir local source_id + local keep # Manage arguments with getopts ynh_handle_getopts_args "$@" - source_id="${source_id:-app}" # If the argument is not given, source_id equals "app" + source_id="${source_id:-app}" + keep="${keep:-}" - local src_file_path="$YNH_CWD/../conf/${source_id}.src" - # In case of restore script the src file is in an other path. - # So try to use the restore path if the general path point to no file. - if [ ! -e "$src_file_path" ]; then - src_file_path="$YNH_CWD/../settings/conf/${source_id}.src" - fi + local src_file_path="$YNH_APP_BASEDIR/conf/${source_id}.src" # Load value from configuration file (see above for a small doc about this file # format) - local src_url=$(grep 'SOURCE_URL=' "$src_file_path" | cut -d= -f2-) - local src_sum=$(grep 'SOURCE_SUM=' "$src_file_path" | cut -d= -f2-) - local src_sumprg=$(grep 'SOURCE_SUM_PRG=' "$src_file_path" | cut -d= -f2-) - local src_format=$(grep 'SOURCE_FORMAT=' "$src_file_path" | cut -d= -f2-) - local src_extract=$(grep 'SOURCE_EXTRACT=' "$src_file_path" | cut -d= -f2-) - local src_in_subdir=$(grep 'SOURCE_IN_SUBDIR=' "$src_file_path" | cut -d= -f2-) - local src_filename=$(grep 'SOURCE_FILENAME=' "$src_file_path" | cut -d= -f2-) + local src_url=$(grep 'SOURCE_URL=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_sum=$(grep 'SOURCE_SUM=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_sumprg=$(grep 'SOURCE_SUM_PRG=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_format=$(grep 'SOURCE_FORMAT=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_extract=$(grep 'SOURCE_EXTRACT=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_in_subdir=$(grep 'SOURCE_IN_SUBDIR=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_filename=$(grep 'SOURCE_FILENAME=' "$src_file_path" | cut --delimiter='=' --fields=2-) # Default value src_sumprg=${src_sumprg:-sha256sum} @@ -166,24 +129,61 @@ ynh_setup_source () { src_format=${src_format:-tar.gz} src_format=$(echo "$src_format" | tr '[:upper:]' '[:lower:]') src_extract=${src_extract:-true} - if [ "$src_filename" = "" ] ; then + if [ "$src_filename" = "" ]; then src_filename="${source_id}.${src_format}" fi + + + # (Unused?) mecanism where one can have the file in a special local cache to not have to download it... local local_src="/opt/yunohost-apps-src/${YNH_APP_ID}/${src_filename}" + mkdir -p /var/cache/yunohost/download/${YNH_APP_ID}/ + src_filename="/var/cache/yunohost/download/${YNH_APP_ID}/${src_filename}" + if test -e "$local_src" - then # Use the local source file if it is present + then cp $local_src $src_filename - else # If not, download the source - local out=`wget -nv -O $src_filename $src_url 2>&1` || ynh_print_err --message="$out" + else + [ -n "$src_url" ] || ynh_die "Couldn't parse SOURCE_URL from $src_file_path ?" + + # NB. we have to declare the var as local first, + # otherwise 'local foo=$(false) || echo 'pwet'" does'nt work + # because local always return 0 ... + local out + # Timeout option is here to enforce the timeout on dns query and tcp connect (c.f. man wget) + out=$(wget --tries 3 --no-dns-cache --timeout 900 --no-verbose --output-document=$src_filename $src_url 2>&1) \ + || ynh_die --message="$out" fi # Check the control sum - echo "${src_sum} ${src_filename}" | ${src_sumprg} -c --status \ + echo "${src_sum} ${src_filename}" | ${src_sumprg} --check --status \ || ynh_die --message="Corrupt source" + # Keep files to be backup/restored at the end of the helper + # Assuming $dest_dir already exists + rm -rf /var/cache/yunohost/files_to_keep_during_setup_source/ + if [ -n "$keep" ] && [ -e "$dest_dir" ] + then + local keep_dir=/var/cache/yunohost/files_to_keep_during_setup_source/${YNH_APP_ID} + mkdir -p $keep_dir + local stuff_to_keep + for stuff_to_keep in $keep + do + if [ -e "$dest_dir/$stuff_to_keep" ] + then + mkdir --parents "$(dirname "$keep_dir/$stuff_to_keep")" + cp --archive "$dest_dir/$stuff_to_keep" "$keep_dir/$stuff_to_keep" + fi + done + fi + # Extract source into the app dir - mkdir -p "$dest_dir" + mkdir --parents "$dest_dir" + + if [ -n "${final_path:-}" ] && [ "$dest_dir" == "$final_path" ] + then + _ynh_apply_default_permissions $dest_dir + fi if ! "$src_extract" then @@ -192,60 +192,89 @@ ynh_setup_source () { then # Zip format # Using of a temp directory, because unzip doesn't manage --strip-components - if $src_in_subdir ; then - local tmp_dir=$(mktemp -d) + if $src_in_subdir + then + local tmp_dir=$(mktemp --directory) unzip -quo $src_filename -d "$tmp_dir" - cp -a $tmp_dir/*/. "$dest_dir" + cp --archive $tmp_dir/*/. "$dest_dir" ynh_secure_remove --file="$tmp_dir" else unzip -quo $src_filename -d "$dest_dir" fi + ynh_secure_remove --file="$src_filename" else local strip="" if [ "$src_in_subdir" != "false" ] then - if [ "$src_in_subdir" == "true" ]; then + if [ "$src_in_subdir" == "true" ] + then local sub_dirs=1 else local sub_dirs="$src_in_subdir" fi strip="--strip-components $sub_dirs" fi - if [[ "$src_format" =~ ^tar.gz|tar.bz2|tar.xz$ ]] ; then - tar -xf $src_filename -C "$dest_dir" $strip + if [[ "$src_format" =~ ^tar.gz|tar.bz2|tar.xz$ ]] + then + tar --extract --file=$src_filename --directory="$dest_dir" $strip else ynh_die --message="Archive format unrecognized." fi + ynh_secure_remove --file="$src_filename" fi # Apply patches - if (( $(find $YNH_CWD/../sources/patches/ -type f -name "${source_id}-*.patch" 2> /dev/null | wc -l) > "0" )); then - local old_dir=$(pwd) - (cd "$dest_dir" \ - && for p in $YNH_CWD/../sources/patches/${source_id}-*.patch; do \ - patch -p1 < $p; done) \ - || ynh_die --message="Unable to apply patches" - cd $old_dir + if [ -d "$YNH_APP_BASEDIR/sources/patches/" ] + then + local patches_folder=$(realpath $YNH_APP_BASEDIR/sources/patches/) + if (( $(find $patches_folder -type f -name "${source_id}-*.patch" 2> /dev/null | wc --lines) > "0" )) + then + (cd "$dest_dir" + for p in $patches_folder/${source_id}-*.patch + do + echo $p + patch --strip=1 < $p + done) || ynh_die --message="Unable to apply patches" + fi fi # Add supplementary files - if test -e "$YNH_CWD/../sources/extra_files/${source_id}"; then - cp -a $YNH_CWD/../sources/extra_files/$source_id/. "$dest_dir" + if test -e "$YNH_APP_BASEDIR/sources/extra_files/${source_id}"; then + cp --archive $YNH_APP_BASEDIR/sources/extra_files/$source_id/. "$dest_dir" fi + + # Keep files to be backup/restored at the end of the helper + # Assuming $dest_dir already exists + if [ -n "$keep" ] + then + local keep_dir=/var/cache/yunohost/files_to_keep_during_setup_source/${YNH_APP_ID} + local stuff_to_keep + for stuff_to_keep in $keep + do + if [ -e "$keep_dir/$stuff_to_keep" ] + then + mkdir --parents "$(dirname "$dest_dir/$stuff_to_keep")" + cp --archive "$keep_dir/$stuff_to_keep" "$dest_dir/$stuff_to_keep" + fi + done + fi + rm -rf /var/cache/yunohost/files_to_keep_during_setup_source/ } # Curl abstraction to help with POST requests to local pages (such as installation forms) # -# $domain and $path_url should be defined externally (and correspond to the domain.tld and the /path (of the app?)) -# -# example: ynh_local_curl "/install.php?installButton" "foo=$var1" "bar=$var2" -# # usage: ynh_local_curl "page_uri" "key1=value1" "key2=value2" ... -# | arg: page_uri - Path (relative to $path_url) of the page where POST data will be sent +# | arg: page_uri - Path (relative to `$path_url`) of the page where POST data will be sent # | arg: key1=value1 - (Optionnal) POST key and corresponding value # | arg: key2=value2 - (Optionnal) Another POST key and corresponding value # | arg: ... - (Optionnal) More POST keys and values # +# example: ynh_local_curl "/install.php?installButton" "foo=$var1" "bar=$var2" +# +# For multiple calls, cookies are persisted between each call for the same app +# +# `$domain` and `$path_url` should be defined externally (and correspond to the domain.tld and the /path (of the app?)) +# # Requires YunoHost version 2.6.4 or higher. ynh_local_curl () { # Define url of page to curl @@ -274,12 +303,381 @@ ynh_local_curl () { # Wait untils nginx has fully reloaded (avoid curl fail with http2) sleep 2 + local cookiefile=/tmp/ynh-$app-cookie.txt + touch $cookiefile + chown root $cookiefile + chmod 700 $cookiefile + # Curl the URL - curl --silent --show-error -kL -H "Host: $domain" --resolve $domain:443:127.0.0.1 $POST_data "$full_page_url" + curl --silent --show-error --insecure --location --header "Host: $domain" --resolve $domain:443:127.0.0.1 $POST_data "$full_page_url" --cookie-jar $cookiefile --cookie $cookiefile } +# Create a dedicated config file from a template +# +# usage: ynh_add_config --template="template" --destination="destination" +# | arg: -t, --template= - Template config file to use +# | arg: -d, --destination= - Destination of the config file +# +# examples: +# ynh_add_config --template=".env" --destination="$final_path/.env" +# ynh_add_config --template="../conf/.env" --destination="$final_path/.env" +# ynh_add_config --template="/etc/nginx/sites-available/default" --destination="etc/nginx/sites-available/mydomain.conf" +# +# The template can be by default the name of a file in the conf directory +# of a YunoHost Package, a relative path or an absolute path. +# +# The helper will use the template `template` to generate a config file +# `destination` by replacing the following keywords with global variables +# that should be defined before calling this helper : +# ``` +# __PATH__ by $path_url +# __NAME__ by $app +# __NAMETOCHANGE__ by $app +# __USER__ by $app +# __FINALPATH__ by $final_path +# __PHPVERSION__ by $YNH_PHP_VERSION +# __YNH_NODE_LOAD_PATH__ by $ynh_node_load_PATH +# ``` +# And any dynamic variables that should be defined before calling this helper like: +# ``` +# __DOMAIN__ by $domain +# __APP__ by $app +# __VAR_1__ by $var_1 +# __VAR_2__ by $var_2 +# ``` +# +# The helper will verify the checksum and backup the destination file +# if it's different before applying the new template. +# +# And it will calculate and store the destination file checksum +# into the app settings when configuration is done. +# +# Requires YunoHost version 4.1.0 or higher. +ynh_add_config () { + # Declare an array to define the options of this helper. + local legacy_args=tdv + local -A args_array=( [t]=template= [d]=destination= ) + local template + local destination + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + local template_path + + if [ -f "$YNH_APP_BASEDIR/conf/$template" ]; then + template_path="$YNH_APP_BASEDIR/conf/$template" + elif [ -f "$template" ]; then + template_path=$template + else + ynh_die --message="The provided template $template doesn't exist" + fi + + ynh_backup_if_checksum_is_different --file="$destination" + + # Make sure to set the permissions before we copy the file + # This is to cover a case where an attacker could have + # created a file beforehand to have control over it + # (cp won't overwrite ownership / modes by default...) + touch $destination + chown root:root $destination + chmod 640 $destination + + cp -f "$template_path" "$destination" + + _ynh_apply_default_permissions $destination + + ynh_replace_vars --file="$destination" + + ynh_store_file_checksum --file="$destination" +} + +# Replace variables in a file +# +# [internal] +# +# usage: ynh_replace_vars --file="file" +# | arg: -f, --file= - File where to replace variables +# +# The helper will replace the following keywords with global variables +# that should be defined before calling this helper : +# __PATH__ by $path_url +# __NAME__ by $app +# __NAMETOCHANGE__ by $app +# __USER__ by $app +# __FINALPATH__ by $final_path +# __PHPVERSION__ by $YNH_PHP_VERSION +# __YNH_NODE_LOAD_PATH__ by $ynh_node_load_PATH +# +# And any dynamic variables that should be defined before calling this helper like: +# __DOMAIN__ by $domain +# __APP__ by $app +# __VAR_1__ by $var_1 +# __VAR_2__ by $var_2 +# +# Requires YunoHost version 4.1.0 or higher. +ynh_replace_vars () { + # Declare an array to define the options of this helper. + local legacy_args=f + local -A args_array=( [f]=file= ) + local file + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + + # Replace specific YunoHost variables + if test -n "${path_url:-}" + then + # path_url_slash_less is path_url, or a blank value if path_url is only '/' + local path_url_slash_less=${path_url%/} + ynh_replace_string --match_string="__PATH__/" --replace_string="$path_url_slash_less/" --target_file="$file" + ynh_replace_string --match_string="__PATH__" --replace_string="$path_url" --target_file="$file" + fi + if test -n "${app:-}"; then + ynh_replace_string --match_string="__NAME__" --replace_string="$app" --target_file="$file" + ynh_replace_string --match_string="__NAMETOCHANGE__" --replace_string="$app" --target_file="$file" + ynh_replace_string --match_string="__USER__" --replace_string="$app" --target_file="$file" + fi + if test -n "${final_path:-}"; then + ynh_replace_string --match_string="__FINALPATH__" --replace_string="$final_path" --target_file="$file" + fi + if test -n "${YNH_PHP_VERSION:-}"; then + ynh_replace_string --match_string="__PHPVERSION__" --replace_string="$YNH_PHP_VERSION" --target_file="$file" + fi + if test -n "${ynh_node_load_PATH:-}"; then + ynh_replace_string --match_string="__YNH_NODE_LOAD_PATH__" --replace_string="$ynh_node_load_PATH" --target_file="$file" + fi + + # Replace others variables + + # List other unique (__ __) variables in $file + local uniques_vars=( $(grep -oP '__[A-Z0-9]+?[A-Z0-9_]*?[A-Z0-9]*?__' $file | sort --unique | sed "s@__\([^.]*\)__@\L\1@g" )) + + # Do the replacement + local delimit=@ + for one_var in "${uniques_vars[@]}" + do + # Validate that one_var is indeed defined + # -v checks if the variable is defined, for example: + # -v FOO tests if $FOO is defined + # -v $FOO tests if ${!FOO} is defined + # More info: https://stackoverflow.com/questions/3601515/how-to-check-if-a-variable-is-set-in-bash/17538964#comment96392525_17538964 + [[ -v "${one_var:-}" ]] || ynh_die --message="Variable \$$one_var wasn't initialized when trying to replace __${one_var^^}__ in $file" + + # Escape delimiter in match/replace string + match_string="__${one_var^^}__" + match_string=${match_string//${delimit}/"\\${delimit}"} + replace_string="${!one_var}" + replace_string=${replace_string//\\/\\\\} + replace_string=${replace_string//${delimit}/"\\${delimit}"} + + # Actually replace (sed is used instead of ynh_replace_string to avoid triggering an epic amount of debug logs) + sed --in-place "s${delimit}${match_string}${delimit}${replace_string}${delimit}g" "$file" + done +} + +# Get a value from heterogeneous file (yaml, json, php, python...) +# +# usage: ynh_read_var_in_file --file=PATH --key=KEY +# | arg: -f, --file= - the path to the file +# | arg: -k, --key= - the key to get +# +# This helpers match several var affectation use case in several languages +# We don't use jq or equivalent to keep comments and blank space in files +# This helpers work line by line, it is not able to work correctly +# if you have several identical keys in your files +# +# Example of line this helpers can managed correctly +# .yml +# title: YunoHost documentation +# email: 'yunohost@yunohost.org' +# .json +# "theme": "colib'ris", +# "port": 8102 +# "some_boolean": false, +# "user": null +# .ini +# some_boolean = On +# action = "Clear" +# port = 20 +# .php +# $user= +# user => 20 +# .py +# USER = 8102 +# user = 'https://donate.local' +# CUSTOM['user'] = 'YunoHost' +# +# Requires YunoHost version 4.3 or higher. +ynh_read_var_in_file() { + # Declare an array to define the options of this helper. + local legacy_args=fka + local -A args_array=( [f]=file= [k]=key= [a]=after=) + local file + local key + local after + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + after="${after:-}" + + [[ -f $file ]] || ynh_die --message="File $file does not exists" + + set +o xtrace # set +x + + # Get the line number after which we search for the variable + local line_number=1 + if [[ -n "$after" ]]; + then + line_number=$(grep -n $after $file | cut -d: -f1) + if [[ -z "$line_number" ]]; + then + set -o xtrace # set -x + return 1 + fi + fi + + local filename="$(basename -- "$file")" + local ext="${filename##*.}" + local endline=',;' + local assign="=>|:|=" + local comments="#" + local string="\"'" + if [[ "$ext" =~ ^ini|env|toml|yml|yaml$ ]]; then + endline='#' + fi + if [[ "$ext" =~ ^ini|env$ ]]; then + comments="[;#]" + fi + if [[ "php" == "$ext" ]] || [[ "$ext" == "js" ]]; then + comments="//" + fi + local list='\[\s*['$string']?\w+['$string']?\]' + local var_part='^\s*((const|var|let)\s+)?\$?(\w+('$list')*(->|\.|\[))*\s*' + var_part+="[$string]?${key}[$string]?" + var_part+='\s*\]?\s*' + var_part+="($assign)" + var_part+='\s*' + + # Extract the part after assignation sign + local expression_with_comment="$(tail +$line_number ${file} | grep -i -o -P $var_part'\K.*$' || echo YNH_NULL | head -n1)" + if [[ "$expression_with_comment" == "YNH_NULL" ]]; then + set -o xtrace # set -x + echo YNH_NULL + return 0 + fi + + # Remove comments if needed + local expression="$(echo "$expression_with_comment" | sed "s@$comments[^$string]*\$@@g" | sed "s@\s*[$endline]*\s*]*\$@@")" + + local first_char="${expression:0:1}" + if [[ "$first_char" == '"' ]] ; then + echo "$expression" | grep -m1 -o -P '"\K([^"](\\")?)*[^\\](?=")' | head -n1 | sed 's/\\"/"/g' + elif [[ "$first_char" == "'" ]] ; then + echo "$expression" | grep -m1 -o -P "'\K([^'](\\\\')?)*[^\\\\](?=')" | head -n1 | sed "s/\\\\'/'/g" + else + echo "$expression" + fi + set -o xtrace # set -x +} + +# Set a value into heterogeneous file (yaml, json, php, python...) +# +# usage: ynh_write_var_in_file --file=PATH --key=KEY --value=VALUE +# | arg: -f, --file= - the path to the file +# | arg: -k, --key= - the key to set +# | arg: -v, --value= - the value to set +# +# Requires YunoHost version 4.3 or higher. +ynh_write_var_in_file() { + # Declare an array to define the options of this helper. + local legacy_args=fkva + local -A args_array=( [f]=file= [k]=key= [v]=value= [a]=after=) + local file + local key + local value + local after + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + after="${after:-}" + + [[ -f $file ]] || ynh_die --message="File $file does not exists" + + set +o xtrace # set +x + + # Get the line number after which we search for the variable + local line_number=1 + if [[ -n "$after" ]]; + then + line_number=$(grep -n $after $file | cut -d: -f1) + if [[ -z "$line_number" ]]; + then + set -o xtrace # set -x + return 1 + fi + fi + local range="${line_number},\$ " + + local filename="$(basename -- "$file")" + local ext="${filename##*.}" + local endline=',;' + local assign="=>|:|=" + local comments="#" + local string="\"'" + if [[ "$ext" =~ ^ini|env|toml|yml|yaml$ ]]; then + endline='#' + fi + if [[ "$ext" =~ ^ini|env$ ]]; then + comments="[;#]" + fi + if [[ "php" == "$ext" ]] || [[ "$ext" == "js" ]]; then + comments="//" + fi + local list='\[\s*['$string']?\w+['$string']?\]' + local var_part='^\s*((const|var|let)\s+)?\$?(\w+('$list')*(->|\.|\[))*\s*' + var_part+="[$string]?${key}[$string]?" + var_part+='\s*\]?\s*' + var_part+="($assign)" + var_part+='\s*' + + # Extract the part after assignation sign + local expression_with_comment="$(tail +$line_number ${file} | grep -i -o -P $var_part'\K.*$' || echo YNH_NULL | head -n1)" + if [[ "$expression_with_comment" == "YNH_NULL" ]]; then + set -o xtrace # set -x + return 1 + fi + + # Remove comments if needed + local expression="$(echo "$expression_with_comment" | sed "s@$comments[^$string]*\$@@g" | sed "s@\s*[$endline]*\s*]*\$@@")" + endline=${expression_with_comment#"$expression"} + endline="$(echo "$endline" | sed 's/\\/\\\\/g')" + value="$(echo "$value" | sed 's/\\/\\\\/g')" + local first_char="${expression:0:1}" + delimiter=$'\001' + if [[ "$first_char" == '"' ]] ; then + # \ and sed is quite complex you need 2 \\ to get one in a sed + # So we need \\\\ to go through 2 sed + value="$(echo "$value" | sed 's/"/\\\\"/g')" + sed -ri "${range}s$delimiter"'(^'"${var_part}"'")([^"]|\\")*("[\s;,]*)(\s*'$comments'.*)?$'$delimiter'\1'"${value}"'"'"${endline}${delimiter}i" ${file} + elif [[ "$first_char" == "'" ]] ; then + # \ and sed is quite complex you need 2 \\ to get one in a sed + # However double quotes implies to double \\ to + # So we need \\\\\\\\ to go through 2 sed and 1 double quotes str + value="$(echo "$value" | sed "s/'/\\\\\\\\'/g")" + sed -ri "${range}s$delimiter(^${var_part}')([^']|\\')*('"'[\s,;]*)(\s*'$comments'.*)?$'$delimiter'\1'"${value}'${endline}${delimiter}i" ${file} + else + if [[ "$value" == *"'"* ]] || [[ "$value" == *'"'* ]] || [[ "$ext" =~ ^php|py|json|js$ ]] ; then + value='\"'"$(echo "$value" | sed 's/"/\\\\"/g')"'\"' + fi + if [[ "$ext" =~ ^yaml|yml$ ]] ; then + value=" $value" + fi + sed -ri "${range}s$delimiter(^${var_part}).*\$$delimiter\1${value}${endline}${delimiter}i" ${file} + fi + set -o xtrace # set -x +} + + # Render templates with Jinja2 # +# [internal] +# # Attention : Variables should be exported before calling this helper to be # accessible inside templates. # @@ -287,13 +685,13 @@ ynh_local_curl () { # | arg: some_template - Template file to be rendered # | arg: output_path - The path where the output will be redirected to 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( + local template_path=$1 + local output_path=$2 + mkdir -p "$(dirname $output_path)" + # Taken from https://stackoverflow.com/a/35009576 + python3 -c 'import os, sys, jinja2; sys.stdout.write( jinja2.Template(sys.stdin.read() - ).render(os.environ));' < $template_path > $output_path + ).render(os.environ));' < $template_path > $output_path } # Fetch the Debian release codename @@ -303,7 +701,7 @@ ynh_render_template() { # # Requires YunoHost version 2.7.12 or higher. ynh_get_debian_release () { - echo $(lsb_release --codename --short) + echo $(lsb_release --codename --short) } # Create a directory under /tmp @@ -318,7 +716,7 @@ ynh_mkdir_tmp() { ynh_print_warn --message="The helper ynh_mkdir_tmp is deprecated." ynh_print_warn --message="You should use 'mktemp -d' instead and manage permissions \ properly with chmod/chown." - local TMP_DIR=$(mktemp -d) + local TMP_DIR=$(mktemp --directory) # Give rights to other users could be a security risk. # But for retrocompatibility we need it. (This helpers is deprecated) @@ -329,16 +727,17 @@ properly with chmod/chown." # Remove a file or a directory securely # # usage: ynh_secure_remove --file=path_to_remove -# | arg: -f, --file - File or directory to remove +# | arg: -f, --file= - File or directory to remove # # Requires YunoHost version 2.6.4 or higher. ynh_secure_remove () { # Declare an array to define the options of this helper. local legacy_args=f - declare -Ar args_array=( [f]=file= ) + local -A args_array=( [f]=file= ) local file # Manage arguments with getopts ynh_handle_getopts_args "$@" + set +o xtrace # set +x local forbidden_path=" \ /var/www \ @@ -349,32 +748,32 @@ ynh_secure_remove () { ynh_print_warn --message="/!\ Packager ! You provided more than one argument to ynh_secure_remove but it will be ignored... Use this helper with one argument at time." fi - if [[ "$forbidden_path" =~ "$file" \ + if [[ -z "$file" ]] + then + ynh_print_warn --message="ynh_secure_remove called with empty argument, ignoring." + elif [[ "$forbidden_path" =~ "$file" \ # Match all paths or subpaths in $forbidden_path || "$file" =~ ^/[[:alnum:]]+$ \ # Match all first level paths from / (Like /var, /root, etc...) || "${file:${#file}-1}" = "/" ]] # Match if the path finishes by /. Because it seems there is an empty variable then - ynh_print_warn --message="Avoid deleting $file." + ynh_print_warn --message="Not deleting '$file' because it is not an acceptable path to delete." + elif [ -e "$file" ] + then + rm --recursive "$file" else - if [ -e "$file" ] - then - sudo rm -R "$file" - else - ynh_print_info --message="$file wasn't deleted because it doesn't exist." - fi + ynh_print_info --message="'$file' wasn't deleted because it doesn't exist." fi + + set -o xtrace # set -x } # Extract a key from a plain command output # -# example: yunohost user info tata --output-as plain | ynh_get_plain_key mail +# [internal] # -# usage: ynh_get_plain_key key [subkey [subsubkey ...]] -# | ret: string - the key's value -# -# Requires YunoHost version 2.2.4 or higher. +# (Deprecated, use --output-as json and jq instead) ynh_get_plain_key() { local prefix="#" local founded=0 @@ -382,12 +781,16 @@ ynh_get_plain_key() { # an info to be redacted by the core local key_=$1 shift - while read line; do - if [[ "$founded" == "1" ]] ; then + while read line + do + if [[ "$founded" == "1" ]] + then [[ "$line" =~ ^${prefix}[^#] ]] && return echo $line - elif [[ "$line" =~ ^${prefix}${key_}$ ]]; then - if [[ -n "${1:-}" ]]; then + elif [[ "$line" =~ ^${prefix}${key_}$ ]] + then + if [[ -n "${1:-}" ]] + then prefix+="#" key_=$1 shift @@ -400,120 +803,183 @@ ynh_get_plain_key() { # Read the value of a key in a ynh manifest file # -# usage: ynh_read_manifest manifest key -# | arg: -m, --manifest= - Path of the manifest to read -# | arg: -k, --key= - Name of the key to find +# usage: ynh_read_manifest --manifest="manifest.json" --key="key" +# | arg: -m, --manifest= - Path of the manifest to read +# | arg: -k, --key= - Name of the key to find +# | ret: the value associate to that key # # Requires YunoHost version 3.5.0 or higher. ynh_read_manifest () { - # Declare an array to define the options of this helper. - local legacy_args=mk - declare -Ar args_array=( [m]=manifest= [k]=manifest_key= ) - local manifest - local manifest_key - # Manage arguments with getopts - ynh_handle_getopts_args "$@" + # Declare an array to define the options of this helper. + local legacy_args=mk + local -A args_array=( [m]=manifest= [k]=manifest_key= ) + local manifest + local manifest_key + # Manage arguments with getopts + ynh_handle_getopts_args "$@" - if [ ! -e "$manifest" ]; then - # If the manifest isn't found, try the common place for backup and restore script. - manifest="../settings/manifest.json" - fi + if [ ! -e "$manifest" ]; then + # If the manifest isn't found, try the common place for backup and restore script. + manifest="$YNH_APP_BASEDIR/manifest.json" + fi - jq ".$manifest_key" "$manifest" --raw-output + jq ".$manifest_key" "$manifest" --raw-output } -# Read the upstream version from the manifest +# Read the upstream version from the manifest or `$YNH_APP_MANIFEST_VERSION` # -# The version number in the manifest is defined by ~ynh -# For example : 4.3-2~ynh3 -# This include the number before ~ynh -# In the last example it return 4.3-2 +# usage: ynh_app_upstream_version [--manifest="manifest.json"] +# | arg: -m, --manifest= - Path of the manifest to read +# | ret: the version number of the upstream app # -# usage: ynh_app_upstream_version [-m manifest] -# | arg: -m, --manifest= - Path of the manifest to read +# If the `manifest` is not specified, the envvar `$YNH_APP_MANIFEST_VERSION` will be used. +# +# The version number in the manifest is defined by `~ynh`. +# +# For example, if the manifest contains `4.3-2~ynh3` the function will return `4.3-2` # # Requires YunoHost version 3.5.0 or higher. ynh_app_upstream_version () { # Declare an array to define the options of this helper. local legacy_args=m - declare -Ar args_array=( [m]=manifest= ) + local -A args_array=( [m]=manifest= ) local manifest # Manage arguments with getopts ynh_handle_getopts_args "$@" + manifest="${manifest:-}" - manifest="${manifest:-../manifest.json}" - version_key=$(ynh_read_manifest --manifest="$manifest" --manifest_key="version") - echo "${version_key/~ynh*/}" + if [[ "$manifest" != "" ]] && [[ -e "$manifest" ]]; + then + version_key_=$(ynh_read_manifest --manifest="$manifest" --manifest_key="version") + else + version_key_=$YNH_APP_MANIFEST_VERSION + fi + + echo "${version_key_/~ynh*/}" } # Read package version from the manifest # -# The version number in the manifest is defined by ~ynh -# For example : 4.3-2~ynh3 -# This include the number after ~ynh -# In the last example it return 3 +# usage: ynh_app_package_version [--manifest="manifest.json"] +# | arg: -m, --manifest= - Path of the manifest to read +# | ret: the version number of the package # -# usage: ynh_app_package_version [-m manifest] -# | arg: -m, --manifest= - Path of the manifest to read +# The version number in the manifest is defined by `~ynh`. +# +# For example, if the manifest contains `4.3-2~ynh3` the function will return `3` # # Requires YunoHost version 3.5.0 or higher. ynh_app_package_version () { # Declare an array to define the options of this helper. local legacy_args=m - declare -Ar args_array=( [m]=manifest= ) + local -A args_array=( [m]=manifest= ) local manifest # Manage arguments with getopts ynh_handle_getopts_args "$@" - manifest="${manifest:-../manifest.json}" - version_key=$(ynh_read_manifest --manifest="$manifest" --manifest_key="version") - echo "${version_key/*~ynh/}" + version_key_=$YNH_APP_MANIFEST_VERSION + echo "${version_key_/*~ynh/}" } # Checks the app version to upgrade with the existing app version and returns: # -# - UPGRADE_APP if the upstream app version has changed -# - UPGRADE_PACKAGE if only the YunoHost package has changed -# -# It stops the current script without error if the package is up-to-date +# usage: ynh_check_app_version_changed +# | ret: `UPGRADE_APP` if the upstream version changed, `UPGRADE_PACKAGE` otherwise. # # This helper should be used to avoid an upgrade of an app, or the upstream part # of it, when it's not needed # -# To force an upgrade, even if the package is up to date, -# you have to set the variable YNH_FORCE_UPGRADE before. -# example: sudo YNH_FORCE_UPGRADE=1 yunohost app upgrade MyApp -# -# usage: ynh_check_app_version_changed -# +# You can force an upgrade, even if the package is up to date, with the `--force` (or `-F`) argument : +# ``` +# sudo yunohost app upgrade --force +# ``` # Requires YunoHost version 3.5.0 or higher. ynh_check_app_version_changed () { - local force_upgrade=${YNH_FORCE_UPGRADE:-0} - local package_check=${PACKAGE_CHECK_EXEC:-0} + local return_value=${YNH_APP_UPGRADE_TYPE} - # By default, upstream app version has changed - local return_value="UPGRADE_APP" + if [ "$return_value" == "UPGRADE_FULL" ] || [ "$return_value" == "UPGRADE_FORCED" ] || [ "$return_value" == "DOWNGRADE_FORCED" ] + then + return_value="UPGRADE_APP" + fi - local current_version=$(ynh_read_manifest --manifest="/etc/yunohost/apps/$YNH_APP_INSTANCE_NAME/manifest.json" --manifest_key="version" || echo 1.0) - local current_upstream_version="$(ynh_app_upstream_version --manifest="/etc/yunohost/apps/$YNH_APP_INSTANCE_NAME/manifest.json")" - local update_version=$(ynh_read_manifest --manifest="../manifest.json" --manifest_key="version" || echo 1.0) - local update_upstream_version="$(ynh_app_upstream_version)" - - if [ "$current_version" == "$update_version" ] ; then - # Complete versions are the same - if [ "$force_upgrade" != "0" ] - then - ynh_print_info --message="Upgrade forced by YNH_FORCE_UPGRADE." - unset YNH_FORCE_UPGRADE - elif [ "$package_check" != "0" ] - then - ynh_print_info --message="Upgrade forced for package check." - else - ynh_die "Up-to-date, nothing to do" 0 - fi - elif [ "$current_upstream_version" == "$update_upstream_version" ] ; then - # Upstream versions are the same, only YunoHost package versions differ - return_value="UPGRADE_PACKAGE" - fi - echo $return_value + echo $return_value +} + +# Compare the current package version against another version given as an argument. +# +# usage: ynh_compare_current_package_version --comparison (lt|le|eq|ne|ge|gt) --version +# | arg: --comparison - Comparison type. Could be : `lt` (lower than), `le` (lower or equal), `eq` (equal), `ne` (not equal), `ge` (greater or equal), `gt` (greater than) +# | arg: --version - The version to compare. Need to be a version in the yunohost package version type (like `2.3.1~ynh4`) +# | ret: 0 if the evaluation is true, 1 if false. +# +# example: ynh_compare_current_package_version --comparison lt --version 2.3.2~ynh1 +# +# This helper is usually used when we need to do some actions only for some old package versions. +# +# Generally you might probably use it as follow in the upgrade script : +# ``` +# if ynh_compare_current_package_version --comparison lt --version 2.3.2~ynh1 +# then +# # Do something that is needed for the package version older than 2.3.2~ynh1 +# fi +# ``` +# +# Requires YunoHost version 3.8.0 or higher. +ynh_compare_current_package_version() { + local legacy_args=cv + declare -Ar args_array=( [c]=comparison= [v]=version= ) + local version + local comparison + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + + local current_version=$YNH_APP_CURRENT_VERSION + + # Check the syntax of the versions + if [[ ! $version =~ '~ynh' ]] || [[ ! $current_version =~ '~ynh' ]] + then + ynh_die --message="Invalid argument for version." + fi + + # Check validity of the comparator + if [[ ! $comparison =~ (lt|le|eq|ne|ge|gt) ]]; then + ynh_die --message="Invalid comparator must be : lt, le, eq, ne, ge, gt" + fi + + # Return the return value of dpkg --compare-versions + dpkg --compare-versions $current_version $comparison $version +} + +# Check if we should enforce sane default permissions (= disable rwx for 'others') +# on file/folders handled with ynh_setup_source and ynh_add_config +# +# [internal] +# +# Having a file others-readable or a folder others-executable(=enterable) +# is a security risk comparable to "chmod 777" +# +# Configuration files may contain secrets. Or even just being able to enter a +# folder may allow an attacker to do nasty stuff (maybe a file or subfolder has +# some write permission enabled for 'other' and the attacker may edit the +# content or create files as leverage for priviledge escalation ...) +# +# The sane default should be to set ownership to $app:$app. +# In specific case, you may want to set the ownership to $app:www-data +# for example if nginx needs access to static files. +# +_ynh_apply_default_permissions() { + local target=$1 + + local ynh_requirement=$(jq -r '.requirements.yunohost' $YNH_APP_BASEDIR/manifest.json | tr -d '>= ') + + if [ -z "$ynh_requirement" ] || [ "$ynh_requirement" == "null" ] || dpkg --compare-versions $ynh_requirement ge 4.2 + then + chmod o-rwx $target + chmod g-w $target + chown -R root:root $target + if ynh_system_user_exists $app + then + chown $app:$app $target + fi + fi } diff --git a/data/hooks/backup/05-conf_ldap b/data/hooks/backup/05-conf_ldap old mode 100755 new mode 100644 index 9ae22095e..b28ea39ca --- a/data/hooks/backup/05-conf_ldap +++ b/data/hooks/backup/05-conf_ldap @@ -10,8 +10,8 @@ source /usr/share/yunohost/helpers backup_dir="${1}/conf/ldap" # Backup the configuration -ynh_backup "/etc/ldap/slapd.conf" "${backup_dir}/slapd.conf" -sudo slapcat -b cn=config -l "${backup_dir}/cn=config.master.ldif" +ynh_backup "/etc/ldap/ldap.conf" "${backup_dir}/ldap.conf" +slapcat -b cn=config -l "${backup_dir}/cn=config.master.ldif" # Backup the database -sudo slapcat -b dc=yunohost,dc=org -l "${backup_dir}/dc=yunohost-dc=org.ldif" +slapcat -b dc=yunohost,dc=org -l "${backup_dir}/dc=yunohost-dc=org.ldif" diff --git a/data/hooks/backup/08-conf_ssh b/data/hooks/backup/08-conf_ssh deleted file mode 100755 index ee976080c..000000000 --- a/data/hooks/backup/08-conf_ssh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -# Exit hook on subcommand error or unset variable -set -eu - -# Source YNH helpers -source /usr/share/yunohost/helpers - -# Backup destination -backup_dir="${1}/conf/ssh" - -# Backup the configuration -if [ -d /etc/ssh/ ]; then - ynh_backup "/etc/ssh" "$backup_dir" -else - echo "SSH is not installed" -fi diff --git a/data/hooks/backup/11-conf_ynh_mysql b/data/hooks/backup/11-conf_ynh_mysql deleted file mode 100755 index 031707337..000000000 --- a/data/hooks/backup/11-conf_ynh_mysql +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# Exit hook on subcommand error or unset variable -set -eu - -# Source YNH helpers -source /usr/share/yunohost/helpers - -# Backup destination -backup_dir="${1}/conf/ynh/mysql" - -# Save MySQL root password -ynh_backup "/etc/yunohost/mysql" "${backup_dir}/root_pwd" diff --git a/data/hooks/backup/17-data_home b/data/hooks/backup/17-data_home old mode 100755 new mode 100644 diff --git a/data/hooks/backup/18-data_multimedia b/data/hooks/backup/18-data_multimedia new file mode 100644 index 000000000..f80cff0b3 --- /dev/null +++ b/data/hooks/backup/18-data_multimedia @@ -0,0 +1,17 @@ +#!/bin/bash + +# Exit hook on subcommand error or unset variable +set -eu + +# Source YNH helpers +source /usr/share/yunohost/helpers + +# Backup destination +backup_dir="${1}/data/multimedia" + +if [ -e "/home/yunohost.multimedia/.nobackup" ]; then + exit 0 +fi + +# Backup multimedia directory +ynh_backup --src_path="/home/yunohost.multimedia" --dest_path="${backup_dir}" --is_big --not_mandatory diff --git a/data/hooks/backup/20-conf_ynh_firewall b/data/hooks/backup/20-conf_ynh_firewall deleted file mode 100755 index 98be3eb09..000000000 --- a/data/hooks/backup/20-conf_ynh_firewall +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# Exit hook on subcommand error or unset variable -set -eu - -# Source YNH helpers -source /usr/share/yunohost/helpers - -# Backup destination -backup_dir="${1}/conf/ynh/firewall" - -# Backup the configuration -ynh_backup "/etc/yunohost/firewall.yml" "${backup_dir}/firewall.yml" diff --git a/data/hooks/backup/20-conf_ynh_settings b/data/hooks/backup/20-conf_ynh_settings new file mode 100644 index 000000000..9b56f1579 --- /dev/null +++ b/data/hooks/backup/20-conf_ynh_settings @@ -0,0 +1,18 @@ +#!/bin/bash + +# Exit hook on subcommand error or unset variable +set -eu + +# Source YNH helpers +source /usr/share/yunohost/helpers + +# Backup destination +backup_dir="${1}/conf/ynh" + +# Backup the configuration +ynh_backup "/etc/yunohost/firewall.yml" "${backup_dir}/firewall.yml" +ynh_backup "/etc/yunohost/current_host" "${backup_dir}/current_host" +ynh_backup "/etc/yunohost/domains" "${backup_dir}/domains" +[ ! -e "/etc/yunohost/settings.json" ] || ynh_backup "/etc/yunohost/settings.json" "${backup_dir}/settings.json" +[ ! -d "/etc/yunohost/dyndns" ] || ynh_backup "/etc/yunohost/dyndns" "${backup_dir}/dyndns" +[ ! -d "/etc/dkim" ] || ynh_backup "/etc/dkim" "${backup_dir}/dkim" diff --git a/data/hooks/backup/21-conf_ynh_certs b/data/hooks/backup/21-conf_ynh_certs old mode 100755 new mode 100644 diff --git a/data/hooks/backup/23-data_mail b/data/hooks/backup/23-data_mail old mode 100755 new mode 100644 diff --git a/data/hooks/backup/26-conf_xmpp b/data/hooks/backup/26-conf_xmpp deleted file mode 100755 index b55ad2bfc..000000000 --- a/data/hooks/backup/26-conf_xmpp +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -# Exit hook on subcommand error or unset variable -set -eu - -# Source YNH helpers -source /usr/share/yunohost/helpers - -# Backup destination -backup_dir="${1}/conf/xmpp" - -# Backup the configuration -ynh_backup /etc/metronome "${backup_dir}/etc" -ynh_backup /var/lib/metronome "${backup_dir}/var" diff --git a/data/hooks/backup/27-data_xmpp b/data/hooks/backup/27-data_xmpp new file mode 100644 index 000000000..2cd93e02b --- /dev/null +++ b/data/hooks/backup/27-data_xmpp @@ -0,0 +1,13 @@ +#!/bin/bash + +# Exit hook on subcommand error or unset variable +set -eu + +# Source YNH helpers +source /usr/share/yunohost/helpers + +# Backup destination +backup_dir="${1}/data/xmpp" + +ynh_backup /var/lib/metronome "${backup_dir}/var_lib_metronome" +ynh_backup /var/xmpp-upload/ "${backup_dir}/var_xmpp-upload" diff --git a/data/hooks/backup/29-conf_nginx b/data/hooks/backup/29-conf_nginx deleted file mode 100755 index 81e145e24..000000000 --- a/data/hooks/backup/29-conf_nginx +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# Exit hook on subcommand error or unset variable -set -eu - -# Source YNH helpers -source /usr/share/yunohost/helpers - -# Backup destination -backup_dir="${1}/conf/nginx" - -# Backup the configuration -ynh_backup "/etc/nginx/conf.d" "$backup_dir" diff --git a/data/hooks/backup/32-conf_cron b/data/hooks/backup/32-conf_cron deleted file mode 100755 index acbd009ab..000000000 --- a/data/hooks/backup/32-conf_cron +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - -# Exit hook on subcommand error or unset variable -set -eu - -# Source YNH helpers -source /usr/share/yunohost/helpers - -# Backup destination -backup_dir="${1}/conf/cron" - -# Backup the configuration -for f in $(ls -1B /etc/cron.d/yunohost* 2> /dev/null); do - ynh_backup "$f" "${backup_dir}/${f##*/}" -done diff --git a/data/hooks/backup/40-conf_ynh_currenthost b/data/hooks/backup/40-conf_ynh_currenthost deleted file mode 100755 index 6a98fd0d2..000000000 --- a/data/hooks/backup/40-conf_ynh_currenthost +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# Exit hook on subcommand error or unset variable -set -eu - -# Source YNH helpers -source /usr/share/yunohost/helpers - -# Backup destination -backup_dir="${1}/conf/ynh" - -# Backup the configuration -ynh_backup "/etc/yunohost/current_host" "${backup_dir}/current_host" diff --git a/data/hooks/backup/50-conf_manually_modified_files b/data/hooks/backup/50-conf_manually_modified_files new file mode 100644 index 000000000..2cca11afb --- /dev/null +++ b/data/hooks/backup/50-conf_manually_modified_files @@ -0,0 +1,18 @@ +#!/bin/bash + +source /usr/share/yunohost/helpers +ynh_abort_if_errors +YNH_CWD="${YNH_BACKUP_DIR%/}/conf/manually_modified_files" +mkdir -p "$YNH_CWD" +cd "$YNH_CWD" + +yunohost tools shell -c "from yunohost.regenconf import manually_modified_files; print('\n'.join(manually_modified_files()))" > ./manually_modified_files_list + +ynh_backup --src_path="./manually_modified_files_list" + +for file in $(cat ./manually_modified_files_list) +do + [[ -e $file ]] && ynh_backup --src_path="$file" +done + +ynh_backup --src_path="/etc/ssowat/conf.json.persistent" diff --git a/data/hooks/conf_regen/01-yunohost b/data/hooks/conf_regen/01-yunohost index faf041110..14af66933 100755 --- a/data/hooks/conf_regen/01-yunohost +++ b/data/hooks/conf_regen/01-yunohost @@ -2,8 +2,6 @@ set -e -services_path="/etc/yunohost/services.yml" - do_init_regen() { if [[ $EUID -ne 0 ]]; then echo "You must be root to run this script" 1>&2 @@ -19,14 +17,53 @@ do_init_regen() { || echo "yunohost.org" > /etc/yunohost/current_host # copy default services and firewall - [[ -f $services_path ]] \ - || cp services.yml "$services_path" [[ -f /etc/yunohost/firewall.yml ]] \ || cp firewall.yml /etc/yunohost/firewall.yml # allow users to access /media directory [[ -d /etc/skel/media ]] \ || (mkdir -p /media && ln -s /media /etc/skel/media) + + # Cert folders + mkdir -p /etc/yunohost/certs + chown -R root:ssl-cert /etc/yunohost/certs + chmod 750 /etc/yunohost/certs + + # App folders + mkdir -p /etc/yunohost/apps + chmod 700 /etc/yunohost/apps + mkdir -p /home/yunohost.app + chmod 755 /home/yunohost.app + + # Domain settings + mkdir -p /etc/yunohost/domains + chmod 700 /etc/yunohost/domains + + # Backup folders + mkdir -p /home/yunohost.backup/archives + chmod 750 /home/yunohost.backup/archives + chown root:root /home/yunohost.backup/archives # This is later changed to admin:root once admin user exists + + # Empty ssowat json persistent conf + echo "{}" > '/etc/ssowat/conf.json.persistent' + chmod 644 /etc/ssowat/conf.json.persistent + chown root:root /etc/ssowat/conf.json.persistent + + # Empty service conf + touch /etc/yunohost/services.yml + + mkdir -p /var/cache/yunohost/repo + chown root:root /var/cache/yunohost + chmod 700 /var/cache/yunohost + + cp yunoprompt.service /etc/systemd/system/yunoprompt.service + cp dpkg-origins /etc/dpkg/origins/yunohost + + # Change dpkg vendor + # see https://wiki.debian.org/Derivatives/Guidelines#Vendor + readlink -f /etc/dpkg/origins/default | grep -q debian \ + && rm -f /etc/dpkg/origins/default \ + && ln -s /etc/dpkg/origins/yunohost /etc/dpkg/origins/default } do_pre_regen() { @@ -34,99 +71,170 @@ do_pre_regen() { cd /usr/share/yunohost/templates/yunohost - # update services.yml - if [[ -f $services_path ]]; then - tmp_services_path="${services_path}-tmp" - new_services_path="${services_path}-new" - sudo cp "$services_path" "$tmp_services_path" - _update_services "$new_services_path" || { - sudo mv "$tmp_services_path" "$services_path" - exit 1 - } - if [[ -f $new_services_path ]]; then - # replace services.yml with new one - sudo mv "$new_services_path" "$services_path" - sudo mv "$tmp_services_path" "${services_path}-old" - else - sudo rm -f "$tmp_services_path" - fi - else - sudo cp services.yml /etc/yunohost/services.yml + # Legacy code that can be removed once on bullseye + touch /etc/yunohost/services.yml + yunohost tools shell -c "from yunohost.service import _get_services, _save_services; _save_services(_get_services())" + + mkdir -p $pending_dir/etc/systemd/system + mkdir -p $pending_dir/etc/cron.d/ + mkdir -p $pending_dir/etc/cron.daily/ + + # add cron job for diagnosis to be ran at 7h and 19h + a random delay between + # 0 and 20min, meant to avoid every instances running their diagnosis at + # exactly the same time, which may overload the diagnosis server. + cat > $pending_dir/etc/cron.d/yunohost-diagnosis << EOF +SHELL=/bin/bash +0 7,19 * * * root : YunoHost Automatic Diagnosis; sleep \$((RANDOM\\%1200)); yunohost diagnosis run --email > /dev/null 2>/dev/null || echo "Running the automatic diagnosis failed miserably" +EOF + + # Cron job that upgrade the app list everyday + cat > $pending_dir/etc/cron.daily/yunohost-fetch-apps-catalog << EOF +#!/bin/bash +(sleep \$((RANDOM%3600)); yunohost tools update --apps > /dev/null) & +EOF + + # Cron job that renew lets encrypt certificates if there's any that needs renewal + cat > $pending_dir/etc/cron.daily/yunohost-certificate-renew << EOF +#!/bin/bash +yunohost domain cert renew --email +EOF + + # If we subscribed to a dyndns domain, add the corresponding cron + # - delay between 0 and 60 secs to spread the check over a 1 min window + # - do not run the command if some process already has the lock, to avoid queuing hundreds of commands... + if ls -l /etc/yunohost/dyndns/K*.private 2>/dev/null + then + cat > $pending_dir/etc/cron.d/yunohost-dyndns << EOF +SHELL=/bin/bash +*/10 * * * * root : YunoHost DynDNS update; sleep \$((RANDOM\\%60)); test -e /var/run/moulinette_yunohost.lock || yunohost dyndns update >> /dev/null +EOF fi - mkdir -p "$pending_dir"/etc/etckeeper/ - cp etckeeper.conf "$pending_dir"/etc/etckeeper/ -} + # legacy stuff to avoid yunohost reporting etckeeper as manually modified + # (this make sure that the hash is null / file is flagged as to-delete) + mkdir -p $pending_dir/etc/etckeeper + touch $pending_dir/etc/etckeeper/etckeeper.conf -_update_services() { - sudo python2 - << EOF -import yaml + # Skip ntp if inside a container (inspired from the conf of systemd-timesyncd) + mkdir -p ${pending_dir}/etc/systemd/system/ntp.service.d/ + echo " +[Unit] +ConditionCapability=CAP_SYS_TIME +ConditionVirtualization=!container +" > ${pending_dir}/etc/systemd/system/ntp.service.d/ynh-override.conf - -with open('services.yml') as f: - new_services = yaml.load(f) - -with open('/etc/yunohost/services.yml') as f: - services = yaml.load(f) - -updated = False - - -for service, conf in new_services.items(): - # remove service with empty conf - if conf is None: - if service in services: - print("removing '{0}' from services".format(service)) - del services[service] - updated = True - - # add new service - elif not services.get(service, None): - print("adding '{0}' to services".format(service)) - services[service] = conf - updated = True - - # update service conf - else: - conffiles = services[service].pop('conffiles', {}) - - # status need to be removed - if "status" not in conf and "status" in services[service]: - print("update '{0}' service status access".format(service)) - del services[service]["status"] - updated = True - - if services[service] != conf: - print("update '{0}' service".format(service)) - services[service].update(conf) - updated = True - - if conffiles: - services[service]['conffiles'] = conffiles - - -if updated: - with open('/etc/yunohost/services.yml-new', 'w') as f: - yaml.safe_dump(services, f, default_flow_style=False) + # Make nftable conflict with yunohost-firewall + mkdir -p ${pending_dir}/etc/systemd/system/nftables.service.d/ + cat > ${pending_dir}/etc/systemd/system/nftables.service.d/ynh-override.conf << EOF +[Unit] +# yunohost-firewall and nftables conflict with each other +Conflicts=yunohost-firewall.service +ConditionFileIsExecutable=!/etc/init.d/yunohost-firewall +ConditionPathExists=!/etc/systemd/system/multi-user.target.wants/yunohost-firewall.service EOF + + # Don't suspend computer on LidSwitch + mkdir -p ${pending_dir}/etc/systemd/logind.conf.d/ + cat > ${pending_dir}/etc/systemd/logind.conf.d/ynh-override.conf << EOF +[Login] +HandleLidSwitch=ignore +HandleLidSwitchDocked=ignore +HandleLidSwitchExternalPower=ignore +EOF + + cp yunoprompt.service ${pending_dir}/etc/systemd/system/yunoprompt.service + + if [[ "$(yunohost settings get 'security.experimental.enabled')" == "True" ]] + then + cp proc-hidepid.service ${pending_dir}/etc/systemd/system/proc-hidepid.service + else + touch ${pending_dir}/etc/systemd/system/proc-hidepid.service + fi + + mkdir -p ${pending_dir}/etc/dpkg/origins/ + cp dpkg-origins ${pending_dir}/etc/dpkg/origins/yunohost + } -FORCE=${2:-0} -DRY_RUN=${3:-0} +do_post_regen() { + regen_conf_files=$1 -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - ;; - init) - do_init_regen - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac + ###################### + # Enfore permissions # + ###################### -exit 0 + chmod 750 /home/admin + chmod 750 /home/yunohost.conf + chmod 750 /home/yunohost.backup + chmod 750 /home/yunohost.backup/archives + chown root:root /home/yunohost.conf + chown admin:root /home/yunohost.backup + chown admin:root /home/yunohost.backup/archives + + # Certs + # We do this with find because there could be a lot of them... + chown -R root:ssl-cert /etc/yunohost/certs + chmod 750 /etc/yunohost/certs + find /etc/yunohost/certs/ -type f -exec chmod 640 {} \; + find /etc/yunohost/certs/ -type d -exec chmod 750 {} \; + + find /etc/cron.*/yunohost-* -type f -exec chmod 755 {} \; + find /etc/cron.d/yunohost-* -type f -exec chmod 644 {} \; + find /etc/cron.*/yunohost-* -type f -exec chown root:root {} \; + + chown root:root /var/cache/yunohost + chmod 700 /var/cache/yunohost + chown root:root /var/cache/moulinette + chmod 700 /var/cache/moulinette + + setfacl -m g:all_users:--- /var/www + setfacl -m g:all_users:--- /var/log/nginx + setfacl -m g:all_users:--- /etc/yunohost + setfacl -m g:all_users:--- /etc/ssowat + + for USER in $(yunohost user list --quiet --output-as json | jq -r '.users | .[] | .username') + do + [ ! -e "/home/$USER" ] || setfacl -m g:all_users:--- /home/$USER + done + + # Domain settings + mkdir -p /etc/yunohost/domains + + # Misc configuration / state files + chown root:root $(ls /etc/yunohost/{*.yml,*.yaml,*.json,mysql,psql} 2>/dev/null) + chmod 600 $(ls /etc/yunohost/{*.yml,*.yaml,*.json,mysql,psql} 2>/dev/null) + + # Apps folder, custom hooks folder + [[ ! -e /etc/yunohost/hooks.d ]] || (chown root /etc/yunohost/hooks.d && chmod 700 /etc/yunohost/hooks.d) + [[ ! -e /etc/yunohost/apps ]] || (chown root /etc/yunohost/apps && chmod 700 /etc/yunohost/apps) + [[ ! -e /etc/yunohost/domains ]] || (chown root /etc/yunohost/domains && chmod 700 /etc/yunohost/domains) + + # Create ssh.app and sftp.app groups if they don't exist yet + grep -q '^ssh.app:' /etc/group || groupadd ssh.app + grep -q '^sftp.app:' /etc/group || groupadd sftp.app + + # Propagates changes in systemd service config overrides + [[ ! "$regen_conf_files" =~ "ntp.service.d/ynh-override.conf" ]] || { systemctl daemon-reload; systemctl restart ntp; } + [[ ! "$regen_conf_files" =~ "nftables.service.d/ynh-override.conf" ]] || systemctl daemon-reload + [[ ! "$regen_conf_files" =~ "login.conf.d/ynh-override.conf" ]] || systemctl daemon-reload + if [[ "$regen_conf_files" =~ "yunoprompt.service" ]] + then + systemctl daemon-reload + action=$([[ -e /etc/systemd/system/yunoprompt.service ]] && echo 'enable' || echo 'disable') + systemctl $action yunoprompt --quiet --now + fi + if [[ "$regen_conf_files" =~ "proc-hidepid.service" ]] + then + systemctl daemon-reload + action=$([[ -e /etc/systemd/system/proc-hidepid.service ]] && echo 'enable' || echo 'disable') + systemctl $action proc-hidepid --quiet --now + fi + + # Change dpkg vendor + # see https://wiki.debian.org/Derivatives/Guidelines#Vendor + readlink -f /etc/dpkg/origins/default | grep -q debian \ + && rm -f /etc/dpkg/origins/default \ + && ln -s /etc/dpkg/origins/yunohost /etc/dpkg/origins/default +} + +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/02-ssl b/data/hooks/conf_regen/02-ssl index 1df3a3260..2b40c77a2 100755 --- a/data/hooks/conf_regen/02-ssl +++ b/data/hooks/conf_regen/02-ssl @@ -3,84 +3,97 @@ set -e ssl_dir="/usr/share/yunohost/yunohost-config/ssl/yunoCA" +ynh_ca="/etc/yunohost/certs/yunohost.org/ca.pem" +ynh_crt="/etc/yunohost/certs/yunohost.org/crt.pem" +ynh_key="/etc/yunohost/certs/yunohost.org/key.pem" +openssl_conf="/usr/share/yunohost/templates/ssl/openssl.cnf" + +regen_local_ca() { + + domain="$1" + + echo -e "\n# Creating local certification authority with domain=$domain\n" + + # create certs and SSL directories + mkdir -p "/etc/yunohost/certs/yunohost.org" + mkdir -p "${ssl_dir}/"{ca,certs,crl,newcerts} + + pushd ${ssl_dir} + + # (Update the serial so that it's specific to this very instance) + # N.B. : the weird RANDFILE thing comes from: + # https://stackoverflow.com/questions/94445/using-openssl-what-does-unable-to-write-random-state-mean + RANDFILE=.rnd openssl rand -hex 19 > serial + rm -f index.txt + touch index.txt + cp /usr/share/yunohost/templates/ssl/openssl.cnf openssl.ca.cnf + sed -i "s/yunohost.org/${domain}/g" openssl.ca.cnf + openssl req -x509 \ + -new \ + -config openssl.ca.cnf \ + -days 3650 \ + -out ca/cacert.pem \ + -keyout ca/cakey.pem \ + -nodes \ + -batch \ + -subj /CN=${domain}/O=${domain%.*} 2>&1 + + chmod 640 ca/cacert.pem + chmod 640 ca/cakey.pem + + cp ca/cacert.pem $ynh_ca + ln -sf "$ynh_ca" /etc/ssl/certs/ca-yunohost_crt.pem + update-ca-certificates + + popd +} do_init_regen() { - if [[ $EUID -ne 0 ]]; then - echo "You must be root to run this script" 1>&2 - exit 1 - fi - LOGFILE="/tmp/yunohost-ssl-init" + LOGFILE=/tmp/yunohost-ssl-init + echo "" > $LOGFILE + chown root:root $LOGFILE + chmod 640 $LOGFILE - echo "Initializing a local SSL certification authority ..." - echo "(logs available in $LOGFILE)" - - rm -f $LOGFILE - touch $LOGFILE - - # create certs and SSL directories - mkdir -p "/etc/yunohost/certs/yunohost.org" - mkdir -p "${ssl_dir}/"{ca,certs,crl,newcerts} - - # initialize some files - # N.B. : the weird RANDFILE thing comes from: - # https://stackoverflow.com/questions/94445/using-openssl-what-does-unable-to-write-random-state-mean - [[ -f "${ssl_dir}/serial" ]] \ - || RANDFILE=.rnd openssl rand -hex 19 > "${ssl_dir}/serial" - [[ -f "${ssl_dir}/index.txt" ]] \ - || touch "${ssl_dir}/index.txt" - - openssl_conf="/usr/share/yunohost/templates/ssl/openssl.cnf" - ynh_ca="/etc/yunohost/certs/yunohost.org/ca.pem" - ynh_crt="/etc/yunohost/certs/yunohost.org/crt.pem" - ynh_key="/etc/yunohost/certs/yunohost.org/key.pem" + # Make sure this conf exists + mkdir -p ${ssl_dir} + cp /usr/share/yunohost/templates/ssl/openssl.cnf ${ssl_dir}/openssl.ca.cnf # create default certificates if [[ ! -f "$ynh_ca" ]]; then - echo -e "\n# Creating the CA key (?)\n" >>$LOGFILE - - openssl req -x509 \ - -new \ - -config "$openssl_conf" \ - -days 3650 \ - -out "${ssl_dir}/ca/cacert.pem" \ - -keyout "${ssl_dir}/ca/cakey.pem" \ - -nodes -batch >>$LOGFILE 2>&1 - - cp "${ssl_dir}/ca/cacert.pem" "$ynh_ca" - ln -sf "$ynh_ca" /etc/ssl/certs/ca-yunohost_crt.pem - update-ca-certificates + regen_local_ca yunohost.org >>$LOGFILE fi if [[ ! -f "$ynh_crt" ]]; then - echo -e "\n# Creating initial key and certificate (?)\n" >>$LOGFILE + echo -e "\n# Creating initial key and certificate \n" >>$LOGFILE openssl req -new \ -config "$openssl_conf" \ -days 730 \ -out "${ssl_dir}/certs/yunohost_csr.pem" \ -keyout "${ssl_dir}/certs/yunohost_key.pem" \ - -nodes -batch >>$LOGFILE 2>&1 + -nodes -batch &>>$LOGFILE openssl ca \ -config "$openssl_conf" \ -days 730 \ -in "${ssl_dir}/certs/yunohost_csr.pem" \ -out "${ssl_dir}/certs/yunohost_crt.pem" \ - -batch >>$LOGFILE 2>&1 + -batch &>>$LOGFILE - last_cert=$(ls $ssl_dir/newcerts/*.pem | sort -V | tail -n 1) chmod 640 "${ssl_dir}/certs/yunohost_key.pem" - chmod 640 "$last_cert" + chmod 640 "${ssl_dir}/certs/yunohost_crt.pem" cp "${ssl_dir}/certs/yunohost_key.pem" "$ynh_key" - cp "$last_cert" "$ynh_crt" + cp "${ssl_dir}/certs/yunohost_crt.pem" "$ynh_crt" ln -sf "$ynh_crt" /etc/ssl/certs/yunohost_crt.pem ln -sf "$ynh_key" /etc/ssl/private/yunohost_key.pem fi chown -R root:ssl-cert /etc/yunohost/certs/yunohost.org/ chmod o-rwx /etc/yunohost/certs/yunohost.org/ + + install -D -m 644 $openssl_conf "${ssl_dir}/openssl.cnf" } do_pre_regen() { @@ -94,41 +107,16 @@ do_pre_regen() { do_post_regen() { regen_conf_files=$1 - # Ensure that index.txt exists - index_txt=/usr/share/yunohost/yunohost-config/ssl/yunoCA/index.txt - [[ -f "${index_txt}" ]] || { - if [[ -f "${index_txt}.saved" ]]; then - # use saved database from 2.2 - sudo cp "${index_txt}.saved" "${index_txt}" - elif [[ -f "${index_txt}.old" ]]; then - # ... or use the state-1 database - sudo cp "${index_txt}.old" "${index_txt}" - else - # ... or create an empty one - sudo touch "${index_txt}" - fi - } + current_local_ca_domain=$(openssl x509 -in $ynh_ca -text | tr ',' '\n' | grep Issuer | awk '{print $4}') + main_domain=$(cat /etc/yunohost/current_host) - # TODO: regenerate certificates if conf changed? + if [[ "$current_local_ca_domain" != "$main_domain" ]] + then + regen_local_ca $main_domain + # Idk how useful this is, but this was in the previous python code (domain.main_domain()) + ln -sf /etc/yunohost/certs/$domain/crt.pem /etc/ssl/certs/yunohost_crt.pem + ln -sf /etc/yunohost/certs/$domain/key.pem /etc/ssl/private/yunohost_key.pem + fi } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - init) - do_init_regen - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/03-ssh b/data/hooks/conf_regen/03-ssh index 54b7c55b7..f10dbb653 100755 --- a/data/hooks/conf_regen/03-ssh +++ b/data/hooks/conf_regen/03-ssh @@ -25,7 +25,7 @@ do_pre_regen() { # Support different strategy for security configurations export compatibility="$(yunohost settings get 'security.ssh.compatibility')" - + export port="$(yunohost settings get 'security.ssh.port')" export ssh_keys export ipv6_enabled ynh_render_template "sshd_config" "${pending_dir}/etc/ssh/sshd_config" @@ -48,20 +48,4 @@ do_post_regen() { systemctl restart ssh } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/06-slapd b/data/hooks/conf_regen/06-slapd index 4f7adda78..49b1bf354 100755 --- a/data/hooks/conf_regen/06-slapd +++ b/data/hooks/conf_regen/06-slapd @@ -2,7 +2,10 @@ set -e -tmp_backup_dir_file="/tmp/slapd-backup-dir.txt" +tmp_backup_dir_file="/root/slapd-backup-dir.txt" + +config="/usr/share/yunohost/templates/slapd/config.ldif" +db_init="/usr/share/yunohost/templates/slapd/db_init.ldif" do_init_regen() { if [[ $EUID -ne 0 ]]; then @@ -12,27 +15,95 @@ do_init_regen() { do_pre_regen "" - # fix some permissions - chown root:openldap /etc/ldap/slapd.conf + # Drop current existing slapd data + + rm -rf /var/backups/*.ldapdb + rm -rf /var/backups/slapd-* + + debconf-set-selections << EOF +slapd slapd/password1 password yunohost +slapd slapd/password2 password yunohost +slapd slapd/domain string yunohost.org +slapd shared/organization string yunohost.org +slapd slapd/allow_ldap_v2 boolean false +slapd slapd/invalid_config boolean true +slapd slapd/backend select MDB +slapd slapd/move_old_database boolean true +slapd slapd/no_configuration boolean false +slapd slapd/purge_database boolean false +EOF + + DEBIAN_FRONTEND=noninteractive dpkg-reconfigure slapd -u + + # Enforce permissions chown -R openldap:openldap /etc/ldap/schema/ usermod -aG ssl-cert openldap - # check the slapd config file at first - slaptest -Q -u -f /etc/ldap/slapd.conf + # (Re-)init data according to default ldap entries + echo ' Initializing LDAP with YunoHost DB structure' - # regenerate LDAP config directory from slapd.conf + rm -rf /etc/ldap/slapd.d + mkdir -p /etc/ldap/slapd.d + slapadd -F /etc/ldap/slapd.d -b cn=config -l "$config" 2>&1 \ + | grep -v "none elapsed\|Closing DB" || true + chown -R openldap: /etc/ldap/slapd.d + + rm -rf /var/lib/ldap + mkdir -p /var/lib/ldap + slapadd -F /etc/ldap/slapd.d -b dc=yunohost,dc=org -l "$db_init" 2>&1 \ + | grep -v "none elapsed\|Closing DB" || true + chown -R openldap: /var/lib/ldap + + nscd -i group || true + nscd -i passwd || true + + systemctl restart slapd + + # We don't use mkhomedir_helper because 'admin' may not be recognized + # when this script is ran in a chroot (e.g. ISO install) + # We also refer to admin as uid 1007 for the same reason + if [ ! -d /home/admin ] + then + cp -r /etc/skel /home/admin + chown -R 1007:1007 /home/admin + fi +} + +_regenerate_slapd_conf() { + + # Validate the new slapd config + # To do so, we have to use the .ldif to generate the config directory + # so we use a temporary directory slapd_new.d + rm -Rf /etc/ldap/slapd_new.d + mkdir /etc/ldap/slapd_new.d + slapadd -b cn=config -l "$config" -F /etc/ldap/slapd_new.d/ 2>&1 \ + | grep -v "none elapsed\|Closing DB" || true + # Actual validation (-Q is for quiet, -u is for dry-run) + slaptest -Q -u -F /etc/ldap/slapd_new.d + + # "Commit" / apply the new config (meaning we delete the old one and replace + # it with the new one) rm -Rf /etc/ldap/slapd.d - mkdir /etc/ldap/slapd.d - slaptest -f /etc/ldap/slapd.conf -F /etc/ldap/slapd.d/ 2>&1 - chown -R openldap:openldap /etc/ldap/slapd.d/ + mv /etc/ldap/slapd_new.d /etc/ldap/slapd.d - service slapd restart + chown -R openldap:openldap /etc/ldap/slapd.d/ } do_pre_regen() { pending_dir=$1 - cd /usr/share/yunohost/templates/slapd + # remove temporary backup file + rm -f "$tmp_backup_dir_file" + + # Define if we need to migrate from hdb to mdb + curr_backend=$(grep '^database' /etc/ldap/slapd.conf 2>/dev/null | awk '{print $2}') + if [ -e /etc/ldap/slapd.conf ] && [ -n "$curr_backend" ] && \ + [ $curr_backend != 'mdb' ]; then + backup_dir="/var/backups/dc=yunohost,dc=org-${curr_backend}-$(date +%s)" + mkdir -p "$backup_dir" + slapcat -b dc=yunohost,dc=org -l "${backup_dir}/dc=yunohost-dc=org.ldif" + echo "$backup_dir" > "$tmp_backup_dir_file" + fi # create needed directories ldap_dir="${pending_dir}/etc/ldap" @@ -40,28 +111,18 @@ do_pre_regen() { mkdir -p "$ldap_dir" "$schema_dir" # remove legacy configuration file - [ ! -f /etc/ldap/slapd-yuno.conf ] \ - || touch "${pending_dir}/etc/ldap/slapd-yuno.conf" + [ ! -f /etc/ldap/slapd-yuno.conf ] || touch "${ldap_dir}/slapd-yuno.conf" + [ ! -f /etc/ldap/slapd.conf ] || touch "${ldap_dir}/slapd.conf" + [ ! -f /etc/ldap/schema/yunohost.schema ] || touch "${schema_dir}/yunohost.schema" - # remove temporary backup file - rm -f "$tmp_backup_dir_file" - - # retrieve current and new backends - curr_backend=$(grep '^database' /etc/ldap/slapd.conf 2>/dev/null | awk '{print $2}') - new_backend=$(grep '^database' slapd.conf | awk '{print $2}') - - # save current database before any conf changes - if [[ -n "$curr_backend" && "$curr_backend" != "$new_backend" ]]; then - backup_dir="/var/backups/dc=yunohost,dc=org-${curr_backend}-$(date +%s)" - mkdir -p "$backup_dir" - slapcat -b dc=yunohost,dc=org \ - -l "${backup_dir}/dc=yunohost-dc=org.ldif" - echo "$backup_dir" > "$tmp_backup_dir_file" - fi + cd /usr/share/yunohost/templates/slapd # copy configuration files - cp -a ldap.conf slapd.conf "$ldap_dir" - cp -a sudo.schema mailserver.schema yunohost.schema "$schema_dir" + cp -a ldap.conf "$ldap_dir" + cp -a sudo.ldif mailserver.ldif permission.ldif "$schema_dir" + + mkdir -p ${pending_dir}/etc/systemd/system/slapd.service.d/ + cp systemd-override.conf ${pending_dir}/etc/systemd/system/slapd.service.d/ynh-override.conf install -D -m 644 slapd.default "${pending_dir}/etc/default/slapd" } @@ -69,51 +130,56 @@ do_pre_regen() { do_post_regen() { regen_conf_files=$1 - # ensure that slapd.d exists - mkdir -p /etc/ldap/slapd.d - # fix some permissions - echo "Making sure we have the right permissions needed ..." + echo "Enforce permissions on ldap/slapd directories and certs ..." # penldap user should be in the ssl-cert group to let it access the certificate for TLS usermod -aG ssl-cert openldap - chown root:openldap /etc/ldap/slapd.conf chown -R openldap:openldap /etc/ldap/schema/ chown -R openldap:openldap /etc/ldap/slapd.d/ - chown -R root:ssl-cert /etc/yunohost/certs/yunohost.org/ - chmod o-rwx /etc/yunohost/certs/yunohost.org/ + + # If we changed the systemd ynh-override conf + if echo "$regen_conf_files" | sed 's/,/\n/g' | grep -q "^/etc/systemd/system/slapd.service.d/ynh-override.conf$" + then + systemctl daemon-reload + systemctl restart slapd + sleep 3 + fi + + # For some reason, old setups don't have the admins group defined... + if ! slapcat | grep -q 'cn=admins,ou=groups,dc=yunohost,dc=org' + then + slapadd -F /etc/ldap/slapd.d -b dc=yunohost,dc=org <<< \ +"dn: cn=admins,ou=groups,dc=yunohost,dc=org +cn: admins +gidNumber: 4001 +memberUid: admin +objectClass: posixGroup +objectClass: top" + chown -R openldap: /var/lib/ldap + systemctl restart slapd + nscd -i group + fi [ -z "$regen_conf_files" ] && exit 0 - # check the slapd config file at first - slaptest -Q -u -f /etc/ldap/slapd.conf + # regenerate LDAP config directory from slapd.conf + echo "Regenerate LDAP config directory from config.ldif" + _regenerate_slapd_conf - # check if a backup should be restored + # If there's a backup, re-import its data backup_dir=$(cat "$tmp_backup_dir_file" 2>/dev/null || true) if [[ -n "$backup_dir" && -f "${backup_dir}/dc=yunohost-dc=org.ldif" ]]; then # regenerate LDAP config directory and import database as root - # since the admin user may be unavailable - echo "Regenerate LDAP config directory and import the database using slapadd" - sh -c "rm -Rf /etc/ldap/slapd.d; - mkdir /etc/ldap/slapd.d; - slaptest -f /etc/ldap/slapd.conf -F /etc/ldap/slapd.d; - chown -R openldap:openldap /etc/ldap/slapd.d; - slapadd -F /etc/ldap/slapd.d -b dc=yunohost,dc=org \ - -l '${backup_dir}/dc=yunohost-dc=org.ldif'; - chown -R openldap:openldap /var/lib/ldap" 2>&1 - else - # regenerate LDAP config directory from slapd.conf - echo "Regenerate LDAP config directory from slapd.conf" - rm -Rf /etc/ldap/slapd.d - mkdir /etc/ldap/slapd.d - slaptest -f /etc/ldap/slapd.conf -F /etc/ldap/slapd.d/ 2>&1 - chown -R openldap:openldap /etc/ldap/slapd.d/ + echo "Import the database using slapadd" + slapadd -F /etc/ldap/slapd.d -b dc=yunohost,dc=org -l "${backup_dir}/dc=yunohost-dc=org.ldif" + chown -R openldap:openldap /var/lib/ldap 2>&1 fi echo "Running slapdindex" su openldap -s "/bin/bash" -c "/usr/sbin/slapindex" echo "Reloading slapd" - service slapd force-reload + systemctl force-reload slapd # on slow hardware/vm this regen conf would exit before the admin user that # is stored in ldap is available because ldap seems to slow to restart @@ -126,30 +192,11 @@ do_post_regen() { # wait a maximum time of 5 minutes # yes, force-reload behave like a restart number_of_wait=0 - while ! sudo su admin -c '' && ((number_of_wait < 60)) + while ! su admin -c '' && ((number_of_wait < 60)) do sleep 5 ((number_of_wait += 1)) done } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - init) - do_init_regen - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/09-nslcd b/data/hooks/conf_regen/09-nslcd index 5071ac1fd..cefd05cd3 100755 --- a/data/hooks/conf_regen/09-nslcd +++ b/data/hooks/conf_regen/09-nslcd @@ -2,6 +2,11 @@ set -e +do_init_regen() { + do_pre_regen "" + systemctl restart nslcd +} + do_pre_regen() { pending_dir=$1 @@ -14,23 +19,7 @@ do_post_regen() { regen_conf_files=$1 [[ -z "$regen_conf_files" ]] \ - || sudo service nslcd restart + || systemctl restart nslcd } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/10-apt b/data/hooks/conf_regen/10-apt new file mode 100755 index 000000000..1c80b6706 --- /dev/null +++ b/data/hooks/conf_regen/10-apt @@ -0,0 +1,57 @@ +#!/bin/bash + +set -e + +do_pre_regen() { + pending_dir=$1 + + mkdir --parents "${pending_dir}/etc/apt/preferences.d" + + packages_to_refuse_from_sury="php php-fpm php-mysql php-xml php-zip php-mbstring php-ldap php-gd php-curl php-bz2 php-json php-sqlite3 php-intl openssl libssl1.1 libssl-dev" + for package in $packages_to_refuse_from_sury + do + echo " +Package: $package +Pin: origin \"packages.sury.org\" +Pin-Priority: -1" >> "${pending_dir}/etc/apt/preferences.d/extra_php_version" + done + + echo " + +# PLEASE READ THIS WARNING AND DON'T EDIT THIS FILE + +# You are probably reading this file because you tried to install apache2 or +# bind9. These 2 packages conflict with YunoHost. + +# Installing apache2 will break nginx and break the entire YunoHost ecosystem +# on your server, therefore don't remove those lines! + +# You have been warned. + +Package: apache2 +Pin: release * +Pin-Priority: -1 + +Package: apache2-bin +Pin: release * +Pin-Priority: -1 + +# Also bind9 will conflict with dnsmasq. +# Same story as for apache2. +# Don't install it, don't remove those lines. + +Package: bind9 +Pin: release * +Pin-Priority: -1 +" >> "${pending_dir}/etc/apt/preferences.d/ban_packages" + +} + +do_post_regen() { + regen_conf_files=$1 + + # Make sure php7.3 is the default version when using php in cli + update-alternatives --set php /usr/bin/php7.3 +} + +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/12-metronome b/data/hooks/conf_regen/12-metronome index 4214722fc..ab9fca173 100755 --- a/data/hooks/conf_regen/12-metronome +++ b/data/hooks/conf_regen/12-metronome @@ -14,7 +14,6 @@ do_pre_regen() { # retrieve variables main_domain=$(cat /etc/yunohost/current_host) - domain_list=$(sudo yunohost domain list --output-as plain --quiet) # install main conf file cat metronome.cfg.lua \ @@ -22,7 +21,7 @@ do_pre_regen() { > "${metronome_dir}/metronome.cfg.lua" # add domain conf files - for domain in $domain_list; do + for domain in $YNH_DOMAINS; do cat domain.tpl.cfg.lua \ | sed "s/{{ domain }}/${domain}/g" \ > "${metronome_conf_dir}/${domain}.cfg.lua" @@ -33,7 +32,7 @@ do_pre_regen() { | awk '/^[^\.]+\.[^\.]+.*\.cfg\.lua$/ { print $1 }') for file in $conf_files; do domain=${file%.cfg.lua} - [[ $domain_list =~ $domain ]] \ + [[ $YNH_DOMAINS =~ $domain ]] \ || touch "${metronome_conf_dir}/${file}" done } @@ -41,36 +40,34 @@ do_pre_regen() { do_post_regen() { regen_conf_files=$1 - # fix some permissions - sudo chown -R metronome: /var/lib/metronome/ - sudo chown -R metronome: /etc/metronome/conf.d/ - # retrieve variables - domain_list=$(sudo yunohost domain list --output-as plain --quiet) + main_domain=$(cat /etc/yunohost/current_host) + + # FIXME : small optimization to do to avoid calling a yunohost command ... + # maybe another env variable like YNH_MAIN_DOMAINS idk + domain_list=$(yunohost domain list --exclude-subdomains --output-as plain --quiet) # create metronome directories for domains for domain in $domain_list; do - sudo mkdir -p "/var/lib/metronome/${domain//./%2e}/pep" + mkdir -p "/var/lib/metronome/${domain//./%2e}/pep" + # http_upload directory must be writable by metronome and readable by nginx + mkdir -p "/var/xmpp-upload/${domain}/upload" + # sgid bit allows that file created in that dir will be owned by www-data + # despite the fact that metronome ain't in the www-data group + chmod g+s "/var/xmpp-upload/${domain}/upload" done + # fix some permissions + [ ! -e '/var/xmpp-upload' ] || chown -R metronome:www-data "/var/xmpp-upload/" + [ ! -e '/var/xmpp-upload' ] || chmod 750 "/var/xmpp-upload/" + + # metronome should be in ssl-cert group to let it access SSL certificates + usermod -aG ssl-cert metronome + chown -R metronome: /var/lib/metronome/ + chown -R metronome: /etc/metronome/conf.d/ + [[ -z "$regen_conf_files" ]] \ - || sudo service metronome restart + || systemctl restart metronome } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/15-nginx b/data/hooks/conf_regen/15-nginx index 59654a771..c158ecd09 100755 --- a/data/hooks/conf_regen/15-nginx +++ b/data/hooks/conf_regen/15-nginx @@ -23,10 +23,17 @@ do_init_regen() { rm -f "${nginx_dir}/sites-enabled/default" export compatibility="intermediate" + ynh_render_template "security.conf.inc" "${nginx_conf_dir}/security.conf.inc" ynh_render_template "yunohost_admin.conf" "${nginx_conf_dir}/yunohost_admin.conf" + ynh_render_template "yunohost_admin.conf.inc" "${nginx_conf_dir}/yunohost_admin.conf.inc" + ynh_render_template "yunohost_api.conf.inc" "${nginx_conf_dir}/yunohost_api.conf.inc" + + mkdir -p $nginx_conf_dir/default.d/ + cp "redirect_to_admin.conf" $nginx_conf_dir/default.d/ # Restart nginx if conf looks good, otherwise display error and exit unhappy - nginx -t 2>/dev/null && service nginx restart || (nginx -t && exit 1) + nginx -t 2>/dev/null || { nginx -t; exit 1; } + systemctl restart nginx || { journalctl --no-pager --lines=10 -u nginx >&2; exit 1; } exit 0 } @@ -42,16 +49,26 @@ do_pre_regen() { # install / update plain conf files cp plain/* "$nginx_conf_dir" + # remove the panel overlay if this is specified in settings + panel_overlay=$(yunohost settings get 'ssowat.panel_overlay.enabled') + if [ "$panel_overlay" == "false" ] || [ "$panel_overlay" == "False" ] + then + echo "#" > "${nginx_conf_dir}/yunohost_panel.conf.inc" + fi # retrieve variables main_domain=$(cat /etc/yunohost/current_host) - domain_list=$(sudo yunohost domain list --output-as plain --quiet) # Support different strategy for security configurations + export redirect_to_https="$(yunohost settings get 'security.nginx.redirect_to_https')" export compatibility="$(yunohost settings get 'security.nginx.compatibility')" + export experimental="$(yunohost settings get 'security.experimental.enabled')" + ynh_render_template "security.conf.inc" "${nginx_conf_dir}/security.conf.inc" + + cert_status=$(yunohost domain cert status --json) # add domain conf files - for domain in $domain_list; do + for domain in $YNH_DOMAINS; do domain_conf_dir="${nginx_conf_dir}/${domain}.d" mkdir -p "$domain_conf_dir" mail_autoconfig_dir="${pending_dir}/var/www/.well-known/${domain}/autoconfig/mail/" @@ -59,27 +76,34 @@ do_pre_regen() { # NGINX server configuration export domain - export domain_cert_ca=$(yunohost domain cert-status $domain --json \ + export domain_cert_ca=$(echo $cert_status \ | jq ".certificates.\"$domain\".CA_type" \ | tr -d '"') ynh_render_template "server.tpl.conf" "${nginx_conf_dir}/${domain}.conf" ynh_render_template "autoconfig.tpl.xml" "${mail_autoconfig_dir}/config-v1.1.xml" - [[ $main_domain != $domain ]] \ - && touch "${domain_conf_dir}/yunohost_local.conf" \ - || cp yunohost_local.conf "${domain_conf_dir}/yunohost_local.conf" + touch "${domain_conf_dir}/yunohost_local.conf" # Clean legacy conf files done + export webadmin_allowlist_enabled=$(yunohost settings get security.webadmin.allowlist.enabled) + if [ "$webadmin_allowlist_enabled" == "True" ] + then + export webadmin_allowlist=$(yunohost settings get security.webadmin.allowlist) + fi + ynh_render_template "yunohost_admin.conf.inc" "${nginx_conf_dir}/yunohost_admin.conf.inc" + ynh_render_template "yunohost_api.conf.inc" "${nginx_conf_dir}/yunohost_api.conf.inc" ynh_render_template "yunohost_admin.conf" "${nginx_conf_dir}/yunohost_admin.conf" + mkdir -p $nginx_conf_dir/default.d/ + cp "redirect_to_admin.conf" $nginx_conf_dir/default.d/ # remove old domain conf files conf_files=$(ls -1 /etc/nginx/conf.d \ | awk '/^[^\.]+\.[^\.]+.*\.conf$/ { print $1 }') for file in $conf_files; do domain=${file%.conf} - [[ $domain_list =~ $domain ]] \ + [[ $YNH_DOMAINS =~ $domain ]] \ || touch "${nginx_conf_dir}/${file}" done @@ -87,7 +111,7 @@ do_pre_regen() { autoconfig_files=$(ls -1 /var/www/.well-known/*/autoconfig/mail/config-v1.1.xml 2>/dev/null || true) for file in $autoconfig_files; do domain=$(basename $(readlink -f $(dirname $file)/../..)) - [[ $domain_list =~ $domain ]] \ + [[ $YNH_DOMAINS =~ $domain ]] \ || (mkdir -p "$(dirname ${pending_dir}/${file})" && touch "${pending_dir}/${file}") done @@ -101,35 +125,28 @@ do_post_regen() { [ -z "$regen_conf_files" ] && exit 0 - # retrieve variables - domain_list=$(sudo yunohost domain list --output-as plain --quiet) - # create NGINX conf directories for domains - for domain in $domain_list; do - sudo mkdir -p "/etc/nginx/conf.d/${domain}.d" + for domain in $YNH_DOMAINS; do + mkdir -p "/etc/nginx/conf.d/${domain}.d" done - # Reload nginx configuration - pgrep nginx && sudo service nginx reload + # Get rid of legacy lets encrypt snippets + for domain in $YNH_DOMAINS; do + # If the legacy letsencrypt / acme-challenge domain-specific snippet is still there + if [ -e /etc/nginx/conf.d/${domain}.d/000-acmechallenge.conf ] + then + # And if we're effectively including the new domain-independant snippet now + if grep -q "include /etc/nginx/conf.d/acme-challenge.conf.inc;" /etc/nginx/conf.d/${domain}.conf + then + # Delete the old domain-specific snippet + rm /etc/nginx/conf.d/${domain}.d/000-acmechallenge.conf + fi + fi + done + + # Reload nginx if conf looks good, otherwise display error and exit unhappy + nginx -t 2>/dev/null || { nginx -t; exit 1; } + pgrep nginx && systemctl reload nginx || { journalctl --no-pager --lines=10 -u nginx >&2; exit 1; } } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - init) - do_init_regen - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/19-postfix b/data/hooks/conf_regen/19-postfix index b37425984..c569e1ca1 100755 --- a/data/hooks/conf_regen/19-postfix +++ b/data/hooks/conf_regen/19-postfix @@ -20,22 +20,43 @@ do_pre_regen() { # prepare main.cf conf file main_domain=$(cat /etc/yunohost/current_host) - domain_list=$(sudo yunohost domain list --output-as plain --quiet | tr '\n' ' ') # Support different strategy for security configurations export compatibility="$(yunohost settings get 'security.postfix.compatibility')" + + # Add possibility to specify a relay + # Could be useful with some isp with no 25 port open or more complex setup + export relay_port="" + export relay_user="" + export relay_host="$(yunohost settings get 'smtp.relay.host')" + if [ -n "${relay_host}" ] + then + relay_port="$(yunohost settings get 'smtp.relay.port')" + relay_user="$(yunohost settings get 'smtp.relay.user')" + relay_password="$(yunohost settings get 'smtp.relay.password')" + + # Avoid to display "Relay account paswword" to other users + touch ${postfix_dir}/sasl_passwd + chmod 750 ${postfix_dir}/sasl_passwd + # Avoid "postmap: warning: removing zero-length database file" + chown postfix ${pending_dir}/etc/postfix + chown postfix ${pending_dir}/etc/postfix/sasl_passwd + cat <<< "[${relay_host}]:${relay_port} ${relay_user}:${relay_password}" > ${postfix_dir}/sasl_passwd + postmap ${postfix_dir}/sasl_passwd + fi export main_domain - export domain_list + export domain_list="$YNH_DOMAINS" ynh_render_template "main.cf" "${postfix_dir}/main.cf" cat postsrsd \ | sed "s/{{ main_domain }}/${main_domain}/g" \ - | sed "s/{{ domain_list }}/${domain_list}/g" \ + | sed "s/{{ domain_list }}/${YNH_DOMAINS}/g" \ > "${default_dir}/postsrsd" # adapt it for IPv4-only hosts - if [ ! -f /proc/net/if_inet6 ]; then + ipv6="$(yunohost settings get 'smtp.allow_ipv6')" + if [ "$ipv6" == "False" ] || [ ! -f /proc/net/if_inet6 ]; then sed -i \ 's/ \[::ffff:127.0.0.0\]\/104 \[::1\]\/128//g' \ "${postfix_dir}/main.cf" @@ -48,25 +69,15 @@ do_pre_regen() { do_post_regen() { regen_conf_files=$1 + if [ -e /etc/postfix/sasl_passwd ] + then + chmod 750 /etc/postfix/sasl_passwd* + chown postfix:root /etc/postfix/sasl_passwd* + fi + [[ -z "$regen_conf_files" ]] \ - || { sudo service postfix restart && sudo service postsrsd restart; } + || { systemctl restart postfix && systemctl restart postsrsd; } } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/25-dovecot b/data/hooks/conf_regen/25-dovecot index 4c5ae24c1..a0663a4a6 100755 --- a/data/hooks/conf_regen/25-dovecot +++ b/data/hooks/conf_regen/25-dovecot @@ -2,6 +2,8 @@ set -e +. /usr/share/yunohost/helpers + do_pre_regen() { pending_dir=$1 @@ -14,11 +16,10 @@ do_pre_regen() { cp dovecot-ldap.conf "${dovecot_dir}/dovecot-ldap.conf" cp dovecot.sieve "${dovecot_dir}/global_script/dovecot.sieve" - # prepare dovecot.conf conf file - main_domain=$(cat /etc/yunohost/current_host) - cat dovecot.conf \ - | sed "s/{{ main_domain }}/${main_domain}/g" \ - > "${dovecot_dir}/dovecot.conf" + export pop3_enabled="$(yunohost settings get 'pop3.enabled')" + export main_domain=$(cat /etc/yunohost/current_host) + + ynh_render_template "dovecot.conf" "${dovecot_dir}/dovecot.conf" # adapt it for IPv4-only hosts if [ ! -f /proc/net/if_inet6 ]; then @@ -35,44 +36,31 @@ do_pre_regen() { do_post_regen() { regen_conf_files=$1 - sudo mkdir -p "/etc/dovecot/yunohost.d/pre-ext.d" - sudo mkdir -p "/etc/dovecot/yunohost.d/post-ext.d" + mkdir -p "/etc/dovecot/yunohost.d/pre-ext.d" + mkdir -p "/etc/dovecot/yunohost.d/post-ext.d" # create vmail user id vmail > /dev/null 2>&1 \ - || sudo adduser --system --ingroup mail --uid 500 vmail + || adduser --system --ingroup mail --uid 500 vmail --home /var/vmail --no-create-home + + # Delete legacy home for vmail that existed in the past but was empty, poluting /home/ + [ ! -e /home/vmail ] || rmdir --ignore-fail-on-non-empty /home/vmail # fix permissions - sudo chown -R vmail:mail /etc/dovecot/global_script - sudo chmod 770 /etc/dovecot/global_script - sudo chown root:mail /var/mail - sudo chmod 1775 /var/mail + chown -R vmail:mail /etc/dovecot/global_script + chmod 770 /etc/dovecot/global_script + chown root:mail /var/mail + chmod 1775 /var/mail [ -z "$regen_conf_files" ] && exit 0 # compile sieve script [[ "$regen_conf_files" =~ dovecot\.sieve ]] && { - sudo sievec /etc/dovecot/global_script/dovecot.sieve - sudo chown -R vmail:mail /etc/dovecot/global_script + sievec /etc/dovecot/global_script/dovecot.sieve + chown -R vmail:mail /etc/dovecot/global_script } - sudo service dovecot restart + systemctl restart dovecot } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/31-rspamd b/data/hooks/conf_regen/31-rspamd index d263d9cc9..da9b35dfe 100755 --- a/data/hooks/conf_regen/31-rspamd +++ b/data/hooks/conf_regen/31-rspamd @@ -22,58 +22,41 @@ do_post_regen() { ## # create DKIM directory with proper permission - sudo mkdir -p /etc/dkim - sudo chown _rspamd /etc/dkim - - # retrieve domain list - domain_list=$(sudo yunohost domain list --output-as plain --quiet) + mkdir -p /etc/dkim + chown _rspamd /etc/dkim # create DKIM key for domains - for domain in $domain_list; do + for domain in $YNH_DOMAINS; do domain_key="/etc/dkim/${domain}.mail.key" [ ! -f "$domain_key" ] && { # We use a 1024 bit size because nsupdate doesn't seem to be able to # handle 2048... - sudo opendkim-genkey --domain="$domain" \ + opendkim-genkey --domain="$domain" \ --selector=mail --directory=/etc/dkim -b 1024 - sudo mv /etc/dkim/mail.private "$domain_key" - sudo mv /etc/dkim/mail.txt "/etc/dkim/${domain}.mail.txt" + mv /etc/dkim/mail.private "$domain_key" + mv /etc/dkim/mail.txt "/etc/dkim/${domain}.mail.txt" } done # fix DKIM keys permissions - sudo chown _rspamd /etc/dkim/*.mail.key - sudo chmod 400 /etc/dkim/*.mail.key + chown _rspamd /etc/dkim/*.mail.key + chmod 400 /etc/dkim/*.mail.key + + [ ! -e /var/log/rspamd ] || chown -R _rspamd:_rspamd /var/log/rspamd regen_conf_files=$1 [ -z "$regen_conf_files" ] && exit 0 # compile sieve script [[ "$regen_conf_files" =~ rspamd\.sieve ]] && { - sudo sievec /etc/dovecot/global_script/rspamd.sieve - sudo chown -R vmail:mail /etc/dovecot/global_script - sudo systemctl restart dovecot + sievec /etc/dovecot/global_script/rspamd.sieve + chown -R vmail:mail /etc/dovecot/global_script + systemctl restart dovecot } # Restart rspamd due to the upgrade # https://rspamd.com/announce/2016/08/01/rspamd-1.3.1.html - sudo systemctl -q restart rspamd.service + systemctl -q restart rspamd.service } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/34-mysql b/data/hooks/conf_regen/34-mysql index 8f7b5455e..41afda110 100755 --- a/data/hooks/conf_regen/34-mysql +++ b/data/hooks/conf_regen/34-mysql @@ -1,7 +1,6 @@ #!/bin/bash set -e -MYSQL_PKG="$(dpkg --list | sed -ne 's/^ii \(mariadb-server-[[:digit:].]\+\) .*$/\1/p')" . /usr/share/yunohost/helpers do_pre_regen() { @@ -15,62 +14,59 @@ do_pre_regen() { do_post_regen() { regen_conf_files=$1 - if [ ! -f /etc/yunohost/mysql ]; then + if [[ ! -d /var/lib/mysql/mysql ]] + then + # dpkg-reconfigure will initialize mysql (if it ain't already) + # It enabled auth_socket for root, so no need to define any root password... + # c.f. : cat /var/lib/dpkg/info/mariadb-server-10.3.postinst | grep install_db -C3 + MYSQL_PKG="$(dpkg --list | sed -ne 's/^ii \(mariadb-server-[[:digit:].]\+\) .*$/\1/p')" + dpkg-reconfigure -freadline -u "$MYSQL_PKG" 2>&1 - # ensure that mysql is running - sudo systemctl -q is-active mysql.service \ - || sudo service mysql start + systemctl -q is-active mariadb.service \ + || systemctl start mariadb - # generate and set new root password - mysql_password=$(ynh_string_random 10) - sudo mysqladmin -s -u root -pyunohost password "$mysql_password" || { - if [ $FORCE -eq 1 ]; then - echo "It seems that you have already configured MySQL." \ - "YunoHost needs to have a root access to MySQL to runs its" \ - "applications, and is going to reset the MySQL root password." \ - "You can find this new password in /etc/yunohost/mysql." >&2 + sleep 5 - # set new password with debconf - sudo debconf-set-selections << EOF -$MYSQL_PKG mysql-server/root_password password $mysql_password -$MYSQL_PKG mysql-server/root_password_again password $mysql_password -EOF + echo "" | mysql && echo "Can't connect to mysql using unix_socket auth ... something went wrong during initial configuration of mysql !?" >&2 + fi - # reconfigure Debian package - sudo dpkg-reconfigure -freadline -u "$MYSQL_PKG" 2>&1 - else - echo "It seems that you have already configured MySQL." \ - "YunoHost needs to have a root access to MySQL to runs its" \ - "applications, but the MySQL root password is unknown." \ - "You must either pass --force to reset the password or" \ - "put the current one into the file /etc/yunohost/mysql." >&2 - exit 1 - fi - } + # Legacy code to get rid of /etc/yunohost/mysql ... + # Nowadays, we can simply run mysql while being run as root of unix_socket/auth_socket is enabled... + if [ -f /etc/yunohost/mysql ]; then - # store new root password - echo "$mysql_password" | sudo tee /etc/yunohost/mysql - sudo chmod 400 /etc/yunohost/mysql + # This is a trick to check if we're able to use mysql without password + # Expect instances installed in stretch to already have unix_socket + #configured, but not old instances from the jessie/wheezy era + if ! echo "" | mysql 2>/dev/null + then + password="$(cat /etc/yunohost/mysql)" + # Enable plugin unix_socket for root on localhost + mysql -u root -p"$password" <<< "GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost' IDENTIFIED WITH unix_socket WITH GRANT OPTION;" + fi + + # If now we're able to login without password, drop the mysql password + if echo "" | mysql 2>/dev/null + then + rm /etc/yunohost/mysql + else + echo "Can't connect to mysql using unix_socket auth ... something went wrong while trying to get rid of mysql password !?" >&2 + fi + fi + + # mysql is supposed to be an alias to mariadb... but in some weird case is not + # c.f. https://forum.yunohost.org/t/mysql-ne-fonctionne-pas/11661 + # Playing with enable/disable allows to recreate the proper symlinks. + if [ ! -e /etc/systemd/system/mysql.service ] + then + systemctl stop mysql -q + systemctl disable mysql -q + systemctl disable mariadb -q + systemctl enable mariadb -q + systemctl is-active mariadb -q || systemctl start mariadb fi [[ -z "$regen_conf_files" ]] \ - || sudo service mysql restart + || systemctl restart mysql } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/35-redis b/data/hooks/conf_regen/35-redis new file mode 100755 index 000000000..da5eac4c9 --- /dev/null +++ b/data/hooks/conf_regen/35-redis @@ -0,0 +1,13 @@ +#!/bin/bash + +do_pre_regen() { + : +} + +do_post_regen() { + # Enforce these damn permissions because for some reason in some weird cases + # they are spontaneously replaced by root:root -_- + chown -R redis:adm /var/log/redis +} + +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/37-avahi-daemon b/data/hooks/conf_regen/37-avahi-daemon deleted file mode 100755 index 655a2e054..000000000 --- a/data/hooks/conf_regen/37-avahi-daemon +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash - -set -e - -do_pre_regen() { - pending_dir=$1 - - cd /usr/share/yunohost/templates/avahi-daemon - - install -D -m 644 avahi-daemon.conf \ - "${pending_dir}/etc/avahi/avahi-daemon.conf" -} - -do_post_regen() { - regen_conf_files=$1 - - [[ -z "$regen_conf_files" ]] \ - || sudo service avahi-daemon restart -} - -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 diff --git a/data/hooks/conf_regen/37-mdns b/data/hooks/conf_regen/37-mdns new file mode 100755 index 000000000..17f7bb8e2 --- /dev/null +++ b/data/hooks/conf_regen/37-mdns @@ -0,0 +1,64 @@ +#!/bin/bash + +set -e + +_generate_config() { + echo "domains:" + echo " - yunohost.local" + for domain in $YNH_DOMAINS + do + # Only keep .local domains (don't keep + [[ "$domain" =~ [^.]+\.[^.]+\.local$ ]] && echo "Subdomain $domain cannot be handled by Bonjour/Zeroconf/mDNS" >&2 + [[ "$domain" =~ ^[^.]+\.local$ ]] || continue + echo " - $domain" + done + + echo "interfaces:" + local_network_interfaces="$(ip --brief a | grep ' 10\.\| 192\.168\.' | awk '{print $1}')" + for interface in $local_network_interfaces + do + echo " - $interface" + done +} + +do_init_regen() { + do_pre_regen + do_post_regen /etc/systemd/system/yunomdns.service + systemctl enable yunomdns +} + +do_pre_regen() { + pending_dir="$1" + + cd /usr/share/yunohost/templates/mdns + mkdir -p ${pending_dir}/etc/systemd/system/ + cp yunomdns.service ${pending_dir}/etc/systemd/system/ + + getent passwd mdns &>/dev/null || useradd --no-create-home --shell /usr/sbin/nologin --system --user-group mdns + + mkdir -p ${pending_dir}/etc/yunohost + _generate_config > ${pending_dir}/etc/yunohost/mdns.yml +} + +do_post_regen() { + regen_conf_files="$1" + + chown mdns:mdns /etc/yunohost/mdns.yml + + # If we changed the systemd ynh-override conf + if echo "$regen_conf_files" | sed 's/,/\n/g' | grep -q "^/etc/systemd/system/yunomdns.service$" + then + systemctl daemon-reload + fi + + # Legacy stuff to enable the new yunomdns service on legacy systems + if [[ -e /etc/avahi/avahi-daemon.conf ]] && grep -q 'yunohost' /etc/avahi/avahi-daemon.conf + then + systemctl enable yunomdns + fi + + [[ -z "$regen_conf_files" ]] \ + || systemctl restart yunomdns +} + +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/40-glances b/data/hooks/conf_regen/40-glances deleted file mode 100755 index a19d35d56..000000000 --- a/data/hooks/conf_regen/40-glances +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash - -set -e - -do_pre_regen() { - pending_dir=$1 - - cd /usr/share/yunohost/templates/glances - - install -D -m 644 glances.default "${pending_dir}/etc/default/glances" -} - -do_post_regen() { - regen_conf_files=$1 - - [[ -z "$regen_conf_files" ]] \ - || sudo service glances restart -} - -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 diff --git a/data/hooks/conf_regen/43-dnsmasq b/data/hooks/conf_regen/43-dnsmasq index ed795c058..f3bed7b04 100755 --- a/data/hooks/conf_regen/43-dnsmasq +++ b/data/hooks/conf_regen/43-dnsmasq @@ -26,23 +26,21 @@ do_pre_regen() { ynh_validate_ip4 "$ipv4" || ipv4='127.0.0.1' ipv6=$(curl -s -6 https://ip6.yunohost.org 2>/dev/null || true) ynh_validate_ip6 "$ipv6" || ipv6='' - domain_list=$(sudo yunohost domain list --output-as plain --quiet) + + export ipv4 + export ipv6 # add domain conf files - for domain in $domain_list; do - cat domain.tpl \ - | sed "s/{{ domain }}/${domain}/g" \ - | sed "s/{{ ip }}/${ipv4}/g" \ - > "${dnsmasq_dir}/${domain}" - [[ -n $ipv6 ]] \ - && echo "address=/${domain}/${ipv6}" >> "${dnsmasq_dir}/${domain}" + for domain in $YNH_DOMAINS; do + export domain + ynh_render_template "domain.tpl" "${dnsmasq_dir}/${domain}" done # remove old domain conf files conf_files=$(ls -1 /etc/dnsmasq.d \ | awk '/^[^\.]+\.[^\.]+.*$/ { print $1 }') for domain in $conf_files; do - [[ $domain_list =~ $domain ]] \ + [[ $YNH_DOMAINS =~ $domain ]] \ || touch "${dnsmasq_dir}/${domain}" done } @@ -50,24 +48,36 @@ do_pre_regen() { do_post_regen() { regen_conf_files=$1 - [[ -z "$regen_conf_files" ]] \ - || sudo service dnsmasq restart + # Fuck it, those domain/search entries from dhclient are usually annoying + # lying shit from the ISP trying to MiTM + if grep -q -E "^ *(domain|search)" /run/resolvconf/resolv.conf + then + if grep -q -E "^ *(domain|search)" /run/resolvconf/interface/*.dhclient 2>/dev/null + then + sed -E "s/^(domain|search)/#\1/g" -i /run/resolvconf/interface/*.dhclient + fi + + grep -q '^supersede domain-name "";' /etc/dhcp/dhclient.conf 2>/dev/null || echo 'supersede domain-name "";' >> /etc/dhcp/dhclient.conf + grep -q '^supersede domain-search "";' /etc/dhcp/dhclient.conf 2>/dev/null || echo 'supersede domain-search "";' >> /etc/dhcp/dhclient.conf + grep -q '^supersede name "";' /etc/dhcp/dhclient.conf 2>/dev/null || echo 'supersede name "";' >> /etc/dhcp/dhclient.conf + systemctl restart resolvconf + fi + + # Some stupid things like rabbitmq-server used by onlyoffice won't work if + # the *short* hostname doesn't exists in /etc/hosts -_- + short_hostname=$(hostname -s) + grep -q "127.0.0.1.*$short_hostname" /etc/hosts || echo -e "\n127.0.0.1\t$short_hostname" >>/etc/hosts + + [[ -n "$regen_conf_files" ]] || return + + # Remove / disable services likely to conflict with dnsmasq + for SERVICE in systemd-resolved bind9 + do + systemctl is-enabled $SERVICE &>/dev/null && systemctl disable $SERVICE 2>/dev/null + systemctl is-active $SERVICE &>/dev/null && systemctl stop $SERVICE + done + + systemctl restart dnsmasq } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/46-nsswitch b/data/hooks/conf_regen/46-nsswitch index 06a596e44..be5cb2b86 100755 --- a/data/hooks/conf_regen/46-nsswitch +++ b/data/hooks/conf_regen/46-nsswitch @@ -2,6 +2,11 @@ set -e +do_init_regen() { + do_pre_regen "" + systemctl restart unscd +} + do_pre_regen() { pending_dir=$1 @@ -14,23 +19,7 @@ do_post_regen() { regen_conf_files=$1 [[ -z "$regen_conf_files" ]] \ - || sudo service unscd restart + || systemctl restart unscd } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/52-fail2ban b/data/hooks/conf_regen/52-fail2ban index 950f27b5b..7aef72ebc 100755 --- a/data/hooks/conf_regen/52-fail2ban +++ b/data/hooks/conf_regen/52-fail2ban @@ -2,6 +2,8 @@ set -e +. /usr/share/yunohost/helpers + do_pre_regen() { pending_dir=$1 @@ -13,30 +15,16 @@ do_pre_regen() { cp yunohost.conf "${fail2ban_dir}/filter.d/yunohost.conf" cp jail.conf "${fail2ban_dir}/jail.conf" - cp yunohost-jails.conf "${fail2ban_dir}/jail.d/" + + export ssh_port="$(yunohost settings get 'security.ssh.port')" + ynh_render_template "yunohost-jails.conf" "${fail2ban_dir}/jail.d/yunohost-jails.conf" } do_post_regen() { regen_conf_files=$1 [[ -z "$regen_conf_files" ]] \ - || sudo service fail2ban restart + || systemctl reload fail2ban } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/diagnosis/00-basesystem.py b/data/hooks/diagnosis/00-basesystem.py new file mode 100644 index 000000000..b472a2d32 --- /dev/null +++ b/data/hooks/diagnosis/00-basesystem.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python + +import os +import json +import subprocess + +from moulinette.utils.process import check_output +from moulinette.utils.filesystem import read_file, read_json, write_to_json +from yunohost.diagnosis import Diagnoser +from yunohost.utils.packages import ynh_packages_version + + +class BaseSystemDiagnoser(Diagnoser): + + id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1] + cache_duration = 600 + dependencies = [] + + def run(self): + + # Detect virt technology (if not bare metal) and arch + # Gotta have this "|| true" because it systemd-detect-virt return 'none' + # with an error code on bare metal ~.~ + virt = check_output("systemd-detect-virt || true", shell=True) + if virt.lower() == "none": + virt = "bare-metal" + + # Detect arch + arch = check_output("dpkg --print-architecture") + hardware = dict( + meta={"test": "hardware"}, + status="INFO", + data={"virt": virt, "arch": arch}, + summary="diagnosis_basesystem_hardware", + ) + + # Also possibly the board / hardware name + if os.path.exists("/proc/device-tree/model"): + model = read_file("/proc/device-tree/model").strip().replace("\x00", "") + hardware["data"]["model"] = model + hardware["details"] = ["diagnosis_basesystem_hardware_model"] + elif os.path.exists("/sys/devices/virtual/dmi/id/sys_vendor"): + model = read_file("/sys/devices/virtual/dmi/id/sys_vendor").strip() + if os.path.exists("/sys/devices/virtual/dmi/id/product_name"): + model = "%s %s" % ( + model, + read_file("/sys/devices/virtual/dmi/id/product_name").strip(), + ) + hardware["data"]["model"] = model + hardware["details"] = ["diagnosis_basesystem_hardware_model"] + + yield hardware + + # Kernel version + kernel_version = read_file("/proc/sys/kernel/osrelease").strip() + yield dict( + meta={"test": "kernel"}, + data={"kernel_version": kernel_version}, + status="INFO", + summary="diagnosis_basesystem_kernel", + ) + + # Debian release + debian_version = read_file("/etc/debian_version").strip() + yield dict( + meta={"test": "host"}, + data={"debian_version": debian_version}, + status="INFO", + summary="diagnosis_basesystem_host", + ) + + # Yunohost packages versions + # We check if versions are consistent (e.g. all 3.6 and not 3 packages with 3.6 and the other with 3.5) + # This is a classical issue for upgrades that failed in the middle + # (or people upgrading half of the package because they did 'apt upgrade' instead of 'dist-upgrade') + # Here, ynh_core_version is for example "3.5.4.12", so [:3] is "3.5" and we check it's the same for all packages + ynh_packages = ynh_packages_version() + ynh_core_version = ynh_packages["yunohost"]["version"] + consistent_versions = all( + infos["version"][:3] == ynh_core_version[:3] + for infos in ynh_packages.values() + ) + ynh_version_details = [ + ( + "diagnosis_basesystem_ynh_single_version", + { + "package": package, + "version": infos["version"], + "repo": infos["repo"], + }, + ) + for package, infos in ynh_packages.items() + ] + + yield dict( + meta={"test": "ynh_versions"}, + data={ + "main_version": ynh_core_version, + "repo": ynh_packages["yunohost"]["repo"], + }, + status="INFO" if consistent_versions else "ERROR", + summary="diagnosis_basesystem_ynh_main_version" + if consistent_versions + else "diagnosis_basesystem_ynh_inconsistent_versions", + details=ynh_version_details, + ) + + if self.is_vulnerable_to_meltdown(): + yield dict( + meta={"test": "meltdown"}, + status="ERROR", + summary="diagnosis_security_vulnerable_to_meltdown", + details=["diagnosis_security_vulnerable_to_meltdown_details"], + ) + + bad_sury_packages = list(self.bad_sury_packages()) + if bad_sury_packages: + cmd_to_fix = "apt install --allow-downgrades " + " ".join( + ["%s=%s" % (package, version) for package, version in bad_sury_packages] + ) + yield dict( + meta={"test": "packages_from_sury"}, + data={"cmd_to_fix": cmd_to_fix}, + status="WARNING", + summary="diagnosis_package_installed_from_sury", + details=["diagnosis_package_installed_from_sury_details"], + ) + + if self.backports_in_sources_list(): + yield dict( + meta={"test": "backports_in_sources_list"}, + status="WARNING", + summary="diagnosis_backports_in_sources_list", + ) + + if self.number_of_recent_auth_failure() > 500: + yield dict( + meta={"test": "high_number_auth_failure"}, + status="WARNING", + summary="diagnosis_high_number_auth_failures", + ) + + def bad_sury_packages(self): + + packages_to_check = ["openssl", "libssl1.1", "libssl-dev"] + for package in packages_to_check: + cmd = "dpkg --list | grep '^ii' | grep gbp | grep -q -w %s" % package + # If version currently installed is not from sury, nothing to report + if os.system(cmd) != 0: + continue + + cmd = ( + "LC_ALL=C apt policy %s 2>&1 | grep http -B1 | tr -d '*' | grep '+deb' | grep -v 'gbp' | head -n 1 | awk '{print $1}'" + % package + ) + version_to_downgrade_to = check_output(cmd) + yield (package, version_to_downgrade_to) + + def backports_in_sources_list(self): + + cmd = "grep -q -nr '^ *deb .*-backports' /etc/apt/sources.list*" + return os.system(cmd) == 0 + + def number_of_recent_auth_failure(self): + + # Those syslog facilities correspond to auth and authpriv + # c.f. https://unix.stackexchange.com/a/401398 + # and https://wiki.archlinux.org/title/Systemd/Journal#Facility + cmd = "journalctl -q SYSLOG_FACILITY=10 SYSLOG_FACILITY=4 --since '1day ago' | grep 'authentication failure' | wc -l" + + n_failures = check_output(cmd) + try: + return int(n_failures) + except Exception: + self.logger_warning( + "Failed to parse number of recent auth failures, expected an int, got '%s'" + % n_failures + ) + return -1 + + def is_vulnerable_to_meltdown(self): + # meltdown CVE: https://security-tracker.debian.org/tracker/CVE-2017-5754 + + # We use a cache file to avoid re-running the script so many times, + # which can be expensive (up to around 5 seconds on ARM) + # and make the admin appear to be slow (c.f. the calls to diagnosis + # from the webadmin) + # + # The cache is in /tmp and shall disappear upon reboot + # *or* we compare it to dpkg.log modification time + # such that it's re-ran if there was package upgrades + # (e.g. from yunohost) + cache_file = "/tmp/yunohost-meltdown-diagnosis" + dpkg_log = "/var/log/dpkg.log" + if os.path.exists(cache_file): + if not os.path.exists(dpkg_log) or os.path.getmtime( + cache_file + ) > os.path.getmtime(dpkg_log): + self.logger_debug( + "Using cached results for meltdown checker, from %s" % cache_file + ) + return read_json(cache_file)[0]["VULNERABLE"] + + # script taken from https://github.com/speed47/spectre-meltdown-checker + # script commit id is store directly in the script + SCRIPT_PATH = "/usr/lib/moulinette/yunohost/vendor/spectre-meltdown-checker/spectre-meltdown-checker.sh" + + # '--variant 3' corresponds to Meltdown + # example output from the script: + # [{"NAME":"MELTDOWN","CVE":"CVE-2017-5754","VULNERABLE":false,"INFOS":"PTI mitigates the vulnerability"}] + try: + self.logger_debug("Running meltdown vulnerability checker") + call = subprocess.Popen( + "bash %s --batch json --variant 3" % SCRIPT_PATH, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + # TODO / FIXME : here we are ignoring error messages ... + # in particular on RPi2 and other hardware, the script complains about + # "missing some kernel info (see -v), accuracy might be reduced" + # Dunno what to do about that but we probably don't want to harass + # users with this warning ... + output, _ = call.communicate() + output = output.decode() + assert call.returncode in (0, 2, 3), "Return code: %s" % call.returncode + + # If there are multiple lines, sounds like there was some messages + # in stdout that are not json >.> ... Try to get the actual json + # stuff which should be the last line + output = output.strip() + if "\n" in output: + self.logger_debug("Original meltdown checker output : %s" % output) + output = output.split("\n")[-1] + + CVEs = json.loads(output) + assert len(CVEs) == 1 + assert CVEs[0]["NAME"] == "MELTDOWN" + except Exception as e: + import traceback + + traceback.print_exc() + self.logger_warning( + "Something wrong happened when trying to diagnose Meltdown vunerability, exception: %s" + % e + ) + raise Exception("Command output for failed meltdown check: '%s'" % output) + + self.logger_debug( + "Writing results from meltdown checker to cache file, %s" % cache_file + ) + write_to_json(cache_file, CVEs) + return CVEs[0]["VULNERABLE"] + + +def main(args, env, loggers): + return BaseSystemDiagnoser(args, env, loggers).diagnose() diff --git a/data/hooks/diagnosis/10-ip.py b/data/hooks/diagnosis/10-ip.py new file mode 100644 index 000000000..408019668 --- /dev/null +++ b/data/hooks/diagnosis/10-ip.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python + +import re +import os +import random + +from moulinette.utils.network import download_text +from moulinette.utils.process import check_output +from moulinette.utils.filesystem import read_file + +from yunohost.diagnosis import Diagnoser +from yunohost.utils.network import get_network_interfaces + + +class IPDiagnoser(Diagnoser): + + id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1] + cache_duration = 600 + dependencies = [] + + def run(self): + + # ############################################################ # + # PING : Check that we can ping outside at least in ipv4 or v6 # + # ############################################################ # + + can_ping_ipv4 = self.can_ping_outside(4) + can_ping_ipv6 = self.can_ping_outside(6) + + if not can_ping_ipv4 and not can_ping_ipv6: + yield dict( + meta={"test": "ping"}, + status="ERROR", + summary="diagnosis_ip_not_connected_at_all", + ) + # Not much else we can do if there's no internet at all + return + + # ###################################################### # + # DNS RESOLUTION : Check that we can resolve domain name # + # (later needed to talk to ip. and ip6.yunohost.org) # + # ###################################################### # + + can_resolve_dns = self.can_resolve_dns() + + # In every case, we can check that resolvconf seems to be okay + # (symlink managed by resolvconf service + pointing to dnsmasq) + good_resolvconf = self.good_resolvconf() + + # If we can't resolve domain names at all, that's a pretty big issue ... + # If it turns out that at the same time, resolvconf is bad, that's probably + # the cause of this, so we use a different message in that case + if not can_resolve_dns: + yield dict( + meta={"test": "dnsresolv"}, + status="ERROR", + summary="diagnosis_ip_broken_dnsresolution" + if good_resolvconf + else "diagnosis_ip_broken_resolvconf", + ) + return + # Otherwise, if the resolv conf is bad but we were able to resolve domain name, + # still warn that we're using a weird resolv conf ... + elif not good_resolvconf: + yield dict( + meta={"test": "dnsresolv"}, + status="WARNING", + summary="diagnosis_ip_weird_resolvconf", + details=["diagnosis_ip_weird_resolvconf_details"], + ) + else: + yield dict( + meta={"test": "dnsresolv"}, + status="SUCCESS", + summary="diagnosis_ip_dnsresolution_working", + ) + + # ##################################################### # + # IP DIAGNOSIS : Check that we're actually able to talk # + # to a web server to fetch current IPv4 and v6 # + # ##################################################### # + + ipv4 = self.get_public_ip(4) if can_ping_ipv4 else None + ipv6 = self.get_public_ip(6) if can_ping_ipv6 else None + + network_interfaces = get_network_interfaces() + + def get_local_ip(version): + local_ip = { + iface: addr[version].split("/")[0] + for iface, addr in network_interfaces.items() + if version in addr + } + if not local_ip: + return None + elif len(local_ip): + return next(iter(local_ip.values())) + else: + return local_ip + + yield dict( + meta={"test": "ipv4"}, + data={"global": ipv4, "local": get_local_ip("ipv4")}, + status="SUCCESS" if ipv4 else "ERROR", + summary="diagnosis_ip_connected_ipv4" if ipv4 else "diagnosis_ip_no_ipv4", + details=["diagnosis_ip_global", "diagnosis_ip_local"] if ipv4 else None, + ) + + yield dict( + meta={"test": "ipv6"}, + data={"global": ipv6, "local": get_local_ip("ipv6")}, + status="SUCCESS" if ipv6 else "WARNING", + summary="diagnosis_ip_connected_ipv6" if ipv6 else "diagnosis_ip_no_ipv6", + details=["diagnosis_ip_global", "diagnosis_ip_local"] + if ipv6 + else ["diagnosis_ip_no_ipv6_tip"], + ) + + # TODO / FIXME : add some attempt to detect ISP (using whois ?) ? + + def can_ping_outside(self, protocol=4): + + assert protocol in [ + 4, + 6, + ], "Invalid protocol version, it should be either 4 or 6 and was '%s'" % repr( + protocol + ) + + # We can know that ipv6 is not available directly if this file does not exists + if protocol == 6 and not os.path.exists("/proc/net/if_inet6"): + return False + + # If we are indeed connected in ipv4 or ipv6, we should find a default route + routes = check_output("ip -%s route show table all" % protocol).split("\n") + + def is_default_route(r): + # Typically the default route starts with "default" + # But of course IPv6 is more complex ... e.g. on internet cube there's + # no default route but a /3 which acts as a default-like route... + # e.g. 2000:/3 dev tun0 ... + return r.startswith("default") or ( + ":" in r and re.match(r".*/[0-3]$", r.split()[0]) + ) + + if not any(is_default_route(r) for r in routes): + self.logger_debug( + "No default route for IPv%s, so assuming there's no IP address for that version" + % protocol + ) + return None + + # We use the resolver file as a list of well-known, trustable (ie not google ;)) IPs that we can ping + resolver_file = ( + "/usr/share/yunohost/templates/dnsmasq/plain/resolv.dnsmasq.conf" + ) + resolvers = [ + r.split(" ")[1] + for r in read_file(resolver_file).split("\n") + if r.startswith("nameserver") + ] + + if protocol == 4: + resolvers = [r for r in resolvers if ":" not in r] + if protocol == 6: + resolvers = [r for r in resolvers if ":" in r] + + assert ( + resolvers != [] + ), "Uhoh, need at least one IPv%s DNS resolver in %s ..." % ( + protocol, + resolver_file, + ) + + # So let's try to ping the first 4~5 resolvers (shuffled) + # If we succesfully ping any of them, we conclude that we are indeed connected + def ping(protocol, target): + return ( + os.system( + "ping%s -c1 -W 3 %s >/dev/null 2>/dev/null" + % ("" if protocol == 4 else "6", target) + ) + == 0 + ) + + random.shuffle(resolvers) + return any(ping(protocol, resolver) for resolver in resolvers[:5]) + + def can_resolve_dns(self): + return os.system("dig +short ip.yunohost.org >/dev/null 2>/dev/null") == 0 + + def good_resolvconf(self): + content = read_file("/etc/resolv.conf").strip().split("\n") + # Ignore comments and empty lines + content = [ + line.strip() + for line in content + if line.strip() + and not line.strip().startswith("#") + and not line.strip().startswith("search") + ] + # We should only find a "nameserver 127.0.0.1" + return len(content) == 1 and content[0].split() == ["nameserver", "127.0.0.1"] + + def get_public_ip(self, protocol=4): + + # FIXME - TODO : here we assume that DNS resolution for ip.yunohost.org is working + # but if we want to be able to diagnose DNS resolution issues independently from + # internet connectivity, we gotta rely on fixed IPs first.... + + assert protocol in [ + 4, + 6, + ], "Invalid protocol version, it should be either 4 or 6 and was '%s'" % repr( + protocol + ) + + url = "https://ip%s.yunohost.org" % ("6" if protocol == 6 else "") + + try: + return download_text(url, timeout=30).strip() + except Exception as e: + self.logger_debug( + "Could not get public IPv%s : %s" % (str(protocol), str(e)) + ) + return None + + +def main(args, env, loggers): + return IPDiagnoser(args, env, loggers).diagnose() diff --git a/data/hooks/diagnosis/12-dnsrecords.py b/data/hooks/diagnosis/12-dnsrecords.py new file mode 100644 index 000000000..16841721f --- /dev/null +++ b/data/hooks/diagnosis/12-dnsrecords.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python + +import os +import re + +from datetime import datetime, timedelta +from publicsuffix import PublicSuffixList + +from moulinette.utils.process import check_output + +from yunohost.utils.dns import dig, YNH_DYNDNS_DOMAINS +from yunohost.diagnosis import Diagnoser +from yunohost.domain import domain_list, _get_maindomain +from yunohost.dns import _build_dns_conf, _get_dns_zone_for_domain + +SPECIAL_USE_TLDS = ["local", "localhost", "onion", "test"] + + +class DNSRecordsDiagnoser(Diagnoser): + + id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1] + cache_duration = 600 + dependencies = ["ip"] + + def run(self): + + main_domain = _get_maindomain() + + all_domains = domain_list(exclude_subdomains=True)["domains"] + for domain in all_domains: + self.logger_debug("Diagnosing DNS conf for %s" % domain) + is_specialusedomain = any( + domain.endswith("." + tld) for tld in SPECIAL_USE_TLDS + ) + for report in self.check_domain( + domain, + domain == main_domain, + is_specialusedomain=is_specialusedomain, + ): + yield report + + # Check if a domain buy by the user will expire soon + psl = PublicSuffixList() + domains_from_registrar = [ + psl.get_public_suffix(domain) for domain in all_domains + ] + domains_from_registrar = [ + domain for domain in domains_from_registrar if "." in domain + ] + domains_from_registrar = set(domains_from_registrar) - set( + YNH_DYNDNS_DOMAINS + ["netlib.re"] + ) + for report in self.check_expiration_date(domains_from_registrar): + yield report + + def check_domain(self, domain, is_main_domain, is_specialusedomain): + + base_dns_zone = _get_dns_zone_for_domain(domain) + basename = domain.replace(base_dns_zone, "").rstrip(".") or "@" + + expected_configuration = _build_dns_conf( + domain, include_empty_AAAA_if_no_ipv6=True + ) + + categories = ["basic", "mail", "xmpp", "extra"] + + if is_specialusedomain: + categories = [] + yield dict( + meta={"domain": domain}, + data={}, + status="INFO", + summary="diagnosis_dns_specialusedomain", + ) + + for category in categories: + + records = expected_configuration[category] + discrepancies = [] + results = {} + + for r in records: + + id_ = r["type"] + ":" + r["name"] + fqdn = r["name"] + "." + base_dns_zone if r["name"] != "@" else domain + + # Ugly hack to not check mail records for subdomains stuff, otherwise will end up in a shitstorm of errors for people with many subdomains... + # Should find a cleaner solution in the suggested conf... + if r["type"] in ["MX", "TXT"] and fqdn not in [ + domain, + f"mail._domainkey.{domain}", + f"_dmarc.{domain}", + ]: + continue + + r["current"] = self.get_current_record(fqdn, r["type"]) + if r["value"] == "@": + r["value"] = domain + "." + + if self.current_record_match_expected(r): + results[id_] = "OK" + else: + if r["current"] is None: + results[id_] = "MISSING" + discrepancies.append(("diagnosis_dns_missing_record", r)) + else: + results[id_] = "WRONG" + discrepancies.append(("diagnosis_dns_discrepancy", r)) + + def its_important(): + # Every mail DNS records are important for main domain + # For other domain, we only report it as a warning for now... + if is_main_domain and category == "mail": + return True + elif category == "basic": + # A bad or missing A record is critical ... + # And so is a wrong AAAA record + # (However, a missing AAAA record is acceptable) + if ( + results[f"A:{basename}"] != "OK" + or results[f"AAAA:{basename}"] == "WRONG" + ): + return True + + return False + + if discrepancies: + status = "ERROR" if its_important() else "WARNING" + summary = "diagnosis_dns_bad_conf" + else: + status = "SUCCESS" + summary = "diagnosis_dns_good_conf" + + output = dict( + meta={"domain": domain, "category": category}, + data=results, + status=status, + summary=summary, + ) + + if discrepancies: + # For ynh-managed domains (nohost.me etc...), tell people to try to "yunohost dyndns update --force" + if any( + domain.endswith(ynh_dyndns_domain) + for ynh_dyndns_domain in YNH_DYNDNS_DOMAINS + ): + output["details"] = ["diagnosis_dns_try_dyndns_update_force"] + # Otherwise point to the documentation + else: + output["details"] = ["diagnosis_dns_point_to_doc"] + output["details"] += discrepancies + + yield output + + def get_current_record(self, fqdn, type_): + + success, answers = dig(fqdn, type_, resolvers="force_external") + + if success != "ok": + return None + else: + return answers[0] if len(answers) == 1 else answers + + def current_record_match_expected(self, r): + if r["value"] is not None and r["current"] is None: + return False + if r["value"] is None and r["current"] is not None: + return False + elif isinstance(r["current"], list): + return False + + if r["type"] == "TXT": + # Split expected/current + # from "v=DKIM1; k=rsa; p=hugekey;" + # to a set like {'v=DKIM1', 'k=rsa', 'p=...'} + # Additionally, for DKIM, because the key is pretty long, + # some DNS registrar sometime split it into several pieces like this: + # "p=foo" "bar" (with a space and quotes in the middle)... + expected = set(r["value"].strip(';" ').replace(";", " ").split()) + current = set( + r["current"].replace('" "', "").strip(';" ').replace(";", " ").split() + ) + + # For SPF, ignore parts starting by ip4: or ip6: + if "v=spf1" in r["value"]: + current = { + part + for part in current + if not part.startswith("ip4:") and not part.startswith("ip6:") + } + return expected == current + elif r["type"] == "MX": + # For MX, we want to ignore the priority + expected = r["value"].split()[-1] + current = r["current"].split()[-1] + return expected == current + else: + return r["current"] == r["value"] + + def check_expiration_date(self, domains): + """ + Alert if expiration date of a domain is soon + """ + details = {"not_found": [], "error": [], "warning": [], "success": []} + + for domain in domains: + expire_date = self.get_domain_expiration(domain) + + if isinstance(expire_date, str): + status_ns, _ = dig(domain, "NS", resolvers="force_external") + status_a, _ = dig(domain, "A", resolvers="force_external") + if "ok" not in [status_ns, status_a]: + # i18n: diagnosis_domain_not_found_details + details["not_found"].append( + ( + "diagnosis_domain_%s_details" % (expire_date), + {"domain": domain}, + ) + ) + else: + self.logger_debug("Dyndns domain: %s" % (domain)) + continue + + expire_in = expire_date - datetime.now() + + alert_type = "success" + if expire_in <= timedelta(15): + alert_type = "error" + elif expire_in <= timedelta(45): + alert_type = "warning" + + args = { + "domain": domain, + "days": expire_in.days - 1, + "expire_date": str(expire_date), + } + details[alert_type].append(("diagnosis_domain_expires_in", args)) + + for alert_type in ["success", "error", "warning", "not_found"]: + if details[alert_type]: + if alert_type == "not_found": + meta = {"test": "domain_not_found"} + else: + meta = {"test": "domain_expiration"} + # Allow to ignore specifically a single domain + if len(details[alert_type]) == 1: + meta["domain"] = details[alert_type][0][1]["domain"] + + # i18n: diagnosis_domain_expiration_not_found + # i18n: diagnosis_domain_expiration_error + # i18n: diagnosis_domain_expiration_warning + # i18n: diagnosis_domain_expiration_success + # i18n: diagnosis_domain_expiration_not_found_details + yield dict( + meta=meta, + data={}, + status=alert_type.upper() + if alert_type != "not_found" + else "WARNING", + summary="diagnosis_domain_expiration_" + alert_type, + details=details[alert_type], + ) + + def get_domain_expiration(self, domain): + """ + Return the expiration datetime of a domain or None + """ + command = "whois -H %s || echo failed" % (domain) + out = check_output(command).split("\n") + + # Reduce output to determine if whois answer is equivalent to NOT FOUND + filtered_out = [ + line + for line in out + if re.search(r"^[a-zA-Z0-9 ]{4,25}:", line, re.IGNORECASE) + and not re.match(r">>> Last update of whois", line, re.IGNORECASE) + and not re.match(r"^NOTICE:", line, re.IGNORECASE) + and not re.match(r"^%%", line, re.IGNORECASE) + and not re.match(r'"https?:"', line, re.IGNORECASE) + ] + + # If there is less than 7 lines, it's NOT FOUND response + if len(filtered_out) <= 6: + return "not_found" + + for line in out: + match = re.search(r"Expir.+(\d{4}-\d{2}-\d{2})", line, re.IGNORECASE) + if match is not None: + return datetime.strptime(match.group(1), "%Y-%m-%d") + + match = re.search(r"Expir.+(\d{2}-\w{3}-\d{4})", line, re.IGNORECASE) + if match is not None: + return datetime.strptime(match.group(1), "%d-%b-%Y") + + return "expiration_not_found" + + +def main(args, env, loggers): + return DNSRecordsDiagnoser(args, env, loggers).diagnose() diff --git a/data/hooks/diagnosis/14-ports.py b/data/hooks/diagnosis/14-ports.py new file mode 100644 index 000000000..7581a1ac6 --- /dev/null +++ b/data/hooks/diagnosis/14-ports.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python + +import os + +from yunohost.diagnosis import Diagnoser +from yunohost.service import _get_services + + +class PortsDiagnoser(Diagnoser): + + id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1] + cache_duration = 600 + dependencies = ["ip"] + + def run(self): + + # TODO: report a warning if port 53 or 5353 is exposed to the outside world... + + # This dict is something like : + # { 80: "nginx", + # 25: "postfix", + # 443: "nginx" + # ... } + ports = {} + services = _get_services() + for service, infos in services.items(): + for port in infos.get("needs_exposed_ports", []): + ports[port] = service + + ipversions = [] + ipv4 = Diagnoser.get_cached_report("ip", item={"test": "ipv4"}) or {} + if ipv4.get("status") == "SUCCESS": + ipversions.append(4) + + # To be discussed: we could also make this check dependent on the + # existence of an AAAA record... + ipv6 = Diagnoser.get_cached_report("ip", item={"test": "ipv6"}) or {} + if ipv6.get("status") == "SUCCESS": + ipversions.append(6) + + # Fetch test result for each relevant IP version + results = {} + for ipversion in ipversions: + try: + r = Diagnoser.remote_diagnosis( + "check-ports", data={"ports": list(ports)}, ipversion=ipversion + ) + results[ipversion] = r["ports"] + except Exception as e: + yield dict( + meta={"reason": "remote_diagnosis_failed", "ipversion": ipversion}, + data={"error": str(e)}, + status="WARNING", + summary="diagnosis_ports_could_not_diagnose", + details=["diagnosis_ports_could_not_diagnose_details"], + ) + continue + + ipversions = results.keys() + if not ipversions: + return + + for port, service in sorted(ports.items()): + port = str(port) + category = services[service].get("category", "[?]") + + # If both IPv4 and IPv6 (if applicable) are good + if all(results[ipversion].get(port) is True for ipversion in ipversions): + yield dict( + meta={"port": port}, + data={"service": service, "category": category}, + status="SUCCESS", + summary="diagnosis_ports_ok", + details=["diagnosis_ports_needed_by"], + ) + # If both IPv4 and IPv6 (if applicable) are failed + elif all( + results[ipversion].get(port) is not True for ipversion in ipversions + ): + yield dict( + meta={"port": port}, + data={"service": service, "category": category}, + status="ERROR", + summary="diagnosis_ports_unreachable", + details=[ + "diagnosis_ports_needed_by", + "diagnosis_ports_forwarding_tip", + ], + ) + # If only IPv4 is failed or only IPv6 is failed (if applicable) + else: + passed, failed = (4, 6) if results[4].get(port) is True else (6, 4) + + # Failing in ipv4 is critical. + # If we failed in IPv6 but there's in fact no AAAA record + # It's an acceptable situation and we shall not report an + # error + # If any AAAA record is set, IPv6 is important... + def ipv6_is_important(): + dnsrecords = Diagnoser.get_cached_report("dnsrecords") or {} + return any( + record["data"].get("AAAA:@") in ["OK", "WRONG"] + for record in dnsrecords.get("items", []) + ) + + if failed == 4 or ipv6_is_important(): + yield dict( + meta={"port": port}, + data={ + "service": service, + "category": category, + "passed": passed, + "failed": failed, + }, + status="ERROR", + summary="diagnosis_ports_partially_unreachable", + details=[ + "diagnosis_ports_needed_by", + "diagnosis_ports_forwarding_tip", + ], + ) + # So otherwise we report a success + # And in addition we report an info about the failure in IPv6 + # *with a different meta* (important to avoid conflicts when + # fetching the other info...) + else: + yield dict( + meta={"port": port}, + data={"service": service, "category": category}, + status="SUCCESS", + summary="diagnosis_ports_ok", + details=["diagnosis_ports_needed_by"], + ) + yield dict( + meta={"test": "ipv6", "port": port}, + data={ + "service": service, + "category": category, + "passed": passed, + "failed": failed, + }, + status="INFO", + summary="diagnosis_ports_partially_unreachable", + details=[ + "diagnosis_ports_needed_by", + "diagnosis_ports_forwarding_tip", + ], + ) + + +def main(args, env, loggers): + return PortsDiagnoser(args, env, loggers).diagnose() diff --git a/data/hooks/diagnosis/21-web.py b/data/hooks/diagnosis/21-web.py new file mode 100644 index 000000000..2072937e5 --- /dev/null +++ b/data/hooks/diagnosis/21-web.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python + +import os +import random +import requests + +from moulinette.utils.filesystem import read_file + +from yunohost.diagnosis import Diagnoser +from yunohost.domain import domain_list + +DIAGNOSIS_SERVER = "diagnosis.yunohost.org" + + +class WebDiagnoser(Diagnoser): + + id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1] + cache_duration = 600 + dependencies = ["ip"] + + def run(self): + + all_domains = domain_list()["domains"] + domains_to_check = [] + for domain in all_domains: + + # If the diagnosis location ain't defined, can't do diagnosis, + # probably because nginx conf manually modified... + nginx_conf = "/etc/nginx/conf.d/%s.conf" % domain + if ".well-known/ynh-diagnosis/" not in read_file(nginx_conf): + yield dict( + meta={"domain": domain}, + status="WARNING", + summary="diagnosis_http_nginx_conf_not_up_to_date", + details=["diagnosis_http_nginx_conf_not_up_to_date_details"], + ) + elif domain.endswith(".local"): + yield dict( + meta={"domain": domain}, + status="INFO", + summary="diagnosis_http_localdomain", + ) + else: + domains_to_check.append(domain) + + self.nonce = "".join(random.choice("0123456789abcedf") for i in range(16)) + os.system("rm -rf /tmp/.well-known/ynh-diagnosis/") + os.system("mkdir -p /tmp/.well-known/ynh-diagnosis/") + os.system("touch /tmp/.well-known/ynh-diagnosis/%s" % self.nonce) + + if not domains_to_check: + return + + # To perform hairpinning test, we gotta make sure that port forwarding + # is working and therefore we'll do it only if at least one ipv4 domain + # works. + self.do_hairpinning_test = False + + ipversions = [] + ipv4 = Diagnoser.get_cached_report("ip", item={"test": "ipv4"}) or {} + if ipv4.get("status") == "SUCCESS": + ipversions.append(4) + + # To be discussed: we could also make this check dependent on the + # existence of an AAAA record... + ipv6 = Diagnoser.get_cached_report("ip", item={"test": "ipv6"}) or {} + if ipv6.get("status") == "SUCCESS": + ipversions.append(6) + + for item in self.test_http(domains_to_check, ipversions): + yield item + + # If at least one domain is correctly exposed to the outside, + # attempt to diagnose hairpinning situations. On network with + # hairpinning issues, the server may be correctly exposed on the + # outside, but from the outside, it will be as if the port forwarding + # was not configured... Hence, calling for example + # "curl --head the.global.ip" will simply timeout... + if self.do_hairpinning_test: + global_ipv4 = ipv4.get("data", {}).get("global", None) + if global_ipv4: + try: + requests.head("http://" + global_ipv4, timeout=5) + except requests.exceptions.Timeout: + yield dict( + meta={"test": "hairpinning"}, + status="WARNING", + summary="diagnosis_http_hairpinning_issue", + details=["diagnosis_http_hairpinning_issue_details"], + ) + except Exception: + # Well I dunno what to do if that's another exception + # type... That'll most probably *not* be an hairpinning + # issue but something else super weird ... + pass + + def test_http(self, domains, ipversions): + + results = {} + for ipversion in ipversions: + try: + r = Diagnoser.remote_diagnosis( + "check-http", + data={"domains": domains, "nonce": self.nonce}, + ipversion=ipversion, + ) + results[ipversion] = r["http"] + except Exception as e: + yield dict( + meta={"reason": "remote_diagnosis_failed", "ipversion": ipversion}, + data={"error": str(e)}, + status="WARNING", + summary="diagnosis_http_could_not_diagnose", + details=["diagnosis_http_could_not_diagnose_details"], + ) + continue + + ipversions = results.keys() + if not ipversions: + return + + for domain in domains: + + # i18n: diagnosis_http_bad_status_code + # i18n: diagnosis_http_connection_error + # i18n: diagnosis_http_timeout + + # If both IPv4 and IPv6 (if applicable) are good + if all( + results[ipversion][domain]["status"] == "ok" for ipversion in ipversions + ): + if 4 in ipversions: + self.do_hairpinning_test = True + yield dict( + meta={"domain": domain}, + status="SUCCESS", + summary="diagnosis_http_ok", + ) + # If both IPv4 and IPv6 (if applicable) are failed + elif all( + results[ipversion][domain]["status"] != "ok" for ipversion in ipversions + ): + detail = results[4 if 4 in ipversions else 6][domain]["status"] + yield dict( + meta={"domain": domain}, + status="ERROR", + summary="diagnosis_http_unreachable", + details=[detail.replace("error_http_check", "diagnosis_http")], + ) + # If only IPv4 is failed or only IPv6 is failed (if applicable) + else: + passed, failed = ( + (4, 6) if results[4][domain]["status"] == "ok" else (6, 4) + ) + detail = results[failed][domain]["status"] + + # Failing in ipv4 is critical. + # If we failed in IPv6 but there's in fact no AAAA record + # It's an acceptable situation and we shall not report an + # error + def ipv6_is_important_for_this_domain(): + dnsrecords = ( + Diagnoser.get_cached_report( + "dnsrecords", item={"domain": domain, "category": "basic"} + ) + or {} + ) + AAAA_status = dnsrecords.get("data", {}).get("AAAA:@") + + return AAAA_status in ["OK", "WRONG"] + + if failed == 4 or ipv6_is_important_for_this_domain(): + yield dict( + meta={"domain": domain}, + data={"passed": passed, "failed": failed}, + status="ERROR", + summary="diagnosis_http_partially_unreachable", + details=[detail.replace("error_http_check", "diagnosis_http")], + ) + # So otherwise we report a success (note that this info is + # later used to know that ACME challenge is doable) + # + # And in addition we report an info about the failure in IPv6 + # *with a different meta* (important to avoid conflicts when + # fetching the other info...) + else: + self.do_hairpinning_test = True + yield dict( + meta={"domain": domain}, + status="SUCCESS", + summary="diagnosis_http_ok", + ) + yield dict( + meta={"test": "ipv6", "domain": domain}, + data={"passed": passed, "failed": failed}, + status="INFO", + summary="diagnosis_http_partially_unreachable", + details=[detail.replace("error_http_check", "diagnosis_http")], + ) + + +def main(args, env, loggers): + return WebDiagnoser(args, env, loggers).diagnose() diff --git a/data/hooks/diagnosis/24-mail.py b/data/hooks/diagnosis/24-mail.py new file mode 100644 index 000000000..c5af4bbc6 --- /dev/null +++ b/data/hooks/diagnosis/24-mail.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python + +import os +import dns.resolver +import re + +from subprocess import CalledProcessError + +from moulinette.utils.process import check_output +from moulinette.utils.filesystem import read_yaml + +from yunohost.diagnosis import Diagnoser +from yunohost.domain import _get_maindomain, domain_list +from yunohost.settings import settings_get +from yunohost.utils.dns import dig + +DEFAULT_DNS_BLACKLIST = "/usr/share/yunohost/other/dnsbl_list.yml" + + +class MailDiagnoser(Diagnoser): + + id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1] + cache_duration = 600 + dependencies = ["ip"] + + def run(self): + + self.ehlo_domain = _get_maindomain() + self.mail_domains = domain_list()["domains"] + self.ipversions, self.ips = self.get_ips_checked() + + # TODO Is a A/AAAA and MX Record ? + # TODO Are outgoing public IPs authorized to send mail by SPF ? + # TODO Validate DKIM and dmarc ? + # TODO check that the recent mail logs are not filled with thousand of email sending (unusual number of mail sent) + # TODO check for unusual failed sending attempt being refused in the logs ? + checks = [ + "check_outgoing_port_25", # i18n: diagnosis_mail_outgoing_port_25_ok + "check_ehlo", # i18n: diagnosis_mail_ehlo_ok + "check_fcrdns", # i18n: diagnosis_mail_fcrdns_ok + "check_blacklist", # i18n: diagnosis_mail_blacklist_ok + "check_queue", # i18n: diagnosis_mail_queue_ok + ] + for check in checks: + self.logger_debug("Running " + check) + reports = list(getattr(self, check)()) + for report in reports: + yield report + if not reports: + name = check[6:] + yield dict( + meta={"test": "mail_" + name}, + status="SUCCESS", + summary="diagnosis_mail_" + name + "_ok", + ) + + def check_outgoing_port_25(self): + """ + Check outgoing port 25 is open and not blocked by router + This check is ran on IPs we could used to send mail. + """ + + for ipversion in self.ipversions: + cmd = "/bin/nc -{ipversion} -z -w2 yunohost.org 25".format( + ipversion=ipversion + ) + if os.system(cmd) != 0: + yield dict( + meta={"test": "outgoing_port_25", "ipversion": ipversion}, + data={}, + status="ERROR", + summary="diagnosis_mail_outgoing_port_25_blocked", + details=[ + "diagnosis_mail_outgoing_port_25_blocked_details", + "diagnosis_mail_outgoing_port_25_blocked_relay_vpn", + ], + ) + + def check_ehlo(self): + """ + Check the server is reachable from outside and it's the good one + This check is ran on IPs we could used to send mail. + """ + + for ipversion in self.ipversions: + try: + r = Diagnoser.remote_diagnosis( + "check-smtp", data={}, ipversion=ipversion + ) + except Exception as e: + yield dict( + meta={ + "test": "mail_ehlo", + "reason": "remote_server_failed", + "ipversion": ipversion, + }, + data={"error": str(e)}, + status="WARNING", + summary="diagnosis_mail_ehlo_could_not_diagnose", + details=["diagnosis_mail_ehlo_could_not_diagnose_details"], + ) + continue + + if r["status"] != "ok": + # i18n: diagnosis_mail_ehlo_bad_answer + # i18n: diagnosis_mail_ehlo_bad_answer_details + # i18n: diagnosis_mail_ehlo_unreachable + # i18n: diagnosis_mail_ehlo_unreachable_details + summary = r["status"].replace("error_smtp_", "diagnosis_mail_ehlo_") + yield dict( + meta={"test": "mail_ehlo", "ipversion": ipversion}, + data={}, + status="ERROR", + summary=summary, + details=[summary + "_details"], + ) + elif r["helo"] != self.ehlo_domain: + yield dict( + meta={"test": "mail_ehlo", "ipversion": ipversion}, + data={"wrong_ehlo": r["helo"], "right_ehlo": self.ehlo_domain}, + status="ERROR", + summary="diagnosis_mail_ehlo_wrong", + details=["diagnosis_mail_ehlo_wrong_details"], + ) + + def check_fcrdns(self): + """ + Check the reverse DNS is well defined by doing a Forward-confirmed + reverse DNS check + This check is ran on IPs we could used to send mail. + """ + + for ip in self.ips: + if ":" in ip: + ipversion = 6 + details = [ + "diagnosis_mail_fcrdns_nok_details", + "diagnosis_mail_fcrdns_nok_alternatives_6", + ] + else: + ipversion = 4 + details = [ + "diagnosis_mail_fcrdns_nok_details", + "diagnosis_mail_fcrdns_nok_alternatives_4", + ] + + rev = dns.reversename.from_address(ip) + subdomain = str(rev.split(3)[0]) + query = subdomain + if ipversion == 4: + query += ".in-addr.arpa" + else: + query += ".ip6.arpa" + + # Do the DNS Query + status, value = dig(query, "PTR", resolvers="force_external") + if status == "nok": + yield dict( + meta={"test": "mail_fcrdns", "ipversion": ipversion}, + data={"ip": ip, "ehlo_domain": self.ehlo_domain}, + status="ERROR", + summary="diagnosis_mail_fcrdns_dns_missing", + details=details, + ) + continue + + rdns_domain = "" + if len(value) > 0: + rdns_domain = value[0][:-1] if value[0].endswith(".") else value[0] + if rdns_domain != self.ehlo_domain: + details = [ + "diagnosis_mail_fcrdns_different_from_ehlo_domain_details" + ] + details + yield dict( + meta={"test": "mail_fcrdns", "ipversion": ipversion}, + data={ + "ip": ip, + "ehlo_domain": self.ehlo_domain, + "rdns_domain": rdns_domain, + }, + status="ERROR", + summary="diagnosis_mail_fcrdns_different_from_ehlo_domain", + details=details, + ) + + def check_blacklist(self): + """ + Check with dig onto blacklist DNS server + This check is ran on IPs and domains we could used to send mail. + """ + + dns_blacklists = read_yaml(DEFAULT_DNS_BLACKLIST) + for item in self.ips + self.mail_domains: + for blacklist in dns_blacklists: + item_type = "domain" + if ":" in item: + item_type = "ipv6" + elif re.match(r"^\d+\.\d+\.\d+\.\d+$", item): + item_type = "ipv4" + + if not blacklist[item_type]: + continue + + # Build the query for DNSBL + subdomain = item + if item_type != "domain": + rev = dns.reversename.from_address(item) + subdomain = str(rev.split(3)[0]) + query = subdomain + "." + blacklist["dns_server"] + + # Do the DNS Query + status, _ = dig(query, "A") + if status != "ok": + continue + + # Try to get the reason + details = [] + status, answers = dig(query, "TXT") + reason = "-" + if status == "ok": + reason = ", ".join(answers) + details.append("diagnosis_mail_blacklist_reason") + + details.append("diagnosis_mail_blacklist_website") + + yield dict( + meta={ + "test": "mail_blacklist", + "item": item, + "blacklist": blacklist["dns_server"], + }, + data={ + "blacklist_name": blacklist["name"], + "blacklist_website": blacklist["website"], + "reason": reason, + }, + status="ERROR", + summary="diagnosis_mail_blacklist_listed_by", + details=details, + ) + + def check_queue(self): + """ + Check mail queue is not filled with hundreds of email pending + """ + + command = ( + 'postqueue -p | grep -v "Mail queue is empty" | grep -c "^[A-Z0-9]" || true' + ) + try: + output = check_output(command) + pending_emails = int(output) + except (ValueError, CalledProcessError) as e: + yield dict( + meta={"test": "mail_queue"}, + data={"error": str(e)}, + status="ERROR", + summary="diagnosis_mail_queue_unavailable", + details="diagnosis_mail_queue_unavailable_details", + ) + else: + if pending_emails > 100: + yield dict( + meta={"test": "mail_queue"}, + data={"nb_pending": pending_emails}, + status="WARNING", + summary="diagnosis_mail_queue_too_big", + ) + else: + yield dict( + meta={"test": "mail_queue"}, + data={"nb_pending": pending_emails}, + status="SUCCESS", + summary="diagnosis_mail_queue_ok", + ) + + def get_ips_checked(self): + outgoing_ipversions = [] + outgoing_ips = [] + ipv4 = Diagnoser.get_cached_report("ip", {"test": "ipv4"}) or {} + if ipv4.get("status") == "SUCCESS": + outgoing_ipversions.append(4) + global_ipv4 = ipv4.get("data", {}).get("global", {}) + if global_ipv4: + outgoing_ips.append(global_ipv4) + + if settings_get("smtp.allow_ipv6"): + ipv6 = Diagnoser.get_cached_report("ip", {"test": "ipv6"}) or {} + if ipv6.get("status") == "SUCCESS": + outgoing_ipversions.append(6) + global_ipv6 = ipv6.get("data", {}).get("global", {}) + if global_ipv6: + outgoing_ips.append(global_ipv6) + return (outgoing_ipversions, outgoing_ips) + + +def main(args, env, loggers): + return MailDiagnoser(args, env, loggers).diagnose() diff --git a/data/hooks/diagnosis/30-services.py b/data/hooks/diagnosis/30-services.py new file mode 100644 index 000000000..adbcc73b9 --- /dev/null +++ b/data/hooks/diagnosis/30-services.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python + +import os + +from yunohost.diagnosis import Diagnoser +from yunohost.service import service_status + + +class ServicesDiagnoser(Diagnoser): + + id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1] + cache_duration = 300 + dependencies = [] + + def run(self): + + all_result = service_status() + + for service, result in sorted(all_result.items()): + + item = dict( + meta={"service": service}, + data={ + "status": result["status"], + "configuration": result["configuration"], + }, + ) + + if result["status"] != "running": + item["status"] = "ERROR" if result["status"] != "unknown" else "WARNING" + item["summary"] = "diagnosis_services_bad_status" + item["details"] = ["diagnosis_services_bad_status_tip"] + + elif result["configuration"] == "broken": + item["status"] = "WARNING" + item["summary"] = "diagnosis_services_conf_broken" + item["details"] = result["configuration-details"] + + else: + item["status"] = "SUCCESS" + item["summary"] = "diagnosis_services_running" + + yield item + + +def main(args, env, loggers): + return ServicesDiagnoser(args, env, loggers).diagnose() diff --git a/data/hooks/diagnosis/50-systemresources.py b/data/hooks/diagnosis/50-systemresources.py new file mode 100644 index 000000000..a662e392e --- /dev/null +++ b/data/hooks/diagnosis/50-systemresources.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python +import os +import psutil +import datetime +import re + +from moulinette.utils.process import check_output + +from yunohost.diagnosis import Diagnoser + + +class SystemResourcesDiagnoser(Diagnoser): + + id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1] + cache_duration = 300 + dependencies = [] + + def run(self): + + MB = 1024 ** 2 + GB = MB * 1024 + + # + # RAM + # + + ram = psutil.virtual_memory() + ram_available_percent = 100 * ram.available / ram.total + item = dict( + meta={"test": "ram"}, + data={ + "total": human_size(ram.total), + "available": human_size(ram.available), + "available_percent": round_(ram_available_percent), + }, + ) + + if ram.available < 100 * MB or ram_available_percent < 5: + item["status"] = "ERROR" + item["summary"] = "diagnosis_ram_verylow" + elif ram.available < 200 * MB or ram_available_percent < 10: + item["status"] = "WARNING" + item["summary"] = "diagnosis_ram_low" + else: + item["status"] = "SUCCESS" + item["summary"] = "diagnosis_ram_ok" + yield item + + # + # Swap + # + + swap = psutil.swap_memory() + item = dict( + meta={"test": "swap"}, + data={"total": human_size(swap.total), "recommended": "512 MiB"}, + ) + if swap.total <= 1 * MB: + item["status"] = "INFO" + item["summary"] = "diagnosis_swap_none" + elif swap.total < 450 * MB: + item["status"] = "INFO" + item["summary"] = "diagnosis_swap_notsomuch" + else: + item["status"] = "SUCCESS" + item["summary"] = "diagnosis_swap_ok" + item["details"] = ["diagnosis_swap_tip"] + yield item + + # FIXME : add a check that swapiness is low if swap is on a sdcard... + + # + # Disks usage + # + + disk_partitions = sorted(psutil.disk_partitions(), key=lambda k: k.mountpoint) + + # Ignore /dev/loop stuff which are ~virtual partitions ? (e.g. mounted to /snap/) + disk_partitions = [ + d + for d in disk_partitions + if d.mountpoint in ["/", "/var"] or not d.device.startswith("/dev/loop") + ] + + for disk_partition in disk_partitions: + device = disk_partition.device + mountpoint = disk_partition.mountpoint + + usage = psutil.disk_usage(mountpoint) + free_percent = 100 - round_(usage.percent) + + item = dict( + meta={"test": "diskusage", "mountpoint": mountpoint}, + data={ + "device": device, + # N.B.: we do not use usage.total because we want + # to take into account the 5% security margin + # correctly (c.f. the doc of psutil ...) + "total": human_size(usage.used + usage.free), + "free": human_size(usage.free), + "free_percent": free_percent, + }, + ) + + # We have an additional absolute constrain on / and /var because + # system partitions are critical, having them full may prevent + # upgrades etc... + if free_percent < 2.5 or ( + mountpoint in ["/", "/var"] and usage.free < 1 * GB + ): + item["status"] = "ERROR" + item["summary"] = "diagnosis_diskusage_verylow" + elif free_percent < 5 or ( + mountpoint in ["/", "/var"] and usage.free < 2 * GB + ): + item["status"] = "WARNING" + item["summary"] = "diagnosis_diskusage_low" + else: + item["status"] = "SUCCESS" + item["summary"] = "diagnosis_diskusage_ok" + + yield item + + # + # Check for minimal space on / + /var + # because some stupid VPS provider only configure a stupidly + # low amount of disk space for the root partition + # which later causes issue when it gets full... + # + + main_disk_partitions = [ + d for d in disk_partitions if d.mountpoint in ["/", "/var"] + ] + main_space = sum( + [psutil.disk_usage(d.mountpoint).total for d in main_disk_partitions] + ) + if main_space < 10 * GB: + yield dict( + meta={"test": "rootfstotalspace"}, + data={"space": human_size(main_space)}, + status="ERROR", + summary="diagnosis_rootfstotalspace_critical", + ) + elif main_space < 14 * GB: + yield dict( + meta={"test": "rootfstotalspace"}, + data={"space": human_size(main_space)}, + status="WARNING", + summary="diagnosis_rootfstotalspace_warning", + ) + + # + # Recent kills by oom_reaper + # + + kills_count = self.recent_kills_by_oom_reaper() + if kills_count: + kills_summary = "\n".join( + ["%s (x%s)" % (proc, count) for proc, count in kills_count] + ) + + yield dict( + meta={"test": "oom_reaper"}, + status="WARNING", + summary="diagnosis_processes_killed_by_oom_reaper", + data={"kills_summary": kills_summary}, + ) + + def recent_kills_by_oom_reaper(self): + if not os.path.exists("/var/log/kern.log"): + return [] + + def analyzed_kern_log(): + + cmd = 'tail -n 10000 /var/log/kern.log | grep "oom_reaper: reaped process" || true' + out = check_output(cmd) + lines = out.split("\n") if out else [] + + now = datetime.datetime.now() + + for line in reversed(lines): + # Lines look like : + # Aug 25 18:48:21 yolo kernel: [ 9623.613667] oom_reaper: reaped process 11509 (uwsgi), now anon-rss:0kB, file-rss:0kB, shmem-rss:328kB + date_str = str(now.year) + " " + " ".join(line.split()[:3]) + date = datetime.datetime.strptime(date_str, "%Y %b %d %H:%M:%S") + diff = now - date + if diff.days >= 1: + break + process_killed = re.search(r"\(.*\)", line).group().strip("()") + yield process_killed + + processes = list(analyzed_kern_log()) + kills_count = [ + (p, len([p_ for p_ in processes if p_ == p])) for p in set(processes) + ] + kills_count = sorted(kills_count, key=lambda p: p[1], reverse=True) + + return kills_count + + +def human_size(bytes_): + # Adapted from https://stackoverflow.com/a/1094933 + for unit in ["", "ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]: + if abs(bytes_) < 1024.0: + return "%s %sB" % (round_(bytes_), unit) + bytes_ /= 1024.0 + return "%s %sB" % (round_(bytes_), "Yi") + + +def round_(n): + # round_(22.124) -> 22 + # round_(9.45) -> 9.4 + n = round(n, 1) + if n > 10: + n = int(round(n)) + return n + + +def main(args, env, loggers): + return SystemResourcesDiagnoser(args, env, loggers).diagnose() diff --git a/data/hooks/diagnosis/70-regenconf.py b/data/hooks/diagnosis/70-regenconf.py new file mode 100644 index 000000000..8ccbeed58 --- /dev/null +++ b/data/hooks/diagnosis/70-regenconf.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python + +import os +import re + +from yunohost.settings import settings_get +from yunohost.diagnosis import Diagnoser +from yunohost.regenconf import _get_regenconf_infos, _calculate_hash +from moulinette.utils.filesystem import read_file + + +class RegenconfDiagnoser(Diagnoser): + + id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1] + cache_duration = 300 + dependencies = [] + + def run(self): + + regenconf_modified_files = list(self.manually_modified_files()) + + if not regenconf_modified_files: + yield dict( + meta={"test": "regenconf"}, + status="SUCCESS", + summary="diagnosis_regenconf_allgood", + ) + else: + for f in regenconf_modified_files: + yield dict( + meta={ + "test": "regenconf", + "category": f["category"], + "file": f["path"], + }, + status="WARNING", + summary="diagnosis_regenconf_manually_modified", + details=["diagnosis_regenconf_manually_modified_details"], + ) + + if ( + any(f["path"] == "/etc/ssh/sshd_config" for f in regenconf_modified_files) + and os.system( + "grep -q '^ *AllowGroups\\|^ *AllowUsers' /etc/ssh/sshd_config" + ) + != 0 + ): + yield dict( + meta={"test": "sshd_config_insecure"}, + status="ERROR", + summary="diagnosis_sshd_config_insecure", + ) + + # Check consistency between actual ssh port in sshd_config vs. setting + ssh_port_setting = settings_get("security.ssh.port") + ssh_port_line = re.findall( + r"\bPort *([0-9]{2,5})\b", read_file("/etc/ssh/sshd_config") + ) + if len(ssh_port_line) == 1 and int(ssh_port_line[0]) != ssh_port_setting: + yield dict( + meta={"test": "sshd_config_port_inconsistency"}, + status="WARNING", + summary="diagnosis_sshd_config_inconsistent", + details=["diagnosis_sshd_config_inconsistent_details"], + ) + + def manually_modified_files(self): + + for category, infos in _get_regenconf_infos().items(): + for path, hash_ in infos["conffiles"].items(): + if hash_ != _calculate_hash(path): + yield {"path": path, "category": category} + + +def main(args, env, loggers): + return RegenconfDiagnoser(args, env, loggers).diagnose() diff --git a/data/hooks/diagnosis/80-apps.py b/data/hooks/diagnosis/80-apps.py new file mode 100644 index 000000000..a75193a45 --- /dev/null +++ b/data/hooks/diagnosis/80-apps.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python + +import os + +from yunohost.app import app_list + +from yunohost.diagnosis import Diagnoser + + +class AppDiagnoser(Diagnoser): + + id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1] + cache_duration = 300 + dependencies = [] + + def run(self): + + apps = app_list(full=True)["apps"] + for app in apps: + app["issues"] = list(self.issues(app)) + + if not any(app["issues"] for app in apps): + yield dict( + meta={"test": "apps"}, + status="SUCCESS", + summary="diagnosis_apps_allgood", + ) + else: + for app in apps: + + if not app["issues"]: + continue + + level = ( + "ERROR" + if any(issue[0] == "error" for issue in app["issues"]) + else "WARNING" + ) + + yield dict( + meta={"test": "apps", "app": app["name"]}, + status=level, + summary="diagnosis_apps_issue", + details=[issue[1] for issue in app["issues"]], + ) + + def issues(self, app): + + # Check quality level in catalog + + if not app.get("from_catalog") or app["from_catalog"].get("state") != "working": + yield ("error", "diagnosis_apps_not_in_app_catalog") + elif ( + not isinstance(app["from_catalog"].get("level"), int) + or app["from_catalog"]["level"] == 0 + ): + yield ("error", "diagnosis_apps_broken") + elif app["from_catalog"]["level"] <= 4: + yield ("warning", "diagnosis_apps_bad_quality") + + # Check for super old, deprecated practices + + yunohost_version_req = ( + app["manifest"].get("requirements", {}).get("yunohost", "").strip(">= ") + ) + if yunohost_version_req.startswith("2."): + yield ("error", "diagnosis_apps_outdated_ynh_requirement") + + deprecated_helpers = [ + "yunohost app setting", + "yunohost app checkurl", + "yunohost app checkport", + "yunohost app initdb", + "yunohost tools port-available", + ] + for deprecated_helper in deprecated_helpers: + if ( + os.system( + f"grep -hr '{deprecated_helper}' {app['setting_path']}/scripts/ | grep -v -q '^\s*#'" + ) + == 0 + ): + yield ("error", "diagnosis_apps_deprecated_practices") + + old_arg_regex = r"^domain=\${?[0-9]" + if ( + os.system( + f"grep -q '{old_arg_regex}' {app['setting_path']}/scripts/install" + ) + == 0 + ): + yield ("error", "diagnosis_apps_deprecated_practices") + + +def main(args, env, loggers): + return AppDiagnoser(args, env, loggers).diagnose() diff --git a/data/hooks/post_user_create/ynh_multimedia b/data/hooks/post_user_create/ynh_multimedia new file mode 100644 index 000000000..26282cdc9 --- /dev/null +++ b/data/hooks/post_user_create/ynh_multimedia @@ -0,0 +1,32 @@ +#!/bin/bash + +user=$1 + +readonly MEDIA_GROUP=multimedia +readonly MEDIA_DIRECTORY=/home/yunohost.multimedia + +# We only do this if multimedia directory is enabled (= the folder exists) +[ -e "$MEDIA_DIRECTORY" ] || exit 0 + +mkdir -p "$MEDIA_DIRECTORY/$user" +mkdir -p "$MEDIA_DIRECTORY/$user/Music" +mkdir -p "$MEDIA_DIRECTORY/$user/Picture" +mkdir -p "$MEDIA_DIRECTORY/$user/Video" +mkdir -p "$MEDIA_DIRECTORY/$user/eBook" +ln -sfn "$MEDIA_DIRECTORY/share" "$MEDIA_DIRECTORY/$user/Share" +# Création du lien symbolique dans le home de l'utilisateur. +#link will only be created if the home directory of the user exists and if it's located in '/home' folder +user_home="$(getent passwd $user | cut -d: -f6 | grep '^/home/')" +if [[ -d "$user_home" ]]; then + ln -sfn "$MEDIA_DIRECTORY/$user" "$user_home/Multimedia" +fi +# Propriétaires des dossiers utilisateurs. +chown -R $user "$MEDIA_DIRECTORY/$user" + +## Application des droits étendus sur le dossier multimedia. +# Droit d'écriture pour le groupe et le groupe multimedia en acl et droit de lecture pour other: +setfacl -RnL -m g:$MEDIA_GROUP:rwX,g::rwX,o:r-X "$MEDIA_DIRECTORY/$user" +# Application de la même règle que précédemment, mais par défaut pour les nouveaux fichiers. +setfacl -RnL -m d:g:$MEDIA_GROUP:rwX,g::rwX,o:r-X "$MEDIA_DIRECTORY/$user" +# Réglage du masque par défaut. Qui garantie (en principe...) un droit maximal à rwx. Donc pas de restriction de droits par l'acl. +setfacl -RL -m m::rwx "$MEDIA_DIRECTORY/$user" diff --git a/data/hooks/post_user_delete/ynh_multimedia b/data/hooks/post_user_delete/ynh_multimedia new file mode 100644 index 000000000..af06e1637 --- /dev/null +++ b/data/hooks/post_user_delete/ynh_multimedia @@ -0,0 +1,8 @@ +#!/bin/bash + +user=$1 +MEDIA_DIRECTORY=/home/yunohost.multimedia + +if [ -n "$user" ] && [ -e "$MEDIA_DIRECTORY/$user" ]; then + sudo rm -r "$MEDIA_DIRECTORY/$user" +fi diff --git a/data/hooks/restore/05-conf_ldap b/data/hooks/restore/05-conf_ldap index eb6824993..c2debe018 100644 --- a/data/hooks/restore/05-conf_ldap +++ b/data/hooks/restore/05-conf_ldap @@ -1,58 +1,53 @@ +#!/bin/bash + backup_dir="${1}/conf/ldap" -if [[ $EUID -ne 0 ]]; then +systemctl stop slapd - # We need to execute this script as root, since the ldap - # service will be shut down during the operation (and sudo - # won't be available) - sudo /bin/bash $(readlink -f $0) $1 +# Create a directory for backup +TMPDIR="/tmp/$(date +%s)" +mkdir -p "$TMPDIR" -else +die() { + state=$1 + error=$2 - service slapd stop || true + # Restore saved configuration and database + [[ $state -ge 1 ]] \ + && (rm -rf /etc/ldap/slapd.d && + mv "${TMPDIR}/slapd.d" /etc/ldap/slapd.d) + [[ $state -ge 2 ]] \ + && (rm -rf /var/lib/ldap && + mv "${TMPDIR}/ldap" /var/lib/ldap) + chown -R openldap: /etc/ldap/slapd.d /var/lib/ldap - # Create a directory for backup - TMPDIR="/tmp/$(date +%s)" - mkdir -p "$TMPDIR" - - die() { - state=$1 - error=$2 - - # Restore saved configuration and database - [[ $state -ge 1 ]] \ - && (rm -rf /etc/ldap/slapd.d && - mv "${TMPDIR}/slapd.d" /etc/ldap/slapd.d) - [[ $state -ge 2 ]] \ - && (rm -rf /var/lib/ldap && - mv "${TMPDIR}/ldap" /var/lib/ldap) - chown -R openldap: /etc/ldap/slapd.d /var/lib/ldap - - service slapd start - rm -rf "$TMPDIR" - - # Print an error message and exit - printf "%s" "$error" 1>&2 - exit 1 - } - - # Restore the configuration - mv /etc/ldap/slapd.d "$TMPDIR" - mkdir -p /etc/ldap/slapd.d - cp -a "${backup_dir}/slapd.conf" /etc/ldap/slapd.conf - slapadd -F /etc/ldap/slapd.d -b cn=config \ - -l "${backup_dir}/cn=config.master.ldif" \ - || die 1 "Unable to restore LDAP configuration" - chown -R openldap: /etc/ldap/slapd.d - - # Restore the database - mv /var/lib/ldap "$TMPDIR" - mkdir -p /var/lib/ldap - slapadd -F /etc/ldap/slapd.d -b dc=yunohost,dc=org \ - -l "${backup_dir}/dc=yunohost-dc=org.ldif" \ - || die 2 "Unable to restore LDAP database" - chown -R openldap: /var/lib/ldap - - service slapd start + systemctl start slapd rm -rf "$TMPDIR" -fi + + # Print an error message and exit + printf "%s" "$error" 1>&2 + exit 1 +} + +# Restore the configuration +mv /etc/ldap/slapd.d "$TMPDIR" +mkdir -p /etc/ldap/slapd.d +cp -a "${backup_dir}/ldap.conf" /etc/ldap/ldap.conf +# Legacy thing but we need it to force the regen-conf in case of it exist +[ ! -e "${backup_dir}/slapd.conf" ] \ + || cp -a "${backup_dir}/slapd.conf" /etc/ldap/slapd.conf +slapadd -F /etc/ldap/slapd.d -b cn=config \ + -l "${backup_dir}/cn=config.master.ldif" \ + || die 1 "Unable to restore LDAP configuration" +chown -R openldap: /etc/ldap/slapd.d + +# Restore the database +mv /var/lib/ldap "$TMPDIR" +mkdir -p /var/lib/ldap +slapadd -F /etc/ldap/slapd.d -b dc=yunohost,dc=org \ + -l "${backup_dir}/dc=yunohost-dc=org.ldif" \ + || die 2 "Unable to restore LDAP database" +chown -R openldap: /var/lib/ldap + +systemctl start slapd +rm -rf "$TMPDIR" diff --git a/data/hooks/restore/08-conf_ssh b/data/hooks/restore/08-conf_ssh deleted file mode 100644 index 0c0f9bf9b..000000000 --- a/data/hooks/restore/08-conf_ssh +++ /dev/null @@ -1,9 +0,0 @@ -backup_dir="$1/conf/ssh" - -if [ -d /etc/ssh/ ]; then - sudo cp -a $backup_dir/. /etc/ssh - sudo service ssh restart -else - echo "SSH is not installed" -fi - diff --git a/data/hooks/restore/11-conf_ynh_mysql b/data/hooks/restore/11-conf_ynh_mysql deleted file mode 100644 index 24cdb1e79..000000000 --- a/data/hooks/restore/11-conf_ynh_mysql +++ /dev/null @@ -1,42 +0,0 @@ -backup_dir="$1/conf/ynh/mysql" -MYSQL_PKG="$(dpkg --list | sed -ne 's/^ii \(mariadb-server-[[:digit:].]\+\) .*$/\1/p')" - -. /usr/share/yunohost/helpers - -# ensure that mysql is running -service mysql status >/dev/null 2>&1 \ - || service mysql start - -# retrieve current and new password -[ -f /etc/yunohost/mysql ] \ - && curr_pwd=$(sudo cat /etc/yunohost/mysql) -new_pwd=$(sudo cat "${backup_dir}/root_pwd" || sudo cat "${backup_dir}/mysql") -[ -z "$curr_pwd" ] && curr_pwd="yunohost" -[ -z "$new_pwd" ] && { - new_pwd=$(ynh_string_random 10) -} - -# attempt to change it -sudo mysqladmin -s -u root -p"$curr_pwd" password "$new_pwd" || { - - echo "It seems that you have already configured MySQL." \ - "YunoHost needs to have a root access to MySQL to runs its" \ - "applications, and is going to reset the MySQL root password." \ - "You can find this new password in /etc/yunohost/mysql." >&2 - - # set new password with debconf - sudo debconf-set-selections << EOF -$MYSQL_PKG mysql-server/root_password password $new_pwd -$MYSQL_PKG mysql-server/root_password_again password $new_pwd -EOF - - # reconfigure Debian package - sudo dpkg-reconfigure -freadline -u "$MYSQL_PKG" 2>&1 -} - -# store new root password -echo "$new_pwd" | sudo tee /etc/yunohost/mysql -sudo chmod 400 /etc/yunohost/mysql - -# reload the grant tables -sudo mysqladmin -s -u root -p"$new_pwd" reload diff --git a/data/hooks/restore/14-conf_ssowat b/data/hooks/restore/14-conf_ssowat deleted file mode 100644 index 01ac787ee..000000000 --- a/data/hooks/restore/14-conf_ssowat +++ /dev/null @@ -1,3 +0,0 @@ -backup_dir="$1/conf/ssowat" - -sudo cp -a $backup_dir/. /etc/ssowat diff --git a/data/hooks/restore/17-data_home b/data/hooks/restore/17-data_home index a7ba2733c..6226eab6d 100644 --- a/data/hooks/restore/17-data_home +++ b/data/hooks/restore/17-data_home @@ -1,3 +1,3 @@ backup_dir="$1/data/home" -sudo cp -a $backup_dir/. /home +cp -a $backup_dir/. /home diff --git a/data/hooks/backup/14-conf_ssowat b/data/hooks/restore/18-data_multimedia old mode 100755 new mode 100644 similarity index 52% rename from data/hooks/backup/14-conf_ssowat rename to data/hooks/restore/18-data_multimedia index d4db72493..eb8ef2608 --- a/data/hooks/backup/14-conf_ssowat +++ b/data/hooks/restore/18-data_multimedia @@ -6,8 +6,4 @@ set -eu # Source YNH helpers source /usr/share/yunohost/helpers -# Backup destination -backup_dir="${1}/conf/ssowat" - -# Backup the configuration -ynh_backup "/etc/ssowat" "$backup_dir" +ynh_restore_file --origin_path="/home/yunohost.multimedia" --not_mandatory diff --git a/data/hooks/restore/20-conf_ynh_firewall b/data/hooks/restore/20-conf_ynh_firewall deleted file mode 100644 index c0ee18818..000000000 --- a/data/hooks/restore/20-conf_ynh_firewall +++ /dev/null @@ -1,4 +0,0 @@ -backup_dir="$1/conf/ynh/firewall" - -sudo cp -a $backup_dir/. /etc/yunohost -sudo yunohost firewall reload diff --git a/data/hooks/restore/20-conf_ynh_settings b/data/hooks/restore/20-conf_ynh_settings new file mode 100644 index 000000000..4c4c6ed5e --- /dev/null +++ b/data/hooks/restore/20-conf_ynh_settings @@ -0,0 +1,8 @@ +backup_dir="$1/conf/ynh" + +cp -a "${backup_dir}/current_host" /etc/yunohost/current_host +cp -a "${backup_dir}/firewall.yml" /etc/yunohost/firewall.yml +cp -a "${backup_dir}/domains" /etc/yunohost/domains +[ ! -e "${backup_dir}/settings.json" ] || cp -a "${backup_dir}/settings.json" "/etc/yunohost/settings.json" +[ ! -d "${backup_dir}/dyndns" ] || cp -raT "${backup_dir}/dyndns" "/etc/yunohost/dyndns" +[ ! -d "${backup_dir}/dkim" ] || cp -raT "${backup_dir}/dkim" "/etc/dkim" diff --git a/data/hooks/restore/21-conf_ynh_certs b/data/hooks/restore/21-conf_ynh_certs index d1eb532ed..a6b45efeb 100644 --- a/data/hooks/restore/21-conf_ynh_certs +++ b/data/hooks/restore/21-conf_ynh_certs @@ -1,8 +1,5 @@ backup_dir="$1/conf/ynh/certs" -sudo mkdir -p /etc/yunohost/certs/ +mkdir -p /etc/yunohost/certs/ -sudo cp -a $backup_dir/. /etc/yunohost/certs/ -sudo yunohost app ssowatconf -sudo service nginx reload -sudo service metronome reload +cp -a $backup_dir/. /etc/yunohost/certs/ diff --git a/data/hooks/restore/23-data_mail b/data/hooks/restore/23-data_mail index 81b9b923f..b3946f341 100644 --- a/data/hooks/restore/23-data_mail +++ b/data/hooks/restore/23-data_mail @@ -1,8 +1,4 @@ backup_dir="$1/data/mail" -sudo cp -a $backup_dir/. /var/mail/ || echo 'No mail found' -sudo chown -R vmail:mail /var/mail/ - -# Restart services to use migrated certs -sudo service postfix restart -sudo service dovecot restart +cp -a $backup_dir/. /var/mail/ || echo 'No mail found' +chown -R vmail:mail /var/mail/ diff --git a/data/hooks/restore/26-conf_xmpp b/data/hooks/restore/26-conf_xmpp deleted file mode 100644 index 61692b316..000000000 --- a/data/hooks/restore/26-conf_xmpp +++ /dev/null @@ -1,7 +0,0 @@ -backup_dir="$1/conf/xmpp" - -sudo cp -a $backup_dir/etc/. /etc/metronome -sudo cp -a $backup_dir/var/. /var/lib/metronome - -# Restart to apply new conf and certs -sudo service metronome restart diff --git a/data/hooks/restore/27-data_xmpp b/data/hooks/restore/27-data_xmpp new file mode 100644 index 000000000..02a4c6703 --- /dev/null +++ b/data/hooks/restore/27-data_xmpp @@ -0,0 +1,4 @@ +backup_dir="$1/data/xmpp" + +cp -a $backup_dir/var_lib_metronome/. /var/lib/metronome +cp -a $backup_dir/var_xmpp-upload/. /var/xmpp-upload diff --git a/data/hooks/restore/29-conf_nginx b/data/hooks/restore/29-conf_nginx deleted file mode 100644 index 0795f53df..000000000 --- a/data/hooks/restore/29-conf_nginx +++ /dev/null @@ -1,7 +0,0 @@ -backup_dir="$1/conf/nginx" - -# Copy all conf except apps specific conf located in DOMAIN.d -sudo find $backup_dir/ -mindepth 1 -maxdepth 1 -name '*.d' -or -exec sudo cp -a {} /etc/nginx/conf.d/ \; - -# Restart to use new conf and certs -sudo service nginx restart diff --git a/data/hooks/restore/32-conf_cron b/data/hooks/restore/32-conf_cron deleted file mode 100644 index 68657963e..000000000 --- a/data/hooks/restore/32-conf_cron +++ /dev/null @@ -1,6 +0,0 @@ -backup_dir="$1/conf/cron" - -sudo cp -a $backup_dir/. /etc/cron.d - -# Restart just in case -sudo service cron restart diff --git a/data/hooks/restore/40-conf_ynh_currenthost b/data/hooks/restore/40-conf_ynh_currenthost deleted file mode 100644 index a0bdf94d3..000000000 --- a/data/hooks/restore/40-conf_ynh_currenthost +++ /dev/null @@ -1,3 +0,0 @@ -backup_dir="$1/conf/ynh" - -sudo cp -a "${backup_dir}/current_host" /etc/yunohost/current_host diff --git a/data/hooks/restore/50-conf_manually_modified_files b/data/hooks/restore/50-conf_manually_modified_files new file mode 100644 index 000000000..2d0943043 --- /dev/null +++ b/data/hooks/restore/50-conf_manually_modified_files @@ -0,0 +1,13 @@ +#!/bin/bash + +source /usr/share/yunohost/helpers +ynh_abort_if_errors +YNH_CWD="${YNH_BACKUP_DIR%/}/conf/manually_modified_files" +cd "$YNH_CWD" + +for file in $(cat ./manually_modified_files_list) +do + ynh_restore_file --origin_path="$file" --not_mandatory +done + +ynh_restore_file --origin_path="/etc/ssowat/conf.json.persistent" --not_mandatory diff --git a/data/other/config_domain.toml b/data/other/config_domain.toml new file mode 100644 index 000000000..93551458b --- /dev/null +++ b/data/other/config_domain.toml @@ -0,0 +1,55 @@ +version = "1.0" +i18n = "domain_config" + +# +# Other things we may want to implement in the future: +# +# - maindomain handling +# - default app +# - autoredirect www in nginx conf +# - ? +# + +[feature] + + [feature.mail] + #services = ['postfix', 'dovecot'] + + [feature.mail.features_disclaimer] + type = "alert" + style = "warning" + icon = "warning" + + [feature.mail.mail_out] + type = "boolean" + default = 1 + + [feature.mail.mail_in] + type = "boolean" + default = 1 + + #[feature.mail.backup_mx] + #type = "tags" + #default = [] + #pattern.regexp = '^([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$' + #pattern.error = "pattern_error" + + [feature.xmpp] + + [feature.xmpp.xmpp] + type = "boolean" + default = 0 + +[dns] + + [dns.registrar] + optional = true + + # This part is automatically generated in DomainConfigPanel + +# [dns.advanced] +# +# [dns.advanced.ttl] +# type = "number" +# min = 0 +# default = 3600 diff --git a/data/other/dnsbl_list.yml b/data/other/dnsbl_list.yml new file mode 100644 index 000000000..1dc0175a3 --- /dev/null +++ b/data/other/dnsbl_list.yml @@ -0,0 +1,172 @@ +# Used by GAFAM +- name: Spamhaus ZEN + dns_server: zen.spamhaus.org + website: https://www.spamhaus.org/zen/ + ipv4: true + ipv6: true + domain: false +- name: Barracuda Reputation Block List + dns_server: b.barracudacentral.org + website: https://barracudacentral.org/rbl/ + ipv4: true + ipv6: false + domain: false +- name: Hostkarma + dns_server: hostkarma.junkemailfilter.com + website: https://ipadmin.junkemailfilter.com/remove.php + ipv4: true + ipv6: false + domain: false +- name: ImproWare IP based spamlist + dns_server: spamrbl.imp.ch + website: https://antispam.imp.ch/ + ipv4: true + ipv6: false + domain: false +- name: ImproWare IP based wormlist + dns_server: wormrbl.imp.ch + website: https://antispam.imp.ch/ + ipv4: true + ipv6: false + domain: false +- name: Backscatterer.org + dns_server: ips.backscatterer.org + website: http://www.backscatterer.org/ + ipv4: true + ipv6: false + domain: false +- name: inps.de + dns_server: dnsbl.inps.de + website: http://dnsbl.inps.de/ + ipv4: true + ipv6: false + domain: false +- name: LASHBACK + dns_server: ubl.unsubscore.com + website: https://blacklist.lashback.com/ + ipv4: true + ipv6: false + domain: false +- name: Mailspike.org + dns_server: bl.mailspike.net + website: http://www.mailspike.net/ + ipv4: true + ipv6: false + domain: false +- name: NiX Spam + dns_server: ix.dnsbl.manitu.net + website: http://www.dnsbl.manitu.net/ + ipv4: true + ipv6: false + domain: false +- name: REDHAWK + dns_server: access.redhawk.org + website: https://www.redhawk.org/SpamHawk/query.php + ipv4: true + ipv6: false + domain: false +- name: SORBS Open SMTP relays + dns_server: smtp.dnsbl.sorbs.net + website: http://www.sorbs.net/ + ipv4: true + ipv6: false + domain: false +- name: SORBS Spamhost (last 28 days) + dns_server: recent.spam.dnsbl.sorbs.net + website: http://www.sorbs.net/ + ipv4: true + ipv6: false + domain: false +- name: SORBS Spamhost (last 48 hours) + dns_server: new.spam.dnsbl.sorbs.net + website: http://www.sorbs.net/ + ipv4: true + ipv6: false + domain: false +- name: SpamCop Blocking List + dns_server: bl.spamcop.net + website: https://www.spamcop.net/bl.shtml + ipv4: true + ipv6: false + domain: false +- name: Spam Eating Monkey SEM-BACKSCATTER + dns_server: backscatter.spameatingmonkey.net + website: https://spameatingmonkey.com/services + ipv4: true + ipv6: false + domain: false +- name: Spam Eating Monkey SEM-BLACK + dns_server: bl.spameatingmonkey.net + website: https://spameatingmonkey.com/services + ipv4: true + ipv6: false + domain: false +- name: Spam Eating Monkey SEM-IPV6BL + dns_server: bl.ipv6.spameatingmonkey.net + website: https://spameatingmonkey.com/services + ipv4: false + ipv6: true + domain: false +- name: SpamRATS! all + dns_server: all.spamrats.com + website: http://www.spamrats.com/ + ipv4: true + ipv6: false + domain: false +- name: PSBL (Passive Spam Block List) + dns_server: psbl.surriel.com + website: http://psbl.surriel.com/ + ipv4: true + ipv6: false + domain: false +- name: SWINOG + dns_server: dnsrbl.swinog.ch + website: https://antispam.imp.ch/ + ipv4: true + ipv6: false + domain: false +- name: GBUdb Truncate + dns_server: truncate.gbudb.net + website: http://www.gbudb.com/truncate/index.jsp + ipv4: true + ipv6: false + domain: false +- name: Weighted Private Block List + dns_server: db.wpbl.info + website: http://www.wpbl.info/ + ipv4: true + ipv6: false + domain: false +# Used by GAFAM +- name: Composite Blocking List + dns_server: cbl.abuseat.org + website: cbl.abuseat.org + ipv4: true + ipv6: false + domain: false +# Used by GAFAM +- name: SenderScore Blacklist + dns_server: bl.score.senderscore.com + website: https://senderscore.com + ipv4: true + ipv6: false + domain: false +# Added cause it supports IPv6 +- name: AntiCaptcha.NET IPv6 + dns_server: dnsbl6.anticaptcha.net + website: http://anticaptcha.net/ + ipv4: false + ipv6: true + domain: false +- name: Suomispam Blacklist + dns_server: bl.suomispam.net + website: http://suomispam.net/ + ipv4: true + ipv6: true + domain: false +- name: NordSpam + dns_server: bl.nordspam.com + website: https://www.nordspam.com/ + ipv4: true + ipv6: true + domain: false diff --git a/data/other/ffdhe2048.pem b/data/other/ffdhe2048.pem new file mode 100644 index 000000000..9b182b720 --- /dev/null +++ b/data/other/ffdhe2048.pem @@ -0,0 +1,8 @@ +-----BEGIN DH PARAMETERS----- +MIIBCAKCAQEA//////////+t+FRYortKmq/cViAnPTzx2LnFg84tNpWp4TZBFGQz ++8yTnc4kmz75fS/jY2MMddj2gbICrsRhetPfHtXV/WVhJDP1H18GbtCFY2VVPe0a +87VXE15/V8k1mE8McODmi3fipona8+/och3xWKE2rec1MKzKT0g6eXq8CrGCsyT7 +YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi +7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD +ssbzSibBsu/6iGtCOGEoXJf//////////wIBAg== +-----END DH PARAMETERS----- diff --git a/data/other/ldap_scheme.yml b/data/other/ldap_scheme.yml deleted file mode 100644 index 11504bbe8..000000000 --- a/data/other/ldap_scheme.yml +++ /dev/null @@ -1,77 +0,0 @@ -parents: - ou=users: - ou: users - objectClass: - - organizationalUnit - - top - - ou=domains: - ou: domains - objectClass: - - organizationalUnit - - top - - ou=apps: - ou: apps - objectClass: - - organizationalUnit - - top - - ou=permission: - ou: permission - objectClass: - - organizationalUnit - - top - - ou=groups: - ou: groups - objectClass: - - organizationalUnit - - top - ou=sudo: - ou: sudo - objectClass: - - organizationalUnit - - top - -children: - cn=admin,ou=sudo: - cn: admin - sudoUser: admin - sudoHost: ALL - sudoCommand: ALL - sudoOption: "!authenticate" - objectClass: - - sudoRole - - top - cn=admins,ou=groups: - cn: admins - gidNumber: "4001" - memberUid: admin - objectClass: - - posixGroup - - top - cn=all_users,ou=groups: - cn: all_users - gidNumber: "4002" - objectClass: - - posixGroup - - groupOfNamesYnh - -depends_children: - cn=main.mail,ou=permission: - cn: main.mail - gidNumber: "5001" - objectClass: - - posixGroup - - permissionYnh - groupPermission: - - "cn=all_users,ou=groups,dc=yunohost,dc=org" - cn=main.metronome,ou=permission: - cn: main.metronome - gidNumber: "5002" - objectClass: - - posixGroup - - permissionYnh - groupPermission: - - "cn=all_users,ou=groups,dc=yunohost,dc=org" diff --git a/data/other/registrar_list.toml b/data/other/registrar_list.toml new file mode 100644 index 000000000..afb213aa1 --- /dev/null +++ b/data/other/registrar_list.toml @@ -0,0 +1,649 @@ +[aliyun] + [aliyun.auth_key_id] + type = "string" + redact = true + + [aliyun.auth_secret] + type = "string" + redact = true + +[aurora] + [aurora.auth_api_key] + type = "string" + redact = true + + [aurora.auth_secret_key] + type = "string" + redact = true + +[azure] + [azure.auth_client_id] + type = "string" + redact = true + + [azure.auth_client_secret] + type = "string" + redact = true + + [azure.auth_tenant_id] + type = "string" + redact = true + + [azure.auth_subscription_id] + type = "string" + redact = true + + [azure.resource_group] + type = "string" + redact = true + +[cloudflare] + [cloudflare.auth_username] + type = "string" + redact = true + + [cloudflare.auth_token] + type = "string" + redact = true + + [cloudflare.zone_id] + type = "string" + redact = true + +[cloudns] + [cloudns.auth_id] + type = "string" + redact = true + + [cloudns.auth_subid] + type = "string" + redact = true + + [cloudns.auth_subuser] + type = "string" + redact = true + + [cloudns.auth_password] + type = "password" + + [cloudns.weight] + type = "number" + + [cloudns.port] + type = "number" +[cloudxns] + [cloudxns.auth_username] + type = "string" + redact = true + + [cloudxns.auth_token] + type = "string" + redact = true + +[conoha] + [conoha.auth_region] + type = "string" + redact = true + + [conoha.auth_token] + type = "string" + redact = true + + [conoha.auth_username] + type = "string" + redact = true + + [conoha.auth_password] + type = "password" + + [conoha.auth_tenant_id] + type = "string" + redact = true + +[constellix] + [constellix.auth_username] + type = "string" + redact = true + + [constellix.auth_token] + type = "string" + redact = true + +[digitalocean] + [digitalocean.auth_token] + type = "string" + redact = true + +[dinahosting] + [dinahosting.auth_username] + type = "string" + redact = true + + [dinahosting.auth_password] + type = "password" + +[directadmin] + [directadmin.auth_password] + type = "password" + + [directadmin.auth_username] + type = "string" + redact = true + + [directadmin.endpoint] + type = "string" + redact = true + +[dnsimple] + [dnsimple.auth_token] + type = "string" + redact = true + + [dnsimple.auth_username] + type = "string" + redact = true + + [dnsimple.auth_password] + type = "password" + + [dnsimple.auth_2fa] + type = "string" + redact = true + +[dnsmadeeasy] + [dnsmadeeasy.auth_username] + type = "string" + redact = true + + [dnsmadeeasy.auth_token] + type = "string" + redact = true + +[dnspark] + [dnspark.auth_username] + type = "string" + redact = true + + [dnspark.auth_token] + type = "string" + redact = true + +[dnspod] + [dnspod.auth_username] + type = "string" + redact = true + + [dnspod.auth_token] + type = "string" + redact = true + +[dreamhost] + [dreamhost.auth_token] + type = "string" + redact = true + +[dynu] + [dynu.auth_token] + type = "string" + redact = true + +[easydns] + [easydns.auth_username] + type = "string" + redact = true + + [easydns.auth_token] + type = "string" + redact = true + +[easyname] + [easyname.auth_username] + type = "string" + redact = true + + [easyname.auth_password] + type = "password" + +[euserv] + [euserv.auth_username] + type = "string" + redact = true + + [euserv.auth_password] + type = "password" + +[exoscale] + [exoscale.auth_key] + type = "string" + redact = true + + [exoscale.auth_secret] + type = "string" + redact = true + +[gandi] + [gandi.auth_token] + type = "string" + redact = true + + [gandi.api_protocol] + type = "string" + choices.rpc = "RPC" + choices.rest = "REST" + default = "rest" + visible = "false" + +[gehirn] + [gehirn.auth_token] + type = "string" + redact = true + + [gehirn.auth_secret] + type = "string" + redact = true + +[glesys] + [glesys.auth_username] + type = "string" + redact = true + + [glesys.auth_token] + type = "string" + redact = true + +[godaddy] + [godaddy.auth_key] + type = "string" + redact = true + + [godaddy.auth_secret] + type = "string" + redact = true + +[googleclouddns] + [goggleclouddns.auth_service_account_info] + type = "string" + redact = true + +[gransy] + [gransy.auth_username] + type = "string" + redact = true + + [gransy.auth_password] + type = "password" + +[gratisdns] + [gratisdns.auth_username] + type = "string" + redact = true + + [gratisdns.auth_password] + type = "password" + +[henet] + [henet.auth_username] + type = "string" + redact = true + + [henet.auth_password] + type = "password" + +[hetzner] + [hetzner.auth_token] + type = "string" + redact = true + +[hostingde] + [hostingde.auth_token] + type = "string" + redact = true + +[hover] + [hover.auth_username] + type = "string" + redact = true + + [hover.auth_password] + type = "password" + +[infoblox] + [infoblox.auth_user] + type = "string" + redact = true + + [infoblox.auth_psw] + type = "password" + + [infoblox.ib_view] + type = "string" + redact = true + + [infoblox.ib_host] + type = "string" + redact = true + +[infomaniak] + [infomaniak.auth_token] + type = "string" + redact = true + +[internetbs] + [internetbs.auth_key] + type = "string" + redact = true + + [internetbs.auth_password] + type = "string" + redact = true + +[inwx] + [inwx.auth_username] + type = "string" + redact = true + + [inwx.auth_password] + type = "password" + +[joker] + [joker.auth_token] + type = "string" + redact = true + +[linode] + [linode.auth_token] + type = "string" + redact = true + +[linode4] + [linode4.auth_token] + type = "string" + redact = true + +[localzone] + [localzone.filename] + type = "string" + redact = true + +[luadns] + [luadns.auth_username] + type = "string" + redact = true + + [luadns.auth_token] + type = "string" + redact = true + +[memset] + [memset.auth_token] + type = "string" + redact = true + +[mythicbeasts] + [mythicbeasts.auth_username] + type = "string" + redact = true + + [mythicbeasts.auth_password] + type = "password" + + [mythicbeasts.auth_token] + type = "string" + redact = true + +[namecheap] + [namecheap.auth_token] + type = "string" + redact = true + + [namecheap.auth_username] + type = "string" + redact = true + + [namecheap.auth_client_ip] + type = "string" + redact = true + + [namecheap.auth_sandbox] + type = "string" + redact = true + +[namesilo] + [namesilo.auth_token] + type = "string" + redact = true + +[netcup] + [netcup.auth_customer_id] + type = "string" + redact = true + + [netcup.auth_api_key] + type = "string" + redact = true + + [netcup.auth_api_password] + type = "string" + redact = true + +[nfsn] + [nfsn.auth_username] + type = "string" + redact = true + + [nfsn.auth_token] + type = "string" + redact = true + +[njalla] + [njalla.auth_token] + type = "string" + redact = true + +[nsone] + [nsone.auth_token] + type = "string" + redact = true + +[onapp] + [onapp.auth_username] + type = "string" + redact = true + + [onapp.auth_token] + type = "string" + redact = true + + [onapp.auth_server] + type = "string" + redact = true + +[online] + [online.auth_token] + type = "string" + redact = true + +[ovh] + [ovh.auth_entrypoint] + type = "select" + choices = ["ovh-eu", "ovh-ca", "soyoustart-eu", "soyoustart-ca", "kimsufi-eu", "kimsufi-ca"] + default = "ovh-eu" + + [ovh.auth_application_key] + type = "string" + redact = true + + [ovh.auth_application_secret] + type = "string" + redact = true + + [ovh.auth_consumer_key] + type = "string" + redact = true + +[plesk] + [plesk.auth_username] + type = "string" + redact = true + + [plesk.auth_password] + type = "password" + + [plesk.plesk_server] + type = "string" + redact = true + +[pointhq] + [pointhq.auth_username] + type = "string" + redact = true + + [pointhq.auth_token] + type = "string" + redact = true + +[powerdns] + [powerdns.auth_token] + type = "string" + redact = true + + [powerdns.pdns_server] + type = "string" + redact = true + + [powerdns.pdns_server_id] + type = "string" + redact = true + + [powerdns.pdns_disable_notify] + type = "boolean" + +[rackspace] + [rackspace.auth_account] + type = "string" + redact = true + + [rackspace.auth_username] + type = "string" + redact = true + + [rackspace.auth_api_key] + type = "string" + redact = true + + [rackspace.auth_token] + type = "string" + redact = true + + [rackspace.sleep_time] + type = "string" + redact = true + +[rage4] + [rage4.auth_username] + type = "string" + redact = true + + [rage4.auth_token] + type = "string" + redact = true + +[rcodezero] + [rcodezero.auth_token] + type = "string" + redact = true + +[route53] + [route53.auth_access_key] + type = "string" + redact = true + + [route53.auth_access_secret] + type = "string" + redact = true + + [route53.private_zone] + type = "string" + redact = true + + [route53.auth_username] + type = "string" + redact = true + + [route53.auth_token] + type = "string" + redact = true + +[safedns] + [safedns.auth_token] + type = "string" + redact = true + +[sakuracloud] + [sakuracloud.auth_token] + type = "string" + redact = true + + [sakuracloud.auth_secret] + type = "string" + redact = true + +[softlayer] + [softlayer.auth_username] + type = "string" + redact = true + + [softlayer.auth_api_key] + type = "string" + redact = true + +[transip] + [transip.auth_username] + type = "string" + redact = true + + [transip.auth_api_key] + type = "string" + redact = true + +[ultradns] + [ultradns.auth_token] + type = "string" + redact = true + + [ultradns.auth_username] + type = "string" + redact = true + + [ultradns.auth_password] + type = "password" + +[vultr] + [vultr.auth_token] + type = "string" + redact = true + +[yandex] + [yandex.auth_token] + type = "string" + redact = true + +[zeit] + [zeit.auth_token] + type = "string" + redact = true + +[zilore] + [zilore.auth_key] + type = "string" + redact = true + +[zonomi] + [zonomy.auth_token] + type = "string" + redact = true + + [zonomy.auth_entrypoint] + type = "string" + redact = true + diff --git a/data/templates/avahi-daemon/avahi-daemon.conf b/data/templates/avahi-daemon/avahi-daemon.conf deleted file mode 100644 index d3542a411..000000000 --- a/data/templates/avahi-daemon/avahi-daemon.conf +++ /dev/null @@ -1,68 +0,0 @@ -# This file is part of avahi. -# -# avahi is free software; you can redistribute it and/or modify it -# under the terms of the GNU Lesser General Public License as -# published by the Free Software Foundation; either version 2 of the -# License, or (at your option) any later version. -# -# avahi 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 General Public -# License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with avahi; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 -# USA. - -# See avahi-daemon.conf(5) for more information on this configuration -# file! - -[server] -host-name=yunohost -domain-name=local -#browse-domains=0pointer.de, zeroconf.org -use-ipv4=yes -use-ipv6=yes -#allow-interfaces=eth0 -#deny-interfaces=eth1 -#check-response-ttl=no -#use-iff-running=no -#enable-dbus=yes -#disallow-other-stacks=no -#allow-point-to-point=no -#cache-entries-max=4096 -#clients-max=4096 -#objects-per-client-max=1024 -#entries-per-entry-group-max=32 -ratelimit-interval-usec=1000000 -ratelimit-burst=1000 - -[wide-area] -enable-wide-area=yes - -[publish] -#disable-publishing=no -#disable-user-service-publishing=no -#add-service-cookie=no -#publish-addresses=yes -#publish-hinfo=yes -#publish-workstation=yes -#publish-domain=yes -#publish-dns-servers=192.168.50.1, 192.168.50.2 -#publish-resolv-conf-dns-servers=yes -#publish-aaaa-on-ipv4=yes -#publish-a-on-ipv6=no - -[reflector] -#enable-reflector=no -#reflect-ipv=no - -[rlimits] -#rlimit-as= -rlimit-core=0 -rlimit-data=4194304 -rlimit-fsize=0 -rlimit-nofile=768 -rlimit-stack=4194304 -rlimit-nproc=3 diff --git a/data/templates/dnsmasq/domain.tpl b/data/templates/dnsmasq/domain.tpl index bbfc2864c..c4bb56d1d 100644 --- a/data/templates/dnsmasq/domain.tpl +++ b/data/templates/dnsmasq/domain.tpl @@ -1,4 +1,9 @@ -address=/{{ domain }}/{{ ip }} +host-record={{ domain }},{{ ipv4 }} +host-record=xmpp-upload.{{ domain }},{{ ipv4 }} +{% if ipv6 %} +host-record={{ domain }},{{ ipv6 }} +host-record=xmpp-upload.{{ domain }},{{ ipv6 }} +{% endif %} txt-record={{ domain }},"v=spf1 mx a -all" mx-host={{ domain }},{{ domain }},5 srv-host=_xmpp-client._tcp.{{ domain }},{{ domain }},5222,0,5 diff --git a/data/templates/dnsmasq/plain/resolv.dnsmasq.conf b/data/templates/dnsmasq/plain/resolv.dnsmasq.conf index 6b3bb95d3..f354ce37c 100644 --- a/data/templates/dnsmasq/plain/resolv.dnsmasq.conf +++ b/data/templates/dnsmasq/plain/resolv.dnsmasq.conf @@ -12,9 +12,6 @@ nameserver 80.67.169.12 nameserver 2001:910:800::12 nameserver 80.67.169.40 nameserver 2001:910:800::40 -# (FR) LDN -nameserver 80.67.188.188 -nameserver 2001:913::8 # (FR) ARN nameserver 89.234.141.66 nameserver 2a00:5881:8100:1000::3 @@ -23,16 +20,10 @@ nameserver 185.233.100.100 nameserver 2a0c:e300::100 nameserver 185.233.100.101 nameserver 2a0c:e300::101 -# (FR) gozmail / grifon -nameserver 80.67.190.200 -nameserver 2a00:5884:8218::1 -# (DE) FoeBud / Digital Courage -nameserver 85.214.20.141 # (DE) CCC Berlin nameserver 195.160.173.53 # (DE) AS250 nameserver 194.150.168.168 -nameserver 2001:4ce8::53 # (DE) Ideal-Hosting nameserver 84.200.69.80 nameserver 2001:1608:10:25::1c04:b12f diff --git a/data/templates/dovecot/dovecot-ldap.conf b/data/templates/dovecot/dovecot-ldap.conf index c7c9785fd..3a80ba47f 100644 --- a/data/templates/dovecot/dovecot-ldap.conf +++ b/data/templates/dovecot/dovecot-ldap.conf @@ -3,7 +3,7 @@ auth_bind = yes ldap_version = 3 base = ou=users,dc=yunohost,dc=org user_attrs = uidNumber=500,gidNumber=8,mailuserquota=quota_rule=*:bytes=%$ -user_filter = (&(objectClass=inetOrgPerson)(uid=%n)(permission=cn=main.mail,ou=permission,dc=yunohost,dc=org)) -pass_filter = (&(objectClass=inetOrgPerson)(uid=%n)(permission=cn=main.mail,ou=permission,dc=yunohost,dc=org)) +user_filter = (&(objectClass=inetOrgPerson)(uid=%n)(permission=cn=mail.main,ou=permission,dc=yunohost,dc=org)) +pass_filter = (&(objectClass=inetOrgPerson)(uid=%n)(permission=cn=mail.main,ou=permission,dc=yunohost,dc=org)) default_pass_scheme = SSHA diff --git a/data/templates/dovecot/dovecot.conf b/data/templates/dovecot/dovecot.conf index 116bb2db7..ee8511f83 100644 --- a/data/templates/dovecot/dovecot.conf +++ b/data/templates/dovecot/dovecot.conf @@ -8,15 +8,30 @@ mail_home = /var/mail/%n mail_location = maildir:/var/mail/%n mail_uid = 500 -protocols = imap sieve +protocols = imap sieve {% if pop3_enabled == "True" %}pop3{% endif %} mail_plugins = $mail_plugins quota +############################################################################### + +# generated 2020-08-18, Mozilla Guideline v5.6, Dovecot 2.3.4, OpenSSL 1.1.1d, intermediate configuration +# https://ssl-config.mozilla.org/#server=dovecot&version=2.3.4&config=intermediate&openssl=1.1.1d&guideline=5.6 + +ssl = required -ssl = yes ssl_cert = /path/to/dhparam +ssl_dh = - ^ -.*\"POST /yunohost/api/login HTTP/1.1\" 401 + ^ -.*\"POST /yunohost/api/login HTTP/\d.\d\" 401 # Option: ignoreregex # Notes.: regex to ignore. If this regex matches, the line is ignored. diff --git a/data/templates/glances/glances.default b/data/templates/glances/glances.default deleted file mode 100644 index 22337a0d9..000000000 --- a/data/templates/glances/glances.default +++ /dev/null @@ -1,5 +0,0 @@ -# Default is to launch glances with '-s' option. -DAEMON_ARGS="-s -B 127.0.0.1" - -# Change to 'true' to have glances running at startup -RUN="true" diff --git a/data/templates/mdns/yunomdns.service b/data/templates/mdns/yunomdns.service new file mode 100644 index 000000000..ce2641b5d --- /dev/null +++ b/data/templates/mdns/yunomdns.service @@ -0,0 +1,13 @@ +[Unit] +Description=YunoHost mDNS service +After=network.target + +[Service] +User=mdns +Group=mdns +Type=simple +ExecStart=/usr/bin/yunomdns +StandardOutput=syslog + +[Install] +WantedBy=default.target diff --git a/data/templates/metronome/domain.tpl.cfg.lua b/data/templates/metronome/domain.tpl.cfg.lua index 2ee9cfaae..e5e169791 100644 --- a/data/templates/metronome/domain.tpl.cfg.lua +++ b/data/templates/metronome/domain.tpl.cfg.lua @@ -1,4 +1,5 @@ VirtualHost "{{ domain }}" + enable = true ssl = { key = "/etc/yunohost/certs/{{ domain }}/key.pem"; certificate = "/etc/yunohost/certs/{{ domain }}/crt.pem"; @@ -8,8 +9,67 @@ VirtualHost "{{ domain }}" hostname = "localhost", user = { basedn = "ou=users,dc=yunohost,dc=org", - filter = "(&(objectClass=posixAccount)(mail=*@{{ domain }})(permission=cn=main.metronome,ou=permission,dc=yunohost,dc=org))", + filter = "(&(objectClass=posixAccount)(mail=*@{{ domain }})(permission=cn=xmpp.main,ou=permission,dc=yunohost,dc=org))", usernamefield = "mail", namefield = "cn", }, } + + -- Discovery items + disco_items = { + { "muc.{{ domain }}" }, + { "pubsub.{{ domain }}" }, + { "jabber.{{ domain }}" }, + { "vjud.{{ domain }}" }, + { "xmpp-upload.{{ domain }}" }, + }; + +-- contact_info = { +-- abuse = { "mailto:abuse@{{ domain }}", "xmpp:admin@{{ domain }}" }; +-- admin = { "mailto:root@{{ domain }}", "xmpp:admin@{{ domain }}" }; +-- }; + +------ Components ------ +-- You can specify components to add hosts that provide special services, +-- like multi-user conferences, and transports. + +---Set up a MUC (multi-user chat) room server +Component "muc.{{ domain }}" "muc" + name = "{{ domain }} Chatrooms" + + modules_enabled = { + "muc_limits"; + "muc_log"; + "muc_log_mam"; + "muc_log_http"; + "muc_vcard"; + } + + muc_event_rate = 0.5 + muc_burst_factor = 10 + room_default_config = { + logging = true, + persistent = true + }; + +---Set up a PubSub server +Component "pubsub.{{ domain }}" "pubsub" + name = "{{ domain }} Publish/Subscribe" + + unrestricted_node_creation = true -- Anyone can create a PubSub node (from any server) + +---Set up a HTTP Upload service +Component "xmpp-upload.{{ domain }}" "http_upload" + name = "{{ domain }} Sharing Service" + + http_file_path = "/var/xmpp-upload/{{ domain }}/upload" + http_external_url = "https://xmpp-upload.{{ domain }}:443" + http_file_base_path = "/upload" + http_file_size_limit = 6*1024*1024 + http_file_quota = 60*1024*1024 + http_upload_file_size_limit = 100 * 1024 * 1024 -- bytes + http_upload_quota = 10 * 1024 * 1024 * 1024 -- bytes + +---Set up a VJUD service +Component "vjud.{{ domain }}" "vjud" + vjud_disco_name = "{{ domain }} User Directory" diff --git a/data/templates/metronome/metronome.cfg.lua b/data/templates/metronome/metronome.cfg.lua index 0640ef9d5..9e21016d9 100644 --- a/data/templates/metronome/metronome.cfg.lua +++ b/data/templates/metronome/metronome.cfg.lua @@ -32,6 +32,7 @@ modules_enabled = { "private"; -- Private XML storage (for room bookmarks, etc.) "vcard"; -- Allow users to set vCards "pep"; -- Allows setting of mood, tune, etc. + "pubsub"; -- Publish-subscribe XEP-0060 "posix"; -- POSIX functionality, sends server to background, enables syslog, etc. "bidi"; -- Enables Bidirectional Server-to-Server Streams. @@ -81,14 +82,6 @@ http_interfaces = { "127.0.0.1", "::1" } -- Enable IPv6 use_ipv6 = true --- Discovery items -disco_items = { - { "muc.{{ main_domain }}" }, - { "pubsub.{{ main_domain }}" }, - { "upload.{{ main_domain }}" }, - { "vjud.{{ main_domain }}" } -}; - -- BOSH configuration (mod_bosh) consider_bosh_secure = true cross_domain_bosh = true @@ -103,6 +96,10 @@ allow_registration = false -- Use LDAP storage backend for all stores storage = "ldap" +-- stanza optimization +csi_config_queue_all_muc_messages_but_mentions = false; + + -- Logging configuration log = { info = "/var/log/metronome/metronome.log"; -- Change 'info' to 'debug' for verbose logging @@ -119,40 +116,6 @@ log = { Component "localhost" "http" modules_enabled = { "bosh" } ----Set up a MUC (multi-user chat) room server -Component "muc.{{ main_domain }}" "muc" - name = "{{ main_domain }} Chatrooms" - - modules_enabled = { - "muc_limits"; - "muc_log"; - "muc_log_mam"; - "muc_log_http"; - "muc_vcard"; - } - - muc_event_rate = 0.5 - muc_burst_factor = 10 - ----Set up a PubSub server -Component "pubsub.{{ main_domain }}" "pubsub" - name = "{{ main_domain }} Publish/Subscribe" - - unrestricted_node_creation = true -- Anyone can create a PubSub node (from any server) - ----Set up a HTTP Upload service -Component "upload.{{ main_domain }}" "http_upload" - name = "{{ main_domain }} Sharing Service" - - http_file_size_limit = 6*1024*1024 - http_file_quota = 60*1024*1024 - - ----Set up a VJUD service -Component "vjud.{{ main_domain }}" "vjud" - ud_disco_name = "{{ main_domain }} User Directory" - - ----------- Virtual hosts ----------- -- You need to add a VirtualHost entry for each domain you wish Metronome to serve. -- Settings under each VirtualHost entry apply *only* to that host. diff --git a/data/templates/mysql/my.cnf b/data/templates/mysql/my.cnf index cf9e6ddd7..3da4377e1 100644 --- a/data/templates/mysql/my.cnf +++ b/data/templates/mysql/my.cnf @@ -28,9 +28,9 @@ port = 3306 socket = /var/run/mysqld/mysqld.sock skip-external-locking key_buffer_size = 16K -max_allowed_packet = 1M +max_allowed_packet = 16M table_open_cache = 4 -sort_buffer_size = 64K +sort_buffer_size = 4M read_buffer_size = 256K read_rnd_buffer_size = 256K net_buffer_length = 2K diff --git a/data/templates/nginx/plain/acme-challenge.conf.inc b/data/templates/nginx/plain/acme-challenge.conf.inc new file mode 100644 index 000000000..35c4b80c2 --- /dev/null +++ b/data/templates/nginx/plain/acme-challenge.conf.inc @@ -0,0 +1,6 @@ +location ^~ '/.well-known/acme-challenge/' +{ + default_type "text/plain"; + alias /tmp/acme-challenge-public/; + gzip off; +} diff --git a/data/templates/nginx/plain/ssowat.conf b/data/templates/nginx/plain/ssowat.conf index c82cd40ea..bd8d5a73a 100644 --- a/data/templates/nginx/plain/ssowat.conf +++ b/data/templates/nginx/plain/ssowat.conf @@ -1,3 +1,3 @@ lua_shared_dict cache 10m; init_by_lua_file /usr/share/ssowat/init.lua; -server_names_hash_bucket_size 64; +server_names_hash_bucket_size 128; diff --git a/data/templates/nginx/plain/yunohost_admin.conf.inc b/data/templates/nginx/plain/yunohost_admin.conf.inc deleted file mode 100644 index 2ab72293d..000000000 --- a/data/templates/nginx/plain/yunohost_admin.conf.inc +++ /dev/null @@ -1,14 +0,0 @@ -# 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; - - # Short cache on handlebars templates - location ~* \.(?:ms)$ { - expires 5m; - add_header Cache-Control "public"; - } -} diff --git a/data/templates/nginx/plain/yunohost_panel.conf.inc b/data/templates/nginx/plain/yunohost_panel.conf.inc index 1c5a2d656..16a6e6b29 100644 --- a/data/templates/nginx/plain/yunohost_panel.conf.inc +++ b/data/templates/nginx/plain/yunohost_panel.conf.inc @@ -1,8 +1,8 @@ # Insert YunoHost button + portal overlay -sub_filter ''; +sub_filter ''; sub_filter_once on; # Apply to other mime types than text/html sub_filter_types application/xhtml+xml; # Prevent YunoHost panel files from being blocked by specific app rules -location ~ (ynh_portal.js|ynh_overlay.css|ynh_userinfo.json) { +location ~ (ynh_portal.js|ynh_overlay.css|ynh_userinfo.json|ynhtheme/custom_portal.js|ynhtheme/custom_overlay.css) { } diff --git a/data/templates/nginx/plain/yunohost_sso.conf.inc b/data/templates/nginx/plain/yunohost_sso.conf.inc new file mode 100644 index 000000000..308e5a9a4 --- /dev/null +++ b/data/templates/nginx/plain/yunohost_sso.conf.inc @@ -0,0 +1,7 @@ +# Avoid the nginx path/alias traversal weakness ( #1037 ) +rewrite ^/yunohost/sso$ /yunohost/sso/ permanent; + +location /yunohost/sso/ { + # This is an empty location, only meant to avoid other locations + # from matching /yunohost/sso, such that it's correctly handled by ssowat +} diff --git a/data/templates/nginx/redirect_to_admin.conf b/data/templates/nginx/redirect_to_admin.conf new file mode 100644 index 000000000..22748daa3 --- /dev/null +++ b/data/templates/nginx/redirect_to_admin.conf @@ -0,0 +1,3 @@ +location / { + return 302 https://$http_host/yunohost/admin; +} diff --git a/data/templates/nginx/security.conf.inc b/data/templates/nginx/security.conf.inc new file mode 100644 index 000000000..bcb821770 --- /dev/null +++ b/data/templates/nginx/security.conf.inc @@ -0,0 +1,51 @@ +ssl_session_timeout 1d; +ssl_session_cache shared:SSL:50m; # about 200000 sessions +ssl_session_tickets off; + +{% if compatibility == "modern" %} +# generated 2020-08-14, Mozilla Guideline v5.6, nginx 1.14.2, OpenSSL 1.1.1d, modern configuration +# https://ssl-config.mozilla.org/#server=nginx&version=1.14.2&config=modern&openssl=1.1.1d&guideline=5.6 +ssl_protocols TLSv1.3; +ssl_prefer_server_ciphers off; +{% else %} +# Ciphers with intermediate compatibility +# generated 2020-08-14, Mozilla Guideline v5.6, nginx 1.14.2, OpenSSL 1.1.1d, intermediate configuration +# https://ssl-config.mozilla.org/#server=nginx&version=1.14.2&config=intermediate&openssl=1.1.1d&guideline=5.6 +ssl_protocols TLSv1.2 TLSv1.3; +ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; +ssl_prefer_server_ciphers off; + +# Pre-defined FFDHE group (RFC 7919) +# From https://ssl-config.mozilla.org/ffdhe2048.txt +# https://security.stackexchange.com/a/149818 +ssl_dhparam /usr/share/yunohost/other/ffdhe2048.pem; +{% endif %} + + +# 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/ +{% if experimental == "True" %} +more_set_headers "Content-Security-Policy : upgrade-insecure-requests; default-src https: data:"; +{% else %} +more_set_headers "Content-Security-Policy : upgrade-insecure-requests"; +{% endif %} +more_set_headers "Content-Security-Policy-Report-Only : default-src https: data: 'unsafe-inline' 'unsafe-eval' "; +more_set_headers "X-Content-Type-Options : nosniff"; +more_set_headers "X-XSS-Protection : 1; mode=block"; +more_set_headers "X-Download-Options : noopen"; +more_set_headers "X-Permitted-Cross-Domain-Policies : none"; +more_set_headers "X-Frame-Options : SAMEORIGIN"; + +# Disable the disaster privacy thing that is FLoC +{% if experimental == "True" %} +more_set_headers "Permissions-Policy : fullscreen=(), geolocation=(), payment=(), accelerometer=(), battery=(), magnetometer=(), usb=(), interest-cohort=()"; +# Force HTTPOnly and Secure for all cookies +proxy_cookie_path ~$ "; HTTPOnly; Secure;"; +{% else %} +more_set_headers "Permissions-Policy : interest-cohort=()"; +{% endif %} + +# Disable gzip to protect against BREACH +# Read https://trac.nginx.org/nginx/ticket/1720 (text/html cannot be disabled!) +gzip off; diff --git a/data/templates/nginx/server.tpl.conf b/data/templates/nginx/server.tpl.conf index 4a5e91557..379b597a7 100644 --- a/data/templates/nginx/server.tpl.conf +++ b/data/templates/nginx/server.tpl.conf @@ -6,20 +6,30 @@ map $http_upgrade $connection_upgrade { server { listen 80; listen [::]:80; - server_name {{ domain }}; + server_name {{ domain }} xmpp-upload.{{ domain }}; access_by_lua_file /usr/share/ssowat/access.lua; - include /etc/nginx/conf.d/{{ domain }}.d/*.conf; + include /etc/nginx/conf.d/acme-challenge.conf.inc; - location /yunohost/admin { - return 301 https://$http_host$request_uri; + location ^~ '/.well-known/ynh-diagnosis/' { + alias /tmp/.well-known/ynh-diagnosis/; } - location /.well-known/autoconfig/mail/ { + location ^~ '/.well-known/autoconfig/mail/' { alias /var/www/.well-known/{{ domain }}/autoconfig/mail/; } + {# Note that this != "False" is meant to be failure-safe, in the case the redrect_to_https would happen to contain empty string or whatever value. We absolutely don't want to disable the HTTPS redirect *except* when it's explicitly being asked to be disabled. #} + {% if redirect_to_https != "False" %} + location / { + return 301 https://$http_host$request_uri; + } + {# The app config snippets are not included in the HTTP conf unless HTTPS redirect is disabled, because app's location may blocks will conflict or bypass/ignore the HTTPS redirection. #} + {% else %} + include /etc/nginx/conf.d/{{ domain }}.d/*.conf; + {% endif %} + access_log /var/log/nginx/{{ domain }}-access.log; error_log /var/log/nginx/{{ domain }}-error.log; } @@ -29,47 +39,14 @@ server { listen [::]:443 ssl http2; server_name {{ domain }}; + include /etc/nginx/conf.d/security.conf.inc; + ssl_certificate /etc/yunohost/certs/{{ domain }}/crt.pem; ssl_certificate_key /etc/yunohost/certs/{{ domain }}/key.pem; - ssl_session_timeout 5m; - ssl_session_cache shared:SSL:50m; - {% if compatibility == "modern" %} - # Ciphers with modern compatibility - # https://mozilla.github.io/server-side-tls/ssl-config-generator/?server=nginx-1.6.2&openssl=1.0.1t&hsts=yes&profile=modern - # The following configuration use modern ciphers, but remove compatibility with some old clients (android < 5.0, Internet Explorer < 10, ...) - ssl_protocols TLSv1.2; - ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256'; - ssl_prefer_server_ciphers on; - {% else %} - # As suggested by Mozilla : https://wiki.mozilla.org/Security/Server_Side_TLS and https://en.wikipedia.org/wiki/Curve25519 - ssl_ecdh_curve secp521r1:secp384r1:prime256v1; - ssl_prefer_server_ciphers on; - - # Ciphers with intermediate compatibility - # https://mozilla.github.io/server-side-tls/ssl-config-generator/?server=nginx-1.6.2&openssl=1.0.1t&hsts=yes&profile=intermediate - ssl_protocols TLSv1 TLSv1.1 TLSv1.2; - ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS'; - - # 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; - {% endif %} - - # 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/ {% if domain_cert_ca != "Self-signed" %} more_set_headers "Strict-Transport-Security : max-age=63072000; includeSubDomains; preload"; {% endif %} - more_set_headers "Content-Security-Policy : upgrade-insecure-requests"; - more_set_headers "Content-Security-Policy-Report-Only : default-src https: data: 'unsafe-inline' 'unsafe-eval'"; - more_set_headers "X-Content-Type-Options : nosniff"; - more_set_headers "X-XSS-Protection : 1; mode=block"; - more_set_headers "X-Download-Options : noopen"; - more_set_headers "X-Permitted-Cross-Domain-Policies : none"; - more_set_headers "X-Frame-Options : SAMEORIGIN"; - {% if domain_cert_ca == "Let's Encrypt" %} # OCSP settings ssl_stapling on; @@ -79,17 +56,61 @@ server { resolver_timeout 5s; {% endif %} - # Disable gzip to protect against BREACH - # Read https://trac.nginx.org/nginx/ticket/1720 (text/html cannot be disabled!) - gzip off; + location ^~ '/.well-known/autoconfig/mail/' { + alias /var/www/.well-known/{{ domain }}/autoconfig/mail/; + } access_by_lua_file /usr/share/ssowat/access.lua; include /etc/nginx/conf.d/{{ domain }}.d/*.conf; + include /etc/nginx/conf.d/yunohost_sso.conf.inc; include /etc/nginx/conf.d/yunohost_admin.conf.inc; include /etc/nginx/conf.d/yunohost_api.conf.inc; access_log /var/log/nginx/{{ domain }}-access.log; error_log /var/log/nginx/{{ domain }}-error.log; } + +# vhost dedicated to XMPP http_upload +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name xmpp-upload.{{ domain }}; + root /dev/null; + + location /upload/ { + alias /var/xmpp-upload/{{ domain }}/upload/; + # Pass all requests to metronome, except for GET and HEAD requests. + limit_except GET HEAD { + proxy_pass http://localhost:5290; + } + + include proxy_params; + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Methods' 'HEAD, GET, PUT, OPTIONS'; + add_header 'Access-Control-Allow-Headers' 'Authorization'; + add_header 'Access-Control-Allow-Credentials' 'true'; + client_max_body_size 105M; # Choose a value a bit higher than the max upload configured in XMPP server + } + + include /etc/nginx/conf.d/security.conf.inc; + + ssl_certificate /etc/yunohost/certs/{{ domain }}/crt.pem; + ssl_certificate_key /etc/yunohost/certs/{{ domain }}/key.pem; + + {% if domain_cert_ca != "Self-signed" %} + more_set_headers "Strict-Transport-Security : max-age=63072000; includeSubDomains; preload"; + {% endif %} + {% if domain_cert_ca == "Let's Encrypt" %} + # OCSP settings + ssl_stapling on; + ssl_stapling_verify on; + ssl_trusted_certificate /etc/yunohost/certs/{{ domain }}/crt.pem; + resolver 127.0.0.1 127.0.1.1 valid=300s; + resolver_timeout 5s; + {% endif %} + + access_log /var/log/nginx/xmpp-upload.{{ domain }}-access.log; + error_log /var/log/nginx/xmpp-upload.{{ domain }}-error.log; +} diff --git a/data/templates/nginx/yunohost_admin.conf b/data/templates/nginx/yunohost_admin.conf index e0d9f6bb1..8a7a40231 100644 --- a/data/templates/nginx/yunohost_admin.conf +++ b/data/templates/nginx/yunohost_admin.conf @@ -2,77 +2,27 @@ server { listen 80 default_server; listen [::]:80 default_server; - location / { - return 302 https://$http_host/yunohost/admin; - } - - location /yunohost/admin { - return 301 https://$http_host$request_uri; - } + include /etc/nginx/conf.d/default.d/*.conf; } server { listen 443 ssl http2 default_server; listen [::]:443 ssl http2 default_server; + include /etc/nginx/conf.d/security.conf.inc; + ssl_certificate /etc/yunohost/certs/yunohost.org/crt.pem; ssl_certificate_key /etc/yunohost/certs/yunohost.org/key.pem; - ssl_session_timeout 5m; - ssl_session_cache shared:SSL:50m; - {% if compatibility == "modern" %} - # Ciphers with modern compatibility - # https://mozilla.github.io/server-side-tls/ssl-config-generator/?server=nginx-1.6.2&openssl=1.0.1t&hsts=yes&profile=modern - # Uncomment the following to use modern ciphers, but remove compatibility with some old clients (android < 5.0, Internet Explorer < 10, ...) - ssl_protocols TLSv1.2; - ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256'; - ssl_prefer_server_ciphers on; - {% else %} - # As suggested by Mozilla : https://wiki.mozilla.org/Security/Server_Side_TLS and https://en.wikipedia.org/wiki/Curve25519 - ssl_ecdh_curve secp521r1:secp384r1:prime256v1; - ssl_prefer_server_ciphers on; - - # Ciphers with intermediate compatibility - # https://mozilla.github.io/server-side-tls/ssl-config-generator/?server=nginx-1.6.2&openssl=1.0.1t&hsts=yes&profile=intermediate - ssl_protocols TLSv1 TLSv1.1 TLSv1.2; - ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS'; - - # 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; - {% endif %} - - # 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/ more_set_headers "Strict-Transport-Security : max-age=63072000; includeSubDomains; preload"; more_set_headers "Referrer-Policy : 'same-origin'"; - more_set_headers "Content-Security-Policy : upgrade-insecure-requests; object-src 'none'; script-src https: 'unsafe-eval'"; - more_set_headers "X-Content-Type-Options : nosniff"; - more_set_headers "X-XSS-Protection : 1; mode=block"; - more_set_headers "X-Download-Options : noopen"; - more_set_headers "X-Permitted-Cross-Domain-Policies : none"; - more_set_headers "X-Frame-Options : SAMEORIGIN"; - - # Disable gzip to protect against BREACH - # Read https://trac.nginx.org/nginx/ticket/1720 (text/html cannot be disabled!) - gzip off; - - location / { - return 302 https://$http_host/yunohost/admin; - } location /yunohost { - # Block crawlers bot - if ($http_user_agent ~ (crawl|Googlebot|Slurp|spider|bingbot|tracker|click|parser|spider|facebookexternalhit) ) { - return 403; - } - # X-Robots-Tag to precise the rules applied. - add_header X-Robots-Tag "nofollow, noindex, noarchive, nosnippet"; # Redirect most of 404 to maindomain.tld/yunohost/sso access_by_lua_file /usr/share/ssowat/access.lua; } include /etc/nginx/conf.d/yunohost_admin.conf.inc; include /etc/nginx/conf.d/yunohost_api.conf.inc; + include /etc/nginx/conf.d/default.d/*.conf; } diff --git a/data/templates/nginx/yunohost_admin.conf.inc b/data/templates/nginx/yunohost_admin.conf.inc new file mode 100644 index 000000000..150fce6f6 --- /dev/null +++ b/data/templates/nginx/yunohost_admin.conf.inc @@ -0,0 +1,18 @@ +# 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; + + {% if webadmin_allowlist_enabled == "True" %} + {% for ip in webadmin_allowlist.split(',') %} + allow {{ ip }}; + {% endfor %} + deny all; + {% endif %} + + more_set_headers "Content-Security-Policy: upgrade-insecure-requests; default-src 'self'; connect-src 'self' https://paste.yunohost.org wss://$host; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; object-src 'none'; img-src 'self' data:;"; + more_set_headers "Content-Security-Policy-Report-Only:"; +} diff --git a/data/templates/nginx/plain/yunohost_api.conf.inc b/data/templates/nginx/yunohost_api.conf.inc similarity index 75% rename from data/templates/nginx/plain/yunohost_api.conf.inc rename to data/templates/nginx/yunohost_api.conf.inc index 4d7887cc6..c9ae34f82 100644 --- a/data/templates/nginx/plain/yunohost_api.conf.inc +++ b/data/templates/nginx/yunohost_api.conf.inc @@ -6,6 +6,13 @@ location /yunohost/api/ { proxy_set_header Connection "upgrade"; proxy_set_header Host $http_host; + {% if webadmin_allowlist_enabled == "True" %} + {% for ip in webadmin_allowlist.split(',') %} + allow {{ ip }}; + {% endfor %} + deny all; + {% endif %} + # Custom 502 error page error_page 502 /yunohost/api/error/502; } diff --git a/data/templates/nginx/yunohost_local.conf b/data/templates/nginx/yunohost_local.conf deleted file mode 100644 index ebf2bd65a..000000000 --- a/data/templates/nginx/yunohost_local.conf +++ /dev/null @@ -1 +0,0 @@ -server_name $server_name yunohost.local; diff --git a/data/templates/nslcd/nslcd.conf b/data/templates/nslcd/nslcd.conf index 091ecb7cc..7cfe73e07 100644 --- a/data/templates/nslcd/nslcd.conf +++ b/data/templates/nslcd/nslcd.conf @@ -15,6 +15,18 @@ base dc=yunohost,dc=org # The LDAP protocol version to use. #ldap_version 3 +# The DN to bind with for normal lookups. +#binddn cn=annonymous,dc=example,dc=net +#bindpw secret + +# The DN used for password modifications by root. +#rootpwmoddn cn=admin,dc=example,dc=com + +# SSL options +#ssl off +#tls_reqcert never +tls_cacertfile /etc/ssl/certs/ca-certificates.crt + # The search scope. #scope sub diff --git a/data/templates/nsswitch/nsswitch.conf b/data/templates/nsswitch/nsswitch.conf index b55e01b02..8f46e4f5d 100644 --- a/data/templates/nsswitch/nsswitch.conf +++ b/data/templates/nsswitch/nsswitch.conf @@ -1,12 +1,8 @@ # /etc/nsswitch.conf -# -# Example configuration of GNU Name Service Switch functionality. -# If you have the `glibc-doc-reference' and `info' packages installed, try: -# `info libc "Name Service Switch"' for information about this file. -passwd: compat ldap -group: compat ldap -shadow: compat ldap +passwd: files systemd ldap +group: files systemd ldap +shadow: files ldap gshadow: files hosts: files myhostname mdns4_minimal [NOTFOUND=return] dns diff --git a/data/templates/postfix/main.cf b/data/templates/postfix/main.cf index 045b8edd0..257783109 100644 --- a/data/templates/postfix/main.cf +++ b/data/templates/postfix/main.cf @@ -18,35 +18,51 @@ append_dot_mydomain = no readme_directory = no # -- TLS for incoming connections -# By default, TLS is disabled in the Postfix SMTP server, so no difference to -# plain Postfix is visible. Explicitly switch it on with "smtpd_tls_security_level = may". -smtpd_tls_security_level=may +############################################################################### +smtpd_use_tls = yes -# Sending AUTH data over an unencrypted channel poses a security risk. -# When TLS layer encryption is optional ("smtpd_tls_security_level = may"), it -# may however still be useful to only offer AUTH when TLS is active. To maintain -# compatibility with non-TLS clients, the default is to accept AUTH without -# encryption. In order to change this behavior, we set "smtpd_tls_auth_only = yes". -smtpd_tls_auth_only=yes +smtpd_tls_security_level = may +smtpd_tls_auth_only = yes smtpd_tls_cert_file = /etc/yunohost/certs/{{ main_domain }}/crt.pem smtpd_tls_key_file = /etc/yunohost/certs/{{ main_domain }}/key.pem -smtpd_tls_exclude_ciphers = aNULL, MD5, DES, ADH, RC4, 3DES + +{% if compatibility == "intermediate" %} +# generated 2020-08-18, Mozilla Guideline v5.6, Postfix 3.4.14, OpenSSL 1.1.1d, intermediate configuration +# https://ssl-config.mozilla.org/#server=postfix&version=3.4.14&config=intermediate&openssl=1.1.1d&guideline=5.6 + +smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 +smtpd_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 +smtpd_tls_mandatory_ciphers = medium + +# curl https://ssl-config.mozilla.org/ffdhe2048.txt > /path/to/dhparam.pem +# not actually 1024 bits, this applies to all DHE >= 1024 bits +smtpd_tls_dh1024_param_file = /usr/share/yunohost/other/ffdhe2048.pem + +tls_medium_cipherlist = ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 +{% else %} +# generated 2020-08-18, Mozilla Guideline v5.6, Postfix 3.4.14, OpenSSL 1.1.1d, modern configuration +# https://ssl-config.mozilla.org/#server=postfix&version=3.4.14&config=modern&openssl=1.1.1d&guideline=5.6 + +smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1, !TLSv1.2 +smtpd_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1, !TLSv1.2 +{% endif %} + +tls_preempt_cipherlist = no +############################################################################### smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache smtpd_tls_loglevel=1 -{% if compatibility == "intermediate" %} -smtpd_tls_mandatory_protocols=!SSLv2,!SSLv3 -{% else %} -smtpd_tls_mandatory_protocols=!SSLv2,!SSLv3,!TLSv1,!TLSv1.1 -{% endif %} -smtpd_tls_mandatory_ciphers=high -smtpd_tls_eecdh_grade = ultra # -- TLS for outgoing connections # Use TLS if this is supported by the remote SMTP server, otherwise use plaintext. -smtp_tls_security_level=may +{% if relay_port == "465" %} +smtp_tls_wrappermode = yes +smtp_tls_security_level = encrypt +{% else %} +smtp_tls_security_level = may +{% endif %} smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache -smtp_tls_exclude_ciphers = $smtpd_tls_exclude_ciphers -smtp_tls_mandatory_ciphers= $smtpd_tls_mandatory_ciphers +smtp_tls_exclude_ciphers = aNULL, MD5, DES, ADH, RC4, 3DES +smtp_tls_mandatory_ciphers= high smtp_tls_loglevel=1 # Configure Root CA certificates @@ -62,15 +78,22 @@ alias_maps = hash:/etc/aliases alias_database = hash:/etc/aliases mydomain = {{ main_domain }} mydestination = localhost +{% if relay_host == "" %} relayhost = +{% else %} +relayhost = [{{ relay_host }}]:{{ relay_port }} +{% endif %} mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 mailbox_command = procmail -a "$EXTENSION" mailbox_size_limit = 0 recipient_delimiter = + inet_interfaces = all -#### Fit to the maximum message size to 30mb, more than allowed by GMail or Yahoo #### -message_size_limit = 31457280 +#### Fit to the maximum message size to 25mb, more than allowed by GMail or Yahoo #### +# /!\ This size is the size of the attachment in base64. +# BASE64_SIZE_IN_BYTE = ORIGINAL_SIZE_IN_MEGABYTE * 1,37 *1024*1024 + 980 +# See https://serverfault.com/questions/346895/postfix-mail-size-counting +message_size_limit = 35914708 # Virtual Domains Control virtual_mailbox_domains = ldap:/etc/postfix/ldap-domains.cf @@ -160,11 +183,24 @@ smtpd_milters = inet:localhost:11332 milter_default_action = accept # Avoid to send simultaneously too many emails -smtp_destination_concurrency_limit = 1 +smtp_destination_concurrency_limit = 2 default_destination_rate_delay = 5s +# Avoid to be blacklisted due to too many recipient +smtpd_client_recipient_rate_limit=150 + # Avoid email adress scanning # By default it's possible to detect if the email adress exist # So it's easly possible to scan a server to know which email adress is valid # and after to send spam -disable_vrfy_command = yes \ No newline at end of file +disable_vrfy_command = yes + +{% if relay_user != "" %} +# Relay email through an other smtp account +# enable SASL authentication +smtp_sasl_auth_enable = yes +# disallow methods that allow anonymous authentication. +smtp_sasl_security_options = noanonymous +# where to find sasl_passwd +smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd +{% endif %} diff --git a/data/templates/postfix/plain/ldap-accounts.cf b/data/templates/postfix/plain/ldap-accounts.cf index 9f6f94e6d..75f38cf58 100644 --- a/data/templates/postfix/plain/ldap-accounts.cf +++ b/data/templates/postfix/plain/ldap-accounts.cf @@ -1,5 +1,5 @@ server_host = localhost server_port = 389 search_base = dc=yunohost,dc=org -query_filter = (&(objectClass=mailAccount)(mail=%s)(permission=cn=main.mail,ou=permission,dc=yunohost,dc=org)) +query_filter = (&(objectClass=mailAccount)(mail=%s)(permission=cn=mail.main,ou=permission,dc=yunohost,dc=org)) result_attribute = uid diff --git a/data/templates/postfix/plain/ldap-aliases.cf b/data/templates/postfix/plain/ldap-aliases.cf index 5e7d3a6c1..46563ae22 100644 --- a/data/templates/postfix/plain/ldap-aliases.cf +++ b/data/templates/postfix/plain/ldap-aliases.cf @@ -1,5 +1,5 @@ server_host = localhost server_port = 389 search_base = dc=yunohost,dc=org -query_filter = (&(objectClass=mailAccount)(mail=%s)(permission=cn=main.mail,ou=permission,dc=yunohost,dc=org)) +query_filter = (&(objectClass=mailAccount)(mail=%s)(permission=cn=mail.main,ou=permission,dc=yunohost,dc=org)) result_attribute = maildrop diff --git a/data/templates/slapd/config.ldif b/data/templates/slapd/config.ldif new file mode 100644 index 000000000..e1fe3b1b5 --- /dev/null +++ b/data/templates/slapd/config.ldif @@ -0,0 +1,236 @@ +# OpenLDAP server configuration for YunoHost +# ------------------------------------------ +# +# Because of the YunoHost's regen-conf mechanism, it is NOT POSSIBLE to +# edit the config database using an LDAP request. +# +# If you wish to edit the config database, you should edit THIS file +# and update the config database based on this file. +# +# Config database customization: +# 1. Edit this file as you want. +# 2. Apply your modifications. For this just run this following command in a shell: +# $ /usr/share/yunohost/hooks/conf_regen/06-slapd apply_config +# +# Note that if you customize this file, YunoHost's regen-conf will NOT +# overwrite this file. But that also means that you should be careful about +# upgrades, because they may ship important/necessary changes to this +# configuration that you will have to propagate yourself. + +# +# Main configuration +# +dn: cn=config +objectClass: olcGlobal +cn: config +olcConfigFile: /etc/ldap/slapd.conf +olcConfigDir: /etc/ldap/slapd.d/ +# List of arguments that were passed to the server +olcArgsFile: /var/run/slapd/slapd.args +# +olcAttributeOptions: lang- +olcAuthzPolicy: none +olcConcurrency: 0 +olcConnMaxPending: 100 +olcConnMaxPendingAuth: 1000 +olcSizeLimit: 50000 +olcIdleTimeout: 0 +olcIndexSubstrIfMaxLen: 4 +olcIndexSubstrIfMinLen: 2 +olcIndexSubstrAnyLen: 4 +olcIndexSubstrAnyStep: 2 +olcIndexIntLen: 4 +olcListenerThreads: 1 +olcLocalSSF: 71 +# Read slapd.conf(5) for possible values +olcLogLevel: None +# Where the pid file is put. The init.d script +# will not stop the server if you change this. +olcPidFile: /var/run/slapd/slapd.pid +olcReverseLookup: FALSE +olcThreads: 16 +# TLS Support +olcTLSCertificateFile: /etc/yunohost/certs/yunohost.org/crt.pem +olcTLSCertificateKeyFile: /etc/yunohost/certs/yunohost.org/key.pem +olcTLSVerifyClient: never +olcTLSProtocolMin: 0.0 +# The tool-threads parameter sets the actual amount of cpu's that is used +# for indexing. +olcToolThreads: 1 +structuralObjectClass: olcGlobal + +# +# Schema and objectClass definitions +# +dn: cn=schema,cn=config +objectClass: olcSchemaConfig +cn: schema + +include: file:///etc/ldap/schema/core.ldif +include: file:///etc/ldap/schema/cosine.ldif +include: file:///etc/ldap/schema/nis.ldif +include: file:///etc/ldap/schema/inetorgperson.ldif +include: file:///etc/ldap/schema/mailserver.ldif +include: file:///etc/ldap/schema/sudo.ldif +include: file:///etc/ldap/schema/permission.ldif + +# +# Module management +# +dn: cn=module{0},cn=config +objectClass: olcModuleList +cn: module{0} +# Where the dynamically loaded modules are stored +olcModulePath: /usr/lib/ldap +olcModuleLoad: {0}back_mdb +olcModuleLoad: {1}memberof +structuralObjectClass: olcModuleList + +# +# Frontend database +# +dn: olcDatabase={-1}frontend,cn=config +objectClass: olcDatabaseConfig +objectClass: olcFrontendConfig +olcDatabase: {-1}frontend +olcAddContentAcl: FALSE +olcLastMod: TRUE +olcSchemaDN: cn=Subschema +# Hashes to be used in generation of user passwords +olcPasswordHash: {SSHA} +structuralObjectClass: olcDatabaseConfig + +# +# Config database Configuration (#0) +# +dn: olcDatabase={0}config,cn=config +objectClass: olcDatabaseConfig +olcDatabase: {0}config +# Give access to root user. +# This give the possiblity to the admin to customize the LDAP configuration +olcAccess: {0}to * by * none +olcAddContentAcl: TRUE +olcLastMod: TRUE +olcRootDN: cn=config +structuralObjectClass: olcDatabaseConfig + +# +# Main database Configuration (#1) +# +dn: olcDatabase={1}mdb,cn=config +objectClass: olcDatabaseConfig +objectClass: olcMdbConfig +olcDatabase: {1}mdb +# The base of your directory in database #1 +olcSuffix: dc=yunohost,dc=org +# +# The userPassword by default can be changed +# by the entry owning it if they are authenticated. +# Others should not be able to see it, except the +# admin entry below +# These access lines apply to database #1 only +olcAccess: {0}to attrs=userPassword,shadowLastChange + by dn.base="cn=admin,dc=yunohost,dc=org" write + by dn.base="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" write + by anonymous auth + by self write + by * none +# +# Personnal information can be changed by the entry +# owning it if they are authenticated. +# Others should be able to see it. +olcAccess: {1}to attrs=cn,gecos,givenName,mail,maildrop,displayName,sn + by dn.base="cn=admin,dc=yunohost,dc=org" write + by dn.base="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" write + by self write + by * read +# +# Ensure read access to the base for things like +# supportedSASLMechanisms. Without this you may +# have problems with SASL not knowing what +# mechanisms are available and the like. +# Note that this is covered by the 'access to *' +# ACL below too but if you change that as people +# are wont to do you'll still need this if you +# want SASL (and possible other things) to work +# happily. +olcAccess: {2}to dn.base="" + by * read +# +# The admin dn has full write access, everyone else +# can read everything. +olcAccess: {3}to * + by dn.base="cn=admin,dc=yunohost,dc=org" write + by dn.base="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" write + by group/groupOfNames/member.exact="cn=admin,ou=groups,dc=yunohost,dc=org" write + by * read +# +olcAddContentAcl: FALSE +# Save the time that the entry gets modified, for database #1 +olcLastMod: TRUE +# Where the database file are physically stored for database #1 +olcDbDirectory: /var/lib/ldap +# Checkpoint the BerkeleyDB database periodically in case of system +# failure and to speed slapd shutdown. +olcDbCheckpoint: 512 30 +olcDbNoSync: FALSE +# Indexing options for database #1 +olcDbIndex: objectClass eq +olcDbIndex: entryUUID eq +olcDbIndex: entryCSN eq +olcDbIndex: cn eq +olcDbIndex: uid eq,sub +olcDbIndex: uidNumber eq +olcDbIndex: gidNumber eq +olcDbIndex: sudoUser eq,sub +olcDbIndex: member eq +olcDbIndex: mail eq +olcDbIndex: memberUid eq +olcDbIndex: uniqueMember eq +olcDbIndex: virtualdomain eq +olcDbIndex: permission eq +olcDbMaxSize: 104857600 +structuralObjectClass: olcMdbConfig + +# +# Configure Memberof Overlay (used for YunoHost permission) +# + +# Link user <-> group +dn: olcOverlay={0}memberof,olcDatabase={1}mdb,cn=config +objectClass: olcOverlayConfig +objectClass: olcMemberOf +olcOverlay: {0}memberof +olcMemberOfDangling: error +olcMemberOfDanglingError: constraintViolation +olcMemberOfRefInt: TRUE +olcMemberOfGroupOC: groupOfNamesYnh +olcMemberOfMemberAD: member +olcMemberOfMemberOfAD: memberOf +structuralObjectClass: olcMemberOf + +# Link permission <-> groupes +dn: olcOverlay={1}memberof,olcDatabase={1}mdb,cn=config +objectClass: olcOverlayConfig +objectClass: olcMemberOf +olcOverlay: {1}memberof +olcMemberOfDangling: error +olcMemberOfDanglingError: constraintViolation +olcMemberOfRefInt: TRUE +olcMemberOfGroupOC: permissionYnh +olcMemberOfMemberAD: groupPermission +olcMemberOfMemberOfAD: permission +structuralObjectClass: olcMemberOf + +# Link permission <-> user +dn: olcOverlay={2}memberof,olcDatabase={1}mdb,cn=config +objectClass: olcOverlayConfig +objectClass: olcMemberOf +olcOverlay: {2}memberof +olcMemberOfDangling: error +olcMemberOfDanglingError: constraintViolation +olcMemberOfRefInt: TRUE +olcMemberOfGroupOC: permissionYnh +olcMemberOfMemberAD: inheritPermission +olcMemberOfMemberOfAD: permission +structuralObjectClass: olcMemberOf diff --git a/data/templates/slapd/db_init.ldif b/data/templates/slapd/db_init.ldif new file mode 100644 index 000000000..be0181dfe --- /dev/null +++ b/data/templates/slapd/db_init.ldif @@ -0,0 +1,120 @@ +dn: dc=yunohost,dc=org +objectClass: top +objectClass: dcObject +objectClass: organization +o: yunohost.org +dc: yunohost + +dn: cn=admin,ou=sudo,dc=yunohost,dc=org +cn: admin +objectClass: sudoRole +objectClass: top +sudoCommand: ALL +sudoUser: admin +sudoOption: !authenticate +sudoHost: ALL + +dn: ou=users,dc=yunohost,dc=org +objectClass: organizationalUnit +objectClass: top +ou: users + +dn: ou=domains,dc=yunohost,dc=org +objectClass: organizationalUnit +objectClass: top +ou: domains + +dn: ou=apps,dc=yunohost,dc=org +objectClass: organizationalUnit +objectClass: top +ou: apps + +dn: ou=permission,dc=yunohost,dc=org +objectClass: organizationalUnit +objectClass: top +ou: permission + +dn: ou=groups,dc=yunohost,dc=org +objectClass: organizationalUnit +objectClass: top +ou: groups + +dn: ou=sudo,dc=yunohost,dc=org +objectClass: organizationalUnit +objectClass: top +ou: sudo + +dn: cn=admin,dc=yunohost,dc=org +objectClass: organizationalRole +objectClass: posixAccount +objectClass: simpleSecurityObject +cn: admin +uid: admin +uidNumber: 1007 +gidNumber: 1007 +homeDirectory: /home/admin +loginShell: /bin/bash +userPassword: yunohost + +dn: cn=admins,ou=groups,dc=yunohost,dc=org +objectClass: posixGroup +objectClass: top +memberUid: admin +gidNumber: 4001 +cn: admins + +dn: cn=all_users,ou=groups,dc=yunohost,dc=org +objectClass: posixGroup +objectClass: groupOfNamesYnh +gidNumber: 4002 +cn: all_users +permission: cn=mail.main,ou=permission,dc=yunohost,dc=org +permission: cn=xmpp.main,ou=permission,dc=yunohost,dc=org + +dn: cn=visitors,ou=groups,dc=yunohost,dc=org +objectClass: posixGroup +objectClass: groupOfNamesYnh +gidNumber: 4003 +cn: visitors + +dn: cn=mail.main,ou=permission,dc=yunohost,dc=org +groupPermission: cn=all_users,ou=groups,dc=yunohost,dc=org +cn: mail.main +objectClass: posixGroup +objectClass: permissionYnh +isProtected: TRUE +label: E-mail +gidNumber: 5001 +showTile: FALSE +authHeader: FALSE + +dn: cn=xmpp.main,ou=permission,dc=yunohost,dc=org +groupPermission: cn=all_users,ou=groups,dc=yunohost,dc=org +cn: xmpp.main +objectClass: posixGroup +objectClass: permissionYnh +isProtected: TRUE +label: XMPP +gidNumber: 5002 +showTile: FALSE +authHeader: FALSE + +dn: cn=ssh.main,ou=permission,dc=yunohost,dc=org +cn: ssh.main +objectClass: posixGroup +objectClass: permissionYnh +isProtected: TRUE +label: SSH +gidNumber: 5003 +showTile: FALSE +authHeader: FALSE + +dn: cn=sftp.main,ou=permission,dc=yunohost,dc=org +cn: sftp.main +objectClass: posixGroup +objectClass: permissionYnh +isProtected: TRUE +label: SFTP +gidNumber: 5004 +showTile: FALSE +authHeader: FALSE diff --git a/data/templates/slapd/ldap.conf b/data/templates/slapd/ldap.conf index 09aeb8b4f..dfcb17e41 100644 --- a/data/templates/slapd/ldap.conf +++ b/data/templates/slapd/ldap.conf @@ -8,7 +8,7 @@ BASE dc=yunohost,dc=org URI ldap://localhost:389 -#SIZELIMIT 12 +SIZELIMIT 10000 #TIMELIMIT 15 #DEREF never diff --git a/data/templates/slapd/mailserver.schema b/data/templates/slapd/mailserver.ldif similarity index 79% rename from data/templates/slapd/mailserver.schema rename to data/templates/slapd/mailserver.ldif index 23d0d24bd..849d1d9e1 100644 --- a/data/templates/slapd/mailserver.schema +++ b/data/templates/slapd/mailserver.ldif @@ -2,58 +2,62 @@ ## Version 0.1 ## Adrien Beudin +dn: cn=mailserver,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: mailserver +# # Attributes -attributetype ( 1.3.6.1.4.1.40328.1.20.2.1 +olcAttributeTypes: ( 1.3.6.1.4.1.40328.1.20.2.1 NAME 'maildrop' DESC 'Mail addresses where mails are forwarded -- ie forwards' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{512}) - -attributetype ( 1.3.6.1.4.1.40328.1.20.2.2 +# +olcAttributeTypes: ( 1.3.6.1.4.1.40328.1.20.2.2 NAME 'mailalias' DESC 'Mail addresses accepted by this account -- ie aliases' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{512}) - -attributetype ( 1.3.6.1.4.1.40328.1.20.2.3 +# +olcAttributeTypes: ( 1.3.6.1.4.1.40328.1.20.2.3 NAME 'mailenable' DESC 'Mail Account validity' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{8}) - -attributetype ( 1.3.6.1.4.1.40328.1.20.2.4 +# +olcAttributeTypes: ( 1.3.6.1.4.1.40328.1.20.2.4 NAME 'mailbox' DESC 'Mailbox path where mails are delivered' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{512}) - -attributetype ( 1.3.6.1.4.1.40328.1.20.2.5 +# +olcAttributeTypes: ( 1.3.6.1.4.1.40328.1.20.2.5 NAME 'virtualdomain' DESC 'A mail domain name' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{512}) - -attributetype ( 1.3.6.1.4.1.40328.1.20.2.6 +# +olcAttributeTypes: ( 1.3.6.1.4.1.40328.1.20.2.6 NAME 'virtualdomaindescription' DESC 'Virtual domain description' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{512}) - -attributetype ( 1.3.6.1.4.1.40328.1.20.2.7 +# +olcAttributeTypes: ( 1.3.6.1.4.1.40328.1.20.2.7 NAME 'mailuserquota' DESC 'Mailbox quota for a user' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{16} SINGLE-VALUE ) - +# # Mail Account Objectclass -objectclass ( 1.3.6.1.4.1.40328.1.1.2.1 +olcObjectClasses: ( 1.3.6.1.4.1.40328.1.1.2.1 NAME 'mailAccount' DESC 'Mail Account' SUP top @@ -65,9 +69,9 @@ objectclass ( 1.3.6.1.4.1.40328.1.1.2.1 mailalias $ maildrop $ mailenable $ mailbox $ mailuserquota ) ) - +# # Mail Domain Objectclass -objectclass ( 1.3.6.1.4.1.40328.1.1.2.2 +olcObjectClasses: ( 1.3.6.1.4.1.40328.1.1.2.2 NAME 'mailDomain' DESC 'Domain mail entry' SUP top @@ -79,9 +83,9 @@ objectclass ( 1.3.6.1.4.1.40328.1.1.2.2 virtualdomaindescription $ mailuserquota ) ) - +# # Mail Group Objectclass -objectclass ( 1.3.6.1.4.1.40328.1.1.2.3 +olcObjectClasses: ( 1.3.6.1.4.1.40328.1.1.2.3 NAME 'mailGroup' SUP top AUXILIARY DESC 'Mail Group' MUST ( mail ) diff --git a/data/templates/slapd/permission.ldif b/data/templates/slapd/permission.ldif new file mode 100644 index 000000000..64222db1d --- /dev/null +++ b/data/templates/slapd/permission.ldif @@ -0,0 +1,50 @@ +# YunoHost schema for group and permission support + +dn: cn=yunohost,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: yunohost +# ATTRIBUTES +# For Permission +olcAttributeTypes: ( 1.3.6.1.4.1.17953.9.1.1 NAME 'permission' + DESC 'YunoHost permission on user and group side' + SUP distinguishedName ) +olcAttributeTypes: ( 1.3.6.1.4.1.17953.9.1.2 NAME 'groupPermission' + DESC 'YunoHost permission for a group on permission side' + SUP distinguishedName ) +olcAttributeTypes: ( 1.3.6.1.4.1.17953.9.1.3 NAME 'inheritPermission' + DESC 'YunoHost permission for user on permission side' + SUP distinguishedName ) +olcAttributeTypes: ( 1.3.6.1.4.1.17953.9.1.4 NAME 'URL' + DESC 'YunoHost permission main URL' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} SINGLE-VALUE ) +olcAttributeTypes: ( 1.3.6.1.4.1.17953.9.1.5 NAME 'additionalUrls' + DESC 'YunoHost permission additionnal URL' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} ) +olcAttributeTypes: ( 1.3.6.1.4.1.17953.9.1.6 NAME 'authHeader' + DESC 'YunoHost application, enable authentication header' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE ) +olcAttributeTypes: ( 1.3.6.1.4.1.17953.9.1.7 NAME 'label' + DESC 'YunoHost permission label, also used for the tile name in the SSO' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} SINGLE-VALUE ) +olcAttributeTypes: ( 1.3.6.1.4.1.17953.9.1.8 NAME 'showTile' + DESC 'YunoHost application, show/hide the tile in the SSO for this permission' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE ) +olcAttributeTypes: ( 1.3.6.1.4.1.17953.9.1.9 NAME 'isProtected' + DESC 'YunoHost application permission protection' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE ) +# OBJECTCLASS +# For Applications +olcObjectClasses: ( 1.3.6.1.4.1.17953.9.2.1 NAME 'groupOfNamesYnh' + DESC 'YunoHost user group' + SUP top AUXILIARY + MAY ( member $ businessCategory $ seeAlso $ owner $ ou $ o $ permission ) ) +olcObjectClasses: ( 1.3.6.1.4.1.17953.9.2.2 NAME 'permissionYnh' + DESC 'a YunoHost application' + SUP top AUXILIARY + MUST ( cn $ authHeader $ label $ showTile $ isProtected ) + MAY ( groupPermission $ inheritPermission $ URL $ additionalUrls ) ) +# For User +olcObjectClasses: ( 1.3.6.1.4.1.17953.9.2.3 NAME 'userPermissionYnh' + DESC 'a YunoHost application' + SUP top AUXILIARY + MAY ( permission ) ) diff --git a/data/templates/slapd/slapd.conf b/data/templates/slapd/slapd.conf deleted file mode 100644 index 76f249060..000000000 --- a/data/templates/slapd/slapd.conf +++ /dev/null @@ -1,153 +0,0 @@ -# This is the main slapd configuration file. See slapd.conf(5) for more -# info on the configuration options. - -####################################################################### -# Global Directives: - -# Features to permit -#allow bind_v2 - -# Schema and objectClass definitions -include /etc/ldap/schema/core.schema -include /etc/ldap/schema/cosine.schema -include /etc/ldap/schema/nis.schema -include /etc/ldap/schema/inetorgperson.schema -include /etc/ldap/schema/mailserver.schema -include /etc/ldap/schema/sudo.schema -include /etc/ldap/schema/yunohost.schema - -# Where the pid file is put. The init.d script -# will not stop the server if you change this. -pidfile /var/run/slapd/slapd.pid - -# List of arguments that were passed to the server -argsfile /var/run/slapd/slapd.args - -# Read slapd.conf(5) for possible values -loglevel none - -# Hashes to be used in generation of user passwords -password-hash {SSHA} - -# Where the dynamically loaded modules are stored -modulepath /usr/lib/ldap -moduleload back_mdb -moduleload memberof - -# The maximum number of entries that is returned for a search operation -sizelimit 500 - -# The tool-threads parameter sets the actual amount of cpu's that is used -# for indexing. -tool-threads 1 - -# TLS Support -TLSCertificateFile /etc/yunohost/certs/yunohost.org/crt.pem -TLSCertificateKeyFile /etc/yunohost/certs/yunohost.org/key.pem - -####################################################################### -# Specific Backend Directives for mdb: -# Backend specific directives apply to this backend until another -# 'backend' directive occurs -backend mdb - -####################################################################### -# Specific Directives for database #1, of type mdb: -# Database specific directives apply to this databasse until another -# 'database' directive occurs -database mdb - -# The base of your directory in database #1 -suffix "dc=yunohost,dc=org" - -# rootdn directive for specifying a superuser on the database. This is needed -# for syncrepl. -# rootdn "cn=admin,dc=yunohost,dc=org" - -# Where the database file are physically stored for database #1 -directory "/var/lib/ldap" - -# Indexing options for database #1 -index objectClass eq -index uid,sudoUser eq,sub -index entryCSN,entryUUID eq -index cn,mail eq -index gidNumber,uidNumber eq -index member,memberUid,uniqueMember eq -index virtualdomain eq - -# Save the time that the entry gets modified, for database #1 -lastmod on - -# Checkpoint the BerkeleyDB database periodically in case of system -# failure and to speed slapd shutdown. -checkpoint 512 30 - -# The userPassword by default can be changed -# by the entry owning it if they are authenticated. -# Others should not be able to see it, except the -# admin entry below -# These access lines apply to database #1 only -access to attrs=userPassword,shadowLastChange - by dn="cn=admin,dc=yunohost,dc=org" write - by dn.exact="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" write - by anonymous auth - by self write - by * none - -# Personnal information can be changed by the entry -# owning it if they are authenticated. -# Others should be able to see it. -access to attrs=cn,gecos,givenName,mail,maildrop,displayName,sn - by dn="cn=admin,dc=yunohost,dc=org" write - by dn.exact="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" write - by self write - by * read - -# Ensure read access to the base for things like -# supportedSASLMechanisms. Without this you may -# have problems with SASL not knowing what -# mechanisms are available and the like. -# Note that this is covered by the 'access to *' -# ACL below too but if you change that as people -# are wont to do you'll still need this if you -# want SASL (and possible other things) to work -# happily. -access to dn.base="" by * read - -# The admin dn has full write access, everyone else -# can read everything. -access to * - by dn="cn=admin,dc=yunohost,dc=org" write - by dn.exact="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" write - by group/groupOfNames/Member="cn=admin,ou=groups,dc=yunohost,dc=org" write - by * read - -# Configure Memberof Overlay (used for Yunohost permission) - -# Link user <-> group -#dn: olcOverlay={0}memberof,olcDatabase={1}mdb,cn=config -overlay memberof -memberof-group-oc groupOfNamesYnh -memberof-member-ad member -memberof-memberof-ad memberOf -memberof-dangling error -memberof-refint TRUE - -# Link permission <-> groupes -#dn: olcOverlay={1}memberof,olcDatabase={1}mdb,cn=config -overlay memberof -memberof-group-oc permissionYnh -memberof-member-ad groupPermission -memberof-memberof-ad permission -memberof-dangling error -memberof-refint TRUE - -# Link permission <-> user -#dn: olcOverlay={2}memberof,olcDatabase={1}mdb,cn=config -overlay memberof -memberof-group-oc permissionYnh -memberof-member-ad inheritPermission -memberof-memberof-ad permission -memberof-dangling error -memberof-refint TRUE diff --git a/data/templates/slapd/sudo.schema b/data/templates/slapd/sudo.ldif similarity index 72% rename from data/templates/slapd/sudo.schema rename to data/templates/slapd/sudo.ldif index d3e95e00c..a7088c855 100644 --- a/data/templates/slapd/sudo.schema +++ b/data/templates/slapd/sudo.ldif @@ -1,76 +1,78 @@ # # OpenLDAP schema file for Sudo -# Save as /etc/openldap/schema/sudo.schema +# Save as /etc/openldap/schema/sudo.ldif # -attributetype ( 1.3.6.1.4.1.15953.9.1.1 +dn: cn=sudo,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: sudo +olcAttributeTypes: ( 1.3.6.1.4.1.15953.9.1.1 NAME 'sudoUser' DESC 'User(s) who may run sudo' EQUALITY caseExactIA5Match SUBSTR caseExactIA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) - -attributetype ( 1.3.6.1.4.1.15953.9.1.2 +# +olcAttributeTypes: ( 1.3.6.1.4.1.15953.9.1.2 NAME 'sudoHost' DESC 'Host(s) who may run sudo' EQUALITY caseExactIA5Match SUBSTR caseExactIA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) - -attributetype ( 1.3.6.1.4.1.15953.9.1.3 +# +olcAttributeTypes: ( 1.3.6.1.4.1.15953.9.1.3 NAME 'sudoCommand' DESC 'Command(s) to be executed by sudo' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) - -attributetype ( 1.3.6.1.4.1.15953.9.1.4 +# +olcAttributeTypes: ( 1.3.6.1.4.1.15953.9.1.4 NAME 'sudoRunAs' DESC 'User(s) impersonated by sudo (deprecated)' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) - -attributetype ( 1.3.6.1.4.1.15953.9.1.5 +# +olcAttributeTypes: ( 1.3.6.1.4.1.15953.9.1.5 NAME 'sudoOption' DESC 'Options(s) followed by sudo' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) - -attributetype ( 1.3.6.1.4.1.15953.9.1.6 +# +olcAttributeTypes: ( 1.3.6.1.4.1.15953.9.1.6 NAME 'sudoRunAsUser' DESC 'User(s) impersonated by sudo' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) - -attributetype ( 1.3.6.1.4.1.15953.9.1.7 +# +olcAttributeTypes: ( 1.3.6.1.4.1.15953.9.1.7 NAME 'sudoRunAsGroup' DESC 'Group(s) impersonated by sudo' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) - -attributetype ( 1.3.6.1.4.1.15953.9.1.8 +# +olcAttributeTypes: ( 1.3.6.1.4.1.15953.9.1.8 NAME 'sudoNotBefore' DESC 'Start of time interval for which the entry is valid' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 ) - -attributetype ( 1.3.6.1.4.1.15953.9.1.9 +# +olcAttributeTypes: ( 1.3.6.1.4.1.15953.9.1.9 NAME 'sudoNotAfter' DESC 'End of time interval for which the entry is valid' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 ) - -attributeTypes ( 1.3.6.1.4.1.15953.9.1.10 +# +olcAttributeTypes: ( 1.3.6.1.4.1.15953.9.1.10 NAME 'sudoOrder' DESC 'an integer to order the sudoRole entries' EQUALITY integerMatch ORDERING integerOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 ) - -objectclass ( 1.3.6.1.4.1.15953.9.2.1 NAME 'sudoRole' SUP top STRUCTURAL +# +olcObjectClasses: ( 1.3.6.1.4.1.15953.9.2.1 NAME 'sudoRole' SUP top STRUCTURAL DESC 'Sudoer Entries' MUST ( cn ) - MAY ( sudoUser $ sudoHost $ sudoCommand $ sudoRunAs $ sudoRunAsUser $ sudoRunAsGroup $ sudoOption $ sudoOrder $ sudoNotBefore $ sudoNotAfter $ - description ) + MAY ( sudoUser $ sudoHost $ sudoCommand $ sudoRunAs $ sudoRunAsUser $ sudoRunAsGroup $ sudoOption $ sudoOrder $ sudoNotBefore $ sudoNotAfter $ description ) ) diff --git a/data/templates/slapd/systemd-override.conf b/data/templates/slapd/systemd-override.conf new file mode 100644 index 000000000..afa821bd4 --- /dev/null +++ b/data/templates/slapd/systemd-override.conf @@ -0,0 +1,9 @@ +[Service] +# Prevent slapd from getting killed by oom reaper as much as possible +OOMScoreAdjust=-1000 +# If slapd exited (for instance if got killed) the service should not be +# considered as active anymore... +RemainAfterExit=no +# Automatically restart the service if the service gets down +Restart=always +RestartSec=3 diff --git a/data/templates/slapd/yunohost.schema b/data/templates/slapd/yunohost.schema deleted file mode 100644 index 7da60a20c..000000000 --- a/data/templates/slapd/yunohost.schema +++ /dev/null @@ -1,33 +0,0 @@ -#dn: cn=yunohost,cn=schema,cn=config -#objectClass: olcSchemaConfig -#cn: yunohost -# ATTRIBUTES -# For Permission -attributetype ( 1.3.6.1.4.1.17953.9.1.1 NAME 'permission' - DESC 'Yunohost permission on user and group side' - SUP distinguishedName ) -attributetype ( 1.3.6.1.4.1.17953.9.1.2 NAME 'groupPermission' - DESC 'Yunohost permission for a group on permission side' - SUP distinguishedName ) -attributetype ( 1.3.6.1.4.1.17953.9.1.3 NAME 'inheritPermission' - DESC 'Yunohost permission for user on permission side' - SUP distinguishedName ) -attributetype ( 1.3.6.1.4.1.17953.9.1.4 NAME 'URL' - DESC 'Yunohost application URL' - SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} ) -# OBJECTCLASS -# For Applications -objectclass ( 1.3.6.1.4.1.17953.9.2.1 NAME 'groupOfNamesYnh' - DESC 'Yunohost user group' - SUP top AUXILIARY - MAY ( member $ businessCategory $ seeAlso $ owner $ ou $ o $ permission ) ) -objectclass ( 1.3.6.1.4.1.17953.9.2.2 NAME 'permissionYnh' - DESC 'a Yunohost application' - SUP top AUXILIARY - MUST cn - MAY ( groupPermission $ inheritPermission $ URL ) ) -# For User -objectclass ( 1.3.6.1.4.1.17953.9.2.3 NAME 'userPermissionYnh' - DESC 'a Yunohost application' - SUP top AUXILIARY - MAY ( permission ) ) diff --git a/data/templates/ssh/sshd_config b/data/templates/ssh/sshd_config index 8dc0e8dfc..1c2854f73 100644 --- a/data/templates/ssh/sshd_config +++ b/data/templates/ssh/sshd_config @@ -2,7 +2,7 @@ # by YunoHost Protocol 2 -Port 22 +Port {{ port }} {% if ipv6_enabled == "true" %}ListenAddress ::{% endif %} ListenAddress 0.0.0.0 @@ -27,9 +27,6 @@ HostKey {{ key }}{% endfor %} 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 {% endif %} -# 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 @@ -67,14 +64,40 @@ PrintLastLog yes ClientAliveInterval 60 AcceptEnv LANG LC_* +# Disallow user without ssh or sftp permissions +AllowGroups ssh.main sftp.main ssh.app sftp.app admins root + +# Allow users to create tunnels or forwarding +AllowTcpForwarding yes +AllowStreamLocalForwarding yes +PermitTunnel yes +PermitUserRC yes + # SFTP stuff Subsystem sftp internal-sftp -Match User sftpusers - ForceCommand internal-sftp - ChrootDirectory /home/%u - AllowTcpForwarding no - GatewayPorts no - X11Forwarding no + +# Apply following instructions to user with sftp perm only +Match Group sftp.main,!ssh.main + ForceCommand internal-sftp + # We can't restrict to /home/%u because the chroot base must be owned by root + # So we chroot only on /home + # See https://serverfault.com/questions/584986/bad-ownership-or-modes-for-chroot-directory-component + ChrootDirectory /home + # Forbid SFTP users from using their account SSH as a VPN (even if SSH login is disabled) + AllowTcpForwarding no + AllowStreamLocalForwarding no + PermitTunnel no + # Disable .ssh/rc, which could be edited (e.g. from Nextcloud or whatever) by users to execute arbitrary commands even if SSH login is disabled + PermitUserRC no + +Match Group sftp.app,!ssh.app + ForceCommand internal-sftp + ChrootDirectory %h + AllowTcpForwarding no + AllowStreamLocalForwarding no + PermitTunnel no + PermitUserRC no + PasswordAuthentication yes # root login is allowed on local networks # It's meant to be a backup solution in case LDAP is down and @@ -82,4 +105,4 @@ Match User sftpusers # 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 + PermitRootLogin yes diff --git a/data/templates/ssl/openssl.cnf b/data/templates/ssl/openssl.cnf index fa5d19fa3..3ef7d80c3 100644 --- a/data/templates/ssl/openssl.cnf +++ b/data/templates/ssl/openssl.cnf @@ -192,7 +192,7 @@ authorityKeyIdentifier=keyid,issuer basicConstraints = CA:FALSE keyUsage = nonRepudiation, digitalSignature, keyEncipherment -subjectAltName=DNS:yunohost.org,DNS:www.yunohost.org,DNS:ns.yunohost.org +subjectAltName=DNS:yunohost.org,DNS:www.yunohost.org,DNS:ns.yunohost.org,DNS:xmpp-upload.yunohost.org [ v3_ca ] diff --git a/data/templates/unattended/02periodic b/data/templates/unattended/02periodic deleted file mode 100644 index f16105466..000000000 --- a/data/templates/unattended/02periodic +++ /dev/null @@ -1,5 +0,0 @@ -# https://wiki.debian.org/UnattendedUpgrades#automatic_call_via_.2Fetc.2Fapt.2Fapt.conf.d.2F02periodic -APT::Periodic::Enable "1"; -APT::Periodic::Update-Package-Lists "1"; -APT::Periodic::Unattended-Upgrade "1"; -APT::Periodic::Verbose "1"; diff --git a/data/templates/unattended/50unattended-upgrades b/data/templates/unattended/50unattended-upgrades deleted file mode 100644 index 49b600a3b..000000000 --- a/data/templates/unattended/50unattended-upgrades +++ /dev/null @@ -1,36 +0,0 @@ -// Automatically upgrade packages from these (origin, archive) pairs -Unattended-Upgrade::Allowed-Origins { - "${distro_id} stable"; - "${distro_id} testing"; - "Depot-Debian testing"; - "${distro_id} ${distro_codename}-security"; - "${distro_id} ${distro_codename}-updates"; -// "${distro_id} ${distro_codename}-proposed-updates"; -}; - -// List of packages to not update -Unattended-Upgrade::Package-Blacklist { -// "vim"; -// "libc6"; -// "libc6-dev"; -// "libc6-i686"; -}; - -// Send email to this address for problems or packages upgrades -// If empty or unset then no email is sent, make sure that you -// have a working mail setup on your system. The package 'mailx' -// must be installed or anything that provides /usr/bin/mail. -//Unattended-Upgrade::Mail "root@localhost"; - -// Do automatic removal of new unused dependencies after the upgrade -// (equivalent to apt-get autoremove) -Unattended-Upgrade::Remove-Unused-Dependencies "true"; - -// Automatically reboot *WITHOUT CONFIRMATION* if a -// the file /var/run/reboot-required is found after the upgrade -Unattended-Upgrade::Automatic-Reboot "false"; - - -// Use apt bandwidth limit feature, this example limits the download -// speed to 70kb/sec -//Acquire::http::Dl-Limit "70"; diff --git a/data/other/dpkg-origins/yunohost b/data/templates/yunohost/dpkg-origins similarity index 100% rename from data/other/dpkg-origins/yunohost rename to data/templates/yunohost/dpkg-origins diff --git a/data/templates/yunohost/etckeeper.conf b/data/templates/yunohost/etckeeper.conf deleted file mode 100644 index 2d11c3dc6..000000000 --- a/data/templates/yunohost/etckeeper.conf +++ /dev/null @@ -1,43 +0,0 @@ -# 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/data/templates/yunohost/firewall.yml b/data/templates/yunohost/firewall.yml index 835a82519..64c6b9326 100644 --- a/data/templates/yunohost/firewall.yml +++ b/data/templates/yunohost/firewall.yml @@ -2,6 +2,8 @@ uPnP: enabled: false TCP: [22, 25, 80, 443, 587, 993, 5222, 5269] UDP: [] + TCP_TO_CLOSE: [] + UDP_TO_CLOSE: [] ipv4: TCP: [22, 25, 53, 80, 443, 587, 993, 5222, 5269] UDP: [53, 5353] diff --git a/data/templates/yunohost/proc-hidepid.service b/data/templates/yunohost/proc-hidepid.service new file mode 100644 index 000000000..ec6fabede --- /dev/null +++ b/data/templates/yunohost/proc-hidepid.service @@ -0,0 +1,14 @@ +[Unit] +Description=Mounts /proc with hidepid=2 +DefaultDependencies=no +Before=sysinit.target +Requires=local-fs.target +After=local-fs.target + +[Service] +Type=oneshot +ExecStart=/bin/mount -o remount,nosuid,nodev,noexec,hidepid=2 /proc +RemainAfterExit=yes + +[Install] +WantedBy=sysinit.target diff --git a/data/templates/yunohost/services.yml b/data/templates/yunohost/services.yml index 0d79b182f..c781d54aa 100644 --- a/data/templates/yunohost/services.yml +++ b/data/templates/yunohost/services.yml @@ -1,37 +1,59 @@ -nginx: - log: /var/log/nginx -avahi-daemon: - log: /var/log/daemon.log dnsmasq: - log: /var/log/daemon.log -fail2ban: - log: /var/log/fail2ban.log + test_conf: dnsmasq --test dovecot: log: [/var/log/mail.log,/var/log/mail.err] -postfix: - log: [/var/log/mail.log,/var/log/mail.err] -rspamd: - log: /var/log/rspamd/rspamd.log -redis-server: - log: /var/log/redis/redis-server.log -mysql: - log: [/var/log/mysql.log,/var/log/mysql.err] - alternates: ['mariadb'] -glances: {} -ssh: - log: /var/log/auth.log + needs_exposed_ports: [993] + category: email +fail2ban: + log: /var/log/fail2ban.log + category: security + test_conf: fail2ban-server --test metronome: log: [/var/log/metronome/metronome.log,/var/log/metronome/metronome.err] + needs_exposed_ports: [5222, 5269] + category: xmpp +mysql: + log: [/var/log/mysql.log,/var/log/mysql.err,/var/log/mysql/error.log] + actual_systemd_service: mariadb + category: database +nginx: + log: /var/log/nginx + test_conf: nginx -t + needs_exposed_ports: [80, 443] + category: web +php7.3-fpm: + log: /var/log/php7.3-fpm.log + test_conf: php-fpm7.3 --test + category: web +postfix: + log: [/var/log/mail.log,/var/log/mail.err] + actual_systemd_service: postfix@- + needs_exposed_ports: [25, 587] + category: email +redis-server: + log: /var/log/redis/redis-server.log + category: database +rspamd: + log: /var/log/rspamd/rspamd.log + category: email slapd: - log: /var/log/syslog -php7.0-fpm: - log: /var/log/php7.0-fpm.log + category: database + test_conf: slapd -Tt +ssh: + log: /var/log/auth.log + test_conf: sshd -t + needs_exposed_ports: [22] + category: admin yunohost-api: log: /var/log/yunohost/yunohost-api.log + category: admin yunohost-firewall: need_lock: true -nslcd: - log: /var/log/syslog + test_status: iptables -S | grep "^-A INPUT" | grep " --dport" | grep -q ACCEPT + category: security +yunomdns: + category: mdns +glances: null nsswitch: null ssl: null yunohost: null @@ -45,3 +67,6 @@ postgrey: null spamassassin: null rmilter: null php5-fpm: null +php7.0-fpm: null +nslcd: null +avahi-daemon: null diff --git a/data/other/yunoprompt.service b/data/templates/yunohost/yunoprompt.service similarity index 91% rename from data/other/yunoprompt.service rename to data/templates/yunohost/yunoprompt.service index 3c4df50f9..effb69590 100644 --- a/data/other/yunoprompt.service +++ b/data/templates/yunohost/yunoprompt.service @@ -1,6 +1,7 @@ [Unit] Description=YunoHost boot prompt After=getty@tty2.service +After=network.target [Service] Type=simple diff --git a/debian/changelog b/debian/changelog index 3eb347456..e6bd5180e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,1155 @@ +yunohost (4.3.0) testing; urgency=low + + - [users] Import/export users from/to CSV ([#1089](https://github.com/YunoHost/yunohost/pull/1089)) + - [domain] Add mDNS for .local domains / replace avahi-daemon ([#1112](https://github.com/YunoHost/yunohost/pull/1112)) + - [settings] new setting to enable experimental security features ([#1290](https://github.com/YunoHost/yunohost/pull/1290)) + - [settings] new setting to handle https redirect ([#1304](https://github.com/YunoHost/yunohost/pull/1304)) + - [diagnosis] add an "app" section to check that app are in catalog with good quality, check for deprecated practices ([#1217](https://github.com/YunoHost/yunohost/pull/1217)) + - [diagnosis] report suspiciously high number of auth failures ([#1292](https://github.com/YunoHost/yunohost/pull/1292)) + - [refactor] Rework the authentication system ([#1183](https://github.com/YunoHost/yunohost/pull/1183)) + - [enh] New config-panel mechanism ([#987](https://github.com/YunoHost/yunohost/pull/987)) + - [enh] Add backup for multimedia files (88063dc7) + - [enh] Configure automatically the DNS records using lexicon ([#1315](https://github.com/YunoHost/yunohost/pull/1315)) + - also brings domain settings, domain config panel, subdomain awareness, improvements in dns recommended conf + - [i18n] Translations updated for Catalan, Chinese (Simplified), Czech, Esperanto, French, Galician, German, Italian, Occitan, Persian, Portuguese, Spanish, Ukrainian + + Thanks to all contributors <3 ! (Corentin Mercier, Daniel, Éric Gaspar, Flavio Cristoforetti, Gregor Lenz, José M, Kay0u, ljf, MercierCorentin, mifegui, Paco, Parviz Homayun, ppr, tituspijean, Tymofii-Lytvynenko) + + -- Alexandre Aubin Sun, 19 Sep 2021 23:55:21 +0200 + +yunohost (4.2.8.3) stable; urgency=low + + - [fix] mysql: Another bump for sort_buffer_size to make Nextcloud 22 work (34e9246b) + + Thanks to all contributors <3 ! (ljf (zamentur)) + + -- Kay0u Fri, 10 Sep 2021 10:40:38 +0200 + +yunohost (4.2.8.2) stable; urgency=low + + - [fix] mysql: Bump sort_buffer_size to 256K to fix Nextcloud 22 installation (d8c49619) + + Thanks to all contributors <3 ! (ericg) + + -- Alexandre Aubin Tue, 07 Sep 2021 23:23:18 +0200 + +yunohost (4.2.8.1) stable; urgency=low + + - [fix] Safer location for slapd backup during hdb/mdb migration (3c646b3d) + + Thanks to all contributors <3 ! (ljf) + + -- Alexandre Aubin Fri, 27 Aug 2021 01:32:16 +0200 + +yunohost (4.2.8) stable; urgency=low + + - [fix] ynh_permission_has_user not behaving properly when checking if a group is allowed (f0590907) + - [enh] use yaml safeloader everywhere ([#1287](https://github.com/YunoHost/yunohost/pull/1287)) + - [enh] Add --no-safety-backup option to "yunohost app upgrade" ([#1286](https://github.com/YunoHost/yunohost/pull/1286)) + - [enh] Add --purge option to "yunohost app remove" ([#1285](https://github.com/YunoHost/yunohost/pull/1285)) + - [enh] Multimedia helper: check that home folder exists ([#1255](https://github.com/YunoHost/yunohost/pull/1255)) + - [i18n] Translations updated for French, Galician, German, Portuguese + + Thanks to all contributors <3 ! (José M, Kay0u, Krakinou, ljf, Luca, mifegui, ppr, sagessylu) + + -- Alexandre Aubin Thu, 19 Aug 2021 19:11:19 +0200 + +yunohost (4.2.7) stable; urgency=low + + Notable changes: + - [fix] app: 'yunohost app search' was broken (8cf92576) + - [fix] app: actions were broken, fix by reintroducing user arg in hook exec ([#1264](https://github.com/yunohost/yunohost/pull/1264)) + - [enh] app: Add check for available disk space before app install/upgrade ([#1266](https://github.com/yunohost/yunohost/pull/1266)) + - [enh] domains: Better support for non latin domain name ([#1270](https://github.com/yunohost/yunohost/pull/1270)) + - [enh] security: Add settings to restrict webadmin access to a list of IPs ([#1271](https://github.com/yunohost/yunohost/pull/1271)) + - [enh] misc: Avoid to suspend server if we close lidswitch ([#1275](https://github.com/yunohost/yunohost/pull/1275)) + - [i18n] Translations updated for French, Galician, German + + Misc fixes, improvements: + - [fix] logs: Sometimes metadata ends up being empty for some reason and ends up being loaded as None, making the "in" operator crash :| (50129f3a) + - [fix] nginx: Invalid HTML in yunohost_panel #1837 (1c15f644) + - [fix] ssh: set .ssh folder permissions to 600 ([#1269](https://github.com/yunohost/yunohost/pull/1269)) + - [fix] firewall: upnpc.getspecificportmapping expects an int, can't handle port ranges ? (ee70dfe5) + - [fix] php helpers: fix conf path for dedicated php server (7349b229) + - [fix] php helpers: Increase memory limit for composer ([#1278](https://github.com/yunohost/yunohost/pull/1278)) + - [enh] nodejs helpers: Upgrade n version to 7.3.0 ([#1262](https://github.com/yunohost/yunohost/pull/1262)) + - [fix] doc: Example command in yunopaste usage was outdated (a8df60da) + - [fix] doc, diagnosis: update links to SMTP relay configuration ([#1277](https://github.com/yunohost/yunohost/pull/1277)) + - [i18n] Translations fixes/cleanups (780c3cb8, b61082b1, 271e3a26, 4e4173d1, fab248ce, d49ad748) + + Thanks to all contributors <3 ! (Bram, Christian Wehrli, cyxae, Éric Gaspar, José M, Kay0u, Le Libre Au Quotidien, ljf, Luca, Meta Meta, ppr, Stylix58, Tagada, yalh76) + + -- Alexandre Aubin Sun, 08 Aug 2021 19:27:27 +0200 + +yunohost (4.2.6.1) stable; urgency=low + + - [fix] Remove invaluement from free dnsbl list (71489307) + - [i18n] Remove stale strings (079f6762) + - [i18n] Translations updated for Esperanto, French, Galician, German, Greek + + Thanks to all contributors <3 ! (amirale qt, Christian Wehrli, Éric Gaspar, José M, ljf, ppr, qwerty287) + + -- Alexandre Aubin Sat, 19 Jun 2021 17:18:13 +0200 + +yunohost (4.2.6) stable; urgency=low + + - [fix] metronome/xmpp: deactivate stanza mention optimization / have quick notification in chat group ([#1164](https://github.com/YunoHost/yunohost/pull/1164)) + - [enh] metronome/xmpp: activate module pubsub ([#1170](https://github.com/YunoHost/yunohost/pull/1170)) + - [fix] upgrade: undefined 'apps' variable (923f703e) + - [fix] python3: fix string split in postgresql migration (14d4cec8) + - [fix] python3: python2 was still used in helpers (bd196c87) + - [fix] security: fail2ban rule for yunohost-api login (b837d3da) + - [fix] backup: Apply realpath to find mounted points to unmount ([#1239](https://github.com/YunoHost/yunohost/pull/1239)) + - [mod] dnsmasq: Remove LDN from resolver list (a97fce05) + - [fix] logs: redact borg's passphrase (dbe5e51e, c8d4bbf8) + - [i18n] Translations updated for Galician, German, Italian + - Misc fixes/enh for tests and CI (8a5213c8, e5a03cab, [#1249](https://github.com/YunoHost/yunohost/pull/1249), [#1251](https://github.com/YunoHost/yunohost/pull/1251)) + + Thanks to all contributors <3 ! (Christian Wehrli, Flavio Cristoforetti, Gabriel, José M, Kay0u, ljf, tofbouf, yalh76) + + -- Alexandre Aubin Fri, 11 Jun 2021 20:12:20 +0200 + +yunohost (4.2.5.3) stable; urgency=low + + - [fix] doc, helpers: Helper doc auto-generation job (f2886510) + - [fix] doc: Manpage generation ([#1237](https://github.com/yunohost/yunohost/pull/1237)) + - [fix] misc: Yunohost -> YunoHost ([#1235](https://github.com/yunohost/yunohost/pull/1235)) + - [enh] email: Accept attachment of 25MB instead of 21,8MB ([#1243](https://github.com/yunohost/yunohost/pull/1243)) + - [fix] helpers: echo -n is pointless in ynh_systemd_action ([#1241](https://github.com/yunohost/yunohost/pull/1241)) + - [i18n] Translations updated for Chinese (Simplified), French, Galician, German, Italian + + Thanks to all contributors <3 ! (Éric Gaspar, José M, Kay0u, Leandro Noferini, ljf, Meta Meta, Noo Langoo, qwerty287, yahoo~~) + + -- Alexandre Aubin Wed, 02 Jun 2021 20:20:54 +0200 + +yunohost (4.2.5.2) stable; urgency=low + + - Fix install in chroot ... *again* (806b7acf) + + -- Alexandre Aubin Mon, 24 May 2021 22:11:02 +0200 + +yunohost (4.2.5.1) stable; urgency=low + + - Releasing as stable + + -- Alexandre Aubin Mon, 24 May 2021 19:36:35 +0200 + +yunohost (4.2.5) testing; urgency=low + + - [fix] backup: Also catch tarfile.ReadError as possible archive corruption error (4aaf0154) + - [enh] helpers: Update n to version 7.2.2 ([#1224](https://github.com/yunohost/yunohost/pull/1224)) + - [fix] helpers: Define ynh_node_load_path to be compatible with ynh_replace_vars (06f8c1cc) + - [doc] helpers: Add requirements for new helpers (2b0df6c3) + - [fix] helpers: Set YNH_APP_BASEDIR as an absolute path ([#1229](https://github.com/yunohost/yunohost/pull/1229), 27300282) + - [fix] Tweak yunohost-api systemd config as an attempt to fix the API being down after yunohost upgrades (52e30704) + - [fix] python3: encoding issue in nftable migrations (0f10b91f) + - [fix] python3: Email on certificate renewing failed ([#1227](https://github.com/yunohost/yunohost/pull/1227)) + - [fix] permissions: Remove warnings about legacy permission system (now reported in the linter) ([#1228](https://github.com/yunohost/yunohost/pull/1228)) + - [fix] diagnosis, mail: Remove SPFBL because it triggers false positive ([#1231](https://github.com/yunohost/yunohost/pull/1231)) + - [fix] diagnosis: DNS diagnosis taking an awful amount of time because of timeout ([#1233](https://github.com/yunohost/yunohost/pull/1233)) + - [fix] install: Be able to init slapd in a chroot ([#1230](https://github.com/yunohost/yunohost/pull/1230)) + - [i18n] Translations updated for Catalan, Chinese (Simplified), Czech, French, Galician, German + + Thanks to all contributors <3 ! (Christian Wehrli, Éric Gaspar, José M, ljf, Radek S, Salamandar, Stephan Schneider, xaloc33, yahoo~~) + + -- Alexandre Aubin Mon, 24 May 2021 17:20:47 +0200 + +yunohost (4.2.4) stable; urgency=low + + - python3: smtplib's sendmail miserably crashes with encoding issue if accent in mail body (af567c6f) + - ssh_config: add conf block for sftp apps (51478d14) + - ynh_systemd_action: Fix case where service is already stopped ([#1222](https://github.com/yunohost/yunohost/pull/1222)) + - [i18n] Translations updated for German, Italian, Occitan + - Releasing as stable + + Thanks to all contributors <3 ! (Christian Wehrli, Flavio Cristoforetti, Quentí, yalh76) + + -- Alexandre Aubin Sat, 08 May 2021 15:05:43 +0200 + +yunohost (4.2.3.1) testing; urgency=low + + - [fix] Recreate the admins group which for some reason didnt exist on old setups .. (ee83c3f9) + - [i18n] Translations updated for French + + Thanks to all contributors <3 ! (Éric G., ppr) + + -- Alexandre Aubin Wed, 28 Apr 2021 17:59:14 +0200 + +yunohost (4.2.3) testing; urgency=low + + - Fix a stupid issue where an app's tmp work dir would be deleted during upgrade because of the backup process (50af0393) + - cli ux: Don't suggest that we can remove multiple apps (4ae72cc3) + - ynh_port_available: also check ports used by other apps in settings.yml (381f789f) + - ssh: Add ssh.app, sftp.app groups to cover my_webapp and borg needing ssh access ([#1216](https://github.com/yunohost/yunohost/pull/1216)) + - i18n: Translations updated for German + + Thanks to all contributors <3 ! (Bram, Christian W.) + + -- Alexandre Aubin Mon, 26 Apr 2021 16:29:17 +0200 + +yunohost (4.2.2) testing; urgency=low + + - permissions: Add SFTP / SSH permissions ([#606](https://github.com/yunohost/yunohost/pull/606)) + - refactoring: Uniformize API routes ([#1192](https://github.com/yunohost/yunohost/pull/1192)) + - settings: New setting to disable the 'YunoHost' panel overlay in apps ([#1071](https://github.com/yunohost/yunohost/pull/1071), 08fbfa2e) + - settings: New setting for custom ssh port ([#1209](https://github.com/yunohost/yunohost/pull/1209), 37c0825e, 95999fea) + - security: Redact 'passphrase' settings from logs ([#1206](https://github.com/yunohost/yunohost/pull/1206)) + - security: Sane default permissions for files added using ynh_add_config and ynh_setup_source ([#1188](https://github.com/yunohost/yunohost/pull/1188)) + - backup: Support having .tar / .tar.gz in the archive name arg of backup_info/restore (00ec7b2f) + - backup: Don't backup crons + manage crons from the regenconf ([#1184](https://github.com/yunohost/yunohost/pull/1184)) + - backup: Drop support for archive restore from prior 3.8 ([#1203](https://github.com/yunohost/yunohost/pull/1203)) + - backup: Introduce hooks during restore to apply migrations between archive version and current version ([#1203](https://github.com/yunohost/yunohost/pull/1203)) + - backup: Create a proper operation log for backup_create (fe9f0731) + - backup: Improve error management for app restore ([#1191](https://github.com/yunohost/yunohost/pull/1191)) + - backup: Rework content of system backups ([#1185](https://github.com/yunohost/yunohost/pull/1185)) + - backup: Add a --dry-run option to backup_create to fetch an estimate of the backup size ([#1205](https://github.com/yunohost/yunohost/pull/1205)) + - helpers: Add --keep option to ynh_setup_source to keep files that may be overwritten during upgrade ([#1200](https://github.com/yunohost/yunohost/pull/1200)) + - helpers: Bump 'n' to version 7.1.0 ([#1197](https://github.com/yunohost/yunohost/pull/1197)) + - mail: Support SMTPS Relay ([#1159](https://github.com/yunohost/yunohost/pull/1159)) + - nginx: add header to disallow FLoC ([#1211](https://github.com/yunohost/yunohost/pull/1211)) + - app: Add route to fetch app manifest for custom app installs in a forge-agnostic way ([#1213](https://github.com/yunohost/yunohost/pull/1213)) + - perf: add optional 'apps' argument to user_permission_list to speed up user_info / user_list (e6312db3) + - ux: Add '--human-readable' to recommended command to display diagnosis issues in cli ([#1207](https://github.com/yunohost/yunohost/pull/1207)) + - Misc enh/fixes, code quality (42f8c9dc, 86f22d1b, 1468073f, b33e7c16, d1f0064b, c3754dd6, 02a30125, aabe5f19, ce9f6b3d, d7786662, f9419c96, c92e495b, 0616d632, 92eb9704, [#1190](https://github.com/yunohost/yunohost/pull/1190), [#1201](https://github.com/yunohost/yunohost/pull/1201), [#1210](https://github.com/yunohost/yunohost/pull/1210), [#1214](https://github.com/yunohost/yunohost/pull/1214), [#1215](https://github.com/yunohost/yunohost/pull/1215)) + - i18n: Translations updated for French, German + + Thanks to all contributors <3 ! (axolotle, Bram, cyxae, Daniel, Éric G., grenagit, Josué, Kay0u, lapineige, ljf, Scapharnaum) + + -- Alexandre Aubin Sat, 17 Apr 2021 03:45:49 +0200 + +yunohost (4.2.1.1) testing; urgency=low + + - [fix] services.py, python3: missing decode() in subprocess output fetch (357c151c) + - [fix] log.py: don't inject log_ref if the operation didnt start yet (f878d61f) + - [fix] dyndns.py: Missing raw_msg=True (008e9f1d) + - [fix] firewall.py: Don't miserably crash when there are port ranges (6fd5f7e8) + - [fix] nginx conf: CSP rules for admin was blocking small images used for checkboxes, radio, pacman in the new webadmin (575fab8a) + + -- Alexandre Aubin Sun, 11 Apr 2021 20:15:11 +0200 + +yunohost (4.2.1) testing; urgency=low + + - security: Various permissions tweaks to protect from malicious yunohost users (aefc100a, fc26837a) + + -- Alexandre Aubin Sat, 10 Apr 2021 01:08:04 +0200 + +yunohost (4.2.0) testing; urgency=low + + - [mod] Python2 -> Python3 ([#1116](https://github.com/yunohost/yunohost/pull/1116), a97a9df3, 1387dff4, b53859db, f5ab4443, f9478b93, dc6033c3) + - [mod] refactoring: Drop legacy-way of passing arguments in hook_exec, prevent exposing secrets in command line args ([#1096](https://github.com/yunohost/yunohost/pull/1096)) + - [mod] refactoring: use regen_conf instead of service_regen_conf in settings.py (9c11fd58) + - [mod] refactoring: More consistent local CA management for simpler postinstall ([#1062](https://github.com/yunohost/yunohost/pull/1062)) + - [mod] refactoring: init folders during .deb install instead of regen conf ([#1063](https://github.com/yunohost/yunohost/pull/1063)) + - [mod] refactoring: init ldap before the postinstall ([#1064](https://github.com/yunohost/yunohost/pull/1064)) + - [mod] refactoring: simpler and more consistent logging initialization ([#1119](https://github.com/yunohost/yunohost/pull/1119), 0884a0c1) + - [mod] code-quality: add CI job to auto-format code, fix linter errors ([#1142](https://github.com/yunohost/yunohost/pull/1142), [#1161](https://github.com/yunohost/yunohost/pull/1161), 97f26015, [#1162](https://github.com/yunohost/yunohost/pull/1162)) + - [mod] misc: Prevent the installation of apache2 ... ([#1148](https://github.com/yunohost/yunohost/pull/1148)) + - [mod] misc: Drop old cache rules for .ms files, not relevant anymore ([#1150](https://github.com/yunohost/yunohost/pull/1150)) + - [fix] misc: Abort postinstall if /etc/yunohost/apps ain't empty ([#1147](https://github.com/yunohost/yunohost/pull/1147)) + - [mod] misc: No need for mysql root password anymore ([#912](https://github.com/YunoHost/yunohost/pull/912)) + - [fix] app operations: wait for services to finish reloading (4a19a60b) + - [enh] ux: Improve error semantic such that the webadmin can autoredirect to the proper log view ([#1077](https://github.com/yunohost/yunohost/pull/1077), [#1187](https://github.com/YunoHost/yunohost/pull/1187)) + - [mod] cli/api: Misc command and routes renaming / aliasing ([#1146](https://github.com/yunohost/yunohost/pull/1146)) + - [enh] cli: Add a new "yunohost app search" command ([#1070](https://github.com/yunohost/yunohost/pull/1070)) + - [enh] cli: Add '--remove-apps' (and '--force') options to "yunohost domain remove" ([#1125](https://github.com/yunohost/yunohost/pull/1125)) + - [enh] diagnosis: Report low total space for rootfs ([#1145](https://github.com/yunohost/yunohost/pull/1145)) + - [fix] upnp: Handle port closing ([#1154](https://github.com/yunohost/yunohost/pull/1154)) + - [fix] dyndns: clean old madness, improve update strategy, improve cron management, delete dyndns key upon domain removal ([#1149](https://github.com/yunohost/yunohost/pull/1149)) + - [enh] helpers: Adding composer helper ([#1090](https://github.com/yunohost/yunohost/pull/1090)) + - [enh] helpers: Upgrade n to v7.0.2 ([#1178](https://github.com/yunohost/yunohost/pull/1178)) + - [enh] helpers: Add multimedia helpers and hooks ([#1129](https://github.com/yunohost/yunohost/pull/1129), 47420c62) + - [enh] helpers: Normalize conf template handling for nginx, php-fpm, systemd and fail2ban using ynh_add_config ([#1118](https://github.com/yunohost/yunohost/pull/1118)) + - [fix] helpers, doc: Update template for the new doc (grav) ([#1167](https://github.com/yunohost/yunohost/pull/1167), [#1168](https://github.com/yunohost/yunohost/pull/1168), 59d3e387) + - [enh] helpers: Define YNH_APP_BASEDIR to be able to properly point to conf folder depending on the app script we're running ([#1172](https://github.com/yunohost/yunohost/pull/1172)) + - [enh] helpers: Use jq / output-as json to get info from yunohost commands instead of scraping with grep ([#1160](https://github.com/yunohost/yunohost/pull/1160)) + - [fix] helpers: Misc fixes/enh (b85d959d, db93b82b, ce04570b, 07f8d6d7) + - [fix] helpers: download ynh_setup_source stuff in /var/cache/yunohost to prevent situations where it ends up in /etc/yunohost/apps/ (d98ec6ce) + - [i18n] Translations updated for Catalan, Chinese (Simplified), Czech, Dutch, French, German, Italian, Occitan, Polish + + Thanks to all contributors <3 ! (Bram, Christian W., Daniel, Dave, Éric G., Félix P., Flavio C., Kay0u, Krzysztof N., ljf, Mathieu M., Miloš K., MrMorals, Nils V.Z., penguin321, ppr, Quentí, Radek S, Scapharnaum, Sébastien M., xaloc33, yalh76, Yifei D.) + + -- Alexandre Aubin Thu, 25 Mar 2021 01:00:00 +0100 + +yunohost (4.1.7.4) stable; urgency=low + + - [fix] sec: Enforce permissions for /home/yunohost.backup and .conf (41b5a123) + + -- Alexandre Aubin Thu, 11 Mar 2021 03:08:10 +0100 + +yunohost (4.1.7.3) stable; urgency=low + + - [fix] log: Some secrets were not redacted (0c172cd3) + - [fix] log: For some reason sometimes we were redacting 'empty string' which made everything explode (88b414c8) + - [fix] helpers: Various fixes for ynh_add_config / ynh_replace_vars (a43cd72c, 2728801d, 9bbc3b72, 2402a1db, 6ce02270) + - [fix] helpers: Fix permission helpers doc format (d12f403f) + - [fix] helpers: ynh_systemd_action did not properly clean the 'tail' process when service action failed (05969184) + - [fix] i18n: Translation typo in italian translation ... (bd8644a6) + + Thanks to all contributors <3 ! (Kay0u, yalh76) + + -- Alexandre Aubin Tue, 02 Mar 2021 02:03:35 +0100 + +yunohost (4.1.7.2) stable; urgency=low + + - [fix] When migration legacy protected permissions, all users were allowed on the new perm (29bd3c4a) + - [fix] Mysql is a fucking joke (... trying to fix the mysql issue on RPi ...) (cd4fdb2b) + - [fix] Replace \t when converting legacy conf.json.persistent... (f398f463) + + Thanks to all contributors <3 ! (ljf) + + -- Alexandre Aubin Sun, 21 Feb 2021 05:25:49 +0100 + +yunohost (4.1.7.1) stable; urgency=low + + - [enh] helpers: Fix ynh_exec_as regression (ac38e53a7) + + -- Alexandre Aubin Wed, 03 Feb 2021 16:59:05 +0100 + +yunohost (4.1.7) stable; urgency=low + + - [fix] diagnosis: Handle case where DKIM record is split into several pieces (4b876ff0) + - [fix] i18n: de locale was broken (4725e054) + - [enh] diagnosis: Ignore /dev/loop devices in systemresources (536fd9be) + - [fix] backup: fix a small issue dur to var not existing in some edge case ... (2fc016e3) + - [fix] settings: service_regen_conf is deprecated in favor of regen_conf (62e84d8b) + - [fix] users: If uid is less than 1001, nsswitch ignores it (4e335e07, aef3ee14) + - [enh] misc: fixes/enh in yunoprompt (5ab5c83d, 9fbd1a02) + - [enh] helpers: Add ynh_exec_as (b94ff1c2, 6b2d76dd) + - [fix] helpers: Do not ynh_die if systemctl action fails, to avoid exiting during a remove script (29fe7c31) + - [fix] misc: logger.exception -> logger.error (08e7b42c) + + Thanks to all contributors <3 ! (ericgaspar, Kayou, ljf) + + -- Alexandre Aubin Tue, 02 Feb 2021 04:18:01 +0100 + +yunohost (4.1.6) stable; urgency=low + + - [fix] Make dyndns update more resilient to ns0.yunohost.org being down ([#1140](https://github.com/yunohost/yunohost/pull/1140)) + - [fix] Stupid yolopatch for not-normalized app path settings ([#1141](https://github.com/yunohost/yunohost/pull/1141)) + - [i18n] Update translations for German + + Thanks to all contributors <3 ! (Christian W., Daniel, penguin321) + + -- Alexandre Aubin Wed, 20 Jan 2021 01:46:02 +0100 + +yunohost (4.1.5) stable; urgency=low + + - [fix] Update helpers ([#1136](https://github.com/yunohost/yunohost/pull/11346)) + - [fix] Certificate during regen conf on some setup (1d2b1d9) + - [fix] Empty password is not an error if it's optional ([#1135](https://github.com/yunohost/yunohost/pull/11345)) + - [fix] Remove useless warnings during system backup ([#1138](https://github.com/yunohost/yunohost/pull/11348)) + - [fix] We can now use "true" or "false" for a boolean ([#1134](https://github.com/yunohost/yunohost/pull/1134)) + - [i18n] Translations updated for Catalan, French, Italian, Spanish + + Thanks to all contributors <3 ! (Aleks, Kay0u, Omnia89, jorge-vitrubio, YohannEpitech, xaloc33) + + -- Kayou Thu, 14 Jan 2021 21:23:39 +0100 + +yunohost (4.1.4.4) stable; urgency=low + + - [fix] Add the -F flag to grep command for fixed string mode, prevent special chars in the password to be interpreted as regex pattern ([#1132](https://github.com/yunohost/yunohost/pull/1132)) + - [fix] apt helpers: explicitly return 0, otherwise the return code of last command is used, which in that case is 1 ... (c56883d0) + + Thanks to all contributors <3 ! (Saxodwarf) + + -- Alexandre Aubin Mon, 11 Jan 2021 14:17:37 +0100 + +yunohost (4.1.4.3) stable; urgency=low + + - [fix] ynh_replace_vars in case var is defined but empty (30dde208) + + -- Alexandre Aubin Sun, 10 Jan 2021 01:58:35 +0100 + +yunohost (4.1.4.2) stable; urgency=low + + - [fix] Prevent info from being redacted (because of foobar_key=) by the logging system (8f1b05f3) + - [fix] For some reason sometimes submetadata is None ... (00508c96) + - [enh] Reduce the noise in logs because of ynh_app_setting (ac4b62ce) + + -- Alexandre Aubin Sat, 09 Jan 2021 18:59:01 +0100 + +yunohost (4.1.4.1) stable; urgency=low + + - [hotfix] Postfix conf always included the relay snippets (b25cde0b) + + -- Alexandre Aubin Fri, 08 Jan 2021 16:21:07 +0100 + +yunohost (4.1.4) stable; urgency=low + + - [fix] firewall: force source port for UPnP. ([#1109](https://github.com/yunohost/yunohost/pull/1109)) + - Stable release + + Thanks to all contributors <3 ! (Léo Le Bouter) + + -- Alexandre Aubin Fri, 08 Jan 2021 03:09:14 +0100 + +yunohost (4.1.3) testing; urgency=low + + - [enh] Do not advertise upgrades for bad-quality apps ([#1066](https://github.com/yunohost/yunohost/pull/1066)) + - [enh] Display domain_path of app in the output of app list ([#1120](https://github.com/yunohost/yunohost/pull/1120)) + - [enh] Diagnosis: report usage of backports repository in apt's sources.list ([#1069](https://github.com/yunohost/yunohost/pull/1069)) + - [mod] Code cleanup, misc fixes (165d2b32, [#1121](https://github.com/yunohost/yunohost/pull/1121), [#1122](https://github.com/yunohost/yunohost/pull/1122), [#1123](https://github.com/yunohost/yunohost/pull/1123), [#1131](https://github.com/yunohost/yunohost/pull/1131)) + - [mod] Also display app label on remove_domain with apps ([#1124](https://github.com/yunohost/yunohost/pull/1124)) + - [enh] Be able to change user password in CLI without writing it in clear ([#1075](https://github.com/YunoHost/yunohost/pull/1075)) + - [enh] New permissions helpers ([#1117](https://github.com/yunohost/yunohost/pull/1117)) + - [i18n] Translations updated for French, German + + Thanks to all contributors <3 ! (C. Wehrli, cricriiiiii, Kay0u, Bram, ljf, ppr) + + -- Alexandre Aubin Thu, 07 Jan 2021 00:46:09 +0100 + +yunohost (4.1.2) testing; urgency=low + + - [enh] diagnosis: Detect moar hardware name (b685a274) + - [fix] permissions: Handle regexes that may start with ^ or \ (bdff5937) + - [fix] permissions: Tile/protect status for legacy migration ([#1113](https://github.com/yunohost/yunohost/pull/1113)) + - [fix] domain: double return prevent new code from working (0c977d8c) + - [fix] settings: When encountering unknown setting, also save the regular setting so we don't re-encounter the unknown settings everytime (d77d5afb) + - [fix] users: only ask for one letter for first/last name ([#1114](https://github.com/yunohost/yunohost/pull/1114)) + - [fix] apt/sury: Tweak app helpers to not mess with Sury's pinning ([#1110](https://github.com/yunohost/yunohost/pull/1110)) + - [i18n] Translations updated for German + + Thanks to all contributors <3 ! (Bram, C. Wehrli, Kayou) + + -- Alexandre Aubin Thu, 31 Dec 2020 16:26:51 +0100 + +yunohost (4.1.1) testing; urgency=low + + - [fix] Backup/restore DKIM keys ([#1098](https://github.com/yunohost/yunohost/pull/1098), [#1100](https://github.com/yunohost/yunohost/pull/1100)) + - [fix] Backup/restore Dyndns keys ([#1101](https://github.com/yunohost/yunohost/pull/1101)) + - [fix] mail: Add a max limit to number of recipients ([#1094](https://github.com/yunohost/yunohost/pull/1094)) + - [fix] mail: Do not enforce encryption for relays .. some don't support it ... (11fe9d7e) + - [i18n] Translations updated for French, German, Italian, Occitan + + Misc small fixes: + + - [fix] misc: Prevent running `yunohost domain dns-conf` on arbirary domains ([#1099](https://github.com/yunohost/yunohost/pull/1099)) + - [enh] misc: We don't care that 'apt-key output should not be parsed' (5422a49d) + - [fix] dnsmasq: Avoid to define wildcard records locally ([#1102](https://github.com/yunohost/yunohost/pull/1102)) + - [fix] ssowat: Fix indent ([#1103](https://github.com/yunohost/yunohost/pull/1103)) + - [fix] nginx: Force-disable gzip for acme-challenge (c5d06af2) + - [enh] app helpers: Handle change php version ([#1107](https://github.com/yunohost/yunohost/pull/1107)) + - [fix] permissions: Misc fixes ([#1104](https://github.com/yunohost/yunohost/pull/1104), [#1105](https://github.com/yunohost/yunohost/pull/1105)) + - [fix] certificates: Use organization name to check if from Lets Encrypt ([#1093](https://github.com/yunohost/yunohost/pull/1093)) + - [enh] ldap: Increase ldap search size limit? ([#1074](https://github.com/yunohost/yunohost/pull/1074)) + - [fix] app helpers: Avoid unecessarily reloading php7.3 too fast ([#1108](https://github.com/yunohost/yunohost/pull/1108)) + - [fix] log: Fix a small issue where metadata could be None (because of empty yaml maybe?) (f9143d53) + + Thanks to all contributors <3 ! (Christian Wehrli, Eric COURTEAU, Flavio Cristoforetti, Kay0u, Kayou, ljf, ljf (zamentur), Quentí) + + -- Alexandre Aubin Sat, 19 Dec 2020 01:33:36 +0100 + +yunohost (4.1.0) testing; urgency=low + + - [enh] Extends permissions features, improve legacy settings handling (YunoHost#861) + - [enh] During app installs, add a default answer for user-type questions (YunoHost#982) + - [enh] Default questions for common app manifest arguments (YunoHost#981) + - [enh] Only upgrade apps if version actually changed (YunoHost#864) + - [enh] Create uncompressed backup archives by default (instead of .tar.gz) (YunoHost#1020) + - [enh] Add possibility to download backups (YunoHost#1046) + - [enh] Asking an email address during user creation was confusing, now define it a username@domain by default (admin only chooses the domain) (YunoHost#962) + - [enh] Be able to configure an smtp relay (YunoHost#773) + - [enh] Add a diagnosis to detect processes rencently killed by oom_reaper (YunoHost/f5acbffb) + - [enh] Simplify operation log list (YunoHost#955) + - [enh] Smarter sorting of domain list (YunoHost#860) + - [fix] Accept '+' sign in mail forward adresses (YunoHost#818) + - [enh] Add x509 fingerprint in /etc/issue (YunoHost#1056) + - [enh] Add ynh_add_config helper (YunoHost#1055) + - [enh] Upgrade n version (YunoHost#1073) + - [enh] Clean /usr/bin/yunohost, make it easier to use yunohost as a python lib (YunoHost#922) + - [enh] Lazy loading of smtplib to reduce memory footprint a bit (0f2e9ab1) + - [enh] Refactor manifest arguments parsing (YunoHost#1013) + - [enh] Detect misformated arguments in getopts (YunoHost#1052) + - [enh] Refactor app download process, make it github-independent (YunoHost#1049) + - [fix] Test at the beginning of postinstall that iptables is working instead of miserably crashing later (YunoHost/f73ae4ee) + - [enh] Service logs: journalctl -x in fact makes everything bloated, the supposedly additional info it displays does not contains anything relevant... (YunoHost/452b178d) + - [enh] Add redis hook to enforce permissions on /var/log/redis (YunoHost/a1c1057a) + - [enh] Add configuration tests for dnsmasq, fail2ban, slapd (YunoHost/6e69df37) + - [enh] Remove some old fail2ban jails that do not exists anymore (YunoHost/2c6736df) + - [enh] Get rid of yunohost.local in main domain nginx conf (YunoHost/ba884d5b) + - [enh] Ignore some unimportant apt warnings (YunoHost/199cc50) + - [enh] Create the helper doc on new version (YunoHost#1080) + - [enh] The email "abuse@you_domain.tld" is now unavailable for security reason (YunoHost/67e03e6) + - [enh] Remove some warnings during backup (YunoHost#1047) + - [i18n] Translations updated for Catalan, Chinese (Simplified), French, German, Italian, Occitan, Portuguese + + Thanks to all contributors <3 ! (Aleks, Augustin T., Baptiste W., Bram, Christian W., Colin W., cyxae, ekhae, Éric G., Félix P., Josué, Julien J., Kayou, Leandro N., ljf, Maniack C, ppr, Quentí, Quentin D., SiM, yalh76, Yifei D., xaloc33) + + -- Kay0u Thu, 03 Dec 2020 16:34:38 +0100 + +yunohost (4.0.8.3) stable; urgency=low + + - [fix] Certificate renewal for LE (#1092) + + Thanks to all contributors <3 ! (frju365) + + -- Kay0u Thu, 03 Dec 2020 14:01:03 +0000 + +yunohost (4.0.8.2) stable; urgency=low + + - [fix] intermediate_certificate is now included in signed certificate (#1067) + + Thanks to all contributors <3 ! (Bram) + + -- Alexandre Aubin Wed, 04 Nov 2020 23:32:16 +0100 + +yunohost (4.0.8.1) stable; urgency=low + + - [fix] App installs logs were still disclosing secrets when shared sometimes ... + + -- Alexandre Aubin Wed, 04 Nov 2020 17:24:52 +0100 + +yunohost (4.0.8) stable; urgency=low + + - [fix] Diagnose ssl libs installed from sury (#1053) + - [enh] Better problematic apt dependencies auto-investigation mechanism (#1051, 8d4f36e1) + - [fix] Force locale to C during postgresql migration to avoid some stupid issue related to locale (d532cd5e) + - [fix] Use php7.3 by default in CLI (82c0cc92) + - [fix] Typo in fpm_config helper led to install process hanging forever (7dcf4b00) + + Thanks to all contributors <3 ! (Kayou) + + -- Alexandre Aubin Wed, 16 Sep 2020 16:23:04 +0200 + +yunohost (4.0.7.1) stable; urgency=low + + - Forbid users from using SSH as a VPN (even if SSH login is disabled) (#1050) + + Thanks to all contributors <3 ! (ljf) + + -- Alexandre Aubin Fri, 11 Sep 2020 21:06:09 +0200 + +yunohost (4.0.7) stable; urgency=low + + - [fix] Require explicitly php7.3-foo packages because in some cases Sury's php7.4- packages are installed and php7.3-fpm doesn't get installed ... (1288159a) + - [fix] Make sure app nginx confs do not prevent the loading of /yunohost/sso (#1044) + + Thanks to all contributors <3 ! (Kayou, ljf) + + -- Alexandre Aubin Fri, 04 Sep 2020 14:32:07 +0200 + +yunohost (4.0.6.1) stable; urgency=low + + - [fix] Stupid syntax issue in dovecot conf + + -- Alexandre Aubin Tue, 01 Sep 2020 02:00:19 +0200 + +yunohost (4.0.6) stable; urgency=low + + - [mod] Add apt conf regen hook to manage sury pinning policy (#1041) + - [fix] Use proper templating + handle xmpp-upload.domain.tld in dnsmasq conf (bc7344b6, 503e08b5) + - [fix] Explicitly require php-fpm >= 7.3 ... (41813744) + - [i18n] Translations updated for Catalan, French, German + + Thanks to all contributors <3 ! (Christian W., Titus PiJean, xaloc33) + + -- Alexandre Aubin Mon, 31 Aug 2020 19:57:24 +0200 + +yunohost (4.0.5) testing; urgency=low + + - [enh] Update postfix, dovecot, nginx configuration according to Mozilla guidelines (Buster + DH params) (f3a4334a, 89bcf1ba, 2d661737) + - [enh] Update acme_tiny to 4.1.0 (#1037) + - [fix] ref to variable in i18n string (c.f. issue 1647) (7b1f02e0) + - [fix] Recursively enforce ownership for rspamd (8454f2ec) + - [fix] Stupid encoding issue when fetching service description (6ec0e7b6) + - [fix] Misc fixes for CI (ca0a42f2, 485c65a9, #1038, a891d20a) + + Thanks to all contributors <3 ! (Eric G., Kay0u) + + -- Alexandre Aubin Tue, 25 Aug 2020 19:32:27 +0200 + +yunohost (4.0.4) stable; urgency=low + + - Debugging and robustness improvements for postgresql 9.6 -> 11 and xtables->nftables migrations (accc2da4, 59bd7d66, 4cb6f7fd, 4b14402c) + + -- Alexandre Aubin Wed, 12 Aug 2020 18:14:00 +0200 + +yunohost (4.0.3) stable; urgency=low + + - Bump version number for stable release + + -- Alexandre Aubin Wed, 29 Jul 2020 17:00:00 +0200 + +yunohost (4.0.2~beta) testing; urgency=low + + - [mod] Rebase on stretch-unstable to include recent changes + - [fix] Create admin's home during postinstall (#1021) + + Thanks to all contributors <3 ! (Kay0u) + + -- Alexandre Aubin Fri, 19 Jun 2020 15:16:26 +0200 + +yunohost (4.0.1~alpha) testing; urgency=low + + - [fix] It just make no sense to backup/restore the mysql password... (#911) + - [fix] Fix getopts and helpers (#885, #886) + - [fix] Explicitly create home using mkhomedir_helper instead of obscure pam rule that doesn't work anymore (b67ff314) + - [fix] Ldap interface seems to expect lists everywhere now? (fb8c2b7b) + - [deb] Clean control file, remove some legacy Conflicts and Replaces (ca0d4933) + - [deb] Add conflicts with versions from backports for critical dependencies (#967) + - [cleanup] Stale / legacy code (217aaa36, d77da6a0, af047468, 82d468a3) + - [conf] Automatically disable/stop systemd-resolved that conflicts with dnsmasq on fresh setups ... (e7214b37) + - [conf] Remove deprecated option in sshd conf, c.f. https://patchwork.openembedded.org/patch/139981/ (2723d245) + - [conf] Small tweak in dovecot conf (deprecated settings) (dc0481e2) + - [conf] Update nslcd and nsswitch stuff using new Buster's default configs + get rid of nslcd service, only keep the regen-conf part (6ef3520f) + - [php] Migrate from php7.0 to php7.3 (3374e653, 9be10506, dd9564d3, 9679c291, 212a15e4, 25fcaa19, c4ad66f5) + - [psql] Migrate from psql 11 to 9.6 (e88aed72, 4920d4f9, c70b0ae4) + - [firewall] Migrate from xtable to nftable (05fb58f2, 2c4a8b73, 625d5372) + - [slapd] Rework slapd regenconf to use new backend (#984) + + Thanks to all contributors <3 ! (Étienne M., Josué, Kay0u) + + -- Alexandre Aubin Fri, 05 Jun 2020 03:10:09 +0200 + +yunohost (3.8.5.5) stable; urgency=low + + - [enh] Allow to extend the nginx default_server configuration (f1bfc521) + - [mod] Move redirect to /yunohost/admin to a separate nginx conf file to allow customizing it more easily (ac9182d6) + - [enh] Make sure to validate/upgrade that we don't have any active weak certificate used by nginx at the beginning of the buster migration, otherwise nginx will later miserably fail to start (d4358897) + - [fix] get_files_diff crashing if {orig,new}_file is None (7bfe564a) + - [enh] Remove some useless message about file that "wasn't deleted because it doesn't exist." (#1024) + - [mod] Remove useless robot protection code (#1026) + - [fix] Let's not redefine the value for the 'service' var ... (1a2f26dc) + - [fix] More general stretch->buster patching for sources.list (#1028) + - [mod] Tweak custom disclaimer about the migration still being a bit touchy in preparation for stable release (852dea07) + - [mod] Typo/wording in en.json (#1030) + - [i18n] Translations updated for Catalan, French, Italian, Occitan + + Thanks to all contributors <3 ! (É. Gaspar, Kay0u, L. Noferini, ppr, Quentí, xaloc33) + + -- Alexandre Aubin Mon, 27 Jul 2020 19:03:33 +0200 + +yunohost (3.8.5.4) testing; urgency=low + + - [fix] Fix unscd version parsing *again* + - [fix] Enforce permissions on rspamd log directory + - [enh] Ignore stupid warnings about sudo-ldap that is already provided + + -- Alexandre Aubin Sun, 21 Jun 2020 23:37:09 +0200 + +yunohost (3.8.5.3) testing; urgency=low + + - [fix] Fix the fix about unscd downgrade :/ + + -- Alexandre Aubin Fri, 19 Jun 2020 18:50:58 +0200 + +yunohost (3.8.5.2) testing; urgency=low + + - [fix] Small issue with unscd upgrade/downgrade ... new version ain't always 0.53.1, so find it using dirty scrapping + + -- Alexandre Aubin Thu, 18 Jun 2020 16:19:35 +0200 + +yunohost (3.8.5.1) testing; urgency=low + + - [fix] Update Stretch->Buster migration disclaimer to make it clear that this is alpha-stage + + -- Alexandre Aubin Sat, 06 Jun 2020 03:30:00 +0200 + +yunohost (3.8.5) testing; urgency=low + + - [enh] Add migration procedure for Stretch->Buster (a2b83c0f, a26411db, 9f1211e9, e544bf3e, a0511cca) + - [fix] Disable/skip ntp when inside a container (9d0c0924) + + -- Alexandre Aubin Sat, 06 Jun 2020 02:11:51 +0200 + +yunohost (3.8.4.9) stable; urgency=low + + - [fix] Force lowercase on domain names (804f4b3e) + - [fix] Add dirmngr to Depends:, needed for apt-key / gpg (cd115ed8) + - [fix] Improve debugging when diagnosis ain't happy when renewing certs (0f0194be) + - [enh] Add yunohost version to logs metadata (d615546b) + - [enh] Alway filter irrelevant log lines when sharing it (38704cba, 51d53be5) + - [fix] Regen-conf outputing many 'forget-about-it' because of files flagged as to be removed (f4525488) + - [fix] postfix per-domain destination concurrency (#988) + - [fix] Call regenconf for ssh before the general regenconf during the postinstall to avoid an irrelevant warning (7805837b) + - [i18n] Translations updated for Catalan, French, German + + Thanks to all contributors <3 ! (taziden, ljf, ppr, xaloc33, Yasss Gurl) + + -- Alexandre Aubin Thu, 18 Jun 2020 15:13:01 +0200 + +yunohost (3.8.4.8) stable; urgency=low + + - [fix] Don't add unprotected_urls if it's already in skipped_urls (#1005) + - [enh] Add pre-defined DHE group and set up Nginx to use it (#1007) + - [fix] Make sure to propagate change in slapd systemd conf during initial install (2d42480f) + - [fix] More accurate grep to avoid mistakenly grepping commented lines... (2408a620) + - [enh] Update n to 6.5.1 (#1012) + - [fix] Set sury default pinning to 600 (653c5fde) + - [enh] Clean stale file/hashes in regen-conf (#1009) + - [fix] Weirdness in regen-conf mechanism for SSH conf (#1014) + + Thanks to all contributors <3 ! (É. Gaspar, Josué, SohKa) + + -- Alexandre Aubin Sat, 06 Jun 2020 01:59:08 +0200 + +yunohost (3.8.4.7) stable; urgency=low + + - [fix] Remove some remains of glances (17eec25e) + - [fix] Force external resolution for reverse DNS dig (852cd14c) + - [fix] Make sure mysql is an alias to mariadb (e24191ce, ca89607d) + - [fix] Path for ynh_add_fpm_config template in restore (#1001) + - [fix] Add -o Acquire::Retries=3 to fix some stupid network issues happening sometimes with apt (03432349) + - [fix] ynh_setup_source: Retry wget on non-critical failures to try to avoid tmp dns issues (3d66eaec) + - [fix] ynh_setup_source: Calling ynh_print_err in case of error didn't work, and we probably want a ynh_die here (55036fad) + - [i18n] Translations updated for Catalan, French, Italian, Occitan + + Thanks to all contributors <3 ! (JimboJoe, Leandro N., ppr, Quentí, xaloc33, yalh76) + + -- Alexandre Aubin Thu, 04 Jun 2020 02:28:33 +0200 + +yunohost (3.8.4.6) stable; urgency=low + + - [fix] Bump server_names_hash_bucket_size to 128 to avoid nginx exploding for stupid reasons (b3db4d92) + - [fix] More sensible strategy for sury pinning (#1006) + - [fix] Stop trying to fetch log categories that are not implemented yet T.T (77bd9ae3) + - [enh] Add logging and persistent as default config for new muc room (#1008) + - [tests] Moar tests for app args parsing (#1004) + + Thanks to all contributors <3 ! (Gabriel, Kay0u, Bram) + + -- Alexandre Aubin Thu, 28 May 2020 00:22:10 +0200 + +yunohost (3.8.4.5) stable; urgency=low + + - [enh] Tell systemctl to stfu about creating symlinks when enabling/disabling services (6637c8a8) + - [enh] Add maindomain in diagnosis email subject (e30e25fa) + - [fix] Webpath should also be normalized for args_list, so that we can get rid of the 'malformed path' check of the CI... (58ce6e5e) + - [fix] Increase time window for auto diagnosis cron to avoid remote diagnosis server overload (dc221495) + - [fix] encoding bullshit (4c600125, 64596bc1) + - [fix] Typo in diagnosis message + fix FR translation report format of bad DNS conf (#1002, b8f8ea14) + - [fix] Flag old etckeeper.conf as 'should not exist' in regenconf (5a3b382f) + - [enh] Detect dyndns-domains managed by yunohost and advice to use yunohost dyndns update --force (8b169f13) + - [enh] Complain if apps savagely edit system configurations during install and upgrade (a23f02db) + - [i18n] Translations updated for Arabic, Catalan, French, German, Italian + - [tests] CI V2 : Rework CI workflow (#991) + + Thanks to all contributors <3 ! (ButterflyOfFire, Kay0u, L. Noferini, rynas, V. Rubiolo, xaloc33, Yasss Gurl) + + -- Alexandre Aubin Tue, 26 May 2020 03:20:39 +0200 + +yunohost (3.8.4.4) stable; urgency=low + + - [fix] Crash when the services file is empty (85f1802) + - [fix] IPv6 detection when using wg-quick (#997) + - [fix] Use a .get() to avoid crash if key doesn't exist (1f1b2338) + - [enh] Don't display the hostname when calling journalctl, this takes horizontal space for nothing (2bcfb5a1) + - [fix] Add --quiet, otherwise getopts is confused by "-- Logs" at the beginning (bdbf1822) + - [mod] We don't need those color codes... and warnings are already warnings... (2a631fa2) + - [fix] psql_setup_db: Do not create a new password if the user already exists (#998) + - [enh] Add an exception if packaging format is not recognized (f0cc6798) + + Thanks to all contributors <3 ! (Aleks, Julien Rabier, Kayou) + + -- Kay0u Fri, 22 May 2020 19:26:05 +0000 + +yunohost (3.8.4.3) stable; urgency=low + + - [fix] Workaround for the sury pinning issues when installing dependencies + - [i18n] Translations updated for Catalan, French, Occitan + + Thanks to all contributors <3 ! (Aleks, clecle226, Kay0u, ppr, Quenti) + + -- Kay0u Wed, 20 May 2020 18:41:49 +0000 + +yunohost (3.8.4.2) testing; urgency=low + + - [enh] During failed upgrades: Only mention packages that couldn't be upgraded (26fcfed7) + - [enh] Also run dpkg --audit to check if dpkg is in a broken state (09d8500f, 97199d19) + - [enh] Improve logs readability (c6f18496, 9cbd368d, 5850bf61, 413778d2, 5c8c07b8, f73c34bf, 94ea8265) + - [enh] Crash early about apps already installed when attempting to restore (f9e4c96c) + - [fix] Add the damn short hostname to /etc/hosts automagically (c.f. rabbitmq-server) (e67dc791) + - [fix] Don't miserably crash if doveadm fails to run (c9b22138) + - [fix] Diagnosis: Try to not have weird warnings if no diagnosis ran yet... (65c87d55) + - [fix] Diagnosis: Change logic of --email to avoid sending empty mail if some issues are found but ignored (4cd4938e) + - [enh] Diagnosis/services: Report the service status as warning/unknown if service type is oneshot and status exited (dd09758f, 1cd7ffea) + - [fix] Rework ynh_psql_test_if_first_run (#993) + - [tests] Tests for args parsing (#989, 108a3ca4) + + Thanks to all contributors <3 ! (Bram, Kayou) + + -- Alexandre Aubin Tue, 19 May 2020 20:08:47 +0200 + +yunohost (3.8.4.1) testing; urgency=low + + - [mod] Tweak diagnosis threshold for swap warning (429df8c4) + - [fix] Make sure we have a list for log_list + make sure item is in list before using .remove()... (afbeb145, 43facfd5) + - [fix] Sometimes tree-model has a weird \x00 which breaks yunopaste (c346f5f1) + + -- Alexandre Aubin Mon, 11 May 2020 00:50:34 +0200 + +yunohost (3.8.4) testing; urgency=low + + - [fix] Restoration of custom hooks / missing restore hooks (#927) + - [enh] Real CSP headers for the webadmin (#961) + - [enh] Simplify / optimize reading version of yunohost packages... (#968) + - [fix] handle new auto restart of ldap in moulinette (#975) + - [enh] service.py cleanup + add tests for services (#979, #986) + - [fix] Enforce permissions for stuff in /etc/yunohost/ (#963) + - [mod] Remove security diagnosis category for now, Move meltdown check to base system (a799740a) + - [mod] Change warning/errors about swap as info instead ... add a tip about the fact that having swap on SD or SSD is dangerous (23147161) + - [enh] Improve auto diagnosis cron UX, add a --human-readable option to diagnosis_show() (aecbb14a) + - [enh] Rely on new diagnosis for letsencrypt elligibility (#985) + - [i18n] Translations updated for Catalan, Esperanto, French, Spanish + + Thanks to all contributors <3 ! (amirale qt, autra, Bram, clecle226, I. Hernández, Kay0u, xaloc33) + + -- Alexandre Aubin Sat, 09 May 2020 21:20:00 +0200 + +yunohost (3.8.3) testing; urgency=low + + - [fix] Remove dot in reverse DNS check + - [fix] Upgrade of multi-instance apps was broken (#976) + - [fix] Check was broken if an apps with no domain setting was installed (#978) + - [enh] Add a timeout to wget (#972) + - [fix] ynh_get_ram: Enforce choosing --free or --total (#972) + - [fix] Simplify / improve robustness of backup list + - [enh] Make nodejs helpers easier to use (#939) + - [fix] Misc tweak for disk usage diagnosis, some values were inconsistent / bad UX / ... + - [enh] Assert slapd is running to avoid miserably crashing with weird ldap errors + - [enh] Try to show smarter / more useful logs by filtering irrelevant lines like set +x etc + - Technical tweaks for metronome 3.14.0 support + - Misc improvements for tests and linters + + Thanks to all contributors <3 ! (Bram, Kay0u, Maniack C., ljf, Maranda) + + -- Alexandre Aubin Thu, 07 Apr 2020 04:00:00 +0000 + +yunohost (3.8.2.2) testing; urgency=low + + Aleks broke everything /again/ *.* + + -- Alexandre Aubin Thu, 30 Apr 2020 18:05:00 +0000 + +yunohost (3.8.2.1) testing; urgency=low + + - [fix] Make sure DNS queries are dong using absolute names to avoid stupid issues + - [fix] More reliable way to fetch PTR record / reverse DNS + - [fix] Propagate IPv6 default route check to ip diagnoser code as well + + Thanks to ljf for the tests and fixes ! + + -- Alexandre Aubin Thu, 30 Apr 2020 17:30:00 +0000 + +yunohost (3.8.2) testing; urgency=low + + ### Diagnosis + + - [fix] Some DNS queries triggered false negatives about CNAME/A record and email blacklisting (#943) + - [enh] Add a check about domain expiration (#944) + - [enh] Dirty hack to automatically find custom SSH port and diagnose it instead of 22 (b78d722) + - [enh] Add a tip / explanation when IPv6 ain't working / available (426d938) + - [fix] Small false-negative about not having IPv6 when it's actually working (822c731) + + ### Helpers + + - [fix] When setting up a new db, corresponding user should be declared as owner (#813) + - [enh] Add dynamic variables to systemd helper (#937) + - [enh] Clean helpers (#947) + - [fix] getopts behaved in weird way when fed empty parameters (#948) + - [fix] Use ynh_port_available in ynh_find_port (#957) + + ### Others + + - [enh] Setup all XMPP components for each "parent" domains (#916) + - [fix] Previous change in Postfix ciphers broke TLS (#949) + - [fix] Update ACME snippet detection following previous change (#950) + - [fix] Trying to install apps with unpatchable legacy helpers was breaking stuff (#954) + - [fix] Patch usage of old 'yunohost tools diagnosis' (#954) + - [enh] Misc optimizations to speed up regen-conf and other things (#958) + - [enh] When sharing logs, also anonymize folder name containing %2e instead of dot (b392efd) + - [enh] Keep track of yunohost version a backup was made from (54cc684) + - [fix] Re-add 'app fetchlist', 'app list -i', 'app list' filter for backward compatibility... (69938c3) + - [i18n] Improve translations for Catalan, German, French, Esperanto, Spanish, Greek, Nepali, Occitan + + Thanks to all contributors <3 ! (Bram, C. Wehrli, Kay0u, Maniack C., Quentí, Zeik0s, amirale qt, ljf, pitchum, tituspijean, xaloc33, Éric G.) + + -- Alexandre Aubin Wed, 29 Apr 2020 23:15:00 +0000 + +yunohost (3.8.1.1) testing; urgency=low + + - [fix] Stupid issue about path in debian/install ... + + -- Alexandre Aubin Sun, 19 Apr 2020 07:04:00 +0000 + +yunohost (3.8.1) testing; urgency=low + + ## Helpers (PHP, apt) + + - New helpers for extra apt repo, PHP version install, and PHP fpm (#881, #928, #929) + - Pave the way to migration to php7.3 and future ones (#880, #926) + - Option in PHP helper to use a dedicated php service (#915) + + ## Diagnosis + + - Many improvements in diagnosis mechanism (#923, #921, #940) + + ## Misc fixes, improvements + - custom_portal and custom_overlay redirect (#925) + - Improve systemd settings for slapd (#933) + - Spelling and typo corrections (#931) + - Improve translations for French, German, Catalan + + Thanks to all contributors <3 ! (Kay0u, Maniack Crudelis, ljf, E.Gaspar, + xaloc33) + + -- Alexandre Aubin Sun, 19 Apr 2020 06:20:00 +0000 + +yunohost (3.8.0) testing; urgency=low + + # Major stuff + + - [enh] New diagnosis system (#534, #872, #919, a416044, a354425, 4ab3653, decb372, e686dc6, b5d18d6, 69bc124, 937d339, cc2288c, aaa9805, 526a3a2) + - [enh] App categories (#778, #853) + - [enh] Support XMPP http upload (#831) + - [enh] Many small improvements in the way we manage services (#838, fa5c0e9, dd92a34, c97a839) + - [enh] Add subcategories management in bash completion (#839) + - [mod] Add conflict with apache2 and bind9, other minor changes in Depends (#909, 3bd6a7a, 0a482fd) + - [enh] Setting to enable POP3 in email stack (#791) + - [enh] Better UX for CLI/API to change maindomain (#796) + + # Misc technical + + - Update ciphers for nginx, postfix and dovecot according to new Mozilla recommendation (#913, #914) + - Get rid of domain-specific acme-challenge snippet, use a single snippet included in every conf (#917) + - [enh] Persist cookies between multiple ynh_local_curl calls for the same app (#884, #903) + - [fix] ynh_find_port didn't detect port already used on UDP (#827, #907) + - [fix] prevent firefox to mix CA and server certificate (#857) + - [enh] add operation logger for config panel (#869) + - [fix] psql helpers: Revoke sessions before dropping tables (#895) + - [fix] moulinette logs were never displayed #lol (#758) + + # Tests, cleaning, refactoring + + - Add core CI, improve/fix tests (#856, #863, 6eb8efb, c4590ab, 711cc35, 6c24755) + - Refactoring (#805, 101d3be, #784) + - Drop some very-old deprecated app helpers (though still somewhat supporting them through hacky patching) (#780) + - Drop glances and the old monitoring system (#821) + - Drop app_debug (#824) + - Drop app's status.json (#834) + - Drop ynh_add_skipped/(un)protected_uris helpers (#910) + - Use a common security.conf.inc instead of having cipher setting in each nginx's domain file (1285776, 4d99cbe, be8427d, 22b9565) + - Don't add weird tmp redirected_urls after postinstall (#902) + - Don't do weird stuff with yunohost-firewall during debian's postinst (978d9d5) + + # i18n, messaging + + - Unit tests / lint / cleaning for translation files (#901) + - Improve message wording, spelling (8b0c9e5, 9fe43b1, f69ab4c, 0decb64, 986f38f, 8d40c73, 8fe343a, 1d84f17) + - Improve translations for French, Catalan, Bengali (Bangladesh), Italian, Dutch, Norwegian Bokmål, Chinese, Occitan, Spanish, Esperanto, German, Nepali, Portuguese, Arabic, Russian, Hungarian, Hindi, Polish, Greek + + Thanks to all contributors <3 ! (Aeris One, Aleks, Allan N., Alvaro, Armando F., Arthur L., Augustin T., Bram, ButterflyOfFire, Damien P., Gustavo M., Jeroen F., Jimmy M., Josué, Kay0u, Maniack Crudelis, Mario, Matthew D., Mélanie C., Patrick B., Quentí, Yasss Gurl, amirale qt, Elie G., ljf, pitchum, Romain R., tituspijean, xaloc33, yalh76) + + -- Kay0u Thu, 09 Apr 2020 19:59:18 +0000 + +yunohost (3.7.1.3) stable; urgency=low + + - [fix] Fix the hotfix about trailing slash, it was breaking access to app installed on domain root.. + + -- Alexandre Aubin Thu, 28 Apr 2020 19:00:00 +0000 + +yunohost (3.7.1.2) stable; urgency=low + + - [fix] Be more robust against some situation where some archives are corrupted + - [fix] Make nginx regen-conf more robust against broken config or service failing to start, show info to help debugging + - [fix] Force-flush the regen-conf for nginx domain conf when adding/removing a domain... + - [fix] app_map : Make sure to return / and not empty string for stuff on domain root + - [fix] Improve ynh_systemd_action to wait for fail2ban to reload + - [fix] Improper use of logger.exception in app.py leading to infamous weird "KeyError: label" + + -- Alexandre Aubin Mon, 27 Apr 2020 23:50:00 +0000 + +yunohost (3.7.1.1) stable; urgency=low + + - [fix] lxc uid number is limited to 65536 by default (0c9a4509) + - [fix] also invalidate group cache when creating users (aaabf8c7) + - [fix] Make sure to have a path that include sbin for stupid cron jobs (f03bb82a) + + -- Alexandre Aubin Sun, 12 Apr 2020 23:15:00 +0000 + +yunohost (3.7.1) stable; urgency=low + + - [enh] Add ynh_permission_has_user helper (#905) + - [mod] Change behavior of ynh_setting_delete to try to make migrating away from legacy permissions easier (#906) + - [fix] app_config_apply should also return 'app' info (#918) + - [fix] uid/gid conflicts in user_create because of inconsistent comparison (#924) + - [fix] Ensure metronome owns its directories (1f623830, 031f8a6e) + - [mod] Remove useless sudos in helpers (be88a283) + - [enh] Improve message wording for services (3c844292) + - [enh] Attempt to anonymize data pasted to paste.yunohost.org (f56f4724) + - [enh] Lazy load yunohost.certificate to possibly improve perfs (af8981e4) + - [fix] Improve logging / debugging (1eef9b67, 7d323814, d17fcaf9, 210d5f3f) + + Thanks to all contributors <3 ! (Bram, Kay0u, Maniack, Matthew D.) + + -- Alexandre Aubin Thu, 9 Apr 2020 14:52:00 +0000 + +yunohost (3.7.0.12) stable; urgency=low + + - Fix previous buggy hotfix about deleting existing primary groups ... + + -- Alexandre Aubin Sat, 28 Mar 2020 14:52:00 +0000 + +yunohost (3.7.0.11) stable; urgency=low + + - [fix] Mess due to automatic translation tools ~_~ + + -- Kay0u Fri, 27 Mar 2020 23:49:45 +0000 + +yunohost (3.7.0.10) stable; urgency=low + + - [fix] On some weird setup, this folder and content ain't readable by group ... gotta make sure to make rx for group other slapd will explode + + -- Alexandre Aubin Fri, 27 Mar 2020 21:45:00 +0000 + +yunohost (3.7.0.9) stable; urgency=low + + - [fix] Automatically remove existing system group if it exists when creating primary groups + - [fix] Require moulinette and ssowat to be at least 3.7 to avoid funky situations where regen-conf fails because moulinette ain't upgraded yet + - [i18n] Improve translations for Arabic, Bengali, Catalan, Chinese, Dutch, Esperanto, French, German, Greek, Hindi, Hungarian, Italian, Norwegian Bokmål, Occitan, Polish, Portuguese, Russian, Spanish + + Thanks to all contributors <3 ! (Aeris One, Allan N., Alvaro, amirale qt, Armando F., ButterflyOfFire, Elie G., Gustavo M., Jeroen F., Kayou, Mario, Mélanie C., Patrick B., Quentí, tituspijean, xaloc33, yalh76, Yasss Gurl) + + -- Alexandre Aubin Fri, 27 Mar 2020 21:00:00 +0000 + +yunohost (3.7.0.8) stable; urgency=low + + - [fix] App_setting delete add if the key doesn't exist + + -- Kay0u Fri, 27 Mar 2020 00:36:46 +0000 + +yunohost (3.7.0.7) stable; urgency=low + + - [fix] Allow public apps with no sso tile (#894) + - [fix] Slapd now index permission to avoid log error + + Thanks to all contributors <3 ! (Aleks, Kay0u) + + -- Kay0u Thu, 26 Mar 2020 21:53:22 +0000 + +yunohost (3.7.0.6) testing; urgency=low + + - [fix] Make sure the group permission update contains unique elements + + Thanks to all contributors <3 ! (Aleks) + + -- Kay0u Sun, 15 Mar 2020 22:34:27 +0000 + +yunohost (3.7.0.5) testing; urgency=low + + - [fix] Permission url (#871) + - [fix] DNS resolver (#859) + - [fix] Legacy permission management (#868, #855) + - [enh] More informations in hooks permission (#877) + + Thanks to all contributors <3 ! (Bram, ljf, Aleks, Josué, Maniack, Kay0u) + + -- Kay0u Sun, 15 Mar 2020 15:07:24 +0000 + +yunohost (3.7.0.4) testing; urgency=low + + - [fix] Also add all_users when allowing visitors (#855) + - [fix] Fix handling of skipped_uris (c.f. also SSOwat#149) + - [i18n] Improve translations for Catalan + + -- Alexandre Aubin Mon, 2 Dec 2019 20:44:00 +0000 + +yunohost (3.7.0.3) testing; urgency=low + + - [mod] Some refactoring for permissions create/update/reset (#837) + - [fix] Fix some edge cases for ynh_secure_remove and ynh_clean_check_starting + - [i18n] Improve translations for French, Catalan + + -- Alexandre Aubin Sat, 23 Nov 2019 19:30:00 +0000 + +yunohost (3.7.0.2) testing; urgency=low + + - [fix] Make sure the users actually exists when migrating legacy custom permissions + - [mod] Move debug log dump from ynh_exit_properly to the core after failed app operation (#833) + - [enh] Improve app_upgrade error management (#832) + - [mod] Refactor group permission (#837) + - [enh] Add permission name in permission callback when adding/removing allowed users (#836) + - [enh] Improve permission helpers (#840) + - [i18n] Improve translations for German, Catalan, Swedish, Spanish, Turkish, Basque, French, Esperanto, Occitan + + -- Alexandre Aubin Fri, 15 Nov 2019 16:45:00 +0000 + +yunohost (3.7.0.1) testing; urgency=low + + - Hotfix to avoid having a shitload of warnings displayed during the permission migration + + -- Alexandre Aubin Thu, 31 Oct 2019 20:35:00 +0000 + +yunohost (3.7.0) testing; urgency=low + + # ~ Major stuff + + - [enh] Add group and permission mechanism (YunoHost#585, YunoHost#763, YunoHost#789, YunoHost#790, YunoHost#795, YunoHost#797, SSOwat#147, Moulinette#189, YunoHost-admin#257) + - [mod] Rework migration system to have independent migrations (YunoHost#768, YunoHost#774, YunoHost-admin#258) + - [enh] Many improvements in the way app action failures are handled (YunoHost#769, YunoHost#811) + - [enh] Improve checks for system anomalies after app operations (YunoHost#785) + - [mod] Spookier warnings for dangerous app installs (YunoHost#814, Moulinette/808f620) + - [enh] Support app manifests in toml (YunoHost#748, Moulinette#204, Moulinette/55515cb) + - [mod] Get rid of etckeeper (YunoHost#803) + - [enh] Quite a lot of messages improvements, string cleaning, language rework... (YunoHost#793, YunoHost#799, YunoHost#823, SSOwat#143, YunoHost#766, YunoHost#767, YunoHost/fd99ef0, YunoHost/92a6315, YunoHost-admin/10ea04a, Moulinette/599bec3, Moulinette#208, Moulinette#213, Moulinette/b7d415d, Moulinette/a8966b8, Moulinette/fdf9a71, Moulinette/d895ae3, Moulinette/bdf0a1c, YunoHost#817, YunoHost#823, YunoHost/79627d7, YunoHost/9ee3d23, YunoHost-admin#265) + - [i18n] Improved translations for Catalan, Occitan, French, Esperanto, Arabic, German, Spanish, Norwegian Bokmål, Portuguese + + # Smaller or pretty technical fix/enh + + - [enh] Add unit/functional tests for apps + improve other tests (YunoHost#779, YunoHost#808) + - [enh] Preparations for moulinette Python3 migration (Tox, Pytest and unit tests) (Moulinette#203, Moulinette#206, Moulinette#207, Moulinette#210, Moulinette#211 Moulinette#212, Moulinette/2403ee1, Moulinette/69b0d49, Moulinette/49c749c, Moulinette/2c84ee1, Moulinette/cef72f7, YunoHost/6365a26) + - [enh] Support python hooks (YunoHost#747) + - [enh] Upgrade n version + compatibility with arm64 (YunoHost#753) + - [enh] Add OpenLDAP TLS support (YunoHost#755, YunoHost/0a2d1c7, YunoHost/2dc8095) + - [enh] Improve PostgreSQL password security (YunoHost#762) + - [enh] Integrate actions/config-panel into operation logs (YunoHost#764) + - [mod] Assume that apps without any 'path' setting defined aren't webapps (YunoHost#765) + - [fix] Set dpkg vendor to YunoHost (YunoHost#749, YunoHost#772) + - [enh] Adding variable 'token' to data to redact from logs (YunoHost#783) + - [enh] Add --force and --dry-run options to 'yunohost dyndns update' (YunoHost#786) + - [fix] Don't throw a fatal error if we can't change the hostname (YunoHost/fe3ecd7) + - [enh] Dynamically evaluate proper mariadb-server- (YunoHost/f0440fb) + - [fix] Bad format for backup info.json ... (YunoHost/7d0119a) + - [fix] Inline buttons responsiveness on migration screen (YunoHost-admin#259) + - [enh] Add debug logs to SSOwat (SSOwat#145) + - [enh] Add a write_to_yaml utility similar to write_to_json (Moulinette/2e2e627) + - [enh] Warn the user about long locks (Moulinette#205) + - [mod] Tweak stuff about setuptools and moulinette deps? (Moulinette/b739f27, Moulinette/da00fc9, Moulinette/d8cbbb0) + - [fix] Misc micro bugfixes or improvements (YunoHost#743, YunoHost#792, YunoHost/6f48d1d, YunoHost/d516cf8, YunoHost#819, Moulinette/83d9e77, YunoHost/63d364e, YunoHost/68e9724, YunoHost/0849adb, YunoHost/19dbe87, YunoHost/61931f2, YunoHost/6dc720f, YunoHost/4def4df, SSOwat#140, SSOwat#141, YunoHost#829) + - [doc] Fix doc building + add doc build tests with Tox (Moulinette/f1ac5b8, Moulinette/df7d478, Moulinette/74c8f79, Moulinette/bcf92c7, Moulinette/af2c80c, Moulinette/d52a574, Moulinette/307f660, Moulinette/dced104, Moulinette/ed3823b) + - [enh] READMEs improvements (YunoHost/b3398e7, SSOwat/ee67b6f, Moulinette/1541b74, Moulinette/ad1eeef, YunoHost/25afdd4, YunoHost/73741f6) + + Thanks to all contributors <3 ! (accross all repo: Yunohost, Moulinette, SSOwat, Yunohost-admin) : advocatux, Aksel K., Aleks, Allan N., amirale qt, Armin P., Bram, ButterflyOfFire, Carles S. A., chema o. r., decentral1se, Emmanuel V., Etienne M., Filip B., Geoff M., htsr, Jibec, Josué, Julien J., Kayou, liberodark, ljf, lucaskev, Lukas D., madtibo, Martin D., Mélanie C., nr 458 h, pitfd, ppr, Quentí, sidddy, troll, tufek yamero, xaloc33, yalh76 + + -- Alexandre Aubin Thu, 31 Oct 2019 18:00:00 +0000 + +yunohost (3.6.5.3) stable; urgency=low + + - [fix] More general grep for the php/sury dependency nightmare fix (followup of #809) + + -- Alexandre Aubin Tue, 29 Oct 2019 03:48:00 +0000 + +yunohost (3.6.5.2) stable; urgency=low + + - [fix] Alex was drunk and released an epic stupid bug in stable (2623d385) + + -- Alexandre Aubin Thu, 10 Oct 2019 01:00:00 +0000 + +yunohost (3.6.5.1) stable; urgency=low + + - [mod] Change maxretry of fail2ban from 6 to 10 (fe8fd1b) + + -- Alexandre Aubin Tue, 08 Oct 2019 20:00:00 +0000 + +yunohost (3.6.5) stable; urgency=low + + - [enh] Detect and warn early about unavailable full domains... (#798) + - [mod] Change maxretry of fail2ban from 6 to 10 (#802) + - [fix] Epicly ugly workaround for the goddamn dependency nighmare about sury fucking up php7.0 dependencies (#809) + - [fix] Support logfiles not ending with .log in logrotate ... (#810) + + -- Alexandre Aubin Tue, 08 Oct 2019 19:00:00 +0000 + yunohost (3.6.4.6) stable; urgency=low - [fix] Hopefully fix the issue about corrupted logs metadata files (d507d447, 1cec9d78) @@ -91,7 +1243,7 @@ yunohost (3.6.1.2) testing; urgency=low yunohost (3.6.1.1) testing; urgency=low - [fix] Weird issue in slapd triggered by indexing uidNumber / gidNumber - + -- Alexandre Aubin Tue, 04 Jun 2019 15:10:00 +0000 yunohost (3.6.1) testing; urgency=low @@ -561,19 +1713,19 @@ yunohost (3.0.0~beta1.2) testing; urgency=low Removing http2 also from yunohost_admin.conf since there still are some issues with wordpress ? - + -- Alexandre Aubin Tue, 08 May 2018 05:52:00 +0000 yunohost (3.0.0~beta1.1) testing; urgency=low Fixes in the postgresql migration - + -- Alexandre Aubin Sun, 06 May 2018 03:06:00 +0000 yunohost (3.0.0~beta1) testing; urgency=low Beta release for Stretch - + -- Alexandre Aubin Thu, 03 May 2018 03:04:45 +0000 yunohost (2.7.14) stable; urgency=low @@ -612,7 +1764,7 @@ yunohost (2.7.13.4) testing; urgency=low * Increase backup filename length (Fixes by Bram <3) - + -- Alexandre Aubin Tue, 05 Jun 2018 18:22:00 +0000 yunohost (2.7.13.3) testing; urgency=low @@ -703,7 +1855,7 @@ yunohost (2.7.11) testing; urgency=low * [helpers] Allow for 'or' in dependencies (#381) * [helpers] Tweak the usage of BACKUP_CORE_ONLY (#398) * [helpers] Tweak systemd config helpers (optional service name and template name) (#425) - * [i18n] Improve translations for Arabic, French, German, Occitan, Spanish + * [i18n] Improve translations for Arabic, French, German, Occitan, Spanish Thanks to all contributors (ariasuni, ljf, JimboJoe, frju365, Maniack, J-B Lescher, Josue, Aleks, Bram, jibec) and the several translators (ButterflyOfFire, Eric G., Cedric, J. Keerl, beyercenter, P. Gatzka, Quenti, bjarkan) <3 ! @@ -794,11 +1946,11 @@ yunohost (2.7.3) testing; urgency=low Major changes : * [fix] Refactor/clean madness related to DynDNS (#353) - * [i18n] Improve french translation (#355) + * [i18n] Improve french translation (#355) * [fix] Use cryptorandom to generate password (#358) * [enh] Support for single app upgrade from the webadmin (#359) * [enh] Be able to give lock to son processes detached by systemctl (#367) - * [enh] Make MySQL dumps with a single transaction to ensure backup consistency (#370) + * [enh] Make MySQL dumps with a single transaction to ensure backup consistency (#370) Misc fixes/improvements : @@ -806,7 +1958,7 @@ yunohost (2.7.3) testing; urgency=low * [fix] Allow dash at the beginning of app settings value (#357) * [enh] Handle root path in nginx conf (#361) * [enh] Add debugging in ldap init (#365) - * [fix] Fix app_upgrade_string with missing key + * [fix] Fix app_upgrade_string with missing key * [fix] Fix for change_url path normalizing with root url (#368) * [fix] Missing 'ask_path' string (#369) * [enh] Remove date from sql dump (#371) @@ -853,7 +2005,7 @@ yunohost (2.7.1) testing; urgency=low * [fix] Make read-only mount bind actually read-only (#343) (ljf) ### dyndns * Regen dnsmasq conf if it's not up to date :| (Alexandre Aubin) - * [fix] timeout on request to avoid blocking process (Laurent Peuch) + * [fix] timeout on request to avoid blocking process (Laurent Peuch) * Put request url in an intermediate variable (Alexandre Aubin) ### other * clean users.py (Laurent Peuch) @@ -1291,7 +2443,7 @@ yunohost (2.5.2) testing; urgency=low Other fixes and improvements: * [enh] remove timeout from cli interface - * [fix] [#662](https://dev.yunohost.org/issues/662): missing 'python-openssl' dependency for Let's Encrypt integration. + * [fix] #662: missing 'python-openssl' dependency for Let's Encrypt integration. * [fix] --no-remove-on-failure for app install should behave as a flag. * [fix] don't remove trailing char if it's not a slash @@ -1587,7 +2739,7 @@ yunohost (2.3.12) testing; urgency=low * [fix] Check for tty in root_handlers before remove it in bin/yunohost * [fix] Use dyndns.yunohost.org instead of dynhost.yunohost.org * [fix] Set found private key and don't validate it in dyndns_update - * [fix] Update first registered domain with DynDNS instead of current_host + * [fix] Update first registered domain with DynDNS instead of current_host * [i18n] Rename app_requirements_failed err named variable * [i18n] Update translations from Weblate @@ -1595,7 +2747,7 @@ yunohost (2.3.12) testing; urgency=low * [enh] Better message during service regenconf. * [enh] Display hook path on error message. * [enh] Use named arguments when calling m18n in service.py - * [enh] Use named arguments with m18n. + * [enh] Use named arguments with m18n. * [enh] Use named arguments for user_unknown string. -- Jérôme Lebleu Sat, 09 Apr 2016 12:13:10 +0200 diff --git a/debian/conf/pam/mkhomedir b/debian/conf/pam/mkhomedir deleted file mode 100644 index eedc8b745..000000000 --- a/debian/conf/pam/mkhomedir +++ /dev/null @@ -1,6 +0,0 @@ -Name: Create home directory during login -Default: yes -Priority: 900 -Session-Type: Additional -Session: - required pam_mkhomedir.so umask=0022 skel=/etc/skel diff --git a/debian/control b/debian/control index 64c7cd31d..90bac0a0d 100644 --- a/debian/control +++ b/debian/control @@ -2,52 +2,52 @@ Source: yunohost Section: utils Priority: extra Maintainer: YunoHost Contributors -Build-Depends: debhelper (>=9), dh-systemd, dh-python, python-all (>= 2.7), python-yaml, python-jinja2 +Build-Depends: debhelper (>=9), dh-systemd, dh-python, python3-all (>= 3.7), python3-yaml, python3-jinja2 Standards-Version: 3.9.6 -X-Python-Version: >= 2.7 Homepage: https://yunohost.org/ Package: yunohost Essential: yes Architecture: all -Depends: ${python:Depends}, ${misc:Depends} - , moulinette (>= 2.7.1), ssowat (>= 2.7.1) - , python-psutil, python-requests, python-dnspython, python-openssl - , python-apt, python-miniupnpc, python-dbus, python-jinja2 - , python-toml - , glances, apt-transport-https - , dnsutils, bind9utils, unzip, git, curl, cron, wget, jq - , ca-certificates, netcat-openbsd, iproute - , mariadb-server, php-mysql | php-mysqlnd +Depends: ${python3:Depends}, ${misc:Depends} + , moulinette (>= 4.2), ssowat (>= 4.0) + , python3-psutil, python3-requests, python3-dnspython, python3-openssl + , python3-miniupnpc, python3-dbus, python3-jinja2 + , python3-toml, python3-packaging, python3-publicsuffix, + , python3-ldap, python3-zeroconf, python3-lexicon, + , apt, apt-transport-https, apt-utils, dirmngr + , php7.3-common, php7.3-fpm, php7.3-ldap, php7.3-intl + , mariadb-server, php7.3-mysql + , openssh-server, iptables, fail2ban, dnsutils, bind9utils + , openssl, ca-certificates, netcat-openbsd, iproute2 , slapd, ldap-utils, sudo-ldap, libnss-ldapd, unscd, libpam-ldapd - , postfix-ldap, postfix-policyd-spf-perl, postfix-pcre, procmail, mailutils, postsrsd - , dovecot-ldap, dovecot-lmtpd, dovecot-managesieved - , dovecot-antispam, fail2ban, iptables - , nginx-extras (>=1.6.2), php-fpm, php-ldap, php-intl - , dnsmasq, openssl, avahi-daemon, libnss-mdns, resolvconf, libnss-myhostname - , metronome - , rspamd (>= 1.6.0), redis-server, opendkim-tools - , haveged, fake-hwclock - , equivs, lsof + , dnsmasq, resolvconf, libnss-myhostname + , postfix, postfix-ldap, postfix-policyd-spf-perl, postfix-pcre + , dovecot-core, dovecot-ldap, dovecot-lmtpd, dovecot-managesieved, dovecot-antispam + , rspamd, opendkim-tools, postsrsd, procmail, mailutils + , redis-server + , metronome (>=3.14.0) + , acl + , git, curl, wget, cron, unzip, jq, bc, at + , lsb-release, haveged, fake-hwclock, equivs, lsof, whois Recommends: yunohost-admin - , openssh-server, ntp, inetutils-ping | iputils-ping - , bash-completion, rsyslog, etckeeper - , php-gd, php-curl, php-gettext, php-mcrypt - , python-pip + , ntp, inetutils-ping | iputils-ping + , bash-completion, rsyslog + , php7.3-gd, php7.3-curl, php-gettext + , python3-pip , unattended-upgrades , libdbd-ldap-perl, libnet-dns-perl Suggests: htop, vim, rsync, acpi-support-base, udisks2 Conflicts: iptables-persistent - , moulinette-yunohost, yunohost-config - , yunohost-config-others, yunohost-config-postfix - , yunohost-config-dovecot, yunohost-config-slapd - , yunohost-config-nginx, yunohost-config-amavis - , yunohost-config-mysql, yunohost-predepends -Replaces: moulinette-yunohost, yunohost-config - , yunohost-config-others, yunohost-config-postfix - , yunohost-config-dovecot, yunohost-config-slapd - , yunohost-config-nginx, yunohost-config-amavis - , yunohost-config-mysql, yunohost-predepends + , apache2 + , bind9 + , nginx-extras (>= 1.16) + , openssl (>= 1.1.1g) + , slapd (>= 2.4.49) + , dovecot-core (>= 1:2.3.7) + , redis-server (>= 5:5.0.7) + , fail2ban (>= 0.11) + , iptables (>= 1.8.3) Description: manageable and configured self-hosting server YunoHost aims to make self-hosting accessible to everyone. It configures an email, Web and IM server alongside a LDAP base. It also provides diff --git a/debian/copyright b/debian/copyright index 8dd627ca5..59483b81e 100644 --- a/debian/copyright +++ b/debian/copyright @@ -1,5 +1,5 @@ Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Source: https://github.com/YunoHost/moulinette-yunohost +Source: https://github.com/YunoHost/yunohost Files: * Copyright: 2015 YUNOHOST.ORG diff --git a/debian/install b/debian/install index e0743cdd1..8c6ba01dd 100644 --- a/debian/install +++ b/debian/install @@ -1,17 +1,8 @@ bin/* /usr/bin/ sbin/* /usr/sbin/ +data/* /usr/share/yunohost/ data/bash-completion.d/yunohost /etc/bash_completion.d/ doc/yunohost.8.gz /usr/share/man/man8/ -data/actionsmap/* /usr/share/moulinette/actionsmap/ -data/hooks/* /usr/share/yunohost/hooks/ -data/other/yunoprompt.service /etc/systemd/system/ -data/other/password/* /usr/share/yunohost/other/password/ -data/other/dpkg-origins/yunohost /etc/dpkg/origins -data/other/* /usr/share/yunohost/yunohost-config/moulinette/ -data/templates/* /usr/share/yunohost/templates/ -data/helpers /usr/share/yunohost/ -data/helpers.d/* /usr/share/yunohost/helpers.d/ -debian/conf/pam/* /usr/share/pam-configs/ lib/metronome/modules/* /usr/lib/metronome/modules/ locales/* /usr/lib/moulinette/yunohost/locales/ src/yunohost /usr/lib/moulinette diff --git a/debian/postinst b/debian/postinst index 7b7080513..ceeed3cdf 100644 --- a/debian/postinst +++ b/debian/postinst @@ -5,56 +5,35 @@ set -e do_configure() { rm -rf /var/cache/moulinette/* + mkdir -p /usr/share/moulinette/actionsmap/ + ln -sf /usr/share/yunohost/actionsmap/yunohost.yml /usr/share/moulinette/actionsmap/yunohost.yml + if [ ! -f /etc/yunohost/installed ]; then - bash /usr/share/yunohost/hooks/conf_regen/01-yunohost init - bash /usr/share/yunohost/hooks/conf_regen/02-ssl init - bash /usr/share/yunohost/hooks/conf_regen/06-slapd init - bash /usr/share/yunohost/hooks/conf_regen/15-nginx init + # If apps/ is not empty, we're probably already installed in the past and + # something funky happened ... + if [ -d /etc/yunohost/apps/ ] && ls /etc/yunohost/apps/* >/dev/null 2>&1 + then + echo "Sounds like /etc/yunohost/installed mysteriously disappeared ... You should probably contact the Yunohost support ..." + else + bash /usr/share/yunohost/hooks/conf_regen/01-yunohost init + bash /usr/share/yunohost/hooks/conf_regen/02-ssl init + bash /usr/share/yunohost/hooks/conf_regen/09-nslcd init + bash /usr/share/yunohost/hooks/conf_regen/46-nsswitch init + bash /usr/share/yunohost/hooks/conf_regen/06-slapd init + bash /usr/share/yunohost/hooks/conf_regen/15-nginx init + bash /usr/share/yunohost/hooks/conf_regen/37-mdns init + fi else echo "Regenerating configuration, this might take a while..." yunohost tools regen-conf --output-as none - echo "Launching migrations.." - yunohost tools migrations migrate --auto + echo "Launching migrations..." + yunohost tools migrations run --auto - # restart yunohost-firewall if it's running - service yunohost-firewall status >/dev/null \ - && restart_yunohost_firewall \ - || echo "yunohost-firewall service is not running, you should " \ - "consider to start it by doing 'service yunohost-firewall start'." - fi - - # Change dpkg vendor - # see https://wiki.debian.org/Derivatives/Guidelines#Vendor - readlink -f /etc/dpkg/origins/default | grep -q debian \ - && rm -f /etc/dpkg/origins/default \ - && ln -s /etc/dpkg/origins/yunohost /etc/dpkg/origins/default - - # Yunoprompt - systemctl enable yunoprompt.service - - # remove old PAM config and update it - [[ ! -f /usr/share/pam-configs/my_mkhomedir ]] \ - || rm /usr/share/pam-configs/my_mkhomedir - pam-auth-update --package -} - -restart_yunohost_firewall() { - echo "Restarting YunoHost firewall..." - - deb-systemd-helper unmask yunohost-firewall.service >/dev/null || true - if deb-systemd-helper --quiet was-enabled yunohost-firewall.service; then - deb-systemd-helper enable yunohost-firewall.service >/dev/null || true - else - deb-systemd-helper update-state yunohost-firewall.service >/dev/null || true + echo "Re-diagnosing server health..." + yunohost diagnosis run --force fi - if [ -x /etc/init.d/yunohost-firewall ]; then - update-rc.d yunohost-firewall enable >/dev/null - if [ -n "$2" ]; then - invoke-rc.d yunohost-firewall restart >/dev/null || exit $? - fi - fi } # summary of how this script can be called: diff --git a/debian/rules b/debian/rules index 8afe372b5..3790c0ef2 100755 --- a/debian/rules +++ b/debian/rules @@ -5,12 +5,12 @@ #export DH_VERBOSE=1 %: - dh ${@} --with=python2,systemd + dh ${@} --with=python3,systemd override_dh_auto_build: # Generate bash completion file - python data/actionsmap/yunohost_completion.py - python doc/generate_manpages.py --gzip --output doc/yunohost.8.gz + python3 data/actionsmap/yunohost_completion.py + python3 doc/generate_manpages.py --gzip --output doc/yunohost.8.gz override_dh_installinit: dh_installinit -pyunohost --name=yunohost-api --restart-after-upgrade diff --git a/debian/yunohost-api.init b/debian/yunohost-api.init deleted file mode 100644 index 3cda507e6..000000000 --- a/debian/yunohost-api.init +++ /dev/null @@ -1,132 +0,0 @@ -#! /bin/sh - -### BEGIN INIT INFO -# Provides: yunohost-api -# Required-Start: $local_fs $remote_fs $network $syslog -# Required-Stop: $local_fs $remote_fs $network $syslog -# Default-Start: 2 3 4 5 -# Default-Stop: 0 1 6 -# Short-Description: Manage YunoHost API Server -# Description: Manage YunoHost API Server -### END INIT INFO - -set -e - -DESC="YunoHost API Server" -NAME="yunohost-api" -DAEMON=/usr/bin/$NAME -DAEMON_OPTS="" -PATH=/sbin:/usr/sbin:/bin:/usr/bin -PIDFILE=/var/run/$NAME.pid -SCRIPTNAME=/etc/init.d/$NAME -LOGFILE=/var/log/$NAME.log - -# Include yunohost-api defaults if available -if [ -r /etc/default/yunohost-api ]; then - . /etc/default/yunohost-api -fi - -# Exit if the package is not installed -[ -x "$DAEMON" ] || exit 0 - -# Load the VERBOSE setting and other rcS variables -. /lib/init/vars.sh - -# Define LSB log_* functions. -# Depend on lsb-base (>= 3.2-14) to ensure that this file is present -# and status_of_proc is working. -. /lib/lsb/init-functions - -# -# Function that starts the daemon/service -# -do_start() -{ - # Return - # 0 if daemon has been started - # 1 if daemon was already running - # 2 if daemon could not be started - start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --test > /dev/null \ - || return 1 - start-stop-daemon --start --background --make-pidfile --quiet --no-close \ - --pidfile $PIDFILE --exec $DAEMON -- \ - $DAEMON_OPTS >>$LOGFILE 2>&1 \ - || return 2 -} - -# -# Function that stops the daemon/service -# -do_stop() -{ - # Return - # 0 if daemon has been stopped - # 1 if daemon was already stopped - # 2 if daemon could not be stopped - # other if a failure occurred - start-stop-daemon --stop --oknodo --pidfile $PIDFILE - RETVAL="$?" - - sleep 1 - return "$RETVAL" -} - -# -# Function that sends a SIGHUP to the daemon/service -# -do_reload() { - # Send a SIGHUP to reload the daemon. - start-stop-daemon --stop --signal 1 --quiet --pidfile $PIDFILE --name $NAME - return 0 -} - -case "$1" in - start) - [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME" - do_start - case "$?" in - 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; - 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; - esac - ;; - stop) - [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" - do_stop - case "$?" in - 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; - 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; - esac - ;; - status) - status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? - ;; - reload) - log_daemon_msg "Reloading $DESC" "$NAME" - do_reload - log_end_msg $? - ;; - restart|force-reload) - log_daemon_msg "Restarting $DESC" "$NAME" - do_stop - case "$?" in - 0|1) - do_start - case "$?" in - 0) log_end_msg 0 ;; - 1) log_end_msg 1 ;; # Old process is still running - *) log_end_msg 1 ;; # Failed to start - esac - ;; - *) - # Failed to stop - log_end_msg 1 - ;; - esac - ;; - *) - echo "Usage: $SCRIPTNAME {start|stop|status|restart|reload}" >&2 - exit 3 - ;; -esac - -: diff --git a/debian/yunohost-api.service b/debian/yunohost-api.service index 4e71eadac..850255127 100644 --- a/debian/yunohost-api.service +++ b/debian/yunohost-api.service @@ -7,9 +7,9 @@ Type=simple Environment=DAEMON_OPTS= EnvironmentFile=-/etc/default/yunohost-api ExecStart=/usr/bin/yunohost-api $DAEMON_OPTS -ExecReload=/bin/kill -HUP $MAINPID Restart=always -RestartSec=1 +RestartSec=5 +TimeoutStopSec=30 [Install] WantedBy=multi-user.target diff --git a/debian/yunohost-firewall.init b/debian/yunohost-firewall.init deleted file mode 100644 index fd1443494..000000000 --- a/debian/yunohost-firewall.init +++ /dev/null @@ -1,53 +0,0 @@ -#! /bin/bash -### BEGIN INIT INFO -# Provides: yunohost-firewall -# Required-Start: $local_fs $remote_fs $network $syslog -# Required-Stop: $local_fs $remote_fs $network $syslog -# Default-Start: 2 3 4 5 -# Default-Stop: 0 1 6 -# Short-Description: Start/stop YunoHost firewall -# Description: Start/stop YunoHost firewall -### END INIT INFO - -DAEMON=/usr/bin/yunohost -DAEMON_OPTS="" - -test -x $DAEMON || exit 0 - -. /lib/lsb/init-functions - -logger "YunoHost firewall: Start script executed" - -case "$1" in - start) - logger "YunoHost firewall: Starting" - log_daemon_msg "Starting firewall: YunoHost" - /usr/bin/yunohost firewall reload - log_end_msg $? - ;; - stop) - logger "YunoHost firewall: Stopping" - log_daemon_msg "Stopping firewall: YunoHost" - /usr/bin/yunohost firewall stop - log_end_msg $? - ;; - restart|force-reload) - logger "YunoHost firewall: Restarting" - log_daemon_msg "Restarting firewall: YunoHost" - /usr/bin/yunohost firewall reload - log_end_msg $? - ;; - status) - logger "YunoHost API: Running" - log_daemon_msg "YunoHost API: Running" - iptables -L | grep "Chain INPUT (policy DROP)" > /dev/null 2>&1 - log_end_msg $? - ;; - *) - logger "YunoHost API: Invalid usage" - echo "Usage: /etc/init.d/yunohost-api {start|stop|restart|force-reload|status}" >&2 - exit 1 - ;; -esac - -exit 0 diff --git a/doc/generate_helper_doc.py b/doc/generate_helper_doc.py index a7866b85b..f2d5bf444 100644 --- a/doc/generate_helper_doc.py +++ b/doc/generate_helper_doc.py @@ -1,15 +1,33 @@ -#!/usr/env/python2.7 +#!/usr/env/python3 import os import glob import datetime +import subprocess + + +def get_current_commit(): + p = subprocess.Popen( + "git rev-parse --verify HEAD", + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + stdout, stderr = p.communicate() + + current_commit = stdout.strip().decode("utf-8") + return current_commit + def render(helpers): - data = { "helpers": helpers, - "date": datetime.datetime.now().strftime("%m/%d/%Y"), - "version": open("../debian/changelog").readlines()[0].split()[1].strip("()") - } + current_commit = get_current_commit() + + data = { + "helpers": helpers, + "date": datetime.datetime.now().strftime("%m/%d/%Y"), + "version": open("../debian/changelog").readlines()[0].split()[1].strip("()"), + } from jinja2 import Template from ansi2html import Ansi2HTMLConverter @@ -21,16 +39,22 @@ def render(helpers): def shell_to_html(shell): return conv.convert(shell, False) - template = open("helper_doc_template.html", "r").read() - t = Template(template) - t.globals['now'] = datetime.datetime.utcnow - result = t.render(data=data, convert=shell_to_html, shell_css=shell_css) - open("helpers.html", "w").write(result) + template = open("helper_doc_template.md", "r").read() + t = Template(template) + t.globals["now"] = datetime.datetime.utcnow + result = t.render( + current_commit=current_commit, + data=data, + convert=shell_to_html, + shell_css=shell_css, + ) + open("helpers.md", "w").write(result) + ############################################################################## -class Parser(): +class Parser: def __init__(self, filename): self.filename = filename @@ -42,15 +66,12 @@ class Parser(): self.blocks = [] current_reading = "void" - current_block = { "name": None, - "line": -1, - "comments": [], - "code": [] } + current_block = {"name": None, "line": -1, "comments": [], "code": []} for i, line in enumerate(self.file): if line.startswith("#!/bin/bash"): - continue + continue line = line.rstrip().replace("\t", " ") @@ -62,7 +83,7 @@ class Parser(): current_block["comments"].append(line[2:]) else: pass - #assert line == "", malformed_error(i) + # assert line == "", malformed_error(i) continue elif current_reading == "comments": @@ -73,11 +94,12 @@ class Parser(): elif line.strip() == "": # Well eh that was not an actual helper definition ... start over ? current_reading = "void" - current_block = { "name": None, - "line": -1, - "comments": [], - "code": [] - } + current_block = { + "name": None, + "line": -1, + "comments": [], + "code": [], + } elif not (line.endswith("{") or line.endswith("()")): # Well we're not actually entering a function yet eh # (c.f. global vars) @@ -85,7 +107,10 @@ class Parser(): else: # We're getting out of a comment bloc, we should find # the name of the function - assert len(line.split()) >= 1, "Malformed line %s in %s" % (i, self.filename) + assert len(line.split()) >= 1, "Malformed line %s in %s" % ( + i, + self.filename, + ) current_block["line"] = i current_block["name"] = line.split()[0].strip("(){") # Then we expect to read the function @@ -99,12 +124,14 @@ class Parser(): # Then we keep this bloc and start a new one # (we ignore helpers containing [internal] ...) - if not "[internal]" in current_block["comments"]: + if "[internal]" not in current_block["comments"]: self.blocks.append(current_block) - current_block = { "name": None, - "line": -1, - "comments": [], - "code": [] } + current_block = { + "name": None, + "line": -1, + "comments": [], + "code": [], + } else: current_block["code"].append(line) @@ -118,7 +145,7 @@ class Parser(): b["args"] = [] b["ret"] = "" - subblocks = '\n'.join(b["comments"]).split("\n\n") + subblocks = "\n".join(b["comments"]).split("\n\n") for i, subblock in enumerate(subblocks): subblock = subblock.strip() @@ -180,13 +207,14 @@ class Parser(): b["usage"] = b["usage"].strip() - def is_global_comment(line): - return line.startswith('#') + return line.startswith("#") + def malformed_error(line_number): return "Malformed file line {} ?".format(line_number) + def main(): helper_files = sorted(glob.glob("../data/helpers.d/*")) @@ -194,7 +222,7 @@ def main(): for helper_file in helper_files: category_name = os.path.basename(helper_file) - print "Parsing %s ..." % category_name + print("Parsing %s ..." % category_name) p = Parser(helper_file) p.parse_blocks() for b in p.blocks: @@ -204,5 +232,5 @@ def main(): render(helpers) -main() +main() diff --git a/doc/generate_manpages.py b/doc/generate_manpages.py index 0b1251c28..fa042fb17 100644 --- a/doc/generate_manpages.py +++ b/doc/generate_manpages.py @@ -22,20 +22,24 @@ template = Template(open(os.path.join(base_path, "manpage.template")).read()) THIS_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -ACTIONSMAP_FILE = os.path.join(THIS_SCRIPT_DIR, '../data/actionsmap/yunohost.yml') +ACTIONSMAP_FILE = os.path.join(THIS_SCRIPT_DIR, "../data/actionsmap/yunohost.yml") def ordered_yaml_load(stream): - class OrderedLoader(yaml.Loader): + class OrderedLoader(yaml.SafeLoader): pass + OrderedLoader.add_constructor( yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, - lambda loader, node: OrderedDict(loader.construct_pairs(node))) + lambda loader, node: OrderedDict(loader.construct_pairs(node)), + ) return yaml.load(stream, OrderedLoader) def main(): - parser = argparse.ArgumentParser(description="generate yunohost manpage based on actionsmap.yml") + parser = argparse.ArgumentParser( + description="generate yunohost manpage based on actionsmap.yml" + ) parser.add_argument("-o", "--output", default="output/yunohost") parser.add_argument("-z", "--gzip", action="store_true", default=False) @@ -55,12 +59,12 @@ def main(): output_path = args.output # man pages of "yunohost *" - with open(ACTIONSMAP_FILE, 'r') as actionsmap: + with open(ACTIONSMAP_FILE, "r") as actionsmap: # Getting the dictionary containning what actions are possible per domain actionsmap = ordered_yaml_load(actionsmap) - for i in actionsmap.keys(): + for i in list(actionsmap.keys()): if i.startswith("_"): del actionsmap[i] @@ -78,8 +82,8 @@ def main(): output.write(result) else: with gzip.open(output_path, mode="w", compresslevel=9) as output: - output.write(result) + output.write(result.encode()) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/doc/helper_doc_template.html b/doc/helper_doc_template.html deleted file mode 100644 index 92611c737..000000000 --- a/doc/helper_doc_template.html +++ /dev/null @@ -1,113 +0,0 @@ - - -

App helpers

- -{% for category, helpers in data.helpers %} - -

{{ category }}

- -{% for h in helpers %} - -
-
-
-
{{ h.name }}
-
{{ h.brief }}
-
-
-
-

- {% if not '\n' in h.usage %} - Usage: {{ h.usage }} - {% else %} - Usage: {{ h.usage }} - {% endif %} -

- {% if h.args %} -

- Arguments: -

    - {% for infos in h.args %} - {% if infos|length == 2 %} -
  • {{ infos[0] }} : {{ infos[1] }}
  • - {% else %} -
  • {{ infos[0] }}, {{ infos[1] }} : {{ infos[2] }}
  • - {% endif %} - {% endfor %} -
-

- {% endif %} - {% if h.ret %} -

- Returns: {{ h.ret }} -

- {% endif %} - {% if "example" in h.keys() %} -

- Example: {{ h.example }} -

- {% endif %} - {% if "examples" in h.keys() %} -

- Examples:

    - {% for example in h.examples %} - {% if not example.strip().startswith("# ") %} - {{ example }} - {% else %} - {{ example.strip("# ") }} - {% endif %} -
    - {% endfor %} -
-

- {% endif %} - {% if h.details %} -

- Details: -

- {{ h.details.replace('\n', '
') }} -

-

- {% endif %} -

- Dude, show me the code ! -

- -
-
- -
- -{% endfor %} -{% endfor %} - -

Generated by this script on {{data.date}} (Yunohost version {{data.version}})

- - - diff --git a/doc/helper_doc_template.md b/doc/helper_doc_template.md new file mode 100644 index 000000000..d41c0b6e9 --- /dev/null +++ b/doc/helper_doc_template.md @@ -0,0 +1,59 @@ +--- +title: App helpers +template: docs +taxonomy: + category: docs +routes: + default: '/packaging_apps_helpers' +--- + +Doc auto-generated by [this script](https://github.com/YunoHost/yunohost/blob/{{ current_commit }}/doc/generate_helper_doc.py) on {{data.date}} (YunoHost version {{data.version}}) + +{% for category, helpers in data.helpers %} +## {{ category.upper() }} +{% for h in helpers %} +#### {{ h.name }} +[details summary="{{ h.brief }}" class="helper-card-subtitle text-muted"] + +**Usage**: `{{ h.usage }}` +{%- if h.args %} + +**Arguments**: + {%- for infos in h.args %} + {%- if infos|length == 2 %} +- `{{ infos[0] }}`: {{ infos[1] }} + {%- else %} +- `{{ infos[0] }}`, `{{ infos[1] }}`: {{ infos[2] }} + {%- endif %} + {%- endfor %} +{%- endif %} +{%- if h.ret %} + +**Returns**: {{ h.ret }} +{%- endif %} +{%- if "example" in h.keys() %} + +**Example**: `{{ h.example }}` +{%- endif %} +{%- if "examples" in h.keys() %} + +**Examples**: + {% for example in h.examples %} + {% if not example.strip().startswith("# ") %} +- `{{ example }}` + {% else %} +- `{{ example.strip("# ") }}` + {% endif %} + {% endfor %} +{%- endif %} +{%- if h.details %} + +**Details**:
+{{ h.details }} +{%- endif %} + +[Dude, show me the code!](https://github.com/YunoHost/yunohost/blob/{{ current_commit }}/data/helpers.d/{{ category }}#L{{ h.line + 1 }}) +[/details] +---------------- +{% endfor %} +{% endfor %} diff --git a/doc/manpage.template b/doc/manpage.template index a246e59ac..33f68a2b5 100644 --- a/doc/manpage.template +++ b/doc/manpage.template @@ -93,7 +93,7 @@ usage: yunohost {{ name }} {{ '{' }}{{ ",".join(value["actions"].keys()) }}{{ '} {# each subcategory #} {% for subcategory_name, subcategory in value.get("subcategories", {}).items() %} {% for action, action_value in subcategory["actions"].items() %} -.SS "yunohost {{ subcategory_name }} {{ name }} {{ action }} \ +.SS "yunohost {{ name }} {{ subcategory_name }} {{ action }} \ {% for argument_name, argument_value in action_value.get("arguments", {}).items() %}\ {% set required=(not str(argument_name).startswith("-")) or argument_value.get("extra", {}).get("required", False) %}\ {% if not required %}[{% endif %}\ diff --git a/lib/metronome/modules/mod_storage_ldap.lua b/lib/metronome/modules/mod_storage_ldap.lua index 83fb4d003..87092382c 100644 --- a/lib/metronome/modules/mod_storage_ldap.lua +++ b/lib/metronome/modules/mod_storage_ldap.lua @@ -228,7 +228,7 @@ function driver:stores(username, type, pattern) return nil, "not implemented"; end -function driver:store_exists(username, datastore, type) +function driver:store_exists(username, type) return nil, "not implemented"; end @@ -236,7 +236,7 @@ function driver:purge(username) return nil, "not implemented"; end -function driver:users() +function driver:nodes(type) return nil, "not implemented"; end diff --git a/locales/ar.json b/locales/ar.json index 285a0f819..487091995 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -1,389 +1,89 @@ { - "action_invalid": "إجراء غير صالح '{action:s}'", + "action_invalid": "إجراء غير صالح '{action}'", "admin_password": "كلمة السر الإدارية", - "admin_password_change_failed": "تعذرت عملية تعديل كلمة السر", + "admin_password_change_failed": "لا يمكن تعديل الكلمة السرية", "admin_password_changed": "تم تعديل الكلمة السرية الإدارية", - "app_already_installed": "{app:s} تم تنصيبه مِن قبل", - "app_already_installed_cant_change_url": "", - "app_already_up_to_date": "{app:s} تم تحديثه مِن قَبل", - "app_argument_choice_invalid": "", - "app_argument_invalid": "", - "app_argument_required": "المُعامِل '{name:s}' مطلوب", - "app_change_no_change_url_script": "إنّ التطبيق {app_name:s} لا يدعم تغيير الرابط، مِن الممكن أنه يتوجب عليكم تحدثيه.", - "app_change_url_failed_nginx_reload": "فشلت عملية إعادة تشغيل nginx. ها هي نتيجة الأمر 'nginx -t':\n{nginx_errors:s}", - "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_already_installed": "{app} تم تنصيبه مِن قبل", + "app_already_up_to_date": "{app} تم تحديثه مِن قَبل", + "app_argument_required": "المُعامِل '{name}' مطلوب", "app_extraction_failed": "تعذر فك الضغط عن ملفات التنصيب", - "app_id_invalid": "Invalid app id", - "app_incompatible": "إن التطبيق {app} غير متوافق مع إصدار واي يونوهوست YunoHost الخاص بك", "app_install_files_invalid": "ملفات التنصيب خاطئة", - "app_location_already_used": "The app '{app}' is already installed on that location ({path})", - "app_make_default_location_already_used": "Can't make the app '{app}' the default on the domain {domain} is already used by the other app '{other_app}'", - "app_location_install_failed": "Unable to install the app in this location because it conflit with the app '{other_app}' already installed on '{other_path}'", - "app_location_unavailable": "This url is not available or conflicts with an already installed app", - "app_manifest_invalid": "Invalid app manifest: {error}", - "app_no_upgrade": "البرمجيات لا تحتاج إلى تحديث", - "app_not_correctly_installed": "يبدو أن التطبيق {app:s} لم يتم تنصيبه بشكل صحيح", - "app_not_installed": "إنّ التطبيق {app:s} غير مُنصَّب", - "app_not_properly_removed": "لم يتم حذف تطبيق {app:s} بشكلٍ جيّد", - "app_package_need_update": "The app {app} package needs to be updated to follow YunoHost changes", - "app_removed": "تمت إزالة تطبيق {app:s}", + "app_not_correctly_installed": "يبدو أن التطبيق {app} لم يتم تنصيبه بشكل صحيح", + "app_not_installed": "إنّ التطبيق {app} غير مُنصَّب", + "app_not_properly_removed": "لم يتم حذف تطبيق {app} بشكلٍ جيّد", + "app_removed": "تمت إزالة تطبيق {app}", "app_requirements_checking": "جار فحص الحزم اللازمة لـ {app}…", - "app_requirements_failed": "Unable to meet requirements for {app}: {error}", - "app_requirements_unmeet": "Requirements are not met for {app}, the package {pkgname} ({version}) must be {spec}", "app_sources_fetch_failed": "تعذرت عملية جلب مصادر الملفات", "app_unknown": "برنامج مجهول", - "app_unsupported_remote_type": "Unsupported remote type used for the app", "app_upgrade_app_name": "جارٍ تحديث تطبيق {app}…", - "app_upgrade_failed": "تعذرت عملية ترقية {app:s}", - "app_upgrade_some_app_failed": "تعذرت عملية ترقية بعض البرمجيات", - "app_upgraded": "تم تحديث التطبيق {app:s}", - "appslist_corrupted_json": "Could not load the application lists. It looks like {filename:s} is corrupted.", - "appslist_could_not_migrate": "Could not migrate app list {appslist:s} ! Unable to parse the url... The old cron job has been kept in {bkp_file:s}.", - "appslist_fetched": "تم جلب قائمة تطبيقات {appslist:s}", - "appslist_migrating": "Migrating application list {appslist:s} …", - "appslist_name_already_tracked": "There is already a registered application list with name {name:s}.", - "appslist_removed": "تم حذف قائمة البرمجيات {appslist:s}", - "appslist_retrieve_bad_format": "Retrieved file for application list {appslist:s} is not valid", - "appslist_retrieve_error": "Unable to retrieve the remote application list {appslist:s}: {error:s}", - "appslist_unknown": "قائمة البرمجيات {appslist:s} مجهولة.", - "appslist_url_already_tracked": "There is already a registered application list with url {url:s}.", - "ask_current_admin_password": "كلمة السر الإدارية الحالية", - "ask_email": "عنوان البريد الإلكتروني", + "app_upgrade_failed": "تعذرت عملية ترقية {app}", + "app_upgrade_some_app_failed": "تعذرت عملية ترقية بعض التطبيقات", + "app_upgraded": "تم تحديث التطبيق {app}", "ask_firstname": "الإسم", "ask_lastname": "اللقب", - "ask_list_to_remove": "القائمة المختارة للحذف", "ask_main_domain": "النطاق الرئيسي", "ask_new_admin_password": "كلمة السر الإدارية الجديدة", "ask_password": "كلمة السر", - "ask_path": "المسار", - "backup_abstract_method": "This backup method hasn't yet been implemented", - "backup_action_required": "You must specify something to save", - "backup_app_failed": "Unable to back up the app '{app:s}'", - "backup_applying_method_borg": "Sending all files to backup into borg-backup repository…", "backup_applying_method_copy": "جارٍ نسخ كافة الملفات إلى النسخة الإحتياطية …", - "backup_applying_method_custom": "Calling the custom backup method '{method:s}'…", - "backup_applying_method_tar": "جارٍ إنشاء ملف tar للنسخة الاحتياطية…", - "backup_archive_app_not_found": "App '{app:s}' not found in the backup archive", - "backup_archive_broken_link": "Unable to access backup archive (broken link to {path:s})", - "backup_archive_mount_failed": "Mounting the backup archive failed", - "backup_archive_name_exists": "The backup's archive name already exists", - "backup_archive_name_unknown": "Unknown local backup archive named '{name:s}'", - "backup_archive_open_failed": "Unable to open the backup archive", - "backup_archive_system_part_not_available": "System part '{part:s}' not available in this backup", - "backup_archive_writing_error": "Unable to add files to backup into the compressed archive", - "backup_ask_for_copying_if_needed": "Some files couldn't be prepared to be backuped using the method that avoid to temporarily waste space on the system. To perform the backup, {size:s}MB should be used temporarily. Do you agree?", - "backup_borg_not_implemented": "Borg backup method is not yet implemented", - "backup_cant_mount_uncompress_archive": "Unable to mount in readonly mode the uncompress archive directory", - "backup_cleaning_failed": "Unable to clean-up the temporary backup directory", - "backup_copying_to_organize_the_archive": "Copying {size:s}MB to organize the archive", - "backup_couldnt_bind": "Couldn't bind {src:s} to {dest:s}.", + "backup_applying_method_tar": "جارٍ إنشاء ملف TAR للنسخة الاحتياطية…", "backup_created": "تم إنشاء النسخة الإحتياطية", - "backup_creating_archive": "جارٍ إنشاء ملف النسخة الاحتياطية…", - "backup_creation_failed": "Backup creation failed", - "backup_csv_addition_failed": "Unable to add files to backup into the CSV file", - "backup_csv_creation_failed": "Unable to create the CSV file needed for future restore operations", - "backup_custom_backup_error": "Custom backup method failure on 'backup' step", - "backup_custom_mount_error": "Custom backup method failure on 'mount' step", - "backup_custom_need_mount_error": "Custom backup method failure on 'need_mount' step", - "backup_delete_error": "Unable to delete '{path:s}'", - "backup_deleted": "The backup has been deleted", - "backup_extracting_archive": "Extracting the backup archive…", - "backup_hook_unknown": "Backup hook '{hook:s}' unknown", - "backup_invalid_archive": "نسخة إحتياطية غير صالحة", - "backup_method_borg_finished": "Backup into borg finished", "backup_method_copy_finished": "إنتهت عملية النسخ الإحتياطي", - "backup_method_custom_finished": "Custom backup method '{method:s}' finished", - "backup_method_tar_finished": "Backup tar archive created", - "backup_no_uncompress_archive_dir": "Uncompress archive directory doesn't exist", "backup_nothings_done": "ليس هناك أي شيء للحفظ", - "backup_output_directory_forbidden": "Forbidden output directory. Backups can't be created in /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var or /home/yunohost.backup/archives sub-folders", - "backup_output_directory_not_empty": "The output directory is not empty", "backup_output_directory_required": "يتوجب عليك تحديد مجلد لتلقي النسخ الإحتياطية", - "backup_output_symlink_dir_broken": "You have a broken symlink instead of your archives directory '{path:s}'. You may have a specific setup to backup your data on an other filesystem, in this case you probably forgot to remount or plug your hard dirve or usb key.", - "backup_running_app_script": "Running backup script of app '{app:s}'...", - "backup_running_hooks": "Running backup hooks…", - "backup_system_part_failed": "Unable to backup the '{part:s}' system part", - "backup_unable_to_organize_files": "Unable to organize files in the archive with the quick method", - "backup_with_no_backup_script_for_app": "App {app:s} has no backup script. Ignoring.", - "backup_with_no_restore_script_for_app": "App {app:s} has no restore script, you won't be able to automatically restore the backup of this app.", - "certmanager_acme_not_configured_for_domain": "Certificate for domain {domain:s} does not appear to be correctly installed. Please run cert-install for this domain first.", - "certmanager_attempt_to_renew_nonLE_cert": "The certificate for domain {domain:s} is not issued by Let's Encrypt. Cannot renew it automatically!", - "certmanager_attempt_to_renew_valid_cert": "The certificate for domain {domain:s} is not about to expire! Use --force to bypass", - "certmanager_attempt_to_replace_valid_cert": "You are attempting to overwrite a good and valid certificate for domain {domain:s}! (Use --force to bypass)", - "certmanager_cannot_read_cert": "Something wrong happened when trying to open current certificate for domain {domain:s} (file: {file:s}), reason: {reason:s}", - "certmanager_cert_install_success": "تمت عملية تنصيب شهادة Let's Encrypt بنجاح على النطاق {domain:s} !", - "certmanager_cert_install_success_selfsigned": "Successfully installed a self-signed certificate for domain {domain:s}!", - "certmanager_cert_renew_success": "نجحت عملية تجديد شهادة Let's Encrypt الخاصة باسم النطاق {domain:s} !", + "certmanager_cert_install_success": "تمت عملية تنصيب شهادة Let's Encrypt بنجاح على النطاق {domain} !", + "certmanager_cert_install_success_selfsigned": "نجحت عملية تثبيت الشهادة الموقعة ذاتيا الخاصة بالنطاق {domain}", + "certmanager_cert_renew_success": "نجحت عملية تجديد شهادة Let's Encrypt الخاصة باسم النطاق {domain} !", "certmanager_cert_signing_failed": "فشل إجراء توقيع الشهادة الجديدة", - "certmanager_certificate_fetching_or_enabling_failed": "Sounds like enabling the new certificate for {domain:s} failed somehow…", - "certmanager_conflicting_nginx_file": "Unable to prepare domain for ACME challenge: the nginx configuration file {filepath:s} is conflicting and should be removed first", - "certmanager_couldnt_fetch_intermediate_cert": "Timed out when trying to fetch intermediate certificate from Let's Encrypt. Certificate installation/renewal aborted - please try again later.", - "certmanager_domain_cert_not_selfsigned": "The certificate for domain {domain:s} is not self-signed. Are you sure you want to replace it? (Use --force)", - "certmanager_domain_dns_ip_differs_from_public_ip": "The DNS 'A' record for domain {domain:s} is different from this server IP. If you recently modified your A record, please wait for it to propagate (some DNS propagation checkers are available online). (If you know what you are doing, use --no-checks to disable those checks.)", - "certmanager_domain_http_not_working": "It seems that the domain {domain:s} cannot be accessed through HTTP. Please check your DNS and nginx configuration is okay", - "certmanager_domain_not_resolved_locally": "The domain {domain:s} cannot be resolved from inside your Yunohost server. This might happen if you recently modified your DNS record. If so, please wait a few hours for it to propagate. If the issue persists, consider adding {domain:s} to /etc/hosts. (If you know what you are doing, use --no-checks to disable those checks.)", - "certmanager_domain_unknown": "النطاق مجهول {domain:s}", - "certmanager_error_no_A_record": "No DNS 'A' record found for {domain:s}. You need to make your domain name point to your machine to be able to install a Let's Encrypt certificate! (If you know what you are doing, use --no-checks to disable those checks.)", - "certmanager_hit_rate_limit": "Too many certificates already issued for exact set of domains {domain:s} recently. Please try again later. See https://letsencrypt.org/docs/rate-limits/ for more details", - "certmanager_http_check_timeout": "Timed out when server tried to contact itself through HTTP using public IP address (domain {domain:s} with ip {ip:s}). You may be experiencing hairpinning issue or the firewall/router ahead of your server is misconfigured.", - "certmanager_no_cert_file": "تعذرت عملية قراءة شهادة نطاق {domain:s} (الملف : {file:s})", - "certmanager_old_letsencrypt_app_detected": "", - "certmanager_self_ca_conf_file_not_found": "Configuration file not found for self-signing authority (file: {file:s})", - "certmanager_unable_to_parse_self_CA_name": "Unable to parse name of self-signing authority (file: {file:s})", - "custom_app_url_required": "You must provide a URL to upgrade your custom app {app:s}", - "custom_appslist_name_required": "You must provide a name for your custom app list", - "diagnosis_debian_version_error": "لم نتمكن من العثور على إصدار ديبيان : {error}", - "diagnosis_kernel_version_error": "Can't retrieve kernel version: {error}", - "diagnosis_monitor_disk_error": "Can't monitor disks: {error}", - "diagnosis_monitor_network_error": "Can't monitor network: {error}", - "diagnosis_monitor_system_error": "Can't monitor system: {error}", - "diagnosis_no_apps": "لم تقم بتنصيب أية تطبيقات بعد", - "dnsmasq_isnt_installed": "dnsmasq does not seem to be installed, please run 'apt-get remove bind9 && apt-get install dnsmasq'", - "domain_cannot_remove_main": "Cannot remove main domain. Set a new main domain first", - "domain_cert_gen_failed": "Unable to generate certificate", + "certmanager_no_cert_file": "تعذرت عملية قراءة شهادة نطاق {domain} (الملف : {file})", "domain_created": "تم إنشاء النطاق", "domain_creation_failed": "تعذرت عملية إنشاء النطاق", "domain_deleted": "تم حذف النطاق", - "domain_deletion_failed": "Unable to delete domain", - "domain_dns_conf_is_just_a_recommendation": "This command shows you what is the *recommended* configuration. It does not actually set up the DNS configuration for you. It is your responsability to configure your DNS zone in your registrar according to this recommendation.", - "domain_dyndns_already_subscribed": "You've already subscribed to a DynDNS domain", - "domain_dyndns_dynette_is_unreachable": "Unable to reach YunoHost dynette, either your YunoHost is not correctly connected to the internet or the dynette server is down. Error: {error}", - "domain_dyndns_invalid": "Invalid domain to use with DynDNS", - "domain_dyndns_root_unknown": "Unknown DynDNS root domain", "domain_exists": "اسم النطاق موجود مِن قبل", - "domain_hostname_failed": "Failed to set new hostname", - "domain_uninstall_app_first": "One or more apps are installed on this domain. Please uninstall them before proceeding to domain removal", - "domain_unknown": "النطاق مجهول", - "domain_zone_exists": "ملف منطقة أسماء النطاقات موجود مِن قبل", - "domain_zone_not_found": "DNS zone file not found for domain {:s}", "domains_available": "النطاقات المتوفرة :", "done": "تم", "downloading": "عملية التنزيل جارية …", - "dyndns_could_not_check_provide": "Could not check if {provider:s} can provide {domain:s}.", - "dyndns_cron_installed": "The DynDNS cron job has been installed", - "dyndns_cron_remove_failed": "Unable to remove the DynDNS cron job", - "dyndns_cron_removed": "The DynDNS cron job has been removed", - "dyndns_ip_update_failed": "Unable to update IP address on DynDNS", "dyndns_ip_updated": "لقد تم تحديث عنوان الإيبي الخاص بك على نظام أسماء النطاقات الديناميكي", "dyndns_key_generating": "عملية توليد مفتاح نظام أسماء النطاقات جارية. يمكن للعملية أن تستغرق بعضا من الوقت…", "dyndns_key_not_found": "لم يتم العثور على مفتاح DNS الخاص باسم النطاق هذا", - "dyndns_no_domain_registered": "No domain has been registered with DynDNS", - "dyndns_registered": "The DynDNS domain has been registered", - "dyndns_registration_failed": "Unable to register DynDNS domain: {error:s}", - "dyndns_domain_not_provided": "Dyndns provider {provider:s} cannot provide domain {domain:s}.", - "dyndns_unavailable": "Domain {domain:s} is not available.", - "executing_command": "Executing command '{command:s}'…", - "executing_script": "Executing script '{script:s}'…", "extracting": "عملية فك الضغط جارية …", - "field_invalid": "Invalid field '{:s}'", - "firewall_reload_failed": "Unable to reload the firewall", - "firewall_reloaded": "The firewall has been reloaded", - "firewall_rules_cmd_failed": "Some firewall rules commands have failed. For more information, see the log.", - "format_datetime_short": "%m/%d/%Y %I:%M %p", - "global_settings_bad_choice_for_enum": "Bad value for setting {setting:s}, received {received_type:s}, except {expected_type:s}", - "global_settings_bad_type_for_setting": "Bad type for setting {setting:s}, received {received_type:s}, except {expected_type:s}", - "global_settings_cant_open_settings": "Failed to open settings file, reason: {reason:s}", - "global_settings_cant_serialize_settings": "Failed to serialize settings data, reason: {reason:s}", - "global_settings_cant_write_settings": "Failed to write settings file, reason: {reason:s}", - "global_settings_key_doesnt_exists": "The key '{settings_key:s}' doesn't exists in the global settings, you can see all the available keys by doing 'yunohost settings list'", - "global_settings_reset_success": "Success. Your previous settings have been backuped in {path:s}", - "global_settings_setting_example_bool": "Example boolean option", - "global_settings_setting_example_enum": "Example enum option", - "global_settings_setting_example_int": "Example int option", - "global_settings_setting_example_string": "Example string option", - "global_settings_unknown_setting_from_settings_file": "Unknown key in settings: '{setting_key:s}', discarding it and save it in /etc/yunohost/unkown_settings.json", - "global_settings_unknown_type": "Unexpected situation, the setting {setting:s} appears to have the type {unknown_type:s} but it's not a type supported by the system.", - "hook_exec_failed": "Script execution failed: {path:s}", - "hook_exec_not_terminated": "Script execution hasn’t terminated: {path:s}", - "hook_list_by_invalid": "Invalid property to list hook by", - "hook_name_unknown": "Unknown hook name '{name:s}'", "installation_complete": "إكتملت عملية التنصيب", - "installation_failed": "Installation failed", - "invalid_url_format": "Invalid URL format", - "ip6tables_unavailable": "You cannot play with ip6tables here. You are either in a container or your kernel does not support it", - "iptables_unavailable": "You cannot play with iptables here. You are either in a container or your kernel does not support it", - "ldap_init_failed_to_create_admin": "LDAP initialization failed to create admin user", - "ldap_initialized": "LDAP has been initialized", - "license_undefined": "undefined", - "mail_alias_remove_failed": "Unable to remove mail alias '{mail:s}'", - "mail_domain_unknown": "Unknown mail address domain '{domain:s}'", - "mail_forward_remove_failed": "Unable to remove mail forward '{mail:s}'", - "mailbox_used_space_dovecot_down": "Dovecot mailbox service need to be up, if you want to get mailbox used space", - "maindomain_change_failed": "Unable to change the main domain", - "maindomain_changed": "The main domain has been changed", - "migrate_tsig_end": "Migration to hmac-sha512 finished", - "migrate_tsig_failed": "Migrating the dyndns domain {domain} to hmac-sha512 failed, rolling back. Error: {error_code} - {error}", - "migrate_tsig_start": "Not secure enough key algorithm detected for TSIG signature of domain '{domain}', initiating migration to the more secure one hmac-sha512", - "migrate_tsig_wait": "لننتظر الآن 3 دقائق ريثما يأخذ خادم أسماء النطاقات الديناميكية بعين الاعتبار المفتاح الجديد…", - "migrate_tsig_wait_2": "دقيقتين …", - "migrate_tsig_wait_3": "دقيقة واحدة …", - "migrate_tsig_wait_4": "30 ثانية …", - "migrate_tsig_not_needed": "You do not appear to use a dyndns domain, so no migration is needed !", - "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", - "migrations_current_target": "Migration target is {}", - "migrations_error_failed_to_load_migration": "ERROR: failed to load migration {number} {name}", - "migrations_forward": "Migrating forward", - "migrations_loading_migration": "Loading migration {number} {name}…", - "migrations_migration_has_failed": "Migration {number} {name} has failed with exception {exception}, aborting", - "migrations_no_migrations_to_run": "No migrations to run", - "migrations_show_currently_running_migration": "Running migration {number} {name}…", - "migrations_show_last_migration": "Last ran migration is {}", - "migrations_skip_migration": "جارٍ تجاهل التهجير {number} {name}…", - "monitor_disabled": "The server monitoring has been disabled", - "monitor_enabled": "The server monitoring has been enabled", - "monitor_glances_con_failed": "Unable to connect to Glances server", - "monitor_not_enabled": "Server monitoring is not enabled", - "monitor_period_invalid": "Invalid time period", - "monitor_stats_file_not_found": "Statistics file not found", - "monitor_stats_no_update": "No monitoring statistics to update", - "monitor_stats_period_unavailable": "No available statistics for the period", - "mountpoint_unknown": "Unknown mountpoint", - "mysql_db_creation_failed": "MySQL database creation failed", - "mysql_db_init_failed": "MySQL database init failed", - "mysql_db_initialized": "The MySQL database has been initialized", - "network_check_mx_ko": "DNS MX record is not set", - "network_check_smtp_ko": "Outbound mail (SMTP port 25) seems to be blocked by your network", - "network_check_smtp_ok": "Outbound mail (SMTP port 25) is not blocked", - "new_domain_required": "You must provide the new main domain", - "no_appslist_found": "No app list found", - "no_internet_connection": "Server is not connected to the Internet", - "no_ipv6_connectivity": "IPv6 connectivity is not available", - "no_restore_script": "No restore script found for the app '{app:s}'", - "not_enough_disk_space": "Not enough free disk space on '{path:s}'", - "package_not_installed": "Package '{pkgname}' is not installed", - "package_unexpected_error": "An unexpected error occurred processing the package '{pkgname}'", - "package_unknown": "Unknown package '{pkgname}'", - "packages_no_upgrade": "لا يوجد هناك أية حزمة بحاجة إلى تحديث", - "packages_upgrade_critical_later": "Critical packages ({packages:s}) will be upgraded later", - "packages_upgrade_failed": "Unable to upgrade all of the packages", - "path_removal_failed": "Unable to remove path {:s}", - "pattern_backup_archive_name": "Must be a valid filename with max 30 characters, and alphanumeric and -_. characters only", + "main_domain_change_failed": "تعذّر تغيير النطاق الأساسي", + "main_domain_changed": "تم تغيير النطاق الأساسي", + "migrations_skip_migration": "جارٍ تجاهل التهجير {id}…", "pattern_domain": "يتوجب أن يكون إسم نطاق صالح (مثل my-domain.org)", "pattern_email": "يتوجب أن يكون عنوان بريد إلكتروني صالح (مثل someone@domain.org)", - "pattern_firstname": "Must be a valid first name", - "pattern_lastname": "Must be a valid last name", - "pattern_listname": "Must be alphanumeric and underscore characters only", - "pattern_mailbox_quota": "Must be a size with b/k/M/G/T suffix or 0 to disable the quota", "pattern_password": "يتوجب أن تكون مكونة من 3 حروف على الأقل", - "pattern_port": "يجب أن يكون رقم منفذ صالح (مثال 0-65535)", - "pattern_port_or_range": "Must be a valid port number (i.e. 0-65535) or range of ports (e.g. 100:200)", - "pattern_positive_number": "يجب أن يكون عددا إيجابيا", - "pattern_username": "Must be lower-case alphanumeric and underscore characters only", - "port_already_closed": "Port {port:d} is already closed for {ip_version:s} connections", - "port_already_opened": "Port {port:d} is already opened for {ip_version:s} connections", - "port_available": "المنفذ {port:d} متوفر", - "port_unavailable": "Port {port:d} is not available", - "restore_action_required": "You must specify something to restore", - "restore_already_installed_app": "An app is already installed with the id '{app:s}'", - "restore_app_failed": "Unable to restore the app '{app:s}'", - "restore_cleaning_failed": "Unable to clean-up the temporary restoration directory", - "restore_complete": "Restore complete", - "restore_confirm_yunohost_installed": "Do you really want to restore an already installed system? [{answers:s}]", "restore_extracting": "جارٍ فك الضغط عن الملفات التي نحتاجها من النسخة الاحتياطية…", - "restore_failed": "Unable to restore the system", - "restore_hook_unavailable": "Restoration script for '{part:s}' not available on your system and not in the archive either", - "restore_may_be_not_enough_disk_space": "Your system seems not to have enough disk space (freespace: {free_space:d} B, needed space: {needed_space:d} B, security margin: {margin:d} B)", - "restore_mounting_archive": "تنصيب النسخة الإحتياطية على المسار '{path:s}'", - "restore_not_enough_disk_space": "Not enough disk space (freespace: {free_space:d} B, needed space: {needed_space:d} B, security margin: {margin:d} B)", - "restore_nothings_done": "Nothing has been restored", - "restore_removing_tmp_dir_failed": "Unable to remove an old temporary directory", - "restore_running_app_script": "Running restore script of app '{app:s}'…", - "restore_running_hooks": "Running restoration hooks…", - "restore_system_part_failed": "Unable to restore the '{part:s}' system part", "server_shutdown": "سوف ينطفئ الخادوم", - "server_shutdown_confirm": "سوف ينطفئ الخادوم حالا. متأكد ؟ [{answers:s}]", + "server_shutdown_confirm": "سوف ينطفئ الخادوم حالا. متأكد ؟ [{answers}]", "server_reboot": "سيعاد تشغيل الخادوم", - "server_reboot_confirm": "سيعاد تشغيل الخادوم في الحين. هل أنت متأكد ؟ [{answers:s}]", - "service_add_failed": "تعذرت إضافة خدمة '{service:s}'", - "service_added": "The service '{service:s}' has been added", - "service_already_started": "Service '{service:s}' has already been started", - "service_already_stopped": "Service '{service:s}' has already been stopped", - "service_cmd_exec_failed": "Unable to execute command '{command:s}'", - "service_conf_file_backed_up": "The configuration file '{conf}' has been backed up to '{backup}'", - "service_conf_file_copy_failed": "Unable to copy the new configuration file '{new}' to '{conf}'", - "service_conf_file_kept_back": "The configuration file '{conf}' is expected to be deleted by service {service} but has been kept back.", - "service_conf_file_manually_modified": "The configuration file '{conf}' has been manually modified and will not be updated", - "service_conf_file_manually_removed": "The configuration file '{conf}' has been manually removed and will not be created", - "service_conf_file_remove_failed": "Unable to remove the configuration file '{conf}'", - "service_conf_file_removed": "The configuration file '{conf}' has been removed", - "service_conf_file_updated": "The configuration file '{conf}' has been updated", - "service_conf_new_managed_file": "The configuration file '{conf}' is now managed by the service {service}.", - "service_conf_up_to_date": "The configuration is already up-to-date for service '{service}'", - "service_conf_updated": "The configuration has been updated for service '{service}'", - "service_conf_would_be_updated": "The configuration would have been updated for service '{service}'", - "service_disable_failed": "", - "service_disabled": "The service '{service:s}' has been disabled", - "service_enable_failed": "", - "service_enabled": "تم تنشيط خدمة '{service:s}'", - "service_no_log": "ليس لخدمة '{service:s}' أي سِجلّ للعرض", - "service_regenconf_dry_pending_applying": "Checking pending configuration which would have been applied for service '{service}'...", - "service_regenconf_failed": "Unable to regenerate the configuration for service(s): {services}", - "service_regenconf_pending_applying": "Applying pending configuration for service '{service}'...", - "service_remove_failed": "Unable to remove service '{service:s}'", - "service_removed": "تمت إزالة خدمة '{service:s}'", - "service_start_failed": "", - "service_started": "تم إطلاق تشغيل خدمة '{service:s}'", - "service_status_failed": "Unable to determine status of service '{service:s}'", - "service_stop_failed": "", - "service_stopped": "The service '{service:s}' has been stopped", - "service_unknown": "Unknown service '{service:s}'", - "ssowat_conf_generated": "The SSOwat configuration has been generated", - "ssowat_conf_updated": "The SSOwat configuration has been updated", - "ssowat_persistent_conf_read_error": "Error while reading SSOwat persistent configuration: {error:s}. Edit /etc/ssowat/conf.json.persistent file to fix the JSON syntax", - "ssowat_persistent_conf_write_error": "Error while saving SSOwat persistent configuration: {error:s}. Edit /etc/ssowat/conf.json.persistent file to fix the JSON syntax", + "server_reboot_confirm": "سيعاد تشغيل الخادوم في الحين. هل أنت متأكد ؟ [{answers}]", + "service_add_failed": "تعذرت إضافة خدمة '{service}'", + "service_already_stopped": "إنّ خدمة '{service}' متوقفة مِن قبلُ", + "service_disabled": "لن يتم إطلاق خدمة '{service}' أثناء بداية تشغيل النظام.", + "service_enabled": "تم تنشيط خدمة '{service}'", + "service_removed": "تمت إزالة خدمة '{service}'", + "service_started": "تم إطلاق تشغيل خدمة '{service}'", + "service_stopped": "تمّ إيقاف خدمة '{service}'", "system_upgraded": "تمت عملية ترقية النظام", - "system_username_exists": "Username already exists in the system users", - "unbackup_app": "App '{app:s}' will not be saved", - "unexpected_error": "An unexpected error occured", - "unit_unknown": "Unknown unit '{unit:s}'", "unlimit": "دون تحديد الحصة", - "unrestore_app": "App '{app:s}' will not be restored", - "update_cache_failed": "Unable to update APT cache", - "updating_apt_cache": "جارٍ تحديث قائمة الحُزم المتوفرة …", - "upgrade_complete": "إكتملت عملية الترقية و التحديث", + "updating_apt_cache": "جارٍ جلب قائمة حُزم النظام المحدّثة المتوفرة…", + "upgrade_complete": "اكتملت عملية الترقية و التحديث", "upgrading_packages": "عملية ترقية الحُزم جارية …", - "upnp_dev_not_found": "No UPnP device found", - "upnp_disabled": "UPnP has been disabled", - "upnp_enabled": "UPnP has been enabled", - "upnp_port_open_failed": "Unable to open UPnP ports", + "upnp_disabled": "تم تعطيل UPnP", "user_created": "تم إنشاء المستخدم", - "user_creation_failed": "Unable to create user", "user_deleted": "تم حذف المستخدم", "user_deletion_failed": "لا يمكن حذف المستخدم", - "user_home_creation_failed": "Unable to create user home folder", - "user_info_failed": "Unable to retrieve user information", - "user_unknown": "المستخدم {user:s} مجهول", + "user_unknown": "المستخدم {user} مجهول", "user_update_failed": "لا يمكن تحديث المستخدم", "user_updated": "تم تحديث المستخدم", - "yunohost_already_installed": "YunoHost is already installed", - "yunohost_ca_creation_failed": "تعذرت عملية إنشاء هيئة الشهادات", - "yunohost_ca_creation_success": "تم إنشاء هيئة الشهادات المحلية.", - "yunohost_configured": "YunoHost has been configured", "yunohost_installing": "عملية تنصيب يونوهوست جارية …", "yunohost_not_installed": "إنَّ واي يونوهوست ليس مُنَصَّب أو هو مثبت حاليا بشكل خاطئ. قم بتنفيذ الأمر 'yunohost tools postinstall'", - "migration_description_0003_migrate_to_stretch": "تحديث النظام إلى ديبيان ستريتش و واي يونوهوست 3.0", - "migration_0003_patching_sources_list": "عملية تصحيح ملف المصادر sources.lists جارية…", - "migration_0003_main_upgrade": "بداية عملية التحديث الأساسية…", - "migration_0003_fail2ban_upgrade": "بداية عملية تحديث fail2ban…", - "migration_0003_not_jessie": "إن توزيعة ديبيان الحالية تختلف عن جيسي !", - "migration_description_0002_migrate_to_tsig_sha256": "يقوم بتحسين أمان TSIG لنظام أسماء النطاقات الديناميكة باستخدام SHA512 بدلًا مِن MD5", - "migration_0003_backward_impossible": "لا يُمكن إلغاء عملية الإنتقال إلى ستريتش.", - "migration_0003_system_not_fully_up_to_date": "إنّ نظامك غير مُحدَّث بعدُ لذا يرجى القيام بتحديث عادي أولا قبل إطلاق إجراء الإنتقال إلى نظام ستريتش.", "migrations_list_conflict_pending_done": "لا يمكنك استخدام --previous و --done معًا على نفس سطر الأوامر.", - "service_description_avahi-daemon": "يسمح لك بالنفاذ إلى خادومك عبر الشبكة المحلية باستخدام yunohost.local", - "service_description_glances": "يقوم بمراقبة معلومات النظام على خادومك", "service_description_metronome": "يُدير حسابات الدردشة الفورية XMPP", "service_description_nginx": "يقوم بتوفير النفاذ و السماح بالوصول إلى كافة مواقع الويب المستضافة على خادومك", - "service_description_php5-fpm": "يقوم بتشغيل تطبيقات الـ PHP مع خادوم الويب nginx", "service_description_postfix": "يقوم بإرسال و تلقي الرسائل البريدية الإلكترونية", "service_description_yunohost-api": "يقوم بإدارة التفاعلات ما بين واجهة الويب لواي يونوهوست و النظام", - "log_category_404": "فئةالسجل '{category}' لا وجود لها", - "log_app_fetchlist": "إضافة قائمة للتطبيقات", - "log_app_removelist": "حذف قائمة للتطبيقات", "log_app_change_url": "تعديل رابط تطبيق '{}'", "log_app_install": "تنصيب تطبيق '{}'", "log_app_remove": "حذف تطبيق '{}'", @@ -400,23 +100,20 @@ "log_letsencrypt_cert_install": "تنصيب شهادة Let’s Encrypt على النطاق '{}'", "log_selfsigned_cert_install": "تنصيب شهادة موقَّعَة ذاتيا على اسم النطاق '{}'", "log_letsencrypt_cert_renew": "تجديد شهادة Let's Encrypt لـ '{}'", - "log_service_enable": "تنشيط خدمة '{}'", "log_user_create": "إضافة المستخدم '{}'", "log_user_delete": "حذف المستخدم '{}'", "log_user_update": "تحديث معلومات المستخدم '{}'", - "log_tools_maindomain": "جعل '{}' كنطاق أساسي", + "log_domain_main_domain": "جعل '{}' كنطاق أساسي", "log_tools_upgrade": "تحديث حُزم ديبيان", "log_tools_shutdown": "إطفاء الخادم", "log_tools_reboot": "إعادة تشغيل الخادم", - "migration_description_0005_postgresql_9p4_to_9p6": "تهجير قواعد البيانات مِن postgresql 9.4 إلى 9.6", "service_description_dnsmasq": "مُكلَّف بتحليل أسماء النطاقات (DNS)", "service_description_mysql": "يقوم بتخزين بيانات التطبيقات (قواعد بيانات SQL)", "service_description_rspamd": "يقوم بتصفية البريد المزعج و إدارة ميزات أخرى للبريد", - "service_description_yunohost-firewall": "يريد فتح و غلق منافذ الإتصال إلى الخدمات", - "users_available": "المستخدمون المتوفرون:", + "service_description_yunohost-firewall": "يُدير فتح وإغلاق منافذ الاتصال إلى الخدمات", "aborting": "إلغاء.", "admin_password_too_long": "يرجى اختيار كلمة سرية أقصر مِن 127 حرف", - "app_not_upgraded": "لم يتم تحديث التطبيقات التالية: {apps}", + "app_not_upgraded": "", "app_start_install": "جارٍ تثبيت التطبيق {app}…", "app_start_remove": "جارٍ حذف التطبيق {app}…", "app_start_restore": "جارٍ استرجاع التطبيق {app}…", @@ -425,6 +122,41 @@ "ask_new_path": "مسار جديد", "global_settings_setting_security_password_admin_strength": "قوة الكلمة السرية الإدارية", "global_settings_setting_security_password_user_strength": "قوة الكلمة السرية للمستخدم", - "log_app_addaccess": "إضافة ترخيص بالنفاذ إلى '{}'", - "password_too_simple_1": "يجب أن يكون طول الكلمة السرية على الأقل 8 حروف" -} + "password_too_simple_1": "يجب أن يكون طول الكلمة السرية على الأقل 8 حروف", + "already_up_to_date": "كل شيء على ما يرام. ليس هناك ما يتطلّب تحديثًا.", + "service_description_slapd": "يخزّن المستخدمين والنطاقات والمعلومات المتعلقة بها", + "service_reloaded": "تم إعادة تشغيل خدمة '{service}'", + "service_restarted": "تم إعادة تشغيل خدمة '{service}'", + "group_unknown": "الفريق {group} مجهول", + "group_deletion_failed": "فشلت عملية حذف الفريق '{group}': {error}", + "group_deleted": "تم حذف الفريق '{group}'", + "group_created": "تم إنشاء الفريق '{group}'", + "dyndns_could_not_check_available": "لا يمكن التحقق مِن أنّ {domain} متوفر على {provider}.", + "backup_mount_archive_for_restore": "جارٍ تهيئة النسخة الاحتياطية للاسترجاع…", + "root_password_replaced_by_admin_password": "لقد تم استبدال كلمة سر الجذر root بالكلمة الإدارية لـ admin.", + "app_action_broke_system": "يبدو أنّ هذا الإجراء أدّى إلى تحطيم هذه الخدمات المهمة: {services}", + "diagnosis_basesystem_host": "هذا الخادم يُشغّل ديبيان {debian_version}", + "diagnosis_basesystem_kernel": "هذا الخادم يُشغّل نواة لينكس {kernel_version}", + "diagnosis_basesystem_ynh_single_version": "{package} الإصدار: {version} ({repo})", + "diagnosis_basesystem_ynh_main_version": "هذا الخادم يُشغّل YunoHost {main_version} ({repo})", + "diagnosis_everything_ok": "كل شيء يبدو على ما يرام في {category}!", + "diagnosis_ip_connected_ipv4": "الخادم مُتّصل بالإنترنت عبر IPv4!", + "diagnosis_ip_connected_ipv6": "الخادم مُتّصل بالإنترنت عبر IPv6!", + "diagnosis_ip_not_connected_at_all": "يبدو أنّ الخادم غير مُتّصل بتاتا بالإنترنت!؟", + "app_install_failed": "لا يمكن تنصيب {app}: {error}", + "apps_already_up_to_date": "كافة التطبيقات مُحدّثة", + "app_remove_after_failed_install": "جارٍ حذف التطبيق بعدما فشل تنصيبها…", + "apps_catalog_updating": "جارٍ تحديث فهرس التطبيقات…", + "apps_catalog_update_success": "تم تحديث فهرس التطبيقات!", + "diagnosis_domain_expiration_error": "ستنتهي مدة صلاحية بعض النطاقات في القريب العاجل!", + "diagnosis_domain_expiration_warning": "ستنتهي مدة صلاحية بعض النطاقات قريبًا!", + "diagnosis_ports_could_not_diagnose_details": "خطأ: {error}", + "diagnosis_description_regenconf": "إعدادات النظام", + "diagnosis_description_mail": "البريد الإلكتروني", + "diagnosis_description_web": "الويب", + "diagnosis_description_systemresources": "موارد النظام", + "diagnosis_description_services": "حالة الخدمات", + "diagnosis_description_dnsrecords": "تسجيلات خدمة DNS", + "diagnosis_description_ip": "الإتصال بالإنترنت", + "diagnosis_description_basesystem": "النظام الأساسي" +} \ No newline at end of file diff --git a/locales/bn_BD.json b/locales/bn_BD.json index 0967ef424..c912ef50a 100644 --- a/locales/bn_BD.json +++ b/locales/bn_BD.json @@ -1 +1,3 @@ -{} +{ + "password_too_simple_1": "পাসওয়ার্ডটি কমপক্ষে 8 টি অক্ষরের দীর্ঘ হওয়া দরকার" +} \ No newline at end of file diff --git a/locales/br.json b/locales/br.json index 0967ef424..9e26dfeeb 100644 --- a/locales/br.json +++ b/locales/br.json @@ -1 +1 @@ -{} +{} \ No newline at end of file diff --git a/locales/ca.json b/locales/ca.json index 824745c12..b29a94fb6 100644 --- a/locales/ca.json +++ b/locales/ca.json @@ -1,113 +1,82 @@ { - "action_invalid": "Acció '{action:s}' invàlida", + "action_invalid": "Acció '{action}' invàlida", "admin_password": "Contrasenya d'administració", - "admin_password_change_failed": "No s'ha pogut canviar la contrasenya", + "admin_password_change_failed": "No es pot canviar la contrasenya", "admin_password_changed": "S'ha canviat la contrasenya d'administració", - "app_already_installed": "{app:s} ja està instal·lada", - "app_already_installed_cant_change_url": "Aquesta aplicació ja està instal·lada. La URL no és pot canviar únicament amb aquesta funció. Mireu a \"app changeurl\" si està disponible.", - "app_already_up_to_date": "{app:s} ja està actualitzada", - "app_argument_choice_invalid": "Aquesta opció no és vàlida per l'argument '{name:s}', ha de ser una de {choices:s}", - "app_argument_invalid": "Valor invàlid per l'argument '{name:s}':{error:s}", - "app_argument_required": "Es necessita l'argument '{name:s}'", - "app_change_no_change_url_script": "L'aplicació {app_name:s} encara no permet canviar la seva URL, es possible que s'hagi d'actualitzar.", - "app_change_url_failed_nginx_reload": "No s'ha pogut tornar a carregar nginx. Aquí teniu el resultat de \"nginx -t\":\n{nginx_errors:s}", - "app_change_url_identical_domains": "L'antic i el nou domini/camí són idèntics ('{domain:s}{path:s}'), no hi ha res per fer.", - "app_change_url_no_script": "Aquesta aplicació '{app_name:s}' encara no permet modificar la URL. Potser s'ha d'actualitzar l'aplicació.", - "app_change_url_success": "La URL de {app:s} s'ha canviat correctament a {domain:s}{path:s}", + "app_already_installed": "{app} ja està instal·lada", + "app_already_installed_cant_change_url": "Aquesta aplicació ja està instal·lada. La URL no és pot canviar únicament amb aquesta funció. Mireu a `app changeurl` si està disponible.", + "app_already_up_to_date": "{app} ja està actualitzada", + "app_argument_choice_invalid": "Utilitzeu una de les opcions «{choices}» per l'argument «{name}» en lloc de «{value}»", + "app_argument_invalid": "Escolliu un valor vàlid per l'argument «{name}»: {error}", + "app_argument_required": "Es necessita l'argument '{name}'", + "app_change_url_identical_domains": "L'antic i el nou domini/camí són idèntics ('{domain}{path}'), no hi ha res per fer.", + "app_change_url_no_script": "L'aplicació '{app_name}' encara no permet modificar la URL. Potser s'ha d'actualitzar.", + "app_change_url_success": "La URL de {app} ara és {domain}{path}", "app_extraction_failed": "No s'han pogut extreure els fitxers d'instal·lació", - "app_id_invalid": "Id de l'aplicació incorrecte", - "app_incompatible": "L'aplicació {app} no és compatible amb la teva versió de YunoHost", - "app_install_files_invalid": "Fitxers d'instal·lació invàlids", - "app_location_already_used": "L'aplicació '{app}' ja està instal·lada en aquest camí ({path})", - "app_make_default_location_already_used": "No es pot fer l'aplicació '{app}' per defecte en el domini {domain} ja que ja és utilitzat per una altra aplicació '{other_app}'", - "app_location_install_failed": "No s'ha pogut instal·lar l'aplicació en aquest camí ja que entra en conflicte amb l'aplicació '{other_app}' ja instal·lada a '{other_path}'", - "app_location_unavailable": "Aquesta url no està disponible o entra en conflicte amb aplicacions ja instal·lades:\n{apps:s}", - "app_manifest_invalid": "Manifest d'aplicació incorrecte: {error}", - "app_no_upgrade": "No hi ha cap aplicació per actualitzar", - "app_not_correctly_installed": "{app:s} sembla estar mal instal·lada", - "app_not_installed": "{app:s} no està instal·lada", - "app_not_properly_removed": "{app:s} no s'ha pogut suprimir correctament", - "app_package_need_update": "El paquet de l'aplicació {app} ha de ser actualitzat per poder seguir els canvis de YunoHost", - "app_removed": "{app:s} ha estat suprimida", - "app_requirements_checking": "Verificació dels paquets requerits per {app}…", - "app_requirements_failed": "No es poden satisfer els requeriments per {app}: {error}", + "app_id_invalid": "ID de l'aplicació incorrecte", + "app_install_files_invalid": "Aquests fitxers no es poden instal·lar", + "app_make_default_location_already_used": "No es pot fer l'aplicació '{app}' l'aplicació per defecte en el domini «{domain}», ja que ja és utilitzat per '{other_app}'", + "app_location_unavailable": "Aquesta URL no està disponible o entra en conflicte amb aplicacions ja instal·lades:\n{apps}", + "app_manifest_invalid": "Hi ha algun error amb el manifest de l'aplicació: {error}", + "app_not_correctly_installed": "{app} sembla estar mal instal·lada", + "app_not_installed": "No s'ha trobat {app} en la llista d'aplicacions instal·lades: {all_apps}", + "app_not_properly_removed": "{app} no s'ha pogut suprimir correctament", + "app_removed": "{app} ha estat suprimida", + "app_requirements_checking": "Verificació dels paquets requerits per {app}...", "app_requirements_unmeet": "No es compleixen els requeriments per {app}, el paquet {pkgname} ({version}) ha de ser {spec}", "app_sources_fetch_failed": "No s'han pogut carregar els fitxers font, l'URL és correcta?", "app_unknown": "Aplicació desconeguda", "app_unsupported_remote_type": "El tipus remot utilitzat per l'aplicació no està suportat", - "app_upgrade_app_name": "Actualitzant l'aplicació {app}…", - "app_upgrade_failed": "No s'ha pogut actualitzar {app:s}", + "app_upgrade_app_name": "Actualitzant {app}...", + "app_upgrade_failed": "No s'ha pogut actualitzar {app}: {error}", "app_upgrade_some_app_failed": "No s'han pogut actualitzar algunes aplicacions", - "app_upgraded": "{app:s} ha estat actualitzada", - "appslist_corrupted_json": "No s'han pogut carregar les llistes d'aplicacions. Sembla que {filename:s} està danyat.", - "appslist_could_not_migrate": "No s'ha pogut migrar la llista d'aplicacions {appslist:s}! No s'ha pogut analitzar la URL... L'antic cronjob s'ha guardat a {bkp_file:s}.", - "appslist_fetched": "S'ha descarregat la llista d'aplicacions {appslist:s} correctament", - "appslist_migrating": "Migrant la llista d'aplicacions {appslist:s}…", - "appslist_name_already_tracked": "Ja hi ha una llista d'aplicacions registrada amb el nom {name:s}.", - "appslist_removed": "S'ha eliminat la llista d'aplicacions {appslist:s}", - "appslist_retrieve_bad_format": "L'arxiu obtingut per la llista d'aplicacions {appslist:s} no és vàlid", - "appslist_retrieve_error": "No s'ha pogut obtenir la llista d'aplicacions remota {appslist:s}: {error:s}", - "appslist_unknown": "La llista d'aplicacions {appslist:s} es desconeguda.", - "appslist_url_already_tracked": "Ja hi ha una llista d'aplicacions registrada amb al URL {url:s}.", - "ask_current_admin_password": "Contrasenya d'administrador actual", - "ask_email": "Correu electrònic", + "app_upgraded": "S'ha actualitzat {app}", "ask_firstname": "Nom", "ask_lastname": "Cognom", - "ask_list_to_remove": "Llista per a suprimir", "ask_main_domain": "Domini principal", "ask_new_admin_password": "Nova contrasenya d'administrador", "ask_password": "Contrasenya", - "ask_path": "Camí", - "backup_abstract_method": "Encara no s'ha implementat aquest mètode de copia de seguretat", - "backup_action_required": "S'ha d'especificar què s'ha de guardar", - "backup_app_failed": "No s'ha pogut fer la còpia de seguretat de l'aplicació \"{app:s}\"", - "backup_applying_method_borg": "Enviant tots els fitxers de la còpia de seguretat al repositori borg-backup…", - "backup_applying_method_copy": "Còpia de tots els fitxers a la còpia de seguretat…", - "backup_applying_method_custom": "Crida del mètode de còpia de seguretat personalitzat \"{method:s}\"…", - "backup_applying_method_tar": "Creació de l'arxiu tar de la còpia de seguretat…", - "backup_archive_app_not_found": "L'aplicació \"{app:s}\" no es troba dins l'arxiu de la còpia de seguretat", - "backup_archive_broken_link": "No s'ha pogut accedir a l'arxiu de la còpia de seguretat (enllaç invàlid cap a {path:s})", - "backup_archive_mount_failed": "No s'ha pogut carregar l'arxiu de la còpia de seguretat", - "backup_archive_name_exists": "Ja hi ha una còpia de seguretat amb aquest nom", - "backup_archive_name_unknown": "Còpia de seguretat local \"{name:s}\" desconeguda", + "backup_abstract_method": "Encara està per implementar aquest mètode de còpia de seguretat", + "backup_app_failed": "No s'ha pogut fer la còpia de seguretat de {app}", + "backup_applying_method_copy": "Còpia de tots els fitxers a la còpia de seguretat...", + "backup_applying_method_custom": "Crida del mètode de còpia de seguretat personalitzat \"{method}\"...", + "backup_applying_method_tar": "Creació de l'arxiu TAR de la còpia de seguretat...", + "backup_archive_app_not_found": "No s'ha pogut trobar {app} en l'arxiu de la còpia de seguretat", + "backup_archive_broken_link": "No s'ha pogut accedir a l'arxiu de la còpia de seguretat (enllaç invàlid cap a {path})", + "backup_archive_name_exists": "Ja hi ha una còpia de seguretat amb aquest nom.", + "backup_archive_name_unknown": "Còpia de seguretat local \"{name}\" desconeguda", "backup_archive_open_failed": "No s'ha pogut obrir l'arxiu de la còpia de seguretat", - "backup_archive_system_part_not_available": "La part \"{part:s}\" del sistema no està disponible en aquesta copia de seguretat", - "backup_archive_writing_error": "No es poden afegir arxius a l'arxiu comprimit de la còpia de seguretat", - "backup_ask_for_copying_if_needed": "Alguns fitxers no s'han pogut preparar per la còpia de seguretat utilitzant el mètode que evita malgastar espai del sistema temporalment. Per fer la còpia de seguretat, s'han d'utilitzar {size:s}MB temporalment. Hi esteu d'acord?", - "backup_borg_not_implemented": "El mètode de còpia de seguretat Borg encara no està implementat", - "backup_cant_mount_uncompress_archive": "No es pot carregar en mode de lectura només el directori de l'arxiu descomprimit", + "backup_archive_system_part_not_available": "La part «{part}» del sistema no està disponible en aquesta copia de seguretat", + "backup_archive_writing_error": "No es poden afegir els arxius «{source}» (anomenats en l'arxiu «{dest}») a l'arxiu comprimit de la còpia de seguretat «{archive}»", + "backup_ask_for_copying_if_needed": "Voleu fer la còpia de seguretat utilitzant {size}MB temporalment? (S'utilitza aquest mètode ja que alguns dels fitxers no s'han pogut preparar utilitzar un mètode més eficient.)", + "backup_cant_mount_uncompress_archive": "No es pot carregar l'arxiu descomprimit com a protegit contra escriptura", "backup_cleaning_failed": "No s'ha pogut netejar el directori temporal de la còpia de seguretat", - "backup_copying_to_organize_the_archive": "Copiant {size:s}MB per organitzar l'arxiu", - "backup_couldnt_bind": "No es pot lligar {src:s} amb {dest:s}.", + "backup_copying_to_organize_the_archive": "Copiant {size}MB per organitzar l'arxiu", + "backup_couldnt_bind": "No es pot lligar {src} amb {dest}.", "backup_created": "S'ha creat la còpia de seguretat", - "backup_creating_archive": "Creant l'arxiu de la còpia de seguretat…", "aborting": "Avortant.", - "app_not_upgraded": "Les següents aplicacions no s'han actualitzat: {apps}", - "app_start_install": "instal·lant l'aplicació {app}…", - "app_start_remove": "Eliminant l'aplicació {app}…", - "app_start_backup": "Recuperant els fitxers pels que s'ha de fer una còpia de seguretat per {app}…", - "app_start_restore": "Recuperant l'aplicació {app}…", + "app_not_upgraded": "L'aplicació «{failed_app}» no s'ha pogut actualitzar, i com a conseqüència s'ha cancel·lat l'actualització de les següents aplicacions: {apps}", + "app_start_install": "instal·lant {app}...", + "app_start_remove": "Eliminant {app}...", + "app_start_backup": "Recuperant els fitxers pels que s'ha de fer una còpia de seguretat per «{app}»...", + "app_start_restore": "Recuperant {app}...", "app_upgrade_several_apps": "S'actualitzaran les següents aplicacions: {apps}", "ask_new_domain": "Nou domini", "ask_new_path": "Nou camí", - "backup_actually_backuping": "S'està creant un arxiu de còpia de seguretat a partir dels fitxers recuperats…", - "backup_creation_failed": "Ha fallat la creació de la còpia de seguretat", + "backup_actually_backuping": "Creant un arxiu de còpia de seguretat a partir dels fitxers recuperats...", + "backup_creation_failed": "No s'ha pogut crear l'arxiu de la còpia de seguretat", "backup_csv_addition_failed": "No s'han pogut afegir fitxers per a fer-ne la còpia de seguretat al fitxer CSV", - "backup_csv_creation_failed": "No s'ha pogut crear el fitxer CSV necessari per a futures operacions de recuperació", - "backup_custom_backup_error": "El mètode de còpia de seguretat personalitzat ha fallat a l'etapa \"backup\"", - "backup_custom_mount_error": "El mètode de còpia de seguretat personalitzat ha fallat a l'etapa \"mount\"", - "backup_custom_need_mount_error": "El mètode de còpia de seguretat personalitzat ha fallat a l'etapa \"need_mount\"", - "backup_delete_error": "No s'ha pogut suprimir \"{path:s}\"", + "backup_csv_creation_failed": "No s'ha pogut crear el fitxer CSV necessari per a la restauració", + "backup_custom_backup_error": "El mètode de còpia de seguretat personalitzat ha fallat a l'etapa «backup»", + "backup_custom_mount_error": "El mètode de còpia de seguretat personalitzat ha fallat a l'etapa «mount»", + "backup_delete_error": "No s'ha pogut suprimir «{path}»", "backup_deleted": "S'ha suprimit la còpia de seguretat", - "backup_extracting_archive": "Extraient l'arxiu de la còpia de seguretat…", - "backup_hook_unknown": "Script de còpia de seguretat \"{hook:s}\" desconegut", - "backup_invalid_archive": "Arxiu de còpia de seguretat no vàlid", - "backup_method_borg_finished": "La còpia de seguretat a borg ha acabat", + "backup_hook_unknown": "Script de còpia de seguretat «{hook}» desconegut", "backup_method_copy_finished": "La còpia de la còpia de seguretat ha acabat", - "backup_method_custom_finished": "El mètode de còpia de seguretat personalitzat \"{method:s}\" ha acabat", - "backup_method_tar_finished": "S'ha creat l'arxiu de còpia de seguretat tar", - "backup_mount_archive_for_restore": "Preparant l'arxiu per la restauració…", - "good_practices_about_user_password": "Esteu a punt de definir una nova contrasenya d'usuari. La contrasenya ha de tenir un mínim de 8 caràcters ; tot i que és de bona pràctica utilitzar una contrasenya més llarga (és a dir una frase de contrasenya) i/o utilitzar diferents tipus de caràcters (majúscules, minúscules, dígits i caràcters especials).", + "backup_method_custom_finished": "El mètode de còpia de seguretat personalitzat \"{method}\" ha acabat", + "backup_method_tar_finished": "S'ha creat l'arxiu de còpia de seguretat TAR", + "backup_mount_archive_for_restore": "Preparant l'arxiu per la restauració...", + "good_practices_about_user_password": "Esteu a punt de definir una nova contrasenya d'usuari. La contrasenya ha de tenir un mínim de 8 caràcters; tot i que és de bona pràctica utilitzar una contrasenya més llarga (és a dir una frase de contrasenya) i/o utilitzar diferents tipus de caràcters (majúscules, minúscules, dígits i caràcters especials).", "password_listed": "Aquesta contrasenya és una de les més utilitzades en el món. Si us plau utilitzeu-ne una més única.", "password_too_simple_1": "La contrasenya ha de tenir un mínim de 8 caràcters", "password_too_simple_2": "La contrasenya ha de tenir un mínim de 8 caràcters i ha de contenir dígits, majúscules i minúscules", @@ -115,140 +84,102 @@ "password_too_simple_4": "La contrasenya ha de tenir un mínim de 12 caràcters i tenir dígits, majúscules, minúscules i caràcters especials", "backup_no_uncompress_archive_dir": "El directori de l'arxiu descomprimit no existeix", "backup_nothings_done": "No hi ha res a guardar", - "backup_output_directory_forbidden": "Directori de sortida no permès. Les còpies de seguretat no es poden crear ni dins els directoris /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var ni dins els subdirectoris /home/yunohost.backup/archives", - "backup_output_directory_not_empty": "El directori de sortida no està buit", + "backup_output_directory_forbidden": "Escolliu un directori de sortida different. Les còpies de seguretat no es poden crear ni dins els directoris /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var ni dins els subdirectoris /home/yunohost.backup/archives", + "backup_output_directory_not_empty": "Heu d'escollir un directori de sortida buit", "backup_output_directory_required": "Heu d'especificar un directori de sortida per la còpia de seguretat", - "backup_output_symlink_dir_broken": "Teniu un enllaç simbòlic trencat en lloc del directori dels arxius '{path:s}'. Pot ser teniu una configuració per la còpia de seguretat específica en un altre sistema de fitxers, si és el cas segurament heu oblidat muntar o connectar el disc dur o la clau USB.", - "backup_php5_to_php7_migration_may_fail": "No s'ha pogut convertir l'arxiu per suportar php7, la restauració de les vostres aplicacions pot fallar (raó: {error:s})", - "backup_running_hooks": "Executant els scripts de la còpia de seguretat…", - "backup_system_part_failed": "No s'ha pogut fer la còpia de seguretat de la part \"{part:s}\" del sistema", - "backup_unable_to_organize_files": "No s'han pogut organitzar els fitxers dins de l'arxiu amb el mètode ràpid", - "backup_with_no_backup_script_for_app": "L'aplicació {app:s} no té un script de còpia de seguretat. Serà ignorat.", - "backup_with_no_restore_script_for_app": "L'aplicació {app:s} no té un script de restauració, no podreu restaurar automàticament la còpia de seguretat d'aquesta aplicació.", - "certmanager_acme_not_configured_for_domain": "El certificat pel domini {domain:s} sembla que no està instal·lat correctament. Si us plau executeu primer cert-install per aquest domini.", - "certmanager_attempt_to_renew_nonLE_cert": "El certificat pel domini {domain:s} no ha estat emès per Let's Encrypt. No es pot renovar automàticament!", - "certmanager_attempt_to_renew_valid_cert": "El certificat pel domini {domain:s} està a punt de caducar! (Utilitzeu --force si sabeu el que esteu fent)", - "certmanager_attempt_to_replace_valid_cert": "Esteu intentant sobreescriure un certificat correcte i vàlid pel domini {domain:s}! (Utilitzeu --force per ometre)", - "certmanager_cannot_read_cert": "S'ha produït un error al intentar obrir el certificat actual pel domini {domain:s} (arxiu: {file:s}), raó: {reason:s}", - "certmanager_cert_install_success": "S'ha instal·lat correctament un certificat Let's Encrypt pel domini {domain:s}!", - "certmanager_cert_install_success_selfsigned": "S'ha instal·lat correctament un certificat auto-signat pel domini {domain:s}!", - "certmanager_cert_renew_success": "S'ha renovat correctament el certificat Let's Encrypt pel domini {domain:s}!", + "backup_output_symlink_dir_broken": "El directori del arxiu «{path}» es un enllaç simbòlic trencat. Pot ser heu oblidat muntar, tornar a muntar o connectar el mitja d'emmagatzematge al que apunta.", + "backup_running_hooks": "Executant els scripts de la còpia de seguretat...", + "backup_system_part_failed": "No s'ha pogut fer la còpia de seguretat de la part \"{part}\" del sistema", + "backup_unable_to_organize_files": "No s'ha pogut utilitzar el mètode ràpid per organitzar els fitxers dins de l'arxiu", + "backup_with_no_backup_script_for_app": "L'aplicació «{app}» no té un script de còpia de seguretat. Serà ignorat.", + "backup_with_no_restore_script_for_app": "{app} no té un script de restauració, no podreu restaurar automàticament la còpia de seguretat d'aquesta aplicació.", + "certmanager_acme_not_configured_for_domain": "No s'ha pogut executar el ACME challenge pel domini {domain} en aquests moments ja que a la seva configuració de nginx li manca el codi corresponent… Assegureu-vos que la configuració nginx està actualitzada utilitzant «yunohost tools regen-conf nginx --dry-run --with-diff».", + "certmanager_attempt_to_renew_nonLE_cert": "El certificat pel domini «{domain}» no ha estat emès per Let's Encrypt. No es pot renovar automàticament!", + "certmanager_attempt_to_renew_valid_cert": "El certificat pel domini «{domain}» està a punt de caducar! (Utilitzeu --force si sabeu el que esteu fent)", + "certmanager_attempt_to_replace_valid_cert": "Esteu intentant sobreescriure un certificat correcte i vàlid pel domini {domain}! (Utilitzeu --force per ometre)", + "certmanager_cannot_read_cert": "S'ha produït un error al intentar obrir el certificat actual pel domini {domain} (arxiu: {file}), raó: {reason}", + "certmanager_cert_install_success": "S'ha instal·lat correctament un certificat Let's Encrypt pel domini «{domain}»", + "certmanager_cert_install_success_selfsigned": "S'ha instal·lat correctament un certificat auto-signat pel domini «{domain}»", + "certmanager_cert_renew_success": "S'ha renovat correctament el certificat Let's Encrypt pel domini «{domain}»", "certmanager_cert_signing_failed": "No s'ha pogut firmar el nou certificat", - "certmanager_certificate_fetching_or_enabling_failed": "Sembla que l'activació del nou certificat per {domain:s} ha fallat…", - "certmanager_conflicting_nginx_file": "No s'ha pogut preparar el domini per al desafiament ACME: l'arxiu de configuració nginx {filepath:s} entra en conflicte i s'ha d'eliminar primer", - "certmanager_couldnt_fetch_intermediate_cert": "S'ha exhaurit el temps d'esperar al intentar recollir el certificat intermedi des de Let's Encrypt. La instal·lació/renovació del certificat s'ha cancel·lat - torneu a intentar-ho més tard.", - "certmanager_domain_cert_not_selfsigned": "El certificat pel domini {domain:s} no és auto-signat Esteu segur de voler canviar-lo? (Utilitzeu --force per fer-ho)", - "certmanager_domain_dns_ip_differs_from_public_ip": "El registre DNS \"A\" pel domini {domain:s} és diferent a l'adreça IP d'aquest servidor. Si heu modificat recentment el registre A, si us plau espereu a que es propagui (hi ha eines per verificar la propagació disponibles a internet). (Si sabeu el que esteu fent, podeu utilitzar --no-checks per desactivar aquestes comprovacions.)", - "certmanager_domain_http_not_working": "Sembla que el domini {domain:s} no és accessible via HTTP. Si us plau verifiqueu que les configuracions DNS i nginx siguin correctes", - "certmanager_domain_not_resolved_locally": "El domini {domain:s} no es pot resoldre dins del vostre servidor YunoHost. Això pot passar si heu modificat recentment el registre DNS. Si és així, si us plau espereu unes hores per a que es propagui. Si el problema continua, considereu afegir {domain:s} a /etc/hosts. (Si sabeu el que esteu fent, podeu utilitzar --no-checks per desactivar aquestes comprovacions.)", - "certmanager_domain_unknown": "Domini desconegut {domain:s}", - "certmanager_error_no_A_record": "No s'ha trobat cap registre DNS \"A\" per {domain:s}. Heu de fer que el vostre nom de domini apunti cap a la vostra màquina per tal de poder instal·lar un certificat Let's Encrypt! (Si sabeu el que esteu fent, podeu utilitzar --no-checks per desactivar aquestes comprovacions.)", - "certmanager_hit_rate_limit": "S'han emès massa certificats recentment per aquest mateix conjunt de dominis {domain:s}. Si us plau torneu-ho a intentar més tard. Consulteu https://letsencrypt.org/docs/rate-limits/ per obtenir més detalls", - "certmanager_http_check_timeout": "S'ha exhaurit el temps d'espera quan el servidor ha intentat contactar amb ell mateix via HTTP utilitzant la seva adreça IP pública (domini domain:s} amb IP {ip:s}). Pot ser degut a hairpinning o a que el talla focs/router al que està connectat el servidor estan mal configurats.", - "certmanager_no_cert_file": "No s'ha pogut llegir l'arxiu del certificat pel domini {domain:s} (fitxer: {file:s})", - "certmanager_self_ca_conf_file_not_found": "No s'ha trobat el fitxer de configuració per l'autoritat del certificat auto-signat (fitxer: {file:s})", - "certmanager_unable_to_parse_self_CA_name": "No s'ha pogut analitzar el nom de l'autoritat del certificat auto-signat (fitxer: {file:s})", - "confirm_app_install_warning": "Atenció: aquesta aplicació funciona però no està ben integrada amb YunoHost. Algunes característiques com la autenticació única i la còpia de seguretat/restauració poden no estar disponibles. Voleu instal·lar-la de totes maneres? [{answers:s}] ", - "confirm_app_install_danger": "ATENCIÓ! Aquesta aplicació encara és experimental (si no és que no funciona directament) i és probable que trenqui el sistema! No hauríeu d'instal·lar-la a no ser que sapigueu el que feu. Esteu segurs de voler córrer aquest risc? [{answers:s}] ", - "confirm_app_install_thirdparty": "ATENCIÓ! La instal·lació d'aplicacions de terceres parts pot comprometre la integritat i seguretat del seu sistema. Faci-ho sota la seva responsabilitat.No hauríeu d'instal·lar-ne a no ser que sapigueu el que feu. Esteu segurs de voler córrer aquest risc? [{answers:s}] ", - "custom_app_url_required": "Heu de especificar una URL per actualitzar la vostra aplicació personalitzada {app:s}", - "custom_appslist_name_required": "Heu d'especificar un nom per la vostra llista d'aplicacions personalitzada", - "diagnosis_debian_version_error": "No s'ha pogut obtenir la versió Debian: {error}", - "diagnosis_kernel_version_error": "No s'ha pogut obtenir la versió del nucli: {error}", - "diagnosis_monitor_disk_error": "No es poden monitorar els discs: {error}", - "diagnosis_monitor_network_error": "No es pot monitorar la xarxa: {error}", - "diagnosis_monitor_system_error": "No es pot monitorar el sistema: {error}", - "diagnosis_no_apps": "No hi ha cap aplicació instal·lada", + "certmanager_certificate_fetching_or_enabling_failed": "Sembla que utilitzar el nou certificat per {domain} ha fallat...", + "certmanager_domain_cert_not_selfsigned": "El certificat pel domini {domain} no és auto-signat Esteu segur de voler canviar-lo? (Utilitzeu «--force» per fer-ho)", + "certmanager_domain_dns_ip_differs_from_public_ip": "Els registres DNS pel domini «{domain}» són diferents a l'adreça IP d'aquest servidor. Mireu la categoria «registres DNS» (bàsic) al diagnòstic per a més informació. Si heu modificat recentment el registre A, si us plau espereu a que es propagui (hi ha eines per verificar la propagació disponibles a internet). (Si sabeu el que esteu fent, podeu utilitzar «--no-checks» per desactivar aquestes comprovacions.)", + "certmanager_domain_http_not_working": "El domini {domain} sembla que no és accessible via HTTP. Verifiqueu la categoria «Web» en el diagnòstic per a més informació. (Si sabeu el que esteu fent, utilitzeu «--no-checks» per deshabilitar les comprovacions.)", + "certmanager_hit_rate_limit": "S'han emès massa certificats recentment per aquest mateix conjunt de dominis {domain}. Si us plau torneu-ho a intentar més tard. Consulteu https://letsencrypt.org/docs/rate-limits/ per obtenir més detalls", + "certmanager_no_cert_file": "No s'ha pogut llegir l'arxiu del certificat pel domini {domain} (fitxer: {file})", + "certmanager_self_ca_conf_file_not_found": "No s'ha trobat el fitxer de configuració per l'autoritat del certificat auto-signat (fitxer: {file})", + "certmanager_unable_to_parse_self_CA_name": "No s'ha pogut analitzar el nom de l'autoritat del certificat auto-signat (fitxer: {file})", + "confirm_app_install_warning": "Atenció: Aquesta aplicació funciona, però no està ben integrada amb YunoHost. Algunes característiques com la autenticació única i la còpia de seguretat/restauració poden no estar disponibles. Voleu instal·lar-la de totes maneres? [{answers}] ", + "confirm_app_install_danger": "PERILL! Aquesta aplicació encara és experimental (si no és que no funciona directament)! No hauríeu d'instal·lar-la a no ser que sapigueu el que feu. No obtindreu CAP AJUDA si l'aplicació no funciona o trenca el sistema... Si accepteu el risc, escriviu «{answers}»", + "confirm_app_install_thirdparty": "PERILL! Aquesta aplicació no es part del catàleg d'aplicacions de YunoHost. La instal·lació d'aplicacions de terceres parts pot comprometre la integritat i seguretat del seu sistema. No hauríeu d'instal·lar-ne a no ser que sapigueu el que feu. No obtindreu CAP AJUDA si l'aplicació no funciona o trenca el sistema… Si accepteu el risc, escriviu «{answers}»", + "custom_app_url_required": "Heu de especificar una URL per actualitzar la vostra aplicació personalitzada {app}", "admin_password_too_long": "Trieu una contrasenya de menys de 127 caràcters", - "dpkg_is_broken": "No es pot fer això en aquest instant perquè dpkg/apt (els gestors de paquets del sistema) sembla estar mal configurat... Podeu intentar solucionar-ho connectant-vos per ssh i executant \"sudo dpkg --configure -a\".", - "dnsmasq_isnt_installed": "sembla que dnsmasq no està instal·lat, executeu \"apt-get remove bind9 && apt-get install dnsmasq\"", - "domain_cannot_remove_main": "No es pot eliminar el domini principal. S'ha d'establir un nou domini primer", + "dpkg_is_broken": "No es pot fer això en aquest instant perquè dpkg/APT (els gestors de paquets del sistema) sembla estar mal configurat... Podeu intentar solucionar-ho connectant-vos per SSH i executant «sudo apt install --fix-broken» i/o «sudo dpkg --configure -a».", + "domain_cannot_remove_main": "No es pot eliminar «{domain}» ja que és el domini principal, primer s'ha d'establir un nou domini principal utilitzant «yunohost domain main-domain -n »; aquí hi ha una llista dels possibles dominis: {other_domains}", "domain_cert_gen_failed": "No s'ha pogut generar el certificat", "domain_created": "S'ha creat el domini", - "domain_creation_failed": "No s'ha pogut crear el domini", + "domain_creation_failed": "No s'ha pogut crear el domini {domain}: {error}", "domain_deleted": "S'ha eliminat el domini", - "domain_deletion_failed": "No s'ha pogut eliminar el domini", + "domain_deletion_failed": "No s'ha pogut eliminar el domini {domain}: {error}", "domain_exists": "El domini ja existeix", - "app_action_cannot_be_ran_because_required_services_down": "Aquesta aplicació necessita serveis que estan aturats. Abans de continuar, hauríeu d'intentar arrancar de nou els serveis següents (i també investigar perquè estan aturats) : {services}", + "app_action_cannot_be_ran_because_required_services_down": "Aquests serveis necessaris haurien d'estar funcionant per poder executar aquesta acció: {services} Intenteu reiniciar-los per continuar (i possiblement investigar perquè estan aturats).", "domain_dns_conf_is_just_a_recommendation": "Aquesta ordre mostra la configuració *recomanada*. En cap cas fa la configuració del DNS. És la vostra responsabilitat configurar la zona DNS en el vostre registrar en acord amb aquesta recomanació.", "domain_dyndns_already_subscribed": "Ja us heu subscrit a un domini DynDNS", - "domain_dyndns_dynette_is_unreachable": "No s'ha pogut abastar la dynette YunoHost, o bé YunoHost no està connectat a internet correctament o bé el servidor dynette està caigut. Error: {error}", - "domain_dyndns_invalid": "Domini no vàlid per utilitzar amb DynDNS", "domain_dyndns_root_unknown": "Domini DynDNS principal desconegut", - "domain_hostname_failed": "No s'ha pogut establir un nou nom d'amfitrió", - "domain_uninstall_app_first": "Hi ha una o més aplicacions instal·lades en aquest domini. Desinstal·leu les abans d'eliminar el domini", - "domain_unknown": "Domini desconegut", - "domain_zone_exists": "El fitxer de zona DNS ja existeix", - "domain_zone_not_found": "No s'ha trobat el fitxer de zona DNS pel domini {:s}", + "domain_hostname_failed": "No s'ha pogut establir un nou nom d'amfitrió. Això podria causar problemes més tard (podria no passar res).", + "domain_uninstall_app_first": "Aquestes aplicacions encara estan instal·lades en el vostre domini:\n{apps}\n\nDesinstal·leu-les utilitzant l'ordre «yunohost app remove id_de_lapplicació» o moveu-les a un altre domini amb «yunohost app change-url id_de_lapplicació» abans d'eliminar el domini", "domains_available": "Dominis disponibles:", "done": "Fet", - "downloading": "Descarregant…", - "dyndns_could_not_check_provide": "No s'ha pogut verificar si {provider:s} pot oferir {domain:s}.", - "dyndns_could_not_check_available": "No s'ha pogut verificar la disponibilitat de {domain:s} a {provider:s}.", + "downloading": "Descarregant...", + "dyndns_could_not_check_provide": "No s'ha pogut verificar si {provider} pot oferir {domain}.", + "dyndns_could_not_check_available": "No s'ha pogut verificar la disponibilitat de {domain} a {provider}.", "dyndns_ip_update_failed": "No s'ha pogut actualitzar l'adreça IP al DynDNS", "dyndns_ip_updated": "S'ha actualitzat l'adreça IP al DynDNS", - "dyndns_key_generating": "S'està generant la clau DNS, això pot trigar una estona…", + "dyndns_key_generating": "S'està generant la clau DNS... això pot trigar una estona.", "dyndns_key_not_found": "No s'ha trobat la clau DNS pel domini", "dyndns_no_domain_registered": "No hi ha cap domini registrat amb DynDNS", "dyndns_registered": "S'ha registrat el domini DynDNS", - "dyndns_registration_failed": "No s'ha pogut registrar el domini DynDNS: {error:s}", - "dyndns_domain_not_provided": "El proveïdor {provider:s} no pot oferir el domini {domain:s}.", - "dyndns_unavailable": "El domini {domain:s} no està disponible.", - "executing_command": "Execució de l'ordre « {command:s} »…", - "executing_script": "Execució de l'script « {script:s} »…", - "extracting": "Extracció en curs…", - "dyndns_cron_installed": "S'ha instal·lat la tasca cron pel DynDNS", - "dyndns_cron_remove_failed": "No s'ha pogut eliminar la tasca cron pel DynDNS", - "dyndns_cron_removed": "S'ha eliminat la tasca cron pel DynDNS", - "experimental_feature": "Atenció: aquesta funcionalitat és experimental i no es considera estable, no s'ha d'utilitzar a excepció de saber el que esteu fent.", - "field_invalid": "Camp incorrecte « {:s} »", - "file_does_not_exist": "El camí {path:s} no existeix.", - "firewall_reload_failed": "No s'ha pogut tornar a carregar el tallafoc", - "firewall_reloaded": "S'ha tornat a carregar el tallafoc", - "firewall_rules_cmd_failed": "No s'han pogut aplicar algunes regles del tallafoc. Mireu el registre per a més informació.", - "format_datetime_short": "%d/%m/%Y %H:%M", - "global_settings_bad_choice_for_enum": "Opció pel paràmetre {setting:s} incorrecta, s'ha rebut «{choice:s}» però les opcions disponibles són: {available_choices:s}", - "global_settings_bad_type_for_setting": "El tipus del paràmetre {setting:s} és incorrecte. S'ha rebut {received_type:s}, però s'esperava {expected_type:s}", - "global_settings_cant_open_settings": "No s'ha pogut obrir el fitxer de configuració, raó: {reason:s}", - "global_settings_cant_serialize_settings": "No s'ha pogut serialitzar les dades de configuració, raó: {reason:s}", - "global_settings_cant_write_settings": "No s'ha pogut escriure el fitxer de configuració, raó: {reason:s}", - "global_settings_key_doesnt_exists": "La clau « {settings_key:s} » no existeix en la configuració global, podeu veure totes les claus disponibles executant « yunohost settings list »", - "global_settings_reset_success": "Èxit. S'ha fet una còpia de seguretat de la configuració anterior a {path:s}", - "global_settings_setting_example_bool": "Exemple d'opció booleana", - "global_settings_setting_example_enum": "Exemple d'opció de tipus enumeració", - "global_settings_setting_example_int": "Exemple d'opció de tipus enter", - "global_settings_setting_example_string": "Exemple d'opció de tipus cadena", - "global_settings_setting_security_nginx_compatibility": "Solució de compromís entre compatibilitat i seguretat pel servidor web nginx. Afecta els criptògrafs (i altres aspectes relacionats amb la seguretat)", + "dyndns_registration_failed": "No s'ha pogut registrar el domini DynDNS: {error}", + "dyndns_domain_not_provided": "El proveïdor de DynDNS {provider} no pot oferir el domini {domain}.", + "dyndns_unavailable": "El domini {domain} no està disponible.", + "extracting": "Extracció en curs...", + "experimental_feature": "Atenció: Aquesta funcionalitat és experimental i no es considera estable, no s'ha d'utilitzar a excepció de saber el que esteu fent.", + "field_invalid": "Camp incorrecte « {} »", + "file_does_not_exist": "El camí {path} no existeix.", + "firewall_reload_failed": "No s'ha pogut tornar a carregar el tallafocs", + "firewall_reloaded": "S'ha tornat a carregar el tallafocs", + "firewall_rules_cmd_failed": "Han fallat algunes comandes per aplicar regles del tallafocs. Més informació en el registre.", + "global_settings_bad_choice_for_enum": "Opció pel paràmetre {setting} incorrecta, s'ha rebut «{choice}», però les opcions disponibles són: {available_choices}", + "global_settings_bad_type_for_setting": "El tipus del paràmetre {setting} és incorrecte. S'ha rebut {received_type}, però s'esperava {expected_type}", + "global_settings_cant_open_settings": "No s'ha pogut obrir el fitxer de configuració, raó: {reason}", + "global_settings_cant_serialize_settings": "No s'ha pogut serialitzar les dades de configuració, raó: {reason}", + "global_settings_cant_write_settings": "No s'ha pogut escriure el fitxer de configuració, raó: {reason}", + "global_settings_key_doesnt_exists": "La clau « {settings_key} » no existeix en la configuració global, podeu veure totes les claus disponibles executant « yunohost settings list »", + "global_settings_reset_success": "S'ha fet una còpia de seguretat de la configuració anterior a {path}", + "global_settings_setting_security_nginx_compatibility": "Solució de compromís entre compatibilitat i seguretat pel servidor web NGINX. Afecta els criptògrafs (i altres aspectes relacionats amb la seguretat)", "global_settings_setting_security_password_admin_strength": "Robustesa de la contrasenya d'administrador", "global_settings_setting_security_password_user_strength": "Robustesa de la contrasenya de l'usuari", "global_settings_setting_security_ssh_compatibility": "Solució de compromís entre compatibilitat i seguretat pel servidor SSH. Afecta els criptògrafs (i altres aspectes relacionats amb la seguretat)", - "global_settings_unknown_setting_from_settings_file": "Clau de configuració desconeguda: «{setting_key:s}», refusant-la i guardant-la a /etc/yunohost/settings-unknown.json", + "global_settings_unknown_setting_from_settings_file": "Clau de configuració desconeguda: «{setting_key}», refusada i guardada a /etc/yunohost/settings-unknown.json", "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Permetre la clau d'hoste DSA (obsolet) per la configuració del servei SSH", - "global_settings_unknown_type": "Situació inesperada, la configuració {setting:s} sembla tenir el tipus {unknown_type:s} però no és un tipus reconegut pel sistema.", - "good_practices_about_admin_password": "Esteu a punt de definir una nova contrasenya d'administrador. La contrasenya ha de tenir un mínim de 8 caràcters ; tot i que és de bona pràctica utilitzar una contrasenya més llarga (és a dir una frase de contrasenya) i/o utilitzar diferents tipus de caràcters (majúscules, minúscules, dígits i caràcters especials).", - "hook_exec_failed": "No s'ha pogut executar l'script: {path:s}", - "hook_exec_not_terminated": "L'execució de l'script « {path:s} » no s'ha acabat correctament", - "hook_json_return_error": "No s'ha pogut llegir el retorn de l'script {path:s}. Error: {msg:s}. Contingut en brut: {raw_content}", - "hook_list_by_invalid": "Propietat per llistar les accions invàlida", - "hook_name_unknown": "Nom de script « {name:s} » desconegut", + "global_settings_unknown_type": "Situació inesperada, la configuració {setting} sembla tenir el tipus {unknown_type} però no és un tipus reconegut pel sistema.", + "good_practices_about_admin_password": "Esteu a punt de definir una nova contrasenya d'administrador. La contrasenya ha de tenir un mínim de 8 caràcters; tot i que és de bona pràctica utilitzar una contrasenya més llarga (és a dir una frase de contrasenya) i/o utilitzar diferents tipus de caràcters (majúscules, minúscules, dígits i caràcters especials).", + "hook_exec_failed": "No s'ha pogut executar el script: {path}", + "hook_exec_not_terminated": "El script no s'ha acabat correctament: {path}", + "hook_json_return_error": "No s'ha pogut llegir el retorn del script {path}. Error: {msg}. Contingut en brut: {raw_content}", + "hook_list_by_invalid": "Aquesta propietat no es pot utilitzar per llistar els hooks", + "hook_name_unknown": "Nom de script « {name} » desconegut", "installation_complete": "Instal·lació completada", - "installation_failed": "Ha fallat la instal·lació", - "invalid_url_format": "Format d'URL invàlid", "ip6tables_unavailable": "No podeu modificar les ip6tables aquí. O bé sou en un contenidor o bé el vostre nucli no és compatible amb aquesta opció", "iptables_unavailable": "No podeu modificar les iptables aquí. O bé sou en un contenidor o bé el vostre nucli no és compatible amb aquesta opció", - "log_corrupted_md_file": "El fitxer de metadades yaml associat amb els registres està malmès: « {md_file} »", - "log_category_404": "La categoria de registres « {category} » no existeix", + "log_corrupted_md_file": "El fitxer de metadades YAML associat amb els registres està malmès: « {md_file} »\nError: {error}", "log_link_to_log": "El registre complet d'aquesta operació: «{desc}»", - "log_help_to_get_log": "Per veure el registre de l'operació « {desc} », utilitzeu l'ordre « yunohost log display {name} »", - "log_link_to_failed_log": "L'operació « {dec} » ha fallat! Per obtenir ajuda, proveïu el registre complete de l'operació clicant aquí", - "log_help_to_get_failed_log": "L'operació « {dec} » ha fallat! Per obtenir ajuda, compartiu el registre complete de l'operació utilitzant l'ordre « yunohost log display {name} --share »", + "log_help_to_get_log": "Per veure el registre de l'operació « {desc} », utilitzeu l'ordre « yunohost log show {name} »", + "log_link_to_failed_log": "No s'ha pogut completar l'operació « {desc} ». Per obtenir ajuda, proveïu el registre complete de l'operació clicant aquí", + "log_help_to_get_failed_log": "No s'ha pogut completar l'operació « {desc} ». Per obtenir ajuda, compartiu el registre complete de l'operació utilitzant l'ordre « yunohost log share {name} »", "log_does_exists": "No hi ha cap registre per l'operació amb el nom« {log} », utilitzeu « yunohost log list » per veure tots els registre d'operació disponibles", "log_operation_unit_unclosed_properly": "L'operació no s'ha tancat de forma correcta", - "log_app_addaccess": "Afegir accés a « {} »", - "log_app_removeaccess": "Suprimeix accés a « {} »", - "log_app_clearaccess": "Suprimeix tots els accessos a « {} »", - "log_app_fetchlist": "Afegeix una llista d'aplicacions", - "log_app_removelist": "Elimina una llista d'aplicacions", "log_app_change_url": "Canvia l'URL de l'aplicació « {} »", "log_app_install": "Instal·la l'aplicació « {} »", "log_app_remove": "Elimina l'aplicació « {} »", @@ -263,139 +194,51 @@ "log_domain_remove": "Elimina el domini « {} » de la configuració del sistema", "log_dyndns_subscribe": "Subscriure's a un subdomini YunoHost « {} »", "log_dyndns_update": "Actualitza la IP associada al subdomini YunoHost « {} »", - "log_letsencrypt_cert_install": "Instal·la el certificat Let's Encrypt al domini « {} »", + "log_letsencrypt_cert_install": "Instal·la un certificat Let's Encrypt al domini « {} »", "log_selfsigned_cert_install": "Instal·la el certificat autosignat al domini « {} »", "log_letsencrypt_cert_renew": "Renova el certificat Let's Encrypt de « {} »", - "log_service_enable": "Activa el servei « {} »", "log_regen_conf": "Regenera la configuració del sistema « {} »", "log_user_create": "Afegeix l'usuari « {} »", "log_user_delete": "Elimina l'usuari « {} »", "log_user_update": "Actualitza la informació de l'usuari « {} »", - "log_tools_maindomain": "Fes de « {} » el domini principal", - "log_tools_migrations_migrate_forward": "Migrar", - "log_tools_migrations_migrate_backward": "Migrar endarrera", + "log_domain_main_domain": "Fes de « {} » el domini principal", + "log_tools_migrations_migrate_forward": "Executa les migracions", "log_tools_postinstall": "Fer la post instal·lació del servidor YunoHost", "log_tools_upgrade": "Actualitza els paquets del sistema", "log_tools_shutdown": "Apaga el servidor", "log_tools_reboot": "Reinicia el servidor", - "already_up_to_date": "No hi ha res a fer! Tot està al dia!", + "already_up_to_date": "No hi ha res a fer. Tot està actualitzat.", "dpkg_lock_not_available": "No es pot utilitzar aquesta comanda en aquest moment ja que sembla que un altre programa està utilitzant el lock de dpkg (el gestor de paquets del sistema)", "global_settings_setting_security_postfix_compatibility": "Solució de compromís entre compatibilitat i seguretat pel servidor Postfix. Afecta els criptògrafs (i altres aspectes relacionats amb la seguretat)", - "ldap_init_failed_to_create_admin": "La inicialització de LDAP no ha pogut crear l'usuari admin", - "ldap_initialized": "S'ha iniciat LDAP", - "license_undefined": "indefinit", - "mail_alias_remove_failed": "No s'han pogut eliminar els alias del correu «{mail:s}»", - "mail_domain_unknown": "Domini d'adreça de correu «{domain:s}» desconegut", - "mail_forward_remove_failed": "No s'han pogut eliminar el reenviament de correu «{mail:s}»", - "mailbox_used_space_dovecot_down": "S'ha d'engegar el servei de correu Dovecot per poder obtenir l'espai utilitzat per la bústia de correu", - "mail_unavailable": "Aquesta adreça de correu esta reservada i ha de ser atribuïda automàticament el primer usuari", - "maindomain_change_failed": "No s'ha pogut canviar el domini principal", - "maindomain_changed": "S'ha canviat el domini principal", - "migrate_tsig_end": "La migració cap a hmac-sha512 s'ha acabat", - "migrate_tsig_failed": "Ha fallat la migració del domini dyndns {domain} cap a hmac-sha512, anul·lant les modificacions. Error: {error_code} - {error}", - "migrate_tsig_start": "L'algoritme de generació de claus no es prou segur per a la signatura TSIG del domini «{domain}», començant la migració cap a un de més segur hmac-sha512", - "migrate_tsig_wait": "Esperar 3 minuts per a que el servidor dyndns tingui en compte la nova clau…", - "migrate_tsig_wait_2": "2 minuts…", - "migrate_tsig_wait_3": "1 minut…", - "migrate_tsig_wait_4": "30 segons…", - "migrate_tsig_not_needed": "Sembla que no s'utilitza cap domini dyndns, no és necessari fer cap migració!", - "migration_description_0001_change_cert_group_to_sslcert": "Canvia els permisos del grup dels certificats de «metronome» a «ssl-cert»", - "migration_description_0002_migrate_to_tsig_sha256": "Millora la seguretat de dyndns TSIG utilitzant SHA512 en lloc de MD5", - "migration_description_0003_migrate_to_stretch": "Actualització del sistema a Debian Stretch i YunoHost 3.0", - "migration_description_0004_php5_to_php7_pools": "Tornar a configurar els pools PHP per utilitzar PHP 7 en lloc de PHP 5", - "migration_description_0005_postgresql_9p4_to_9p6": "Migració de les bases de dades de postgresql 9.4 a 9.6", - "migration_description_0006_sync_admin_and_root_passwords": "Sincronitzar les contrasenyes admin i root", - "migration_description_0007_ssh_conf_managed_by_yunohost_step1": "La configuració SSH serà gestionada per YunoHost (pas 1, automàtic)", - "migration_description_0008_ssh_conf_managed_by_yunohost_step2": "La configuració SSH serà gestionada per YunoHost (pas 2, manual)", - "migration_description_0009_decouple_regenconf_from_services": "Desvincula el mecanisme regen-conf dels serveis", - "migration_description_0010_migrate_to_apps_json": "Elimina la appslists (desfasat) i utilitza la nova llista unificada «apps.json» en el seu lloc", - "migration_0003_backward_impossible": "La migració Stretch no és reversible.", - "migration_0003_start": "Ha començat la migració a Stretch. Els registres estaran disponibles a {logfile}.", - "migration_0003_patching_sources_list": "Modificant el fitxer sources.lists…", - "migration_0003_main_upgrade": "Començant l'actualització principal…", - "migration_0003_fail2ban_upgrade": "Començant l'actualització de fail2ban…", - "migration_0003_restoring_origin_nginx_conf": "El fitxer /etc/nginx/nginx.conf ha estat editat. La migració el tornarà al seu estat original... El fitxer anterior estarà disponible com a {backup_dest}.", - "migration_0003_yunohost_upgrade": "Començant l'actualització del paquet yunohost... La migració acabarà, però l'actualització actual es farà just després. Després de completar aquesta operació, pot ser que us hagueu de tornar a connectar a la web d'administració.", - "migration_0003_not_jessie": "La distribució Debian actual no és Jessie!", - "migration_0003_system_not_fully_up_to_date": "El vostre sistema no està completament actualitzat. S'ha de fer una actualització normal abans de fer la migració a Stretch.", - "migration_0003_still_on_jessie_after_main_upgrade": "Hi ha hagut un problema durant l'actualització principal: el sistema encara està amb Jessie!? Per investigar el problema, mireu el registres a {log}:s…", - "migration_0003_general_warning": "Tingueu en compte que la migració és una operació delicada. Tot i que l'equip de YunoHost a fet els possibles per revisar-la i provar-la, la migració pot provocar errors en parts del sistema o aplicacions.\n\nPer tant, recomanem:\n - Fer una còpia de seguretat de les dades o aplicacions importants. Més informació a https://yunohost.org/backup;\n - Sigueu pacient un cop llençada la migració: en funció de la connexió a internet i el maquinari, pot trigar fins a unes hores per actualitzar-ho tot.\n\nD'altra banda, el port per SMTP, utilitzat per clients de correu externs (com Thunderbird o K9-Mail) ha canviat de 465 (SSL/TLS) a 587 (STARTTLS). L'antic port 465 serà tancat automàticament i el nou port 587 serà obert en el tallafocs. Tots els usuaris *hauran* d'adaptar la configuració dels clients de correu en acord amb aquests canvis!", - "migration_0003_problematic_apps_warning": "Tingueu en compte que s'han detectat les aplicacions, possiblement, problemàtiques següents. Sembla que aquestes no s'han instal·lat des d'una applist o que no estan marcades com a «working». Per conseqüent, no podem garantir que segueixin funcionant després de l'actualització: {problematic_apps}", - "migration_0003_modified_files": "Tingueu en compte que s'han detectat els següents fitxers que han estat modificats manualment i podrien sobreescriure's al final de l'actualització: {manually_modified_files}", - "migration_0005_postgresql_94_not_installed": "Postgresql no està instal·lat en el sistema. No hi ha res per fer!", - "migration_0005_postgresql_96_not_installed": "S'ha trobat Postgresql 9.4 instal·lat, però no Postgresql 9.6!? Alguna cosa estranya a passat en el sistema :( …", - "migration_0005_not_enough_space": "No hi ha prou espai disponible en {path} per fer la migració en aquest moment :(.", - "migration_0006_disclaimer": "YunoHost esperar que les contrasenyes admin i root estiguin sincronitzades. Fent aquesta migració, la contrasenya root serà reemplaçada per la contrasenya admin.", - "migration_0007_cancelled": "YunoHost no ha pogut millorar la gestió de la configuració SSH.", - "migration_0007_cannot_restart": "No es pot reiniciar SSH després d'haver intentat cancel·lar la migració numero 6.", - "migration_0008_general_disclaimer": "Per millorar la seguretat del servidor, es recomana que sigui YunoHost qui gestioni la configuració SSH. La configuració SSH actual és diferent a la configuració recomanada. Si deixeu que YunoHost ho reconfiguri, la manera de connectar-se al servidor mitjançant SSH canviarà de la següent manera:", - "migration_0008_port": " - la connexió es farà utilitzant el port 22 en lloc del port SSH personalitzat actual. Es pot reconfigurar;", - "migration_0008_root": " - no es podrà connectar com a root a través de SSH. S'haurà d'utilitzar l'usuari admin per fer-ho;", - "migration_0008_dsa": " - es desactivarà la clau DSA. Per tant, es podria haver d'invalidar un missatge esgarrifós del client SSH, i tornar a verificar l'empremta digital del servidor;", - "migration_0008_warning": "Si heu entès els avisos i accepteu que YunoHost sobreescrigui la configuració actual, comenceu la migració. Sinó, podeu saltar-vos la migració, tot i que no està recomanat.", - "migration_0008_no_warning": "No s'han detectat riscs importants per sobreescriure la configuració SSH, però no es pot estar del tot segur ;)! Si accepteu que YunoHost sobreescrigui la configuració actual, comenceu la migració. Sinó, podeu saltar-vos la migració, tot i que no està recomanat.", - "migration_0009_not_needed": "Sembla que ja s'ha fet aquesta migració? Ometent.", - "migrations_backward": "Migració cap enrere.", - "migrations_bad_value_for_target": "Nombre invàlid pel paràmetre target, els nombres de migració disponibles són 0 o {}", - "migrations_cant_reach_migration_file": "No s'ha pogut accedir als fitxers de migració al camí %s", - "migrations_current_target": "La migració objectiu és {}", - "migrations_error_failed_to_load_migration": "ERROR: no s'ha pogut carregar la migració {number} {name}", - "migrations_forward": "Migració endavant", - "migrations_list_conflict_pending_done": "No es pot utilitzar --previous i --done al mateix temps.", - "migrations_loading_migration": "Carregant la migració {number} {name}…", - "migrations_migration_has_failed": "La migració {number} {name} ha fallat amb l'excepció {exception}, cancel·lant", + "mail_alias_remove_failed": "No s'han pogut eliminar els àlies del correu «{mail}»", + "mail_domain_unknown": "El domini «{domain}» de l'adreça de correu no és vàlid. Utilitzeu un domini administrat per aquest servidor.", + "mail_forward_remove_failed": "No s'han pogut eliminar el reenviament de correu «{mail}»", + "mailbox_used_space_dovecot_down": "S'ha d'engegar el servei de correu Dovecot, per poder obtenir l'espai utilitzat per la bústia de correu", + "mail_unavailable": "Aquesta adreça de correu està reservada i ha de ser atribuïda automàticament el primer usuari", + "main_domain_change_failed": "No s'ha pogut canviar el domini principal", + "main_domain_changed": "S'ha canviat el domini principal", + "migrations_cant_reach_migration_file": "No s'ha pogut accedir als fitxers de migració al camí «%s»", + "migrations_list_conflict_pending_done": "No es pot utilitzar «--previous» i «--done» al mateix temps.", + "migrations_loading_migration": "Carregant la migració {id}...", + "migrations_migration_has_failed": "La migració {id} ha fallat, cancel·lant. Error: {exception}", "migrations_no_migrations_to_run": "No hi ha cap migració a fer", - "migrations_show_currently_running_migration": "Fent la migració {number} {name}…", - "migrations_show_last_migration": "L'última migració feta és {}", - "migrations_skip_migration": "Saltant migració {number} {name}…", - "migrations_success": "S'ha completat la migració {number} {name} amb èxit!", - "migrations_to_be_ran_manually": "La migració {number} {name} s'ha de fer manualment. Aneu a Eines > Migracions a la interfície admin, o executeu «yunohost tools migrations migrate».", - "migrations_need_to_accept_disclaimer": "Per fer la migració {number} {name}, heu d'acceptar aquesta clàusula de no responsabilitat:\n---\n{disclaimer}\n---\nSi accepteu fer la migració, torneu a executar l'ordre amb l'opció --accept-disclaimer.", - "monitor_disabled": "El monitoratge del servidor ha estat desactivat", - "monitor_enabled": "El monitoratge del servidor ha estat activat", - "monitor_glances_con_failed": "No s'ha pogut connectar al servidor Glances", - "monitor_not_enabled": "El monitoratge del servidor no està activat", - "monitor_period_invalid": "Període de temps invàlid", - "monitor_stats_file_not_found": "No s'ha pogut trobar el fitxer d'estadístiques", - "monitor_stats_no_update": "No hi ha dades de monitoratge per actualitzar", - "monitor_stats_period_unavailable": "No s'han trobat estadístiques per aquest període", - "mountpoint_unknown": "Punt de muntatge desconegut", - "mysql_db_creation_failed": "No s'ha pogut crear la base de dades MySQL", - "mysql_db_init_failed": "No s'ha pogut inicialitzar la base de dades MySQL", - "mysql_db_initialized": "S'ha inicialitzat la base de dades MySQL", - "network_check_mx_ko": "El registre DNS MX no està configurat", - "network_check_smtp_ko": "El tràfic de correu sortint (SMTP port 25) sembla que està bloquejat per la xarxa", - "network_check_smtp_ok": "El tràfic de correu sortint (SMTP port 25) no està bloquejat", - "new_domain_required": "S'ha d'especificar un nou domini principal", - "no_appslist_found": "No s'ha trobat cap llista d'aplicacions", - "no_internet_connection": "El servidor no està connectat a Internet", - "no_ipv6_connectivity": "La connectivitat IPv6 no està disponible", - "no_restore_script": "No hi ha cap script de restauració per l'aplicació «{app:s}»", - "not_enough_disk_space": "No hi ha prou espai en «{path:s}»", - "package_not_installed": "El paquet «{pkgname}» no està instal·lat", - "package_unexpected_error": "Hi ha hagut un error inesperat processant el paquet «{pkgname}»", - "package_unknown": "Paquet desconegut «{pkgname}»", - "packages_upgrade_critical_later": "Els paquets crítics ({packages:s}) seran actualitzats més tard", + "migrations_skip_migration": "Saltant migració {id}...", + "migrations_to_be_ran_manually": "La migració {id} s'ha de fer manualment. Aneu a Eines → Migracions a la interfície admin, o executeu «yunohost tools migrations run».", + "migrations_need_to_accept_disclaimer": "Per fer la migració {id}, heu d'acceptar aquesta clàusula de no responsabilitat:\n---\n{disclaimer}\n---\nSi accepteu fer la migració, torneu a executar l'ordre amb l'opció «--accept-disclaimer».", + "not_enough_disk_space": "No hi ha prou espai en «{path}»", "packages_upgrade_failed": "No s'han pogut actualitzar tots els paquets", - "path_removal_failed": "No s'ha pogut eliminar el camí {:s}", "pattern_backup_archive_name": "Ha de ser un nom d'arxiu vàlid amb un màxim de 30 caràcters, compost per caràcters alfanumèrics i -_. exclusivament", "pattern_domain": "Ha de ser un nom de domini vàlid (ex.: el-meu-domini.cat)", - "pattern_email": "Ha de ser una adreça de correu vàlida (ex.: algu@domini.cat)", + "pattern_email": "Ha de ser una adreça de correu vàlida, sense el símbol «+» (ex.: algu@domini.cat)", "pattern_firstname": "Ha de ser un nom vàlid", "pattern_lastname": "Ha de ser un cognom vàlid", - "pattern_listname": "Ha d'estar compost per caràcters alfanumèrics i guió baix exclusivament", - "pattern_mailbox_quota": "Ha de ser una mida amb el sufix b/k/M/G/T o 0 per desactivar la quota", + "pattern_mailbox_quota": "Ha de ser una mida amb el sufix b/k/M/G/T o 0 per no tenir quota", "pattern_password": "Ha de tenir un mínim de 3 caràcters", - "pattern_port": "Ha de ser un número de port vàlid (i.e. 0-65535)", "pattern_port_or_range": "Ha de ser un número de port vàlid (i.e. 0-65535) o un interval de ports (ex. 100:200)", - "pattern_positive_number": "Ha de ser un nombre positiu", "pattern_username": "Ha d'estar compost per caràcters alfanumèrics en minúscula i guió baix exclusivament", - "pattern_password_app": "Les contrasenyes no haurien de tenir els següents caràcters: {forbidden_chars}", - "port_already_closed": "El port {port:d} ja està tancat per les connexions {ip_version:s}", - "port_already_opened": "El port {port:d} ja està obert per les connexions {ip_version:s}", - "port_available": "El port {port:d} està disponible", - "port_unavailable": "El port {port:d} no està disponible", - "recommend_to_add_first_user": "La post instal·lació s'ha acabat, però YunoHost necessita com a mínim un usuari per funcionar correctament, hauríeu d'afegir un usuari executant «yunohost user create $username» o amb la interfície d'administració.", + "pattern_password_app": "Les contrasenyes no poden de tenir els següents caràcters: {forbidden_chars}", + "port_already_closed": "El port {port} ja està tancat per les connexions {ip_version}", + "port_already_opened": "El port {port} ja està obert per les connexions {ip_version}", "regenconf_file_backed_up": "S'ha guardat una còpia de seguretat del fitxer de configuració «{conf}» a «{backup}»", "regenconf_file_copy_failed": "No s'ha pogut copiar el nou fitxer de configuració «{new}» a «{conf}»", "regenconf_file_kept_back": "S'espera que el fitxer de configuració «{conf}» sigui suprimit per regen-conf (categoria {category}) però s'ha mantingut.", @@ -406,122 +249,368 @@ "regenconf_file_updated": "El fitxer de configuració «{conf}» ha estat actualitzat", "regenconf_now_managed_by_yunohost": "El fitxer de configuració «{conf}» serà gestionat per YunoHost a partir d'ara (categoria {category}).", "regenconf_up_to_date": "La configuració ja està al dia per la categoria «{category}»", - "regenconf_updated": "La configuració ha estat actualitzada per la categoria «{category}»", + "regenconf_updated": "S'ha actualitzat la configuració per la categoria «{category}»", "regenconf_would_be_updated": "La configuració hagués estat actualitzada per la categoria «{category}»", - "regenconf_dry_pending_applying": "Verificació de la configuració pendent que s'hauria d'haver aplicat per la categoria «{category}»…", + "regenconf_dry_pending_applying": "Verificació de la configuració pendent que s'hauria d'haver aplicat per la categoria «{category}»...", "regenconf_failed": "No s'ha pogut regenerar la configuració per la/les categoria/es : {categories}", - "regenconf_pending_applying": "Aplicació de la configuració pendent per la categoria «{category}»…", - "restore_action_required": "S'ha d'especificar quelcom a restaurar", - "restore_already_installed_app": "Ja hi ha una aplicació instal·lada amb l'id «{app:s}»", - "restore_app_failed": "No s'ha pogut restaurar l'aplicació «{app:s}»", + "regenconf_pending_applying": "Aplicació de la configuració pendent per la categoria «{category}»...", + "restore_already_installed_app": "Una aplicació amb la ID «{app}» ja està instal·lada", + "app_restore_failed": "No s'ha pogut restaurar {app}: {error}", "restore_cleaning_failed": "No s'ha pogut netejar el directori temporal de restauració", "restore_complete": "Restauració completada", - "restore_confirm_yunohost_installed": "Esteu segur de voler restaurar un sistema ja instal·lat? [{answers:s}]", - "restore_extracting": "Extracció dels fitxers necessaris de l'arxiu…", + "restore_confirm_yunohost_installed": "Esteu segur de voler restaurar un sistema ja instal·lat? [{answers}]", + "restore_extracting": "Extracció dels fitxers necessaris de l'arxiu...", "restore_failed": "No s'ha pogut restaurar el sistema", - "restore_hook_unavailable": "L'script de restauració «{part:s}» no està disponible en el sistema i tampoc és en l'arxiu", - "restore_may_be_not_enough_disk_space": "Sembla que no hi ha prou espai disponible en el disc (espai lliure: {free_space:d} B, espai necessari: {needed_space:d} B, marge de seguretat: {margin:d} B)", - "restore_mounting_archive": "Muntatge de l'arxiu a «{path:s}»", - "restore_not_enough_disk_space": "No hi ha prou espai disponible en el disc (espai lliure: {free_space:d} B, espai necessari: {needed_space:d} B, marge de seguretat: {margin:d} B)", + "restore_hook_unavailable": "El script de restauració «{part}» no està disponible en el sistema i tampoc és en l'arxiu", + "restore_may_be_not_enough_disk_space": "Sembla que no hi ha prou espai disponible en el sistema (lliure: {free_space} B, espai necessari: {needed_space} B, marge de seguretat: {margin} B)", + "restore_not_enough_disk_space": "No hi ha prou espai disponible (espai: {free_space} B, espai necessari: {needed_space} B, marge de seguretat: {margin} B)", "restore_nothings_done": "No s'ha restaurat res", "restore_removing_tmp_dir_failed": "No s'ha pogut eliminar un directori temporal antic", - "restore_running_app_script": "Execució de l'script de restauració de l'aplicació «{app:s}»…", - "restore_running_hooks": "Execució dels hooks de restauració…", - "restore_system_part_failed": "No s'ha pogut restaurar la part «{part:s}» del sistema", + "restore_running_app_script": "Restaurant l'aplicació «{app}»...", + "restore_running_hooks": "Execució dels hooks de restauració...", + "restore_system_part_failed": "No s'ha pogut restaurar la part «{part}» del sistema", "root_password_desynchronized": "S'ha canviat la contrasenya d'administració, però YunoHost no ha pogut propagar-ho cap a la contrasenya root!", "root_password_replaced_by_admin_password": "La contrasenya root s'ha substituït per la contrasenya d'administració.", "server_shutdown": "S'aturarà el servidor", - "server_shutdown_confirm": "S'aturarà el servidor immediatament, n'esteu segur? [{answers:s}]", + "server_shutdown_confirm": "S'aturarà el servidor immediatament, n'esteu segur? [{answers}]", "server_reboot": "Es reiniciarà el servidor", - "server_reboot_confirm": "Es reiniciarà el servidor immediatament, n'esteu segur? [{answers:s}]", - "service_add_failed": "No s'ha pogut afegir el servei «{service:s}»", - "service_added": "S'ha afegit el servei «{service:s}»", - "service_already_started": "Ja s'ha iniciat el servei «{service:s}»", - "service_already_stopped": "Ja s'ha aturat el servei «{service:s}»", - "service_cmd_exec_failed": "No s'ha pogut executar l'ordre «{command:s}»", - "service_description_avahi-daemon": "permet accedir al servidor via yunohost.local en la xarxa local", - "service_description_dnsmasq": "gestiona la resolució del nom de domini (DNS)", - "service_description_dovecot": "permet als clients de correu accedir/recuperar correus (via IMAP i POP3)", - "service_description_fail2ban": "protegeix contra els atacs de força bruta i a altres atacs provinents d'Internet", - "service_description_glances": "monitora la informació del sistema en el servidor", - "service_description_metronome": "gestiona els comptes de missatgeria instantània XMPP", - "service_description_mysql": "guarda les dades de les aplicacions (base de dades SQL)", - "service_description_nginx": "serveix o permet l'accés a totes les pàgines web allotjades en el servidor", - "service_description_nslcd": "gestiona les connexions shell dels usuaris YunoHost", - "service_description_php7.0-fpm": "executa les aplicacions escrites en PHP amb nginx", - "service_description_postfix": "utilitzat per enviar i rebre correus", - "service_description_redis-server": "una base de dades especialitzada per l'accés ràpid a dades, files d'espera i comunicació entre programes", - "service_description_rmilter": "verifica diferents paràmetres en els correus", - "service_description_rspamd": "filtra el correu brossa, i altres funcionalitats relacionades al correu", - "service_description_slapd": "guarda el usuaris, dominis i informació relacionada", - "service_description_ssh": "permet la connexió remota al servidor via terminal (protocol SSH)", - "service_description_yunohost-api": "gestiona les interaccions entre la interfície web de YunoHost i el sistema", - "service_description_yunohost-firewall": "gestiona els ports de connexió oberts i tancats als serveis", - "service_disable_failed": "No s'han pogut deshabilitar el servei «{service:s}»\n\nRegistres recents: {logs:s}", - "service_disabled": "S'ha deshabilitat el servei {service:s}", - "service_enable_failed": "No s'ha pogut activar el servei «{service:s}»\n\nRegistres recents: {log:s}", - "service_enabled": "S'ha activat el servei {service:s}", - "service_no_log": "No hi ha cap registre pel servei «{service:s}»", + "server_reboot_confirm": "Es reiniciarà el servidor immediatament, n'esteu segur? [{answers}]", + "service_add_failed": "No s'ha pogut afegir el servei «{service}»", + "service_added": "S'ha afegit el servei «{service}»", + "service_already_started": "El servei «{service}» ja està funcionant", + "service_already_stopped": "Ja s'ha aturat el servei «{service}»", + "service_cmd_exec_failed": "No s'ha pogut executar l'ordre «{command}»", + "service_description_dnsmasq": "Gestiona la resolució del nom de domini (DNS)", + "service_description_dovecot": "Permet als clients de correu accedir/recuperar correus (via IMAP i POP3)", + "service_description_fail2ban": "Protegeix contra els atacs de força bruta i a altres atacs provinents d'Internet", + "service_description_metronome": "Gestiona els comptes de missatgeria instantània XMPP", + "service_description_mysql": "Guarda les dades de les aplicacions (base de dades SQL)", + "service_description_nginx": "Serveix o permet l'accés a totes les pàgines web allotjades en el servidor", + "service_description_postfix": "Utilitzat per enviar i rebre correus", + "service_description_redis-server": "Una base de dades especialitzada per l'accés ràpid a dades, files d'espera i comunicació entre programes", + "service_description_rspamd": "Filtra el correu brossa, i altres funcionalitats relacionades amb el correu", + "service_description_slapd": "Guarda el usuaris, dominis i informació relacionada", + "service_description_ssh": "Permet la connexió remota al servidor via terminal (protocol SSH)", + "service_description_yunohost-api": "Gestiona les interaccions entre la interfície web de YunoHost i el sistema", + "service_description_yunohost-firewall": "Gestiona els ports de connexió oberts i tancats als serveis", + "service_disable_failed": "No s'han pogut fer que el servei «{service}» no comenci a l'arrancada.\n\nRegistres recents: {logs}", + "service_disabled": "El servei «{service}» ja no començarà al arrancar el sistema.", + "service_enable_failed": "No s'ha pogut fer que el servei «{service}» comenci automàticament a l'arrancada.\n\nRegistres recents: {logs}", + "service_enabled": "El servei «{service}» començarà automàticament durant l'arrancada del sistema.", "service_regen_conf_is_deprecated": "«yunohost service regen-conf» està desfasat! Utilitzeu «yunohost tools regen-conf» en el seu lloc.", - "service_remove_failed": "No s'ha pogut eliminar el servei «{service:s}»", - "service_removed": "S'ha eliminat el servei «{service:s}»", - "service_reload_failed": "No s'ha pogut tornar a carregar el servei «{service:s}»\n\nRegistres recents: {logs:s}", - "service_reloaded": "S'ha tornat a carregar el servei «{service:s}»", - "service_restart_failed": "No s'ha pogut reiniciar el servei «{service:s}»\n\nRegistres recents: {logs:s}", - "service_restarted": "S'ha reiniciat el servei «{service:s}»", - "service_reload_or_restart_failed": "No s'ha pogut tornar a carregar o reiniciar el servei «{service:s}»\n\nRegistres recents: {logs:s}", - "service_reloaded_or_restarted": "S'ha tornat a carregar o s'ha reiniciat el servei «{service:s}»", - "service_start_failed": "No s'ha pogut iniciar el servei «{service:s}»\n\nRegistres recents: {logs:s}", - "service_started": "S'ha iniciat el servei «{service:s}»", - "service_status_failed": "No s'ha pogut determinar l'estat del servei «{service:s}»", - "service_stop_failed": "No s'ha pogut aturar el servei «{service:s}»\n\nRegistres recents: {logs:s}", - "service_stopped": "S'ha aturat el servei «{service:s}»", - "service_unknown": "Servei «{service:s}» desconegut", - "ssowat_conf_generated": "S'ha generat la configuració SSOwat", + "service_remove_failed": "No s'ha pogut eliminar el servei «{service}»", + "service_removed": "S'ha eliminat el servei «{service}»", + "service_reload_failed": "No s'ha pogut tornar a carregar el servei «{service}»\n\nRegistres recents: {logs}", + "service_reloaded": "S'ha tornat a carregar el servei «{service}»", + "service_restart_failed": "No s'ha pogut reiniciar el servei «{service}»\n\nRegistres recents: {logs}", + "service_restarted": "S'ha reiniciat el servei «{service}»", + "service_reload_or_restart_failed": "No s'ha pogut tornar a carregar o reiniciar el servei «{service}»\n\nRegistres recents: {logs}", + "service_reloaded_or_restarted": "S'ha tornat a carregar o s'ha reiniciat el servei «{service}»", + "service_start_failed": "No s'ha pogut iniciar el servei «{service}»\n\nRegistres recents: {logs}", + "service_started": "S'ha iniciat el servei «{service}»", + "service_stop_failed": "No s'ha pogut aturar el servei «{service}»\n\nRegistres recents: {logs}", + "service_stopped": "S'ha aturat el servei «{service}»", + "service_unknown": "Servei «{service}» desconegut", + "ssowat_conf_generated": "S'ha regenerat la configuració SSOwat", "ssowat_conf_updated": "S'ha actualitzat la configuració SSOwat", - "ssowat_persistent_conf_read_error": "Error en llegir la configuració persistent de SSOwat: {error:s}. Modifiqueu el fitxer /etc/ssowat/conf.json.persistent per arreglar la sintaxi JSON", - "ssowat_persistent_conf_write_error": "Error guardant la configuració persistent de SSOwat: {error:s}. Modifiqueu el fitxer /etc/ssowat/conf.json.persistent per arreglar la sintaxi JSON", "system_upgraded": "S'ha actualitzat el sistema", - "system_username_exists": "El nom d'usuari ja existeix en els usuaris de sistema", - "this_action_broke_dpkg": "Aquesta acció a trencat dpkg/apt (els gestors de paquets del sistema)… Podeu intentar resoldre el problema connectant-vos amb SSH i executant «sudo dpkg --configure -a».", - "tools_upgrade_at_least_one": "Especifiqueu --apps O --system", + "system_username_exists": "El nom d'usuari ja existeix en la llista d'usuaris de sistema", + "this_action_broke_dpkg": "Aquesta acció a trencat dpkg/APT (els gestors de paquets del sistema)... Podeu intentar resoldre el problema connectant-vos amb SSH i executant «sudo apt install --fix-broken» i/o «sudo dpkg --configure -a».", + "tools_upgrade_at_least_one": "Especifiqueu «apps», o «system»", "tools_upgrade_cant_both": "No es poden actualitzar tant el sistema com les aplicacions al mateix temps", - "tools_upgrade_cant_hold_critical_packages": "No es poden mantenir els paquets crítics…", - "tools_upgrade_cant_unhold_critical_packages": "No es poden deixar de mantenir els paquets crítics…", - "tools_upgrade_regular_packages": "Actualitzant els paquets «normals» (no relacionats amb YunoHost)…", + "tools_upgrade_cant_hold_critical_packages": "No es poden mantenir els paquets crítics...", + "tools_upgrade_cant_unhold_critical_packages": "No es poden deixar de mantenir els paquets crítics...", + "tools_upgrade_regular_packages": "Actualitzant els paquets «normals» (no relacionats amb YunoHost)...", "tools_upgrade_regular_packages_failed": "No s'han pogut actualitzar els paquets següents: {packages_list}", - "tools_upgrade_special_packages": "Actualitzant els paquets «especials» (relacionats amb YunoHost)…", - "tools_upgrade_special_packages_explanation": "Aquesta acció s'acabarà, però l'actualització especial continuarà en segon pla. No comenceu cap altra acció al servidor en els pròxims ~10 minuts (depèn de la velocitat del maquinari). Un cop acabat, pot ser que us hagueu de tornar a connectar a la interfície d'administració. Els registres de l'actualització estaran disponibles a Eines > Registres (a la interfície d'administració) o amb «yunohost log list» (a la línia d'ordres).", - "tools_upgrade_special_packages_completed": "Actualització dels paquets YunoHost acabada!\nPremeu [Enter] per tornar a la línia d'ordres", - "unbackup_app": "L'aplicació «{app:s}» no serà guardada", + "tools_upgrade_special_packages": "Actualitzant els paquets «especials» (relacionats amb YunoHost)...", + "tools_upgrade_special_packages_explanation": "Aquesta actualització especial continuarà en segon pla. No comenceu cap altra acció al servidor en els pròxims ~10 minuts (depèn de la velocitat del maquinari). Després d'això, pot ser que us hagueu de tornar a connectar a la interfície d'administració. Els registres de l'actualització estaran disponibles a Eines → Registres (a la interfície d'administració) o utilitzant «yunohost log list» (des de la línia d'ordres).", + "tools_upgrade_special_packages_completed": "Actualització dels paquets YunoHost acabada.\nPremeu [Enter] per tornar a la línia d'ordres", + "unbackup_app": "{app} no es guardarà", "unexpected_error": "Hi ha hagut un error inesperat: {error}", - "unit_unknown": "Unitat desconeguda «{unit:s}»", "unlimit": "Sense quota", - "unrestore_app": "L'aplicació «{app:s} no serà restaurada", - "update_apt_cache_failed": "No s'ha pogut actualitzar la memòria cau d'APT (el gestor de paquets de Debian). Aquí teniu les línies de sources.list que poden ajudar-vos a identificar les línies problemàtiques:\n{sourceslist}", + "unrestore_app": "{app} no es restaurarà", + "update_apt_cache_failed": "No s'ha pogut actualitzar la memòria cau d'APT (el gestor de paquets de Debian). Aquí teniu les línies de sources.list, que poden ajudar-vos a identificar les línies problemàtiques:\n{sourceslist}", "update_apt_cache_warning": "Hi ha hagut errors al actualitzar la memòria cau d'APT (el gestor de paquets de Debian). Aquí teniu les línies de sources.list que poden ajudar-vos a identificar les línies problemàtiques:\n{sourceslist}", - "updating_apt_cache": "Obtenció de les actualitzacions disponibles per als paquets del sistema…", - "updating_app_lists": "Obtenció de les actualitzacions disponibles per a les aplicacions…", + "updating_apt_cache": "Obtenció de les actualitzacions disponibles per als paquets del sistema...", "upgrade_complete": "Actualització acabada", - "upgrading_packages": "Actualitzant els paquets…", + "upgrading_packages": "Actualitzant els paquets...", "upnp_dev_not_found": "No s'ha trobat cap dispositiu UPnP", "upnp_disabled": "S'ha desactivat UPnP", "upnp_enabled": "S'ha activat UPnP", - "upnp_port_open_failed": "No s'han pogut obrir els ports UPnP", + "upnp_port_open_failed": "No s'ha pogut obrir el port UPnP", "user_created": "S'ha creat l'usuari", - "user_creation_failed": "No s'ha pogut crear l'usuari", + "user_creation_failed": "No s'ha pogut crear l'usuari {user}: {error}", "user_deleted": "S'ha suprimit l'usuari", - "user_deletion_failed": "No s'ha pogut suprimir l'usuari", - "user_home_creation_failed": "No s'ha pogut crear la carpeta personal («home») de l'usuari", - "user_info_failed": "No s'ha pogut obtenir la informació de l'usuari", - "user_unknown": "Usuari desconegut: {user:s}", - "user_update_failed": "No s'ha pogut actualitzar l'usuari", - "user_updated": "S'ha actualitzat l'usuari", - "users_available": "Usuaris disponibles:", + "user_deletion_failed": "No s'ha pogut suprimir l'usuari {user}: {error}", + "user_home_creation_failed": "No s'ha pogut crear la carpeta personal «{home}» per l'usuari", + "user_unknown": "Usuari desconegut: {user}", + "user_update_failed": "No s'ha pogut actualitzar l'usuari {user}: {error}", + "user_updated": "S'ha canviat la informació de l'usuari", "yunohost_already_installed": "YunoHost ja està instal·lat", - "yunohost_ca_creation_failed": "No s'ha pogut crear l'autoritat de certificació", - "yunohost_ca_creation_success": "S'ha creat l'autoritat de certificació local.", - "yunohost_configured": "S'ha configurat YunoHost", - "yunohost_installing": "Instal·lació de YunoHost…", - "yunohost_not_installed": "YunoHost no està instal·lat o no està instal·lat correctament. Executeu «yunohost tools postinstall»" -} + "yunohost_configured": "YunoHost està configurat", + "yunohost_installing": "Instal·lació de YunoHost...", + "yunohost_not_installed": "YunoHost no està instal·lat correctament. Executeu «yunohost tools postinstall»", + "backup_permission": "Permís de còpia de seguretat per {app}", + "group_created": "S'ha creat el grup «{group}»", + "group_creation_failed": "No s'ha pogut crear el grup «{group}»: {error}", + "group_deleted": "S'ha eliminat el grup «{group}»", + "group_deletion_failed": "No s'ha pogut eliminar el grup «{group}»: {error}", + "group_unknown": "Grup {group} desconegut", + "group_updated": "S'ha actualitzat el grup «{group}»", + "group_update_failed": "No s'ha pogut actualitzat el grup «{group}»: {error}", + "log_user_group_delete": "Eliminar grup «{}»", + "log_user_group_update": "Actualitzar grup «{}»", + "mailbox_disabled": "La bústia de correu està desactivada per al usuari {user}", + "permission_already_exist": "El permís «{permission}» ja existeix", + "permission_created": "S'ha creat el permís «{permission}»", + "permission_creation_failed": "No s'ha pogut crear el permís «{permission}»: {error}", + "permission_deleted": "S'ha eliminat el permís «{permission}»", + "permission_deletion_failed": "No s'ha pogut eliminar el permís «{permission}»: {error}", + "permission_not_found": "No s'ha trobat el permís «{permission}»", + "permission_update_failed": "No s'ha pogut actualitzar el permís «{permission}»: {error}", + "permission_updated": "S'ha actualitzat el permís «{permission}»", + "app_full_domain_unavailable": "Aquesta aplicació ha de ser instal·lada en el seu propi domini, però ja hi ha altres aplicacions instal·lades en el domini «{domain}». Podeu utilitzar un subdomini dedicat a aquesta aplicació.", + "migrations_not_pending_cant_skip": "Aquestes migracions no estan pendents, així que no poden ser omeses: {ids}", + "app_action_broke_system": "Aquesta acció sembla haver trencat els següents serveis importants: {services}", + "log_user_group_create": "Crear grup «{}»", + "log_user_permission_update": "Actualitzar els accessos per al permís «{}»", + "log_user_permission_reset": "Restablir el permís «{}»", + "permission_already_disallowed": "El grup «{group}» ja té el permís «{permission}» desactivat", + "migrations_already_ran": "Aquestes migracions ja s'han fet: {ids}", + "migrations_dependencies_not_satisfied": "Executeu aquestes migracions: «{dependencies_id}», abans la migració {id}.", + "migrations_failed_to_load_migration": "No s'ha pogut carregar la migració {id}: {error}", + "migrations_exclusive_options": "«--auto», «--skip», i «--force-rerun» són opcions mútuament excloents.", + "migrations_must_provide_explicit_targets": "Heu de proporcionar objectius explícits al utilitzar «--skip» o «--force-rerun»", + "migrations_no_such_migration": "No hi ha cap migració anomenada «{id}»", + "migrations_pending_cant_rerun": "Aquestes migracions encara estan pendents, així que no es poden tornar a executar: {ids}", + "migrations_running_forward": "Executant la migració {id}...", + "migrations_success_forward": "Migració {id} completada", + "apps_already_up_to_date": "Ja estan actualitzades totes les aplicacions", + "dyndns_provider_unreachable": "No s'ha pogut connectar amb el proveïdor DynDNS {provider}: o el vostre YunoHost no està ben connectat a Internet o el servidor dynette està caigut.", + "operation_interrupted": "S'ha interromput manualment l'operació?", + "group_already_exist": "El grup {group} ja existeix", + "group_already_exist_on_system": "El grup {group} ja existeix en els grups del sistema", + "group_cannot_be_deleted": "El grup {group} no es pot eliminar manualment.", + "group_user_already_in_group": "L'usuari {user} ja està en el grup {group}", + "group_user_not_in_group": "L'usuari {user} no està en el grup {group}", + "log_permission_create": "Crear el permís «{}»", + "log_permission_delete": "Eliminar el permís «{}»", + "permission_already_allowed": "El grup «{group}» ja té el permís «{permission}» activat", + "permission_cannot_remove_main": "No es permet eliminar un permís principal", + "user_already_exists": "L'usuari «{user}» ja existeix", + "app_install_failed": "No s'ha pogut instal·lar {app}: {error}", + "app_install_script_failed": "Hi ha hagut un error en el script d'instal·lació de l'aplicació", + "group_cannot_edit_all_users": "El grup «all_users» no es pot editar manualment. És un grup especial destinat a contenir els usuaris registrats a YunoHost", + "group_cannot_edit_visitors": "El grup «visitors» no es pot editar manualment. És un grup especial que representa els visitants anònims", + "group_cannot_edit_primary_group": "El grup «{group}» no es pot editar manualment. És el grup principal destinat a contenir un usuari específic.", + "log_permission_url": "Actualització de la URL associada al permís «{}»", + "permission_already_up_to_date": "No s'ha actualitzat el permís perquè la petició d'afegir/eliminar ja corresponent a l'estat actual.", + "permission_currently_allowed_for_all_users": "El permís ha el té el grup de tots els usuaris (all_users) a més d'altres grups. Segurament s'hauria de revocar el permís a «all_users» o eliminar els altres grups als que s'ha atribuït.", + "permission_require_account": "El permís {permission} només té sentit per als usuaris que tenen un compte, i per tant no es pot activar per als visitants.", + "app_remove_after_failed_install": "Eliminant l'aplicació després que hagi fallat la instal·lació...", + "diagnosis_basesystem_ynh_main_version": "El servidor funciona amb YunoHost {main_version} ({repo})", + "diagnosis_ram_low": "El sistema només té {available} ({available_percent}%) de memòria RAM disponibles d'un total de {total}. Aneu amb compte.", + "diagnosis_swap_none": "El sistema no té swap. Hauríeu de considerar afegir un mínim de {recommended} de swap per evitar situacions en les que el sistema es queda sense memòria.", + "diagnosis_regenconf_manually_modified": "El fitxer de configuració {file} sembla haver estat modificat manualment.", + "diagnosis_security_vulnerable_to_meltdown_details": "Per arreglar-ho, hauríeu d'actualitzar i reiniciar el sistema per tal de carregar el nou nucli de linux (o contactar amb el proveïdor del servidor si no funciona). Vegeu https://meltdownattack.com/ per a més informació.", + "diagnosis_http_could_not_diagnose": "No s'ha pogut diagnosticar si el domini és accessible des de l'exterior.", + "diagnosis_http_could_not_diagnose_details": "Error: {error}", + "domain_cannot_remove_main_add_new_one": "No es pot eliminar «{domain}» ja que és el domini principal i únic domini, primer s'ha d'afegir un altre domini utilitzant «yunohost domain add », i després fer-lo el domini principal amb «yunohost domain main-domain -n » i després es pot eliminar el domini «{domain}» utilitzant «yunohost domain remove {domain}».", + "diagnosis_basesystem_host": "El servidor funciona amb Debian {debian_version}", + "diagnosis_basesystem_kernel": "El servidor funciona amb el nucli de Linux {kernel_version}", + "diagnosis_basesystem_ynh_single_version": "{package} versió: {version}({repo})", + "diagnosis_basesystem_ynh_inconsistent_versions": "Esteu utilitzant versions inconsistents dels paquets de YunoHost… probablement a causa d'una actualització fallida o parcial.", + "diagnosis_failed_for_category": "Ha fallat el diagnòstic per la categoria «{category}»: {error}", + "diagnosis_cache_still_valid": "(La memòria cau encara és vàlida pel diagnòstic de {category}. No es tornar a diagnosticar de moment!)", + "diagnosis_cant_run_because_of_dep": "No es pot fer el diagnòstic per {category} mentre hi ha problemes importants relacionats amb {dep}.", + "diagnosis_ignored_issues": "(+ {nb_ignored} problema(es) ignorat(s))", + "diagnosis_found_errors": "S'ha trobat problema(es) important(s) {errors} relacionats amb {category}!", + "diagnosis_found_errors_and_warnings": "S'ha trobat problema(es) important(s) {errors} (i avis(os) {warnings}) relacionats amb {category}!", + "diagnosis_found_warnings": "S'han trobat ítems {warnings} que es podrien millorar per {category}.", + "diagnosis_everything_ok": "Tot sembla correcte per {category}!", + "diagnosis_failed": "No s'han pogut obtenir els resultats del diagnòstic per la categoria «{category}»: {error}", + "diagnosis_ip_connected_ipv4": "El servidor està connectat a Internet amb IPv4!", + "diagnosis_ip_no_ipv4": "El servidor no té una IPv4 que funcioni.", + "diagnosis_ip_connected_ipv6": "El servidor està connectat a Internet amb IPv6!", + "diagnosis_ip_no_ipv6": "El servidor no té una IPv6 que funcioni.", + "diagnosis_ip_not_connected_at_all": "Sembla que el servidor no està connectat a internet!?", + "diagnosis_ip_dnsresolution_working": "La resolució de nom de domini està funcionant!", + "diagnosis_ip_broken_dnsresolution": "La resolució de nom de domini falla per algun motiu… Està el tallafocs bloquejant les peticions DNS?", + "diagnosis_ip_broken_resolvconf": "La resolució de nom de domini sembla caiguda en el servidor, podria estar relacionat amb el fet que /etc/resolv.conf no apunta cap a 127.0.0.1.", + "diagnosis_ip_weird_resolvconf": "La resolució DNS sembla estar funcionant, però sembla que esteu utilitzant un versió personalitzada de /etc/resolv.conf.", + "diagnosis_ip_weird_resolvconf_details": "El fitxer etc/resolv.conf hauria de ser un enllaç simbòlic cap a /etc/resolvconf/run/resolv.conf i que aquest apunti cap a 127.0.0.1 (dnsmasq). La configuració del «resolver» real s'hauria de fer a /etc/resolv.dnsmaq.conf.", + "diagnosis_dns_good_conf": "Els registres DNS han estat correctament configurats pel domini {domain} (categoria {category})", + "diagnosis_dns_bad_conf": "Alguns registres DNS són incorrectes o no existeixen pel domini {domain} (categoria {category})", + "diagnosis_dns_missing_record": "Segons la configuració DNS recomanada, hauríeu d'afegir un registre DNS amb la següent informació.
Tipus: {type}
Nom: {name}
Valor: {value}", + "diagnosis_dns_discrepancy": "La configuració DNS següent sembla que no segueix la configuració recomanada:
Tipus: {type}
Nom: {name}
Valor actual: {current}
Valor esperat: {value}", + "diagnosis_services_bad_status": "El servei {service} està {status} :(", + "diagnosis_diskusage_verylow": "El lloc d'emmagatzematge {mountpoint} (en l'aparell {device}) només té disponibles {free} ({free_percent}%). Hauríeu de considerar alliberar una mica d'espai!", + "diagnosis_diskusage_low": "El lloc d'emmagatzematge {mountpoint} (en l'aparell {device}) només té disponibles {free} ({free_percent}%). Aneu amb compte.", + "diagnosis_diskusage_ok": "El lloc d'emmagatzematge {mountpoint} (en l'aparell {device}) encara té {free} ({free_percent}%) lliures!", + "diagnosis_ram_verylow": "El sistema només té {available} ({available_percent}%) de memòria RAM disponibles! (d'un total de {total})", + "diagnosis_ram_ok": "El sistema encara té {available} ({available_percent}%) de memòria RAM disponibles d'un total de {total}.", + "diagnosis_swap_notsomuch": "El sistema només té {total} de swap. Hauríeu de considerar tenir un mínim de {recommended} per evitar situacions en les que el sistema es queda sense memòria.", + "diagnosis_swap_ok": "El sistema té {total} de swap!", + "diagnosis_regenconf_allgood": "Tots els fitxers de configuració estan en acord amb la configuració recomanada!", + "diagnosis_regenconf_manually_modified_details": "No hauria de ser cap problema sempre i quan sapigueu el que esteu fent! YunoHost deixarà d'actualitzar aquest fitxer de manera automàtica… Però tingueu en compte que les actualitzacions de YunoHost podrien tenir canvis recomanats importants. Si voleu podeu mirar les diferències amb yunohost tools regen-conf {category} --dry-run --with-diff i forçar el restabliment de la configuració recomanada amb yunohost tools regen-conf {category} --force", + "diagnosis_security_vulnerable_to_meltdown": "Sembla que el sistema és vulnerable a la vulnerabilitat de seguretat crítica Meltdown", + "diagnosis_description_basesystem": "Sistema de base", + "diagnosis_description_ip": "Connectivitat a Internet", + "diagnosis_description_dnsrecords": "Registres DNS", + "diagnosis_description_services": "Verificació de l'estat dels serveis", + "diagnosis_description_systemresources": "Recursos del sistema", + "diagnosis_description_ports": "Exposició dels ports", + "diagnosis_description_regenconf": "Configuració del sistema", + "diagnosis_ports_could_not_diagnose": "No s'ha pogut diagnosticar si els ports són accessibles des de l'exterior.", + "diagnosis_ports_could_not_diagnose_details": "Error: {error}", + "diagnosis_ports_unreachable": "El port {port} no és accessible des de l'exterior.", + "diagnosis_ports_ok": "El port {port} és accessible des de l'exterior.", + "diagnosis_http_ok": "El domini {domain} és accessible per mitjà de HTTP des de fora de la xarxa local.", + "diagnosis_http_unreachable": "Sembla que el domini {domain} no és accessible a través de HTTP des de fora de la xarxa local.", + "diagnosis_unknown_categories": "Les següents categories són desconegudes: {categories}", + "apps_catalog_init_success": "S'ha iniciat el sistema de catàleg d'aplicacions!", + "apps_catalog_updating": "S'està actualitzant el catàleg d'aplicacions...", + "apps_catalog_failed_to_download": "No s'ha pogut descarregar el catàleg d'aplicacions {apps_catalog}: {error}", + "apps_catalog_obsolete_cache": "La memòria cau del catàleg d'aplicacions és buida o obsoleta.", + "apps_catalog_update_success": "S'ha actualitzat el catàleg d'aplicacions!", + "diagnosis_mail_outgoing_port_25_blocked": "Sembla que el port de sortida 25 està bloquejat. Hauríeu d'intentar desbloquejar-lo al panell de configuració del proveïdor d'accés a internet (o allotjador). Mentrestant, el servidor no podrà enviar correus a altres servidors.", + "diagnosis_description_mail": "Correu electrònic", + "app_upgrade_script_failed": "Hi ha hagut un error en el script d'actualització de l'aplicació", + "diagnosis_services_bad_status_tip": "Podeu intentar reiniciar el servei, i si no funciona, podeu mirar els registres a la pàgina web d'administració (des de la línia de comandes, ho podeu fer utilitzant yunohost service restart {service} i yunohost service log {service}).", + "diagnosis_ports_forwarding_tip": "Per arreglar aquest problema, segurament s'ha de configurar el reenviament de ports en el router tal i com s'explica a https://yunohost.org/isp_box_config", + "diagnosis_http_bad_status_code": "Sembla que una altra màquina (potser el router) a respost en lloc del vostre servidor.
1. La causa més probable per a aquest problema és que el port 80 (i 443) no reenvien correctament cap al vostre servidor.
2. En configuracions més complexes: assegureu-vos que no hi ha cap tallafoc o reverse-proxy interferint.", + "diagnosis_no_cache": "Encara no hi ha memòria cau pel diagnòstic de la categoria «{category}»", + "diagnosis_http_timeout": "S'ha exhaurit el temps d'esperar intentant connectar amb el servidor des de l'exterior.
1. La causa més probable per a aquest problema és que el port 80 (i 443) no reenvien correctament cap al vostre servidor.
2. També us hauríeu d'assegurar que el servei nginx estigui funcionant
3. En configuracions més complexes: assegureu-vos que no hi ha cap tallafoc o reverse-proxy interferint.", + "diagnosis_http_connection_error": "Error de connexió: no s'ha pogut connectar amb el domini demanat, segurament és inaccessible.", + "yunohost_postinstall_end_tip": "S'ha completat la post-instal·lació. Per acabar la configuració, considereu:\n - afegir un primer usuari a través de la secció «Usuaris» a la pàgina web d'administració (o emprant «yunohost user create » a la línia d'ordres);\n - diagnosticar possibles problemes a través de la secció «Diagnòstics» a la pàgina web d'administració (o emprant «yunohost diagnosis run» a la línia d'ordres);\n - llegir les seccions «Finalizing your setup» i «Getting to know YunoHost» a la documentació per administradors: https://yunohost.org/admindoc.", + "diagnosis_services_running": "El servei {service} s'està executant!", + "diagnosis_services_conf_broken": "La configuració pel servei {service} està trencada!", + "diagnosis_ports_needed_by": "És necessari exposar aquest port per a les funcions {category} (servei {service})", + "global_settings_setting_pop3_enabled": "Activa el protocol POP3 per al servidor de correu", + "log_app_action_run": "Executa l'acció de l'aplicació «{}»", + "diagnosis_never_ran_yet": "Sembla que el servidor s'ha configurat recentment i encara no hi cap informe de diagnòstic per mostrar. S'ha d'executar un diagnòstic complet primer, ja sigui des de la pàgina web d'administració o utilitzant la comanda «yunohost diagnosis run» al terminal.", + "diagnosis_description_web": "Web", + "diagnosis_basesystem_hardware": "L'arquitectura del maquinari del servidor és {virt} {arch}", + "group_already_exist_on_system_but_removing_it": "El grup {group} ja existeix en els grups del sistema, però YunoHost l'eliminarà...", + "certmanager_warning_subdomain_dns_record": "El subdomini «{subdomain}» no resol a la mateixa adreça IP que «{domain}». Algunes funcions no estaran disponibles fins que no s'hagi arreglat i s'hagi regenerat el certificat.", + "domain_cannot_add_xmpp_upload": "No podeu afegir dominis començant per «xmpp-upload.». Aquest tipus de nom està reservat per a la funció de pujada de XMPP integrada a YunoHost.", + "diagnosis_display_tip": "Per veure els problemes que s'han trobat, podeu anar a la secció de Diagnòstic a la pàgina web d'administració, o utilitzar « yunohost diagnostic show --issues --human-readable» a la línia de comandes.", + "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "Alguns proveïdors no permeten desbloquejar el port de sortida 25 perquè no els hi importa la Neutralitat de la Xarxa.
- Alguns d'ells ofereixen l'alternativa d'utilitzar un relay de servidor de correu electrònic tot i que implica que el relay serà capaç d'espiar el tràfic de correus electrònics.
- Una alternativa respectuosa amb la privacitat és utilitzar una VPN *amb una IP pública dedicada* per sortejar aquest tipus de limitació. Vegeu https://yunohost.org/#/vpn_advantage
- També podeu considerar canviar-vos a un proveïdor més respectuós de la neutralitat de la xarxa", + "diagnosis_ip_global": "IP global: {global}", + "diagnosis_ip_local": "IP local: {local}", + "diagnosis_dns_point_to_doc": "Consulteu la documentació a https://yunohost.org/dns_config si necessiteu ajuda per configurar els registres DNS.", + "diagnosis_mail_outgoing_port_25_ok": "El servidor de correu electrònic SMTP pot enviar correus electrònics (el port de sortida 25 no està bloquejat).", + "diagnosis_mail_outgoing_port_25_blocked_details": "Primer heu d'intentar desbloquejar el port 25 en la interfície del vostre router o en la interfície del vostre allotjador. (Alguns proveïdors d'allotjament demanen enviar un tiquet de suport en aquests casos).", + "diagnosis_mail_ehlo_ok": "El servidor de correu electrònic SMTP és accessible des de l'exterior i per tant pot rebre correus electrònics!", + "diagnosis_mail_ehlo_unreachable": "El servidor de correu electrònic SMTP no és accessible des de l'exterior amb IPv{ipversion}. No podrà rebre correus electrònics.", + "diagnosis_mail_ehlo_bad_answer": "Un servei no SMTP a respost en el port 25 amb IPv{ipversion}", + "diagnosis_mail_ehlo_bad_answer_details": "Podria ser que sigui per culpa d'una altra màquina responent en lloc del servidor.", + "diagnosis_mail_ehlo_wrong": "Un servidor de correu electrònic SMTP diferent respon amb IPv{ipversion}. És probable que el vostre servidor no pugui rebre correus electrònics.", + "diagnosis_mail_ehlo_could_not_diagnose": "No s'ha pogut diagnosticar si el servidor de correu electrònic postfix és accessible des de l'exterior amb IPv{ipversion}.", + "diagnosis_mail_ehlo_could_not_diagnose_details": "Error: {error}", + "diagnosis_mail_fcrdns_ok": "S'ha configurat correctament el servidor DNS invers!", + "diagnosis_mail_blacklist_ok": "Sembla que les IPs i el dominis d'aquest servidor no són en una llista negra", + "diagnosis_mail_blacklist_listed_by": "La vostra IP o domini {item} està en una llista negra a {blacklist_name}", + "diagnosis_mail_blacklist_reason": "El motiu de ser a la llista negra és: {reason}", + "diagnosis_mail_fcrdns_different_from_ehlo_domain": "El DNS invers no està correctament configurat amb IPv{ipversion}. Alguns correus electrònics poden no arribar al destinatari o ser marcats com correu brossa.", + "diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "DNS invers actual: {rdns_domain}
Valor esperat: {ehlo_domain}", + "diagnosis_mail_queue_ok": "{nb_pending} correus electrònics pendents en les cues de correu electrònic", + "diagnosis_mail_queue_unavailable": "No s'ha pogut consultar el nombre de correus electrònics pendents en la cua", + "diagnosis_mail_queue_unavailable_details": "Error: {error}", + "diagnosis_mail_queue_too_big": "Hi ha massa correus electrònics pendents en la cua ({nb_pending} correus electrònics)", + "diagnosis_http_hairpinning_issue": "Sembla que la vostra xarxa no té el hairpinning activat.", + "diagnosis_http_nginx_conf_not_up_to_date": "La configuració NGINX d'aquest domini sembla que ha estat modificada manualment, i no deixa que YunoHost diagnostiqui si és accessible amb HTTP.", + "diagnosis_http_nginx_conf_not_up_to_date_details": "Per arreglar el problema, mireu les diferències amb la línia d'ordres utilitzant yunohost tools regen-conf nginx --dry-run --with-diff i si els canvis us semblen bé els podeu fer efectius utilitzant yunohost tools regen-conf nginx --force.", + "global_settings_setting_smtp_allow_ipv6": "Permet l'ús de IPv6 per rebre i enviar correus electrònics", + "diagnosis_mail_ehlo_unreachable_details": "No s'ha pogut establir una connexió amb el vostre servidor en el port 25 amb IPv{ipversion}. Sembla que el servidor no és accessible.
1. La causa més comú per aquest problema és que el port 25 no està correctament redireccionat cap al vostre servidor.
2. També us hauríeu d'assegurar que el servei postfix estigui funcionant.
3. En configuracions més complexes: assegureu-vos que que no hi hagi cap tallafoc ni reverse-proxy interferint.", + "diagnosis_mail_ehlo_wrong_details": "El EHLO rebut pel servidor de diagnòstic remot amb IPv{ipversion} és diferent al domini del vostre servidor.
EHLO rebut: {wrong_ehlo}
Esperat: {right_ehlo}
La causa més habitual d'aquest problema és que el port 25 no està correctament reenviat cap al vostre servidor. També podeu comprovar que no hi hagi un tallafocs o un reverse-proxy interferint.", + "diagnosis_mail_fcrdns_dns_missing": "No hi ha cap DNS invers definit per IPv{ipversion}. Alguns correus electrònics poden no entregar-se o poden ser marcats com a correu brossa.", + "diagnosis_mail_blacklist_website": "Després d'haver identificat perquè estàveu llistats i haver arreglat el problema, no dubteu en demanar que la vostra IP o domini sigui eliminat de {blacklist_website}", + "diagnosis_ports_partially_unreachable": "El port {port} no és accessible des de l'exterior amb IPv{failed}.", + "diagnosis_http_partially_unreachable": "El domini {domain} sembla que no és accessible utilitzant HTTP des de l'exterior de la xarxa local amb IPv{failed}, tot i que funciona amb IPv{passed}.", + "diagnosis_mail_fcrdns_nok_details": "Hauríeu d'intentar configurar primer el DNS invers amb {ehlo_domain} en la interfície del router o en la interfície del vostre allotjador. (Alguns allotjadors requereixen que obris un informe de suport per això).", + "diagnosis_mail_fcrdns_nok_alternatives_4": "Alguns proveïdors no permeten configurar el DNS invers (o aquesta funció pot no funcionar…). Si teniu problemes a causa d'això, considereu les solucions següents:
- Alguns proveïdors d'accés a internet (ISP) donen l'alternativa de utilitzar un relay de servidor de correu electrònic tot i que implica que el relay podrà espiar el trànsit de correus electrònics.
- Una alternativa respectuosa amb la privacitat és utilitzar una VPN *amb una IP pública dedicada* per sobrepassar aquest tipus de limitacions. Mireu https://yunohost.org/#/vpn_advantage
- O es pot canviar a un proveïdor diferent", + "diagnosis_mail_fcrdns_nok_alternatives_6": "Alguns proveïdors no permeten configurar el vostre DNS invers (o la funció no els hi funciona…). Si el vostre DNS invers està correctament configurat per IPv4, podeu intentar deshabilitar l'ús de IPv6 per a enviar correus electrònics utilitzant yunohost settings set smtp.allow_ipv6 -v off. Nota: aquesta última solució implica que no podreu enviar o rebre correus electrònics cap a els pocs servidors que hi ha que només tenen IPv-6.", + "diagnosis_http_hairpinning_issue_details": "Això és probablement a causa del router del vostre proveïdor d'accés a internet. El que fa, que gent de fora de la xarxa local pugui accedir al servidor sense problemes, però no la gent de dins la xarxa local (com vostè probablement) quan s'utilitza el nom de domini o la IP global. Podreu segurament millorar la situació fent una ullada a https://yunohost.org/dns_local_network", + "backup_archive_cant_retrieve_info_json": "No s'ha pogut carregar la informació de l'arxiu «{archive}»... No s'ha pogut obtenir el fitxer info.json (o no és un fitxer json vàlid).", + "backup_archive_corrupted": "Sembla que l'arxiu de la còpia de seguretat «{archive}» està corromput : {error}", + "certmanager_domain_not_diagnosed_yet": "Encara no hi ha cap resultat de diagnòstic per al domini {domain}. Torneu a executar el diagnòstic per a les categories «Registres DNS» i «Web» en la secció de diagnòstic per comprovar que el domini està preparat per a Let's Encrypt. (O si sabeu el que esteu fent, utilitzant «--no-checks» per deshabilitar les comprovacions.)", + "diagnosis_ip_no_ipv6_tip": "Utilitzar una IPv6 no és obligatori per a que funcioni el servidor, però és millor per la salut d'Internet en conjunt. La IPv6 hauria d'estar configurada automàticament pel sistema o pel proveïdor si està disponible. Si no és el cas, pot ser necessari configurar alguns paràmetres més de forma manual tal i com s'explica en la documentació disponible aquí: https://yunohost.org/#/ipv6. Si no podeu habilitar IPv6 o us sembla massa tècnic, podeu ignorar aquest avís sense problemes.", + "diagnosis_domain_expiration_not_found": "No s'ha pogut comprovar la data d'expiració d'alguns dominis", + "diagnosis_domain_not_found_details": "El domini {domain} no existeix en la base de dades WHOIS o ha expirat!", + "diagnosis_domain_expiration_not_found_details": "La informació WHOIS pel domini {domain} sembla que no conté informació sobre la data d'expiració?", + "diagnosis_domain_expiration_success": "Els vostres dominis estan registrats i no expiraran properament.", + "diagnosis_domain_expiration_warning": "Alguns dominis expiraran properament!", + "diagnosis_domain_expiration_error": "Alguns dominis expiraran EN BREUS!", + "diagnosis_domain_expires_in": "{domain} expirarà en {days} dies.", + "diagnosis_swap_tip": "Vigileu i tingueu en compte que els servidor està allotjant memòria d'intercanvi en una targeta SD o en l'emmagatzematge SSD, això pot reduir dràsticament l'esperança de vida del dispositiu.", + "restore_already_installed_apps": "No s'han pogut restaurar les següents aplicacions perquè ja estan instal·lades: {apps}", + "app_packaging_format_not_supported": "No es pot instal·lar aquesta aplicació ja que el format del paquet no és compatible amb la versió de YunoHost del sistema. Hauríeu de considerar actualitzar el sistema.", + "diagnosis_dns_try_dyndns_update_force": "La configuració DNS d'aquest domini hauria de ser gestionada automàticament per YunoHost. Si aquest no és el cas, podeu intentar forçar-ne l'actualització utilitzant yunohost dyndns update --force.", + "migration_0015_cleaning_up": "Netejant la memòria cau i els paquets que ja no són necessaris...", + "migration_0015_specific_upgrade": "Començant l'actualització dels paquets del sistema que s'han d'actualitzar de forma independent...", + "migration_0015_modified_files": "Tingueu en compte que s'han trobat els següents fitxers que es van modificar manualment i podria ser que es sobreescriguin durant l'actualització: {manually_modified_files}", + "migration_0015_problematic_apps_warning": "Tingueu en compte que s'han trobat les següents aplicacions que podrien ser problemàtiques. Sembla que aquestes aplicacions no s'han instal·lat des del catàleg d'aplicacions de YunoHost, o no estan marcades com «funcionant». En conseqüència, no es pot garantir que segueixin funcionant després de l'actualització: {problematic_apps}", + "migration_0015_general_warning": "Tingueu en compte que aquesta migració és una operació delicada. L'equip de YunoHost ha fet tots els possibles per revisar i testejar, però tot i això podria ser que la migració trenqui alguna part del sistema o algunes aplicacions.\n\nPer tant, està recomana:\n - Fer una còpia de seguretat de totes les dades o aplicacions crítiques. Més informació a https://yunohost.org/backup;\n - Ser pacient un cop comenci la migració: en funció de la connexió Internet i del maquinari, podria estar unes hores per actualitzar-ho tot.", + "migration_0015_system_not_fully_up_to_date": "El sistema no està completament al dia. Heu de fer una actualització normal abans de fer la migració a Buster.", + "migration_0015_not_enough_free_space": "Hi ha poc espai lliure a /var/! HI hauria d'haver un mínim de 1GB lliure per poder fer aquesta migració.", + "migration_0015_not_stretch": "La distribució actual de Debian no és Stretch!", + "migration_0015_yunohost_upgrade": "Començant l'actualització del nucli de YunoHost...", + "migration_0015_still_on_stretch_after_main_upgrade": "Alguna cosa ha anat malament durant la actualització principal, sembla que el sistema encara està en Debian Stretch", + "migration_0015_main_upgrade": "Començant l'actualització principal...", + "migration_0015_patching_sources_list": "Apedaçament de source.lists...", + "migration_0015_start": "Començant la migració a Buster", + "migration_description_0015_migrate_to_buster": "Actualitza els sistema a Debian Buster i YunoHost 4.x", + "regenconf_need_to_explicitly_specify_ssh": "La configuració ssh ha estat modificada manualment, però heu d'especificar explícitament la categoria «ssh» amb --force per fer realment els canvis.", + "migration_0015_weak_certs": "S'han trobat els següents certificats que encara utilitzen algoritmes de signatura febles i s'han d'actualitzar per a ser compatibles amb la propera versió de nginx: {certs}", + "service_description_php7.3-fpm": "Executa aplicacions escrites en PHP amb NGINX", + "migration_0018_failed_to_reset_legacy_rules": "No s'ha pogut restaurar les regles legacy iptables: {error}", + "migration_0018_failed_to_migrate_iptables_rules": "No s'ha pogut migrar les regles legacy iptables a nftables: {error}", + "migration_0017_not_enough_space": "Feu suficient espai disponible en {path} per a realitzar la migració.", + "migration_0017_postgresql_11_not_installed": "PostgreSQL 9.6 està instal·lat, però postgreSQL 11 no? Potser que hagi passat alguna cosa rara en aquest sistema :(...", + "migration_0017_postgresql_96_not_installed": "PostgreSQL no està instal·lat en aquest sistema. No s'ha de realitzar cap operació.", + "migration_description_0018_xtable_to_nftable": "Migrar les regles del trànsit de xarxa al nou sistema nftable", + "migration_description_0017_postgresql_9p6_to_11": "Migrar les bases de dades de PosrgreSQL 9.6 a 11", + "migration_description_0016_php70_to_php73_pools": "Migrar els fitxers de configuració «pool» php7.0-fpm a php7.3", + "global_settings_setting_backup_compress_tar_archives": "Comprimir els arxius (.tar.gz) en lloc d'arxius no comprimits (.tar) al crear noves còpies de seguretat. N.B.: activar aquesta opció permet fer arxius de còpia de seguretat més lleugers, però el procés inicial de còpia de seguretat serà significativament més llarg i més exigent a nivell de CPU.", + "global_settings_setting_smtp_relay_host": "L'amfitrió de tramesa SMTP que s'ha d'utilitzar per enviar correus electrònics en lloc d'aquesta instància de YunoHost. És útil si esteu en una de les següents situacions: el port 25 està bloquejat per el vostre proveïdor d'accés a internet o proveïdor de servidor privat virtual, si teniu una IP residencial llistada a DUHL, si no podeu configurar el DNS invers o si el servidor no està directament exposat a internet i voleu utilitzar-ne un altre per enviar correus electrònics.", + "unknown_main_domain_path": "Domini o ruta desconeguda per a «{app}». Heu d'especificar un domini i una ruta per a poder especificar una URL per al permís.", + "show_tile_cant_be_enabled_for_regex": "No podeu activar «show_title» ara, perquè la URL per al permís «{permission}» és una expressió regular", + "show_tile_cant_be_enabled_for_url_not_defined": "No podeu activar «show_title» ara, perquè primer s'ha de definir una URL per al permís «{permission}»", + "regex_with_only_domain": "No podeu utilitzar una expressió regular com a domini, només com a ruta", + "regex_incompatible_with_tile": "/!\\ Empaquetadors! El permís «{permission}» té «show_tile» definit a «true» i pertant no pot definir una URL regex com a URL principal", + "permission_protected": "El permís {permission} està protegit. No podeu afegir o eliminar el grup visitants a o d'aquest permís.", + "pattern_email_forward": "Ha de ser una adreça de correu vàlida, s'accepta el símbol «+» (per exemple, algu+etiqueta@exemple.cat)", + "invalid_number": "Ha de ser una xifra", + "migration_0019_slapd_config_will_be_overwritten": "Sembla que heu modificat manualment la configuració sldap. Per a aquesta migració crítica, YunoHist necessita forçar l'actualització de la configuració sldap. Es crearà una còpia de seguretat dels fitxers originals a {conf_backup_folder}.", + "migration_0019_add_new_attributes_in_ldap": "Afegir nous atributs per als permisos en la base de dades LDAP", + "migration_description_0019_extend_permissions_features": "Amplia/refés el sistema de gestió dels permisos de l'aplicació", + "migrating_legacy_permission_settings": "Migració dels paràmetres de permisos antics...", + "invalid_regex": "Regex no vàlid: «{regex}»", + "global_settings_setting_smtp_relay_password": "Tramesa de la contrasenya d'amfitrió SMTP", + "global_settings_setting_smtp_relay_user": "Tramesa de compte d'usuari SMTP", + "global_settings_setting_smtp_relay_port": "Port de tramesa SMTP", + "domain_name_unknown": "Domini «{domain}» desconegut", + "diagnosis_processes_killed_by_oom_reaper": "El sistema ha matat alguns processos recentment perquè s'ha quedat sense memòria. Això acostuma a ser un símptoma de falta de memòria en el sistema o d'un procés que consumeix massa memòria. Llista dels processos que s'han matat:\n{kills_summary}", + "diagnosis_package_installed_from_sury_details": "Alguns paquets s'han instal·lat per equivocació des d'un repositori de tercers anomenat Sury. L'equip de YunoHost a millorat l'estratègia per a gestionar aquests paquets, però s'espera que algunes configuracions que han instal·lat aplicacions PHP7.3 a Stretch puguin tenir algunes inconsistències. Per a resoldre aquesta situació, hauríeu d'intentar executar la següent ordre: {cmd_to_fix}", + "diagnosis_package_installed_from_sury": "Alguns paquets del sistema s'han de tornar a versions anteriors", + "ask_user_domain": "Domini a utilitzar per l'adreçar de correu electrònic i per al compte XMPP", + "app_manifest_install_ask_is_public": "Aquesta aplicació hauria de ser visible per a visitants anònims?", + "app_manifest_install_ask_admin": "Escolliu l'usuari administrador per aquesta aplicació", + "app_manifest_install_ask_password": "Escolliu la contrasenya d'administració per aquesta aplicació", + "app_manifest_install_ask_path": "Escolliu la ruta en la que s'hauria d'instal·lar aquesta aplicació", + "app_manifest_install_ask_domain": "Escolliu el domini en el que s'hauria d'instal·lar aquesta aplicació", + "app_label_deprecated": "Aquesta ordre està desestimada! Si us plau utilitzeu la nova ordre «yunohost user permission update» per gestionar l'etiqueta de l'aplicació.", + "app_argument_password_no_default": "Hi ha hagut un error al analitzar l'argument de la contrasenya «{name}»: l'argument de contrasenya no pot tenir un valor per defecte per raons de seguretat", + "additional_urls_already_removed": "URL addicional «{url}» ja ha estat eliminada per al permís «{permission}»", + "additional_urls_already_added": "URL addicional «{url}» ja ha estat afegida per al permís «{permission}»", + "diagnosis_backports_in_sources_list": "Sembla que apt (el gestor de paquets) està configurat per utilitzar el repositori backports. A menys de saber el que esteu fent, recomanem fortament no instal·lar paquets de backports, ja que poder causar inestabilitats o conflictes en el sistema.", + "diagnosis_basesystem_hardware_model": "El model del servidor és {model}", + "postinstall_low_rootfsspace": "El sistema de fitxers arrel té un total de menys de 10 GB d'espai, el que es preocupant! És molt probable que us quedeu sense espai ràpidament! Es recomana tenir un mínim de 16 GB per al sistema de fitxers arrel. Si voleu instal·lar YunoHost tot i aquest avís, torneu a executar la postinstal·lació amb --force-diskspace", + "diagnosis_rootfstotalspace_critical": "El sistema de fitxers arrel només té {space} en total i és preocupant! És molt probable que us quedeu sense espai ràpidament! Es recomanar tenir un mínim de 16 GB per al sistema de fitxers arrel.", + "diagnosis_rootfstotalspace_warning": "El sistema de fitxers arrel només té {space} en total. Això no hauria de causar cap problema, però haureu de parar atenció ja que us podrieu quedar sense espai ràpidament… Es recomanar tenir un mínim de 16 GB per al sistema de fitxers arrel.", + "diagnosis_sshd_config_inconsistent": "Sembla que el port SSH s'ha modificat manualment a /etc/ssh/sshd_config. Des de YunoHost 4.2, hi ha un nou paràmetre global «security.ssh.port» per evitar modificar manualment la configuració.", + "diagnosis_sshd_config_insecure": "Sembla que la configuració SSH s'ha modificat manualment, i no es segura ha que no conté la directiva «AllowGroups» o «AllowUsers» per limitar l'accés a usuaris autoritzats.", + "backup_create_size_estimation": "L'arxiu tindrà aproximadament {size} de dades.", + "app_restore_script_failed": "S'ha produït un error en el script de restauració de l'aplicació" +} \ No newline at end of file diff --git a/locales/ckb.json b/locales/ckb.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/locales/ckb.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/locales/cs.json b/locales/cs.json new file mode 100644 index 000000000..47262064e --- /dev/null +++ b/locales/cs.json @@ -0,0 +1,67 @@ +{ + "password_too_simple_1": "Heslo musí být aspoň 8 znaků dlouhé", + "app_already_installed": "{app} je již nainstalován/a", + "already_up_to_date": "Neprovedena žádná akce. Vše je již aktuální.", + "admin_password_too_long": "Zvolte prosím heslo kratší než 127 znaků", + "admin_password_changed": "Administrační heslo bylo změněno", + "admin_password_change_failed": "Nebylo možné změnit heslo", + "admin_password": "Administrační heslo", + "additional_urls_already_removed": "Další URL '{url}' již bylo odebráno u oprávnění '{permission}'", + "additional_urls_already_added": "Další URL '{url}' již bylo přidáno pro oprávnění '{permission}'", + "action_invalid": "Nesprávné akce '{action}'", + "aborting": "Zrušeno.", + "app_change_url_identical_domains": "Stará a nová doména/url_cesta jsou totožné ('{domain}{path}'), nebudou provedeny žádné změny.", + "app_argument_invalid": "Vyberte správnou hodnotu pro argument '{name}': {error}", + "app_argument_choice_invalid": "Vyberte jednu z možností '{choices}' pro argument'{name}'", + "app_already_up_to_date": "{app} aplikace je/jsou aktuální", + "app_already_installed_cant_change_url": "Tato aplikace je již nainstalována. URL nemůže být touto akcí změněna. Zkontrolujte `app changeurl` pokud je dostupné.", + "app_action_cannot_be_ran_because_required_services_down": "Pro běh této akce by měli být spuštěné následující služby: {services}. Zkuste je zrestartovat, případně zjistěte, proč neběží.", + "app_action_broke_system": "Zdá se, že tato akce rozbila následující důležité služby: {services}", + "app_install_script_failed": "Vyskytla se chyba uvnitř instalačního skriptu aplikace", + "app_install_failed": "Nelze instalovat {app}: {error}", + "app_install_files_invalid": "Tyto soubory nemohou být instalovány", + "app_id_invalid": "Neplatné ID aplikace", + "app_full_domain_unavailable": "Tato aplikace musí být nainstalována na své vlastní doméně, jiné aplikace tuto doménu již využívají. Můžete použít poddoménu určenou pouze pro tuto aplikaci.", + "app_extraction_failed": "Nelze rozbalit instalační soubory", + "app_change_url_success": "{app} URL je nyní {domain}{path}", + "app_change_url_no_script": "Aplikace '{app_name}' nyní nepodporuje URL modifikace. Zkuste ji aktualizovat.", + "app_argument_required": "Hodnota'{name}' je vyžadována", + "app_argument_password_no_default": "Chyba při zpracování obsahu hesla '{name}': z bezpečnostních důvodů nemůže obsahovat výchozí hodnotu", + "password_too_simple_4": "Heslo musí být aspoň 12 znaků dlouhé a obsahovat čísla, velká a malá písmena a speciální znaky", + "password_too_simple_3": "Heslo musí být aspoň 8 znaků dlouhé a obsahovat čísla, velká a malá písmena a speciální znaky", + "password_too_simple_2": "Heslo musí být aspoň 8 znaků dlouhé a obsahovat číslici, velká a malá písmena", + "password_listed": "Toto heslo je jedním z nejpoužívanějších na světě. Zvolte si prosím něco jedinečnějšího.", + "operation_interrupted": "Operace byla manuálně přerušena?", + "group_user_already_in_group": "Uživatel {user} je již ve skupině {group}", + "group_update_failed": "Nelze upravit skupinu '{group}': {error}", + "group_updated": "Skupina '{group}' upravena", + "group_unknown": "Neznámá skupina '{group}'", + "group_deletion_failed": "Nelze smazat skupinu '{group}': {error}", + "group_deleted": "Skupina '{group}' smazána", + "group_cannot_be_deleted": "Skupina {group} nemůže být smazána.", + "group_cannot_edit_primary_group": "Skupina '{group}' nemůže být upravena. Jde o primární skupinu obsahující pouze jednoho specifického uživatele.", + "group_cannot_edit_all_users": "Skupina 'all_users' nemůže být upravena. Jde o speciální skupinu obsahující všechny registrované uživatele na YunoHost", + "group_cannot_edit_visitors": "Skupina 'visitors' nemůže být upravena. Jde o speciální skupinu představující anonymní (neregistrované na YunoHost) návštěvníky", + "group_creation_failed": "Nelze založit skupinu '{group}': {error}", + "group_created": "Skupina '{group}' vytvořena", + "group_already_exist_on_system_but_removing_it": "Skupina {group} se již nalézá v systémových skupinách, ale YunoHost ji odstraní...", + "group_already_exist_on_system": "Skupina {group} se již nalézá v systémových skupinách", + "group_already_exist": "Skupina {group} již existuje", + "good_practices_about_user_password": "Nyní zvolte nové heslo uživatele. Heslo by mělo být minimálně 8 znaků dlouhé, avšak je dobrou taktikou jej mít delší (např. použít více slov) a použít kombinaci znaků (velké, malé, čísla a speciální znaky).", + "good_practices_about_admin_password": "Nyní zvolte nové administrační heslo. Heslo by mělo být minimálně 8 znaků dlouhé, avšak je dobrou taktikou jej mít delší (např. použít více slov) a použít kombinaci znaků (velké, malé, čísla a speciílní znaky).", + "global_settings_unknown_type": "Neočekávaná situace, nastavení {setting} deklaruje typ {unknown_type} ale toto není systémem podporováno.", + "global_settings_setting_backup_compress_tar_archives": "Komprimovat nové zálohy (.tar.gz) namísto nekomprimovaných (.tar). Poznámka: povolení této volby znamená objemově menší soubory záloh, avšak zálohování bude trvat déle a bude více zatěžovat CPU.", + "global_settings_setting_smtp_relay_password": "SMTP relay heslo uživatele/hostitele", + "global_settings_setting_smtp_relay_user": "SMTP relay uživatelské jméno/účet", + "global_settings_setting_smtp_relay_port": "SMTP relay port", + "global_settings_setting_smtp_relay_host": "Použít SMTP relay hostitele pro odesílání emailů místo této YunoHost instance. Užitečné v různých situacích: port 25 je blokován vaším ISP nebo VPS poskytovatelem, IP adresa je na blacklistu (např. DUHL), nemůžete nastavit reverzní DNS záznam nebo tento server není přímo připojen do internetu a vy chcete použít jiný server k odesílání emailů.", + "global_settings_setting_smtp_allow_ipv6": "Povolit použití IPv6 pro příjem a odesílání emailů", + "global_settings_setting_ssowat_panel_overlay_enabled": "Povolit SSOwat překryvný panel", + "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Povolit použití (zastaralého) DSA klíče hostitele pro konfiguraci SSH služby", + "global_settings_unknown_setting_from_settings_file": "Neznámý klíč v nastavení: '{setting_key}', zrušte jej a uložte v /etc/yunohost/settings-unknown.json", + "global_settings_setting_security_ssh_port": "SSH port", + "global_settings_setting_security_postfix_compatibility": "Kompromis mezi kompatibilitou a bezpečností Postfix serveru. Ovlivní šifry a další související bezpečnostní nastavení", + "global_settings_setting_security_ssh_compatibility": "Kompromis mezi kompatibilitou a bezpečností SSH serveru. Ovlivní šifry a další související bezpečnostní nastavení", + "global_settings_setting_security_password_user_strength": "Síla uživatelského hesla", + "global_settings_setting_security_password_admin_strength": "Síla administračního hesla" +} \ No newline at end of file diff --git a/locales/de.json b/locales/de.json index a4a6c236b..199718c2b 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1,305 +1,634 @@ { - "action_invalid": "Ungültige Aktion '{action:s}'", + "action_invalid": "Ungültige Aktion '{action}'", "admin_password": "Administrator-Passwort", - "admin_password_change_failed": "Passwort kann nicht geändert werden", - "admin_password_changed": "Das Administrator-Kennwort wurde erfolgreich geändert", - "app_already_installed": "{app:s} ist schon installiert", - "app_argument_choice_invalid": "Ungültige Auswahl für Argument '{name:s}'. Es muss einer der folgenden Werte sein {choices:s}", - "app_argument_invalid": "Das Argument '{name:s}' hat einen falschen Wert: {error:s}", - "app_argument_required": "Argument '{name:s}' wird benötigt", + "admin_password_change_failed": "Ändern des Passworts nicht möglich", + "admin_password_changed": "Das Administrator-Kennwort wurde geändert", + "app_already_installed": "{app} ist schon installiert", + "app_argument_choice_invalid": "Wähle einen der folgenden Werte '{choices}' für das Argument '{name}'", + "app_argument_invalid": "Wähle einen gültigen Wert für das Argument '{name}': {error}", + "app_argument_required": "Argument '{name}' wird benötigt", "app_extraction_failed": "Installationsdateien konnten nicht entpackt werden", "app_id_invalid": "Falsche App-ID", - "app_install_files_invalid": "Ungültige Installationsdateien", - "app_location_already_used": "Eine andere App ({app}) ist bereits an diesem Ort ({path}) installiert", - "app_location_install_failed": "Die App kann nicht an diesem Ort installiert werden, da es mit der App {other_app} die bereits in diesem Pfad ({other_path}) installiert ist Probleme geben würde", - "app_manifest_invalid": "Ungültiges App-Manifest: {error}", - "app_no_upgrade": "Keine Aktualisierungen für Apps verfügbar", - "app_not_installed": "{app:s} ist nicht installiert", - "app_recent_version_required": "Für {:s} benötigt eine aktuellere Version von moulinette", - "app_removed": "{app:s} wurde erfolgreich entfernt", - "app_sources_fetch_failed": "Quelldateien konnten nicht abgerufen werden", + "app_install_files_invalid": "Diese Dateien können nicht installiert werden", + "app_manifest_invalid": "Mit dem App-Manifest stimmt etwas nicht: {error}", + "app_not_installed": "{app} konnte nicht in der Liste installierter Apps gefunden werden: {all_apps}", + "app_removed": "{app} wurde entfernt", + "app_sources_fetch_failed": "Quelldateien konnten nicht abgerufen werden, ist die URL korrekt?", "app_unknown": "Unbekannte App", - "app_upgrade_failed": "{app:s} konnte nicht aktualisiert werden", - "app_upgraded": "{app:s} wurde erfolgreich aktualisiert", - "appslist_fetched": "Appliste {appslist:s} wurde erfolgreich heruntergelanden", - "appslist_removed": "Appliste {appslist:s} wurde erfolgreich entfernt", - "appslist_retrieve_error": "Entfernte Appliste {appslist:s} kann nicht empfangen werden: {error:s}", - "appslist_unknown": "Appliste {appslist:s} ist unbekannt.", - "ask_current_admin_password": "Derzeitiges Administrator-Kennwort", - "ask_email": "E-Mail-Adresse", + "app_upgrade_failed": "{app} konnte nicht aktualisiert werden: {error}", + "app_upgraded": "{app} aktualisiert", "ask_firstname": "Vorname", "ask_lastname": "Nachname", - "ask_list_to_remove": "zu entfernende Liste", "ask_main_domain": "Hauptdomain", "ask_new_admin_password": "Neues Verwaltungskennwort", "ask_password": "Passwort", - "backup_action_required": "Du musst etwas zum Speichern auswählen", - "backup_app_failed": "Konnte keine Sicherung für '{app:s}' erstellen", - "backup_archive_app_not_found": "App '{app:s}' konnte in keiner Datensicherung gefunden werden", - "backup_archive_hook_not_exec": "Hook '{hook:s}' konnte für diese Datensicherung nicht ausgeführt werden", - "backup_archive_name_exists": "Datensicherung mit dem selben Namen existiert bereits", - "backup_archive_name_unknown": "Unbekanntes lokale Datensicherung mit Namen '{name:s}' gefunden", + "backup_app_failed": "Konnte keine Sicherung für {app} erstellen", + "backup_archive_app_not_found": "{app} konnte in keiner Datensicherung gefunden werden", + "backup_archive_name_exists": "Datensicherung mit dem selben Namen existiert bereits.", + "backup_archive_name_unknown": "Unbekanntes lokale Datensicherung mit Namen '{name}' gefunden", "backup_archive_open_failed": "Kann Sicherungsarchiv nicht öfnen", "backup_cleaning_failed": "Temporäres Sicherungsverzeichnis konnte nicht geleert werden", "backup_created": "Datensicherung komplett", - "backup_creating_archive": "Datensicherung wird erstellt...", - "backup_delete_error": "Pfad '{path:s}' konnte nicht gelöscht werden", - "backup_deleted": "Datensicherung wurde entfernt", - "backup_extracting_archive": "Entpacke Sicherungsarchiv...", - "backup_hook_unknown": "Datensicherungshook '{hook:s}' unbekannt", - "backup_invalid_archive": "Ungültige Datensicherung", - "backup_nothings_done": "Es gibt keine Änderungen zur Speicherung", - "backup_output_directory_forbidden": "Verbotenes Ausgabeverzeichnis. Datensicherung können nicht in /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var oder in Unterordnern von /home/yunohost.backup/archives erstellt werden", - "backup_output_directory_not_empty": "Ausgabeordner ist nicht leer", + "backup_delete_error": "Pfad '{path}' konnte nicht gelöscht werden", + "backup_deleted": "Backup wurde entfernt", + "backup_hook_unknown": "Der Datensicherungshook '{hook}' unbekannt", + "backup_nothings_done": "Keine Änderungen zur Speicherung", + "backup_output_directory_forbidden": "Wähle ein anderes Ausgabeverzeichnis. Datensicherungen können nicht in /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var oder in Unterordnern von /home/yunohost.backup/archives erstellt werden", + "backup_output_directory_not_empty": "Der gewählte Ausgabeordner sollte leer sein", "backup_output_directory_required": "Für die Datensicherung muss ein Zielverzeichnis angegeben werden", - "backup_running_app_script": "Datensicherung für App '{app:s}' wurd durchgeführt...", "backup_running_hooks": "Datensicherunghook wird ausgeführt...", - "custom_app_url_required": "Es muss eine URL angegeben werden, um deine benutzerdefinierte App {app:s} zu aktualisieren", - "custom_appslist_name_required": "Du musst einen Namen für deine benutzerdefinierte Appliste angeben", - "dnsmasq_isnt_installed": "dnsmasq scheint nicht installiert zu sein. Bitte führe 'apt-get remove bind9 && apt-get install dnsmasq' aus", + "custom_app_url_required": "Sie müssen eine URL angeben, um Ihre benutzerdefinierte App {app} zu aktualisieren", "domain_cert_gen_failed": "Zertifikat konnte nicht erzeugt werden", - "domain_created": "Die Domain wurde angelegt", - "domain_creation_failed": "Konnte Domain nicht erzeugen", - "domain_deleted": "Die Domain wurde gelöscht", - "domain_deletion_failed": "Konnte Domain nicht löschen", - "domain_dyndns_already_subscribed": "Du hast dich schon für eine DynDNS-Domain angemeldet", - "domain_dyndns_invalid": "Domain nicht mittels DynDNS nutzbar", + "domain_created": "Domäne erstellt", + "domain_creation_failed": "Konnte Domäne nicht erzeugen", + "domain_deleted": "Domain wurde gelöscht", + "domain_deletion_failed": "Domain {domain}: {error} konnte nicht gelöscht werden", + "domain_dyndns_already_subscribed": "Sie haben sich schon für eine DynDNS-Domäne registriert", "domain_dyndns_root_unknown": "Unbekannte DynDNS Hauptdomain", - "domain_exists": "Die Domain existiert bereits", - "domain_uninstall_app_first": "Mindestens eine App ist noch für diese Domain installiert. Bitte deinstalliere zuerst die App, bevor du die Domain löschst", - "domain_unknown": "Unbekannte Domain", - "domain_zone_exists": "DNS Zonen Datei existiert bereits", - "domain_zone_not_found": "DNS Zonen Datei kann nicht für Domäne {:s} gefunden werden", + "domain_exists": "Die Domäne existiert bereits", + "domain_uninstall_app_first": "Diese Applikationen sind noch auf Ihrer Domäne installiert; \n{apps}\n\nBitte deinstallieren Sie sie mit dem Befehl 'yunohost app remove the_app_id' oder verschieben Sie sie mit 'yunohost app change-url the_app_id'", "done": "Erledigt", "downloading": "Wird heruntergeladen...", - "dyndns_cron_installed": "DynDNS Cronjob erfolgreich angelegt", - "dyndns_cron_remove_failed": "Der DynDNS Cronjob konnte nicht entfernt werden", - "dyndns_cron_removed": "Der DynDNS Cronjob wurde gelöscht", - "dyndns_ip_update_failed": "IP Adresse konnte nicht für DynDNS aktualisiert werden", - "dyndns_ip_updated": "Deine IP Adresse wurde bei DynDNS aktualisiert", - "dyndns_key_generating": "DNS Schlüssel wird generiert, das könnte eine Weile dauern...", - "dyndns_registered": "Deine DynDNS Domain wurde registriert", - "dyndns_registration_failed": "DynDNS Domain konnte nicht registriert werden: {error:s}", - "dyndns_unavailable": "DynDNS Subdomain ist nicht verfügbar", - "executing_command": "Führe den Behfehl '{command:s}' aus...", - "executing_script": "Skript '{script:s}' wird ausgeührt...", + "dyndns_ip_update_failed": "Konnte die IP-Adresse für DynDNS nicht aktualisieren", + "dyndns_ip_updated": "Aktualisierung Ihrer IP-Adresse bei DynDNS", + "dyndns_key_generating": "Generierung des DNS-Schlüssels..., das könnte eine Weile dauern.", + "dyndns_registered": "DynDNS Domain registriert", + "dyndns_registration_failed": "DynDNS Domain konnte nicht registriert werden: {error}", + "dyndns_unavailable": "Die Domäne {domain} ist nicht verfügbar.", "extracting": "Wird entpackt...", - "field_invalid": "Feld '{:s}' ist unbekannt", - "firewall_reload_failed": "Die Firewall konnte nicht neu geladen werden", - "firewall_reloaded": "Die Firewall wurde neu geladen", - "firewall_rules_cmd_failed": "Einzelne Firewallregeln konnten nicht übernommen werden. Mehr Informationen sind im Log zu finden.", - "format_datetime_short": "%d/%m/%Y %I:%M %p", - "hook_argument_missing": "Fehlend Argument '{:s}'", - "hook_choice_invalid": "ungültige Wahl '{:s}'", - "hook_exec_failed": "Skriptausführung fehlgeschlagen: {path:s}", - "hook_exec_not_terminated": "Skriptausführung noch nicht beendet: {path:s}", - "hook_list_by_invalid": "Ungültiger Wert zur Anzeige von Hooks", - "hook_name_unknown": "Hook '{name:s}' ist nicht bekannt", + "field_invalid": "Feld '{}' ist unbekannt", + "firewall_reload_failed": "Firewall konnte nicht neu geladen werden", + "firewall_reloaded": "Firewall neu geladen", + "firewall_rules_cmd_failed": "Einige Befehle für die Firewallregeln sind gescheitert. Mehr Informationen im Log.", + "hook_exec_failed": "Konnte Skript nicht ausführen: {path}", + "hook_exec_not_terminated": "Skript ist nicht normal beendet worden: {path}", + "hook_list_by_invalid": "Dieser Wert kann nicht verwendet werden, um Hooks anzuzeigen", + "hook_name_unknown": "Hook '{name}' ist nicht bekannt", "installation_complete": "Installation vollständig", - "installation_failed": "Installation fehlgeschlagen", "ip6tables_unavailable": "ip6tables kann nicht verwendet werden. Du befindest dich entweder in einem Container oder es wird nicht vom Kernel unterstützt", "iptables_unavailable": "iptables kann nicht verwendet werden. Du befindest dich entweder in einem Container oder es wird nicht vom Kernel unterstützt", - "ldap_initialized": "LDAP wurde initialisiert", - "license_undefined": "Undeiniert", - "mail_alias_remove_failed": "E-Mail Alias '{mail:s}' konnte nicht entfernt werden", - "mail_domain_unknown": "Unbekannte Mail Domain '{domain:s}'", - "mail_forward_remove_failed": "Mailweiterleitung '{mail:s}' konnte nicht entfernt werden", - "maindomain_change_failed": "Die Hauptdomain konnte nicht geändert werden", - "maindomain_changed": "Die Hauptdomain wurde geändert", - "monitor_disabled": "Das Servermonitoring wurde erfolgreich deaktiviert", - "monitor_enabled": "Das Servermonitoring wurde aktiviert", - "monitor_glances_con_failed": "Verbindung mit Glances nicht möglich", - "monitor_not_enabled": "Servermonitoring ist nicht aktiviert", - "monitor_period_invalid": "Falscher Zeitraum", - "monitor_stats_file_not_found": "Statistikdatei nicht gefunden", - "monitor_stats_no_update": "Keine Monitoringstatistik zur Aktualisierung", - "monitor_stats_period_unavailable": "Keine Statistiken für den gewählten Zeitraum verfügbar", - "mountpoint_unknown": "Unbekannten Einhängepunkt", - "mysql_db_creation_failed": "MySQL Datenbankerzeugung fehlgeschlagen", - "mysql_db_init_failed": "MySQL Datenbankinitialisierung fehlgeschlagen", - "mysql_db_initialized": "Die MySQL Datenbank wurde initialisiert", - "network_check_mx_ko": "Es ist kein DNS MX Eintrag vorhanden", - "network_check_smtp_ko": "Ausgehender Mailverkehr (SMTP Port 25) scheint in deinem Netzwerk blockiert zu sein", - "network_check_smtp_ok": "Ausgehender Mailverkehr (SMTP Port 25) ist blockiert", - "new_domain_required": "Du musst eine neue Hauptdomain angeben", - "no_appslist_found": "Keine Appliste gefunden", - "no_internet_connection": "Der Server ist nicht mit dem Internet verbunden", - "no_ipv6_connectivity": "Eine IPv6 Verbindung steht nicht zur Verfügung", - "no_restore_script": "Es konnte kein Wiederherstellungsskript für '{app:s}' gefunden werden", - "no_such_conf_file": "Datei {file:s}: konnte nicht kopiert werden, da diese nicht existiert", - "packages_no_upgrade": "Es müssen keine Pakete aktualisiert werden", - "packages_upgrade_critical_later": "Ein wichtiges Paket ({packages:s}) wird später aktualisiert", - "packages_upgrade_failed": "Es konnten nicht alle Pakete aktualisiert werden", - "path_removal_failed": "Pfad {:s} konnte nicht entfernt werden", - "pattern_backup_archive_name": "Ein gültiger Dateiname kann nur aus maximal 30 alphanumerischen sowie -_. Zeichen bestehen", + "mail_alias_remove_failed": "Konnte E-Mail-Alias '{mail}' nicht entfernen", + "mail_domain_unknown": "Die Domäne '{domain}' dieser E-Mail-Adresse ist ungültig. Wähle bitte eine Domäne, welche durch diesen Server verwaltet wird.", + "mail_forward_remove_failed": "Die Weiterleitungs-E-Mail '{mail}' konnte nicht gelöscht werden", + "main_domain_change_failed": "Die Hauptdomain konnte nicht geändert werden", + "main_domain_changed": "Die Hauptdomain wurde geändert", + "packages_upgrade_failed": "Konnte nicht alle Pakete aktualisieren", + "pattern_backup_archive_name": "Muss ein gültiger Dateiname mit maximal 30 alphanumerischen sowie -_. Zeichen sein", "pattern_domain": "Muss ein gültiger Domainname sein (z.B. meine-domain.org)", - "pattern_email": "Muss eine gültige E-Mail Adresse sein (z.B. someone@domain.org)", + "pattern_email": "Muss eine gültige E-Mail-Adresse ohne '+' Symbol sein (z.B. someone@example.com)", "pattern_firstname": "Muss ein gültiger Vorname sein", "pattern_lastname": "Muss ein gültiger Nachname sein", - "pattern_listname": "Kann nur Alphanumerische Zeichen oder Unterstriche enthalten", - "pattern_mailbox_quota": "Muss eine Größe inkl. b/k/M/G/T Suffix, oder 0 zum deaktivieren sein", + "pattern_mailbox_quota": "Muss eine Größe mit b/k/M/G/T Suffix, oder 0 zum deaktivieren sein", "pattern_password": "Muss mindestens drei Zeichen lang sein", - "pattern_port": "Es muss ein valider Port (zwischen 0 und 65535) angegeben werden", "pattern_port_or_range": "Muss ein valider Port (z.B. 0-65535) oder ein Bereich (z.B. 100:200) sein", "pattern_username": "Darf nur aus klein geschriebenen alphanumerischen Zeichen und Unterstrichen bestehen", - "port_already_closed": "Der Port {port:d} wurde bereits für {ip_version:s} Verbindungen geschlossen", - "port_already_opened": "Der Port {port:d} wird bereits von {ip_version:s} benutzt", - "port_available": "Der Port {port:d} ist verfügbar", - "port_unavailable": "Der Port {port:d} ist nicht verfügbar", - "restore_action_required": "Du musst etwas zum Wiederherstellen auswählen", - "restore_already_installed_app": "Es ist bereits eine App mit der ID '{app:s}' installiet", - "restore_app_failed": "App '{app:s}' konnte nicht wiederhergestellt werden", - "restore_cleaning_failed": "Das temporäre Wiederherstellungsverzeichnis konnte nicht geleert werden", - "restore_complete": "Wiederherstellung abgeschlossen", - "restore_confirm_yunohost_installed": "Möchtest du die Wiederherstellung wirklich starten? [{answers:s}]", - "restore_failed": "System kann nicht Wiederhergestellt werden", - "restore_hook_unavailable": "Das Wiederherstellungsskript für '{part:s}' steht weder in deinem System noch im Archiv zur Verfügung", - "restore_nothings_done": "Es wurde nicht wiederhergestellt", - "restore_running_app_script": "Wiederherstellung wird ausfeührt für App '{app:s}'...", + "port_already_closed": "Der Port {port} wurde bereits für {ip_version} Verbindungen geschlossen", + "port_already_opened": "Der Port {port} wird bereits von {ip_version} benutzt", + "restore_already_installed_app": "Eine Applikation mit der ID '{app}' ist bereits installiert", + "restore_cleaning_failed": "Das temporäre Dateiverzeichnis für Systemrestaurierung konnte nicht gelöscht werden", + "restore_complete": "Vollständig wiederhergestellt", + "restore_confirm_yunohost_installed": "Möchtest du die Wiederherstellung wirklich starten? [{answers}]", + "restore_failed": "Das System konnte nicht wiederhergestellt werden", + "restore_hook_unavailable": "Das Wiederherstellungsskript für '{part}' steht weder in Ihrem System noch im Archiv zur Verfügung", + "restore_nothings_done": "Nichts wurde wiederhergestellt", + "restore_running_app_script": "App '{app}' wird wiederhergestellt...", "restore_running_hooks": "Wiederherstellung wird gestartet...", - "service_add_configuration": "Füge Konfigurationsdatei {file:s} hinzu", - "service_add_failed": "Der Dienst '{service:s}' kann nicht hinzugefügt werden", - "service_added": "Der Service '{service:s}' wurde erfolgreich hinzugefügt", - "service_already_started": "Der Dienst '{service:s}' läuft bereits", - "service_already_stopped": "Dienst '{service:s}' wurde bereits gestoppt", - "service_cmd_exec_failed": "Der Befehl '{command:s}' konnte nicht ausgeführt werden", - "service_configuration_conflict": "Die Datei {file:s} wurde zwischenzeitlich verändert. Bitte übernehme die Änderungen manuell oder nutze die Option --force (diese wird alle Änderungen überschreiben).", - "service_disable_failed": "Der Dienst '{service:s}' konnte nicht deaktiviert werden", - "service_disabled": "Der Dienst '{service:s}' wurde erfolgreich deaktiviert", - "service_enable_failed": "Der Dienst '{service:s}' konnte nicht aktiviert werden", - "service_enabled": "Der Dienst '{service:s}' wurde erfolgreich aktiviert", - "service_no_log": "Für den Dienst '{service:s}' kann kein Log angezeigt werden", - "service_remove_failed": "Der Dienst '{service:s}' konnte nicht entfernt werden", - "service_removed": "Der Dienst '{service:s}' wurde erfolgreich entfernt", - "service_start_failed": "Der Dienst '{service:s}' konnte nicht gestartet werden", - "service_started": "Der Dienst '{service:s}' wurde erfolgreich gestartet", - "service_status_failed": "Der Status von '{service:s}' kann nicht festgestellt werden", - "service_stop_failed": "Der Dienst '{service:s}' kann nicht gestoppt werden", - "service_stopped": "Der Dienst '{service:s}' wurde erfolgreich beendet", - "service_unknown": "Unbekannter Dienst '{service:s}'", - "services_configured": "Konfiguration erfolgreich erstellt", - "show_diff": "Es gibt folgende Änderungen:\n{diff:s}", - "ssowat_conf_generated": "Die Konfiguration von SSOwat war erfolgreich", - "ssowat_conf_updated": "Die persistente SSOwat Einstellung wurde aktualisiert", - "system_upgraded": "Das System wurde aktualisiert", - "system_username_exists": "Der Benutzername existiert bereits", - "unbackup_app": "App '{app:s}' konnte nicht gespeichert werden", - "unexpected_error": "Ein unerwarteter Fehler ist aufgetreten", - "unit_unknown": "Unbekannte Einheit '{unit:s}'", + "service_add_failed": "Der Dienst '{service}' konnte nicht hinzugefügt werden", + "service_added": "Der Dienst '{service}' wurde erfolgreich hinzugefügt", + "service_already_started": "Der Dienst '{service}' läuft bereits", + "service_already_stopped": "Der Dienst '{service}' wurde bereits gestoppt", + "service_cmd_exec_failed": "Der Befehl '{command}' konnte nicht ausgeführt werden", + "service_disable_failed": "Der Start des Dienstes '{service}' beim Hochfahren konnte nicht verhindert werden.\n\nKürzlich erstellte Logs des Dienstes: {logs}", + "service_disabled": "Der Dienst '{service}' wird beim Hochfahren des Systems nicht mehr gestartet werden.", + "service_enable_failed": "Der Dienst '{service}' konnte beim Hochfahren nicht gestartet werden.\n\nKürzlich erstellte Logs des Dienstes: {logs}", + "service_enabled": "Der Dienst '{service}' wird nun beim Hochfahren des Systems automatisch gestartet.", + "service_remove_failed": "Konnte den Dienst '{service}' nicht entfernen", + "service_removed": "Der Dienst '{service}' wurde erfolgreich entfernt", + "service_start_failed": "Der Dienst '{service}' konnte nicht gestartet werden\n\nKürzlich erstellte Logs des Dienstes: {logs}", + "service_started": "Der Dienst '{service}' wurde erfolgreich gestartet", + "service_stop_failed": "Der Dienst '{service}' kann nicht gestoppt werden\n\nAktuelle Service-Logs: {logs}", + "service_stopped": "Der Dienst '{service}' wurde erfolgreich beendet", + "service_unknown": "Unbekannter Dienst '{service}'", + "ssowat_conf_generated": "Konfiguration von SSOwat neu erstellt", + "ssowat_conf_updated": "Die Konfiguration von SSOwat aktualisiert", + "system_upgraded": "System aktualisiert", + "system_username_exists": "Der Benutzername existiert bereits in der Liste der System-Benutzer", + "unbackup_app": "'{app}' wird nicht gespeichert werden", + "unexpected_error": "Etwas Unerwartetes ist passiert: {error}", "unlimit": "Kein Kontingent", - "unrestore_app": "App '{app:s}' kann nicht Wiederhergestellt werden", - "update_cache_failed": "Konnte APT cache nicht aktualisieren", - "updating_apt_cache": "Die Liste der verfügbaren Pakete wird aktualisiert...", + "unrestore_app": "{app} wird nicht wiederhergestellt werden", + "updating_apt_cache": "Die Liste der verfügbaren Pakete wird aktualisiert…", "upgrade_complete": "Upgrade vollständig", - "upgrading_packages": "Pakete werden aktualisiert...", + "upgrading_packages": "Pakete werden aktualisiert…", "upnp_dev_not_found": "Es konnten keine UPnP Geräte gefunden werden", - "upnp_disabled": "UPnP wurde deaktiviert", - "upnp_enabled": "UPnP wurde aktiviert", - "upnp_port_open_failed": "UPnP Ports konnten nicht geöffnet werden", - "user_created": "Der Benutzer wurde erstellt", - "user_creation_failed": "Nutzer konnte nicht erstellt werden", - "user_deleted": "Der Benutzer wurde entfernt", - "user_deletion_failed": "Nutzer konnte nicht gelöscht werden", - "user_home_creation_failed": "Benutzer Home konnte nicht erstellt werden", - "user_info_failed": "Nutzerinformationen können nicht angezeigt werden", - "user_unknown": "Unbekannter Benutzer: {user:s}", - "user_update_failed": "Benutzer kann nicht aktualisiert werden", - "user_updated": "Der Benutzer wurde aktualisiert", + "upnp_disabled": "UPnP deaktiviert", + "upnp_enabled": "UPnP aktiviert", + "upnp_port_open_failed": "Port konnte nicht via UPnP geöffnet werden", + "user_created": "Benutzer erstellt", + "user_creation_failed": "Benutzer konnte nicht erstellt werden {user}: {error}", + "user_deleted": "Benutzer gelöscht", + "user_deletion_failed": "Benutzer konnte nicht gelöscht werden {user}: {error}", + "user_home_creation_failed": "Persönlicher Ordner des Benutzers konnte nicht erstellt werden", + "user_unknown": "Unbekannter Benutzer: {user}", + "user_update_failed": "Benutzer konnte nicht aktualisiert werden {user}: {error}", + "user_updated": "Benutzerinformationen wurden aktualisiert", "yunohost_already_installed": "YunoHost ist bereits installiert", - "yunohost_ca_creation_failed": "Zertifikatsstelle konnte nicht erstellt werden", - "yunohost_configured": "YunoHost wurde konfiguriert", + "yunohost_configured": "YunoHost ist nun konfiguriert", "yunohost_installing": "YunoHost wird installiert...", - "yunohost_not_installed": "YunoHost ist nicht oder unvollständig installiert worden. Bitte 'yunohost tools postinstall' ausführen", - "app_not_properly_removed": "{app:s} wurde nicht ordnungsgemäß entfernt", - "service_regenconf_failed": "Konnte die Konfiguration für folgende Dienste nicht neu erzeugen: {services}", - "not_enough_disk_space": "Zu wenig freier Speicherplatz unter '{path:s}' verfügbar", - "backup_creation_failed": "Erstellen des Backups fehlgeschlagen", - "service_conf_up_to_date": "Die Konfiguration für den Dienst '{service}' ist bereits aktuell", - "package_not_installed": "Das Paket '{pkgname}' ist nicht installiert", - "pattern_positive_number": "Muss eine positive Zahl sein", - "diagnosis_kernel_version_error": "Kann Kernelversion nicht abrufen: {error}", - "package_unexpected_error": "Ein unerwarteter Fehler trat bei der Verarbeitung des Pakets '{pkgname}' auf", - "app_incompatible": "Die Anwendung {app} ist nicht mit deiner YunoHost-Version kompatibel", - "app_not_correctly_installed": "{app:s} scheint nicht korrekt installiert zu sein", + "yunohost_not_installed": "YunoHost ist nicht oder nur unvollständig installiert worden. Bitte 'yunohost tools postinstall' ausführen", + "app_not_properly_removed": "{app} wurde nicht ordnungsgemäß entfernt", + "not_enough_disk_space": "Nicht genügend Speicherplatz auf '{path}' frei", + "backup_creation_failed": "Konnte Backup-Archiv nicht erstellen", + "app_not_correctly_installed": "{app} scheint nicht korrekt installiert zu sein", "app_requirements_checking": "Überprüfe notwendige Pakete für {app}...", - "app_requirements_failed": "Anforderungen für {app} werden nicht erfüllt: {error}", "app_requirements_unmeet": "Anforderungen für {app} werden nicht erfüllt, das Paket {pkgname} ({version}) muss {spec} sein", "app_unsupported_remote_type": "Für die App wurde ein nicht unterstützer Steuerungstyp verwendet", - "backup_archive_broken_link": "Auf das Backup-Archiv konnte nicht zugegriffen werden (ungültiger Link zu {path:s})", - "diagnosis_debian_version_error": "Debian Version konnte nicht abgerufen werden: {error}", - "diagnosis_monitor_disk_error": "Festplatten können nicht aufgelistet werden: {error}", - "diagnosis_monitor_network_error": "Netzwerk kann nicht angezeigt werden: {error}", - "diagnosis_monitor_system_error": "System kann nicht angezeigt werden: {error}", - "diagnosis_no_apps": "Keine Anwendung ist installiert", + "backup_archive_broken_link": "Auf das Backup-Archiv konnte nicht zugegriffen werden (ungültiger Link zu {path})", "domains_available": "Verfügbare Domains:", "dyndns_key_not_found": "DNS-Schlüssel für die Domain wurde nicht gefunden", - "dyndns_no_domain_registered": "Es wurde keine Domain mit DynDNS registriert", - "ldap_init_failed_to_create_admin": "Die LDAP Initialisierung konnte keinen admin Benutzer erstellen", - "mailbox_used_space_dovecot_down": "Der Dovecot Mailbox Dienst muss gestartet sein, wenn du den von der Mailbox belegten Speicher angezeigen lassen willst", - "package_unknown": "Unbekanntes Paket '{pkgname}'", - "service_conf_file_backed_up": "Von der Konfigurationsdatei {conf} wurde ein Backup in {backup} erstellt", - "service_conf_file_copy_failed": "Die neue Konfigurationsdatei konnte von {new} nach {conf} nicht kopiert werden", - "service_conf_file_manually_modified": "Die Konfigurationsdatei {conf} wurde manuell verändert und wird nicht aktualisiert", - "service_conf_file_manually_removed": "Die Konfigurationsdatei {conf} wurde manuell entfern und wird nicht erstellt", - "service_conf_file_not_managed": "Die Konfigurationsdatei {conf} wurde noch nicht verwaltet und wird nicht aktualisiert", - "service_conf_file_remove_failed": "Die Konfigurationsdatei {conf} konnte nicht entfernt werden", - "service_conf_file_removed": "Die Konfigurationsdatei {conf} wurde entfernt", - "service_conf_file_updated": "Die Konfigurationsdatei {conf} wurde aktualisiert", - "service_conf_updated": "Die Konfigurationsdatei wurde für den Service {service} aktualisiert", - "service_conf_would_be_updated": "Die Konfigurationsdatei sollte für den Service {service} aktualisiert werden", - "ssowat_persistent_conf_read_error": "Ein Fehler ist aufgetreten, als die persistente SSOwat Konfiguration eingelesen wurde {error:s} Bearbeite die persistente Datei /etc/ssowat/conf.json , um die JSON syntax zu korregieren", - "ssowat_persistent_conf_write_error": "Ein Fehler ist aufgetreten, als die persistente SSOwat Konfiguration gespeichert wurde {error:s} Bearbeite die persistente Datei /etc/ssowat/conf.json , um die JSON syntax zu korregieren", - "certmanager_attempt_to_replace_valid_cert": "Du versuchst gerade eine richtiges und gültiges Zertifikat der Domain {domain:s} zu überschreiben! (Benutze --force , um diese Nachricht zu umgehen)", - "certmanager_domain_unknown": "Unbekannte Domain {domain:s}", - "certmanager_domain_cert_not_selfsigned": "Das Zertifikat der Domain {domain:s} is kein selbstsigniertes Zertifikat. Bist du dir sicher, dass du es ersetzen willst? (Benutze --force)", - "certmanager_certificate_fetching_or_enabling_failed": "Es scheint so als wäre die Aktivierung des Zertifikats für die Domain {domain:s} fehlgeschlagen...", - "certmanager_attempt_to_renew_nonLE_cert": "Das Zertifikat der Domain {domain:s} wurde nicht von Let's Encrypt ausgestellt. Es kann nicht automatisch erneuert werden!", - "certmanager_attempt_to_renew_valid_cert": "Das Zertifikat der Domain {domain:s} läuft in Kürze ab! Benutze --force um diese Nachricht zu umgehen", - "certmanager_domain_http_not_working": "Es scheint so, dass die Domain {domain:s} nicht über HTTP erreicht werden kann. Bitte überprüfe, ob deine DNS und nginx Konfiguration in Ordnung ist", - "certmanager_error_no_A_record": "Kein DNS 'A' Eintrag für die Domain {domain:s} gefunden. Dein Domainname muss auf diese Maschine weitergeleitet werden, um ein Let's Encrypt Zertifikat installieren zu können! (Wenn du weißt was du tust, kannst du --no-checks benutzen, um diese Überprüfung zu überspringen. )", - "certmanager_domain_dns_ip_differs_from_public_ip": "Der DNS 'A' Eintrag der Domain {domain:s} unterscheidet sich von dieser Server-IP. Wenn du gerade deinen A Eintrag verändert hast, warte bitte etwas, damit die Änderungen wirksam werden (du kannst die DNS Propagation mittels Website überprüfen) (Wenn du weißt was du tust, kannst du --no-checks benutzen, um diese Überprüfung zu überspringen. )", - "certmanager_domain_not_resolved_locally": "Die Domain {domain:s} konnte von innerhalb des Yunohost-Servers nicht aufgelöst werden. Das kann passieren, wenn du den DNS Eintrag vor Kurzem verändert hast. Falls dies der Fall ist, warte bitte ein paar Stunden, damit die Änderungen wirksam werden. Wenn der Fehler bestehen bleibt, ziehe in Betracht die Domain {domain:s} in /etc/hosts einzutragen. (Wenn du weißt was du tust, benutze --no-checks , um diese Nachricht zu umgehen. )", - "certmanager_cannot_read_cert": "Es ist ein Fehler aufgetreten, als es versucht wurde das aktuelle Zertifikat für die Domain {domain:s} zu öffnen (Datei: {file:s}), Grund: {reason:s}", - "certmanager_cert_install_success_selfsigned": "Ein selbstsigniertes Zertifikat für die Domain {domain:s} wurde erfolgreich installiert!", - "certmanager_cert_install_success": "Für die Domain {domain:s} wurde erfolgreich ein Let's Encrypt installiert!", - "certmanager_cert_renew_success": "Das Let's Encrypt Zertifikat für die Domain {domain:s} wurde erfolgreich erneuert!", - "certmanager_old_letsencrypt_app_detected": "\nYunohost hat erkannt, dass eine Version von 'letsencrypt' installiert ist, die mit den neuen, integrierten Zertifikatsmanagement-Features in Yunohost kollidieren. Wenn du die neuen Features nutzen willst, führe die folgenden Befehle aus:\n\n yunohost app remove letsencrypt\n yunohost domain cert-install\n\nAnm.: Diese Befehle werden die selbstsignierten und Let's Encrypt Zertifikate aller Domains neu installieren", - "certmanager_hit_rate_limit": "Es wurden innerhalb kurzer Zeit schon zu viele Zertifikate für die exakt gleiche Domain {domain:s} ausgestellt. Bitte versuche es später nochmal. Besuche https://letsencrypt.org/docs/rate-limits/ für mehr Informationen", - "certmanager_cert_signing_failed": "Signieren des neuen Zertifikats ist fehlgeschlagen", - "certmanager_no_cert_file": "Die Zertifikatsdatei für die Domain {domain:s} (Datei: {file:s}) konnte nicht gelesen werden", - "certmanager_conflicting_nginx_file": "Die Domain konnte nicht für die ACME challenge vorbereitet werden: Die nginx Konfigurationsdatei {filepath:s} verursacht Probleme und sollte vorher entfernt werden", - "domain_cannot_remove_main": "Die primäre Domain konnten nicht entfernt werden. Lege zuerst einen neue primäre Domain fest", - "certmanager_self_ca_conf_file_not_found": "Die Konfigurationsdatei der Zertifizierungsstelle für selbstsignierte Zertifikate wurde nicht gefunden (Datei {file:s})", - "certmanager_acme_not_configured_for_domain": "Das Zertifikat für die Domain {domain:s} scheint nicht richtig installiert zu sein. Bitte führe den Befehl cert-install für diese Domain nochmals aus.", - "certmanager_unable_to_parse_self_CA_name": "Der Name der Zertifizierungsstelle für selbstsignierte Zertifikate konnte nicht analysiert werden (Datei: {file:s})", - "app_package_need_update": "Es ist notwendig das Paket {app} zu aktualisieren, um Aktualisierungen für YunoHost zu erhalten", - "service_regenconf_dry_pending_applying": "Überprüfe ausstehende Konfigurationen, die für den Server {service} notwendig sind...", - "service_regenconf_pending_applying": "Überprüfe ausstehende Konfigurationen, die für den Server '{service}' notwendig sind...", - "certmanager_http_check_timeout": "Eine Zeitüberschreitung ist aufgetreten als der Server versuchte sich selbst über HTTP mit der öffentlichen IP (Domain {domain:s} mit der IP {ip:s}) zu erreichen. Möglicherweise ist dafür hairpinning oder eine falsch konfigurierte Firewall/Router deines Servers dafür verantwortlich.", - "certmanager_couldnt_fetch_intermediate_cert": "Eine Zeitüberschreitung ist aufgetreten als der Server versuchte die Teilzertifikate von Let's Encrypt zusammenzusetzen. Die Installation/Erneuerung des Zertifikats wurde abgebrochen - bitte versuche es später erneut.", - "appslist_retrieve_bad_format": "Die empfangene Datei der Appliste {appslist:s} ist ungültig", - "domain_hostname_failed": "Erstellen des neuen Hostnamens fehlgeschlagen", - "appslist_name_already_tracked": "Es gibt bereits eine registrierte App-Liste mit Namen {name:s}.", - "appslist_url_already_tracked": "Es gibt bereits eine registrierte Anwendungsliste mit dem URL {url:s}.", - "appslist_migrating": "Migriere Anwendungsliste {appslist:s} ...", - "appslist_could_not_migrate": "Konnte Anwendungsliste {appslist:s} nicht migrieren. Konnte die URL nicht verarbeiten... Der alte Cron-Job wurde unter {bkp_file:s} beibehalten.", - "appslist_corrupted_json": "Konnte die Anwendungslisten. Es scheint, dass {filename:s} beschädigt ist.", - "yunohost_ca_creation_success": "Die lokale Zertifizierungs-Authorität wurde angelegt.", - "app_already_installed_cant_change_url": "Diese Application ist bereits installiert. Die URL kann durch diese Funktion nicht modifiziert werden. Überprüfe ob `app changeurl` verfügbar ist.", - "app_change_no_change_url_script": "Die Application {app_name:s} unterstützt das anpassen der URL noch nicht. Sie muss gegebenenfalls erweitert werden.", - "app_change_url_failed_nginx_reload": "NGINX konnte nicht neu gestartet werden. Hier ist der Output von 'nginx -t':\n{nginx_errors:s}", - "app_change_url_identical_domains": "Die alte und neue domain/url_path sind identisch: ('{domain:s} {path:s}'). Es gibt nichts zu tun.", - "app_already_up_to_date": "{app:s} ist schon aktuell", + "dyndns_no_domain_registered": "Keine Domain mit DynDNS registriert", + "mailbox_used_space_dovecot_down": "Der Dovecot-Mailbox-Dienst muss aktiv sein, wenn Sie den von der Mailbox belegten Speicher abrufen wollen", + "certmanager_attempt_to_replace_valid_cert": "Du versuchst gerade eine richtiges und gültiges Zertifikat der Domain {domain} zu überschreiben! (Benutze --force , um diese Nachricht zu umgehen)", + "certmanager_domain_cert_not_selfsigned": "Das Zertifikat der Domain {domain} ist kein selbstsigniertes Zertifikat. Sind Sie sich sicher, dass Sie es ersetzen wollen? (Benutzen Sie dafür '--force')", + "certmanager_certificate_fetching_or_enabling_failed": "Die Aktivierung des neuen Zertifikats für die {domain} ist fehlgeschlagen...", + "certmanager_attempt_to_renew_nonLE_cert": "Das Zertifikat der Domain '{domain}' wurde nicht von Let's Encrypt ausgestellt. Es kann nicht automatisch erneuert werden!", + "certmanager_attempt_to_renew_valid_cert": "Das Zertifikat der Domain {domain} läuft nicht in Kürze ab! (Benutze --force um diese Nachricht zu umgehen)", + "certmanager_domain_http_not_working": "Es scheint, als ob die Domäne {domain} nicht über HTTP erreicht werden kann. Bitte überprüfen Sie, ob Ihre DNS- und nginx-Konfiguration in Ordnung ist. (Wenn Sie wissen was Sie tun, nutzen Sie \"--no-checks\" um die Überprüfung zu überspringen.)", + "certmanager_domain_dns_ip_differs_from_public_ip": "Der DNS-A-Eintrag der Domain {domain} unterscheidet sich von dieser Server-IP. Für weitere Informationen überprüfen Sie bitte die 'DNS records' (basic) Kategorie in der Diagnose. Wenn Sie gerade Ihren A-Eintrag verändert haben, warten Sie bitte etwas, damit die Änderungen wirksam werden (Sie können die DNS-Propagation mittels Website überprüfen) (Wenn Sie wissen was Sie tun, können Sie --no-checks benutzen, um diese Überprüfung zu überspringen.)", + "certmanager_cannot_read_cert": "Es ist ein Fehler aufgetreten, als es versucht wurde das aktuelle Zertifikat für die Domain {domain} zu öffnen (Datei: {file}), Grund: {reason}", + "certmanager_cert_install_success_selfsigned": "Das selbstsignierte Zertifikat für die Domäne '{domain}' wurde erfolgreich installiert", + "certmanager_cert_install_success": "Let's-Encrypt-Zertifikat für die Domäne {domain} ist jetzt installiert", + "certmanager_cert_renew_success": "Das Let's Encrypt Zertifikat für die Domain {domain} wurde erfolgreich erneuert", + "certmanager_hit_rate_limit": "Es wurden innerhalb kurzer Zeit zu viele Zertifikate für dieselbe Domäne {domain} ausgestellt. Bitte versuchen Sie es später nochmal. Besuchen Sie https://letsencrypt.org/docs/rate-limits/ für mehr Informationen", + "certmanager_cert_signing_failed": "Das neue Zertifikat konnte nicht signiert werden", + "certmanager_no_cert_file": "Die Zertifikatsdatei für die Domain {domain} (Datei: {file}) konnte nicht gelesen werden", + "domain_cannot_remove_main": "Die primäre Domain konnten nicht entfernt werden. Lege zuerst einen neue primäre Domain Sie können die Domäne '{domain}' nicht entfernen, weil Sie die Hauptdomäne ist. Sie müssen zuerst eine andere Domäne als Hauptdomäne festlegen. Sie können das mit dem Befehl 'yunohost domain main-domain -n tun. Hier ist eine Liste der möglichen Domänen: {other_domains}", + "certmanager_self_ca_conf_file_not_found": "Die Konfigurationsdatei der Zertifizierungsstelle für selbstsignierte Zertifikate wurde nicht gefunden (Datei {file})", + "certmanager_acme_not_configured_for_domain": "Die ACME Challenge kann im Moment nicht für {domain} ausgeführt werden, weil in ihrer nginx conf das entsprechende Code-Snippet fehlt... Bitte stellen Sie sicher, dass Ihre nginx-Konfiguration mit 'yunohost tools regen-conf nginx --dry-run --with-diff' auf dem neuesten Stand ist.", + "certmanager_unable_to_parse_self_CA_name": "Der Name der Zertifizierungsstelle für selbstsignierte Zertifikate konnte nicht aufgelöst werden (Datei: {file})", + "domain_hostname_failed": "Sie können keinen neuen Hostnamen verwenden. Das kann zukünftige Probleme verursachen (es kann auch sein, dass es funktioniert).", + "app_already_installed_cant_change_url": "Diese Applikation ist bereits installiert. Die URL kann durch diese Funktion nicht modifiziert werden. Überprüfe ob `app changeurl` verfügbar ist.", + "app_change_url_identical_domains": "Die alte und neue domain/url_path sind identisch: ('{domain} {path}'). Es gibt nichts zu tun.", + "app_already_up_to_date": "{app} ist bereits aktuell", "backup_abstract_method": "Diese Backup-Methode wird noch nicht unterstützt", "backup_applying_method_tar": "Erstellen des Backup-tar Archives...", "backup_applying_method_copy": "Kopiere alle Dateien ins Backup...", - "app_change_url_no_script": "Die Anwendung '{app_name:s}' unterstützt bisher keine URL-Modufikation. Vielleicht gibt es eine Aktualisierung der Anwendung.", - "app_location_unavailable": "Diese URL ist nicht verfügbar oder wird von einer installierten Anwendung genutzt:\n{apps:s}", - "backup_applying_method_custom": "Rufe die benutzerdefinierte Backup-Methode '{method:s}' auf...", - "backup_archive_system_part_not_available": "Der System-Teil '{part:s}' ist in diesem Backup nicht enthalten", - "backup_archive_mount_failed": "Das Einbinden des Backup-Archives ist fehlgeschlagen", - "backup_archive_writing_error": "Die Dateien konnten nicht in der komprimierte Archiv-Backup hinzugefügt werden", - "app_change_url_success": "Erfolgreiche Änderung der URL von {app:s} zu {domain:s}{path:s}", - "backup_applying_method_borg": "Sende alle Dateien zur Sicherung ins borg-backup repository...", - "invalid_url_format": "ungültiges URL Format" -} + "app_change_url_no_script": "Die Applikation '{app_name}' unterstützt bisher keine URL-Modifikation. Vielleicht sollte sie aktualisiert werden.", + "app_location_unavailable": "Diese URL ist nicht verfügbar oder wird von einer installierten Applikation genutzt:\n{apps}", + "backup_applying_method_custom": "Rufe die benutzerdefinierte Backup-Methode '{method}' auf...", + "backup_archive_system_part_not_available": "Der System-Teil '{part}' ist in diesem Backup nicht enthalten", + "backup_archive_writing_error": "Die Dateien '{source} (im Ordner '{dest}') konnten nicht in das komprimierte Archiv-Backup '{archive}' hinzugefügt werden", + "app_change_url_success": "{app} URL ist nun {domain}{path}", + "global_settings_bad_type_for_setting": "Falscher Typ der Einstellung {setting}. Empfangen: {received_type}, aber erwarteter Typ: {expected_type}", + "global_settings_bad_choice_for_enum": "Wert des Einstellungsparameters {setting} ungültig. Der Wert den Sie eingegeben haben: '{choice}', die gültigen Werte für diese Einstellung: {available_choices}", + "file_does_not_exist": "Die Datei {path} existiert nicht.", + "experimental_feature": "Warnung: Der Maintainer hat diese Funktion als experimentell gekennzeichnet. Sie ist nicht stabil. Sie sollten sie nur verwenden, wenn Sie wissen, was Sie tun.", + "dyndns_domain_not_provided": "Der DynDNS-Anbieter {provider} kann die Domäne(n) {domain} nicht bereitstellen.", + "dyndns_could_not_check_available": "Konnte nicht überprüfen, ob {domain} auf {provider} verfügbar ist.", + "dyndns_could_not_check_provide": "Konnte nicht überprüft, ob {provider} die Domain(s) {domain} bereitstellen kann.", + "domain_dns_conf_is_just_a_recommendation": "Dieser Befehl zeigt dir die *empfohlene* Konfiguration. Er konfiguriert *nicht* das DNS für dich. Es liegt in deiner Verantwortung, die DNS-Zone bei deinem DNS-Registrar nach dieser Empfehlung zu konfigurieren.", + "dpkg_lock_not_available": "Dieser Befehl kann momentan nicht ausgeführt werden, da anscheinend ein anderes Programm die Sperre von dpkg (dem Systempaket-Manager) verwendet", + "confirm_app_install_thirdparty": "WARNUNG! Diese Applikation ist nicht Teil des YunoHost-Applikationskatalogs. Das Installieren von Drittanbieterapplikationen könnte die Sicherheit und Integrität Ihres Systems beeinträchtigen. Sie sollten wahrscheinlich NICHT fortfahren, es sei denn, Sie wissen, was Sie tun. Es wird KEIN SUPPORT angeboten, wenn die Applikation nicht funktionieren oder Ihr System beschädigen sollte... Wenn Sie das Risiko trotzdem eingehen möchten, tippen Sie '{answers}'", + "confirm_app_install_danger": "WARNUNG! Diese Applikation ist noch experimentell (wenn nicht ausdrücklich nicht funktionsfähig)! Sie sollten Sie wahrscheinlich NICHT installieren, es sei denn, Sie wissen, was Sie tun. Es wird keine Unterstützung angeboten, falls diese Applikation nicht funktionieren oder Ihr System beschädigen sollte... Falls Sie bereit sind, dieses Risiko einzugehen, tippen Sie '{answers}'", + "confirm_app_install_warning": "Warnung: Diese Applikation funktioniert möglicherweise, ist jedoch nicht gut in YunoHost integriert. Einige Funktionen wie Single Sign-On und Backup / Restore sind möglicherweise nicht verfügbar. Trotzdem installieren? [{answers}] ", + "backup_with_no_restore_script_for_app": "{app} hat kein Wiederherstellungsskript. Das Backup dieser App kann nicht automatisch wiederhergestellt werden.", + "backup_with_no_backup_script_for_app": "Die App {app} hat kein Sicherungsskript. Ignoriere es.", + "backup_unable_to_organize_files": "Dateien im Archiv konnten nicht mit der schnellen Methode organisiert werden", + "backup_system_part_failed": "Der Systemteil '{part}' konnte nicht gesichert werden", + "backup_permission": "Sicherungsberechtigung für {app}", + "backup_output_symlink_dir_broken": "Ihr Archivverzeichnis '{path}' ist ein fehlerhafter Symlink. Vielleicht haben Sie vergessen, das Speichermedium, auf das er verweist, neu zu mounten oder einzustecken.", + "backup_mount_archive_for_restore": "Archiv für Wiederherstellung vorbereiten...", + "backup_method_tar_finished": "Tar-Backup-Archiv erstellt", + "backup_method_custom_finished": "Benutzerdefinierte Sicherungsmethode '{method}' beendet", + "backup_method_copy_finished": "Sicherungskopie beendet", + "backup_custom_mount_error": "Bei der benutzerdefinierten Sicherungsmethode ist beim Arbeitsschritt \"Einhängen/Verbinden\" ein Fehler aufgetreten", + "backup_custom_backup_error": "Bei der benutzerdefinierten Sicherungsmethode ist beim Arbeitsschritt \"Sicherung\" ein Fehler aufgetreten", + "backup_csv_creation_failed": "Die zur Wiederherstellung erforderliche CSV-Datei kann nicht erstellt werden", + "backup_couldnt_bind": "{src} konnte nicht an {dest} angebunden werden.", + "backup_ask_for_copying_if_needed": "Möchten Sie die Sicherung mit {size}MB temporär durchführen? (Dieser Weg wird verwendet, da einige Dateien nicht mit einer effizienteren Methode vorbereitet werden konnten.)", + "backup_actually_backuping": "Erstellt ein Backup-Archiv aus den gesammelten Dateien...", + "ask_new_path": "Neuer Pfad", + "ask_new_domain": "Neue Domain", + "app_upgrade_some_app_failed": "Einige Applikationen können nicht aktualisiert werden", + "app_upgrade_app_name": "{app} wird jetzt aktualisiert...", + "app_upgrade_several_apps": "Die folgenden Apps werden aktualisiert: {apps}", + "app_start_restore": "{app} wird wiederhergestellt...", + "app_start_backup": "Sammeln von Dateien, die für {app} gesichert werden sollen...", + "app_start_remove": "{app} wird entfernt...", + "app_start_install": "{app} wird installiert...", + "app_not_upgraded": "Die App '{failed_app}' konnte nicht aktualisiert werden. Infolgedessen wurden die folgenden App-Upgrades abgebrochen: {apps}", + "app_make_default_location_already_used": "Die App \"{app}\" kann nicht als Standard für die Domain \"{domain}\" festgelegt werden. Sie wird bereits von \"{other_app}\" verwendet", + "aborting": "Breche ab.", + "app_action_cannot_be_ran_because_required_services_down": "Diese erforderlichen Dienste sollten zur Durchführung dieser Aktion laufen: {services}. Versuchen Sie, sie neu zu starten, um fortzufahren (und möglicherweise zu untersuchen, warum sie nicht verfügbar sind).", + "already_up_to_date": "Nichts zu tun. Alles ist bereits auf dem neusten Stand.", + "admin_password_too_long": "Bitte ein Passwort kürzer als 127 Zeichen wählen", + "app_action_broke_system": "Diese Aktion scheint diese wichtigen Dienste unterbrochen zu haben: {services}", + "apps_already_up_to_date": "Alle Apps sind bereits aktuell", + "backup_copying_to_organize_the_archive": "Kopieren von {size} MB, um das Archiv zu organisieren", + "global_settings_setting_security_ssh_compatibility": "Kompatibilitäts- vs. Sicherheits-Kompromiss für den SSH-Server. Betrifft die Ciphers (und andere sicherheitsrelevante Aspekte)", + "group_deleted": "Gruppe '{group}' gelöscht", + "group_deletion_failed": "Konnte Gruppe '{group}' nicht löschen: {error}", + "dyndns_provider_unreachable": "DynDNS-Anbieter {provider} kann nicht erreicht werden: Entweder ist Ihr YunoHost nicht korrekt mit dem Internet verbunden oder der Dynette-Server ist ausgefallen.", + "group_created": "Gruppe '{group}' angelegt", + "group_creation_failed": "Konnte Gruppe '{group}' nicht anlegen", + "group_unknown": "Die Gruppe '{group}' ist unbekannt", + "group_updated": "Gruppe '{group}' erneuert", + "group_update_failed": "Kann Gruppe '{group}' nicht aktualisieren: {error}", + "log_does_exists": "Es gibt kein Operationsprotokoll mit dem Namen'{log}', verwende 'yunohost log list', um alle verfügbaren Operationsprotokolle anzuzeigen", + "log_operation_unit_unclosed_properly": "Die Operationseinheit wurde nicht richtig geschlossen", + "global_settings_setting_security_postfix_compatibility": "Kompatibilitäts- vs. Sicherheits-Kompromiss für den Postfix-Server. Betrifft die Ciphers (und andere sicherheitsrelevante Aspekte)", + "global_settings_unknown_type": "Unerwartete Situation, die Einstellung {setting} scheint den Typ {unknown_type} zu haben, ist aber kein vom System unterstützter Typ.", + "dpkg_is_broken": "Du kannst das gerade nicht tun, weil dpkg/APT (der Systempaketmanager) in einem defekten Zustand zu sein scheint... Du kannst versuchen, dieses Problem zu lösen, indem du dich über SSH verbindest und `sudo apt install --fix-broken` sowie/oder `sudo dpkg --configure -a` ausführst.", + "global_settings_unknown_setting_from_settings_file": "Unbekannter Schlüssel in den Einstellungen: '{setting_key}', verwerfen und speichern in /etc/yunohost/settings-unknown.json", + "log_link_to_log": "Vollständiges Log dieser Operation: '{desc}'", + "log_help_to_get_log": "Um das Protokoll der Operation '{desc}' anzuzeigen, verwenden Sie den Befehl 'yunohost log show {name}'", + "global_settings_setting_security_nginx_compatibility": "Kompatibilitäts- vs. Sicherheits-Kompromiss für den Webserver NGINX. Betrifft die Ciphers (und andere sicherheitsrelevante Aspekte)", + "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Erlaubt die Verwendung eines (veralteten) DSA-Hostkeys für die SSH-Daemon-Konfiguration", + "log_app_remove": "Entferne die Applikation '{}'", + "global_settings_cant_open_settings": "Einstellungsdatei konnte nicht geöffnet werden, Grund: {reason}", + "global_settings_cant_write_settings": "Einstellungsdatei konnte nicht gespeichert werden, Grund: {reason}", + "log_app_install": "Installiere die Applikation '{}'", + "global_settings_reset_success": "Frühere Einstellungen werden nun auf {path} gesichert", + "log_app_upgrade": "Upgrade der Applikation '{}'", + "good_practices_about_admin_password": "Du bist nun dabei, ein neues Administratorpasswort zu definieren. Das Passwort sollte mindestens 8 Zeichen lang sein - es ist jedoch empfehlenswert, ein längeres Passwort (z.B. eine Passphrase) und/oder verschiedene Arten von Zeichen (Groß- und Kleinschreibung, Ziffern und Sonderzeichen) zu verwenden.", + "log_corrupted_md_file": "Die mit Protokollen verknüpfte YAML-Metadatendatei ist beschädigt: '{md_file}\nFehler: {error}''", + "global_settings_cant_serialize_settings": "Einstellungsdaten konnten nicht serialisiert werden, Grund: {reason}", + "log_help_to_get_failed_log": "Der Vorgang'{desc}' konnte nicht abgeschlossen werden. Bitte teile das vollständige Protokoll dieser Operation mit dem Befehl 'yunohost log share {name}', um Hilfe zu erhalten", + "backup_no_uncompress_archive_dir": "Dieses unkomprimierte Archivverzeichnis gibt es nicht", + "log_app_change_url": "Ändere die URL der Applikation '{}'", + "global_settings_setting_security_password_user_strength": "Stärke des Benutzerpassworts", + "good_practices_about_user_password": "Du bist nun dabei, ein neues Nutzerpasswort zu definieren. Das Passwort sollte mindestens 8 Zeichen lang sein - es ist jedoch empfehlenswert, ein längeres Passwort (z.B. eine Passphrase) und/oder verschiedene Arten von Zeichen (Groß- und Kleinschreibung, Ziffern und Sonderzeichen) zu verwenden.", + "log_link_to_failed_log": "Der Vorgang konnte nicht abgeschlossen werden '{desc}'. Bitte gib das vollständige Protokoll dieser Operation mit Klicken Sie hier an, um Hilfe zu erhalten", + "backup_cant_mount_uncompress_archive": "Das unkomprimierte Archiv konnte nicht als schreibgeschützt gemountet werden", + "backup_csv_addition_failed": "Es konnten keine Dateien zur Sicherung in die CSV-Datei hinzugefügt werden", + "global_settings_setting_security_password_admin_strength": "Stärke des Admin-Passworts", + "global_settings_key_doesnt_exists": "Der Schlüssel'{settings_key}' existiert nicht in den globalen Einstellungen, du kannst alle verfügbaren Schlüssel sehen, indem du 'yunohost settings list' ausführst", + "log_app_makedefault": "Mache '{}' zur Standard-Applikation", + "hook_json_return_error": "Konnte die Rückkehr vom Einsprungpunkt {path} nicht lesen. Fehler: {msg}. Unformatierter Inhalt: {raw_content}", + "app_full_domain_unavailable": "Es tut uns leid, aber diese Applikation erfordert die Installation auf einer eigenen Domain, aber einige andere Applikationen sind bereits auf der Domäne'{domain}' installiert. Eine mögliche Lösung ist das Hinzufügen und Verwenden einer Subdomain, die dieser Applikation zugeordnet ist.", + "app_install_failed": "Installation von {app} fehlgeschlagen: {error}", + "app_install_script_failed": "Im Installationsscript ist ein Fehler aufgetreten", + "app_remove_after_failed_install": "Entfernen der App nach fehlgeschlagener Installation...", + "app_upgrade_script_failed": "Es ist ein Fehler im App-Upgrade-Skript aufgetreten", + "diagnosis_basesystem_host": "Server läuft unter Debian {debian_version}", + "diagnosis_basesystem_kernel": "Server läuft unter Linux-Kernel {kernel_version}", + "diagnosis_basesystem_ynh_single_version": "{package} Version: {version} ({repo})", + "diagnosis_basesystem_ynh_main_version": "Server läuft YunoHost {main_version} ({repo})", + "diagnosis_basesystem_ynh_inconsistent_versions": "Sie verwenden inkonsistente Versionen der YunoHost-Pakete... wahrscheinlich wegen eines fehlgeschlagenen oder teilweisen Upgrades.", + "apps_catalog_init_success": "App-Katalogsystem initialisiert!", + "apps_catalog_updating": "Aktualisierung des Applikationskatalogs...", + "apps_catalog_failed_to_download": "Der {apps_catalog} App-Katalog kann nicht heruntergeladen werden: {error}", + "apps_catalog_obsolete_cache": "Der Cache des App-Katalogs ist leer oder veraltet.", + "apps_catalog_update_success": "Der Apps-Katalog wurde aktualisiert!", + "password_too_simple_1": "Das Passwort muss mindestens 8 Zeichen lang sein", + "diagnosis_everything_ok": "Alles schaut gut aus für {category}!", + "diagnosis_failed": "Kann Diagnose-Ergebnis für die Kategorie '{category}' nicht abrufen: {error}", + "diagnosis_ip_connected_ipv4": "Der Server ist mit dem Internet über IPv4 verbunden!", + "diagnosis_no_cache": "Kein Diagnose Cache aktuell für die Kategorie '{category}'", + "diagnosis_ip_no_ipv4": "Der Server hat kein funktionierendes IPv4.", + "diagnosis_ip_connected_ipv6": "Der Server ist mit dem Internet über IPv6 verbunden!", + "diagnosis_ip_no_ipv6": "Der Server hat kein funktionierendes IPv6.", + "diagnosis_ip_not_connected_at_all": "Der Server scheint überhaupt nicht mit dem Internet verbunden zu sein!?", + "diagnosis_failed_for_category": "Diagnose fehlgeschlagen für die Kategorie '{category}': {error}", + "diagnosis_cache_still_valid": "(Der Cache für die Diagnose {category} ist immer noch gültig . Es wird momentan keine neue Diagnose durchgeführt!)", + "diagnosis_cant_run_because_of_dep": "Kann Diagnose für {category} nicht ausführen während wichtige Probleme zu {dep} noch nicht behoben sind.", + "diagnosis_found_errors_and_warnings": "Habe {errors} erhebliche(s) Problem(e) (und {warnings} Warnung(en)) in Verbindung mit {category} gefunden!", + "diagnosis_ip_broken_dnsresolution": "Domänen-Namens-Auflösung scheint aus einem bestimmten Grund nicht zu funktionieren... Blockiert eine Firewall die DNS Anfragen?", + "diagnosis_ip_broken_resolvconf": "Domänen-Namensauflösung scheint nicht zu funktionieren, was daran liegen könnte, dass in /etc/resolv.conf kein Eintrag auf 127.0.0.1 zeigt.", + "diagnosis_ip_weird_resolvconf_details": "Die Datei /etc/resolv.conf muss ein Symlink auf /etc/resolvconf/run/resolv.conf sein, welcher auf 127.0.0.1 (dnsmasq) zeigt. Falls Sie die DNS-Resolver manuell konfigurieren möchten, bearbeiten Sie bitte /etc/resolv.dnsmasq.conf.", + "diagnosis_dns_good_conf": "Die DNS-Einträge für die Domäne {domain} (Kategorie {category}) sind korrekt konfiguriert", + "diagnosis_ignored_issues": "(+ {nb_ignored} ignorierte(s) Problem(e))", + "diagnosis_basesystem_hardware": "Server Hardware Architektur ist {virt} {arch}", + "diagnosis_found_errors": "Habe {errors} erhebliche(s) Problem(e) in Verbindung mit {category} gefunden!", + "diagnosis_found_warnings": "Habe {warnings} Ding(e) gefunden, die verbessert werden könnten für {category}.", + "diagnosis_ip_dnsresolution_working": "Domänen-Namens-Auflösung funktioniert!", + "diagnosis_ip_weird_resolvconf": "DNS Auflösung scheint zu funktionieren, aber seien Sie vorsichtig wenn Sie Ihren eigenen /etc/resolv.conf verwenden.", + "diagnosis_display_tip": "Um die gefundenen Probleme zu sehen, können Sie zum Diagnose-Bereich des webadmin gehen, oder 'yunohost diagnosis show --issues --human-readable' in der Kommandozeile ausführen.", + "backup_archive_corrupted": "Das Backup-Archiv '{archive}' scheint beschädigt: {error}", + "backup_archive_cant_retrieve_info_json": "Die Informationen für das Archiv '{archive}' konnten nicht geladen werden... Die Datei info.json wurde nicht gefunden (oder ist kein gültiges json).", + "app_packaging_format_not_supported": "Diese App kann nicht installiert werden da das Paketformat nicht von der YunoHost-Version unterstützt wird. Denken Sie darüber nach das System zu aktualisieren.", + "certmanager_domain_not_diagnosed_yet": "Für die Domain {domain} gibt es noch keine Diagnose-Resultate. Bitte widerhole die Diagnose für die Kategorien 'DNS records' und 'Web' im Diagnose-Bereich um zu überprüfen ob die Domain für Let's Encrypt bereit ist. (Wenn du weißt was du tust, kannst du --no-checks benutzen, um diese Überprüfung zu überspringen.)", + "migration_0015_patching_sources_list": "sources.lists wird repariert...", + "migration_0015_start": "Start der Migration auf Buster", + "migration_description_0015_migrate_to_buster": "Auf Debian Buster und YunoHost 4.x upgraden", + "mail_unavailable": "Diese E-Mail Adresse ist reserviert und wird dem ersten Benutzer automatisch zugewiesen", + "diagnosis_services_conf_broken": "Die Konfiguration für den Dienst {service} ist fehlerhaft!", + "diagnosis_services_running": "Dienst {service} läuft!", + "diagnosis_domain_expires_in": "{domain} läuft in {days} Tagen ab.", + "diagnosis_domain_expiration_error": "Einige Domänen werden SEHR BALD ablaufen!", + "diagnosis_domain_expiration_success": "Ihre Domänen sind registriert und werden in nächster Zeit nicht ablaufen.", + "diagnosis_domain_not_found_details": "Die Domäne {domain} existiert nicht in der WHOIS-Datenbank oder sie ist abgelaufen!", + "diagnosis_domain_expiration_not_found": "Das Ablaufdatum einiger Domains kann nicht überprüft werden", + "diagnosis_dns_try_dyndns_update_force": "Die DNS-Konfiguration dieser Domain sollte automatisch von YunoHost verwaltet werden. Andernfalls können Sie mittels yunohost dyndns update --force ein Update erzwingen.", + "diagnosis_dns_point_to_doc": "Bitte schauen Sie in die Dokumentation unter https://yunohost.org/dns_config wenn Sie Hilfe bei der Konfiguration der DNS-Einträge brauchen.", + "diagnosis_dns_discrepancy": "Der folgende DNS-Eintrag scheint nicht den empfohlenen Einstellungen zu entsprechen:
Typ: {type}
Name: {name}
Aktueller Wert: {current}
Erwarteter Wert: {value}", + "diagnosis_dns_missing_record": "Gemäß der empfohlenen DNS-Konfiguration sollten Sie einen DNS-Eintrag mit den folgenden Informationen hinzufügen.
Typ: {type}
Name: {name}
Wert: {value}", + "diagnosis_dns_bad_conf": "Einige DNS-Einträge für die Domäne {domain} fehlen oder sind nicht korrekt (Kategorie {category})", + "diagnosis_ip_local": "Lokale IP: {local}", + "diagnosis_ip_global": "Globale IP: {global}", + "diagnosis_ip_no_ipv6_tip": "Die Verwendung von IPv6 ist nicht Voraussetzung für das Funktionieren Ihres Servers, trägt aber zur Gesundheit des Internet als Ganzes bei. IPv6 sollte normalerweise automatisch von Ihrem Server oder Ihrem Provider konfiguriert werden, sofern verfügbar. Andernfalls müßen Sie einige Dinge manuell konfigurieren. Weitere Informationen finden Sie hier: https://yunohost.org/#/ipv6. Wenn Sie IPv6 nicht aktivieren können oder Ihnen das zu technisch ist, können Sie diese Warnung gefahrlos ignorieren.", + "diagnosis_services_bad_status_tip": "Sie können versuchen, den Dienst neu zu starten, und wenn das nicht funktioniert, schauen Sie sich die (Dienst-)Logs in der Verwaltung an (In der Kommandozeile können Sie dies mit yunohost service restart {service} und yunohost service log {service} tun).", + "diagnosis_services_bad_status": "Der Dienst {service} ist {status} :(", + "diagnosis_diskusage_verylow": "Der Speicher {mountpoint} (auf Gerät {device}) hat nur noch {free} ({free_percent}%) freien Speicherplatz (von ingesamt {total}). Sie sollten sich ernsthaft überlegen, einigen Seicherplatz frei zu machen!", + "diagnosis_http_ok": "Die Domäne {domain} ist über HTTP von außerhalb des lokalen Netzwerks erreichbar.", + "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "Einige Hosting-Anbieter werden es Ihnen nicht gestatten, den ausgehenden Port 25 zu öffnen, da diese sich nicht um die Netzneutralität kümmern.
- Einige davon bieten als Alternative an, ein Mailserver-Relay zu verwenden, was jedoch bedeutet, dass das Relay Ihren E-Mail-Verkehr ausspionieren kann.
- Eine die Privatsphäre berücksichtigende Alternative ist die Verwendung eines VPN *mit einer dedizierten öffentlichen IP* um solche Einschränkungen zu umgehen. Schauen Sie unter https://yunohost.org/#/vpn_advantage nach.
- Sie können auch in Betracht ziehen, zu einem netzneutralitätfreundlicheren Anbieter zu wechseln", + "diagnosis_http_timeout": "Wartezeit wurde beim Versuch, von außen eine Verbindung zum Server aufzubauen, überschritten. Er scheint nicht erreichbar zu sein.
1. Die häufigste Ursache für dieses Problem ist daß der Port 80 (und 433) nicht richtig zu Ihrem Server weitergeleitet werden.
2. Sie sollten auch sicherstellen, daß der Dienst nginx läuft.
3. In komplexeren Umgebungen: Stellen Sie sicher, daß keine Firewall oder Reverse-Proxy stört.", + "service_reloaded_or_restarted": "Der Dienst '{service}' wurde erfolgreich neu geladen oder gestartet", + "service_restarted": "Der Dienst '{service}' wurde neu gestartet", + "service_regen_conf_is_deprecated": "'yunohost service regen-conf' ist veraltet! Bitte verwenden Sie stattdessen 'yunohost tools regen-conf'.", + "certmanager_warning_subdomain_dns_record": "Die Subdomäne \"{subdomain}\" löst nicht zur gleichen IP Adresse auf wie \"{domain}\". Einige Funktionen sind nicht verfügbar bis du dies behebst und die Zertifikate neu erzeugst.", + "diagnosis_ports_ok": "Port {port} ist von außen erreichbar.", + "diagnosis_ram_verylow": "Das System hat nur {available} ({available_percent}%) RAM zur Verfügung! (von insgesamt {total})", + "diagnosis_mail_outgoing_port_25_blocked_details": "Sie sollten zuerst versuchen den ausgehenden Port 25 auf Ihrer Router-Konfigurationsoberfläche oder Ihrer Hosting-Anbieter-Konfigurationsoberfläche zu öffnen. (Bei einigen Hosting-Anbieter kann es sein, daß Sie verlangen, daß man dafür ein Support-Ticket sendet).", + "diagnosis_mail_ehlo_ok": "Der SMTP-Server ist von von außen erreichbar und darum auch in der Lage E-Mails zu empfangen!", + "diagnosis_mail_ehlo_bad_answer": "Ein nicht-SMTP-Dienst antwortete auf Port 25 per IPv{ipversion}", + "diagnosis_swap_notsomuch": "Das System hat nur {total} Swap. Sie sollten sich überlegen mindestens {recommended} an Swap einzurichten, um Situationen zu verhindern, in welchen der RAM des Systems knapp wird.", + "diagnosis_swap_ok": "Das System hat {total} Swap!", + "diagnosis_swap_tip": "Wir sind Ihnen sehr dankbar dafür, daß Sie behutsam und sich bewußt sind, dass das Betreiben einer Swap-Partition auf einer SD-Karte oder einem SSD-Speicher das Risiko einer drastischen Verkürzung der Lebenserwartung dieser Platte nach sich zieht.", + "diagnosis_mail_outgoing_port_25_ok": "Der SMTP-Server ist in der Lage E-Mails zu versenden (der ausgehende Port 25 ist nicht blockiert).", + "diagnosis_mail_outgoing_port_25_blocked": "Der SMTP-Server kann keine E-Mails an andere Server senden, weil der ausgehende Port 25 per IPv{ipversion} blockiert ist. Sie können versuchen diesen in der Konfigurations-Oberfläche Ihres Internet-Anbieters (oder Hosters) zu öffnen.", + "diagnosis_mail_ehlo_unreachable": "Der SMTP-Server ist von außen nicht erreichbar per IPv{ipversion}. Er wird nicht in der Lage sein E-Mails zu empfangen.", + "diagnosis_diskusage_low": "Der Speicher {mountpoint} (auf Gerät {device}) hat nur noch {free} ({free_percent}%) freien Speicherplatz (von insgesamt {total}). Seien Sie vorsichtig.", + "diagnosis_ram_low": "Das System hat nur {available} ({available_percent}%) RAM zur Verfügung! (von insgesamt {total}). Seien Sie vorsichtig.", + "service_reload_or_restart_failed": "Der Dienst '{service}' konnte nicht erneut geladen oder gestartet werden.\n\nKürzlich erstellte Logs des Dienstes: {logs}", + "diagnosis_domain_expiration_not_found_details": "Die WHOIS-Informationen für die Domäne {domain} scheint keine Informationen über das Ablaufdatum zu enthalten?", + "diagnosis_domain_expiration_warning": "Einige Domänen werden bald ablaufen!", + "diagnosis_diskusage_ok": "Der Speicher {mountpoint} (auf Gerät {device}) hat immer noch {free} ({free_percent}%) freien Speicherplatz übrig(von insgesamt {total})!", + "diagnosis_ram_ok": "Das System hat immer noch {available} ({available_percent}%) RAM zu Verfügung von {total}.", + "diagnosis_swap_none": "Das System hat gar keinen Swap. Sie sollten sich überlegen mindestens {recommended} an Swap einzurichten, um Situationen zu verhindern, in welchen der RAM des Systems knapp wird.", + "diagnosis_mail_ehlo_unreachable_details": "Konnte keine Verbindung zu Ihrem Server auf dem Port 25 herzustellen per IPv{ipversion}. Er scheint nicht erreichbar zu sein.
1. Das häufigste Problem ist, dass der Port 25 nicht richtig zu Ihrem Server weitergeleitet ist.
2. Sie sollten auch sicherstellen, dass der Postfix-Dienst läuft.
3. In komplexeren Umgebungen: Stellen Sie sicher, daß keine Firewall oder Reverse-Proxy stört.", + "diagnosis_mail_ehlo_wrong": "Ein anderer SMTP-Server antwortet auf IPv{ipversion}. Ihr Server wird wahrscheinlich nicht in der Lage sein, E-Mails zu empfangen.", + "migration_description_0018_xtable_to_nftable": "Alte Netzwerkverkehrsregeln zum neuen nftable-System migrieren", + "service_reload_failed": "Der Dienst '{service}' konnte nicht erneut geladen werden.\n\nKürzlich erstellte Logs des Dienstes: {logs}", + "service_reloaded": "Der Dienst '{service}' wurde erneut geladen", + "service_restart_failed": "Der Dienst '{service}' konnte nicht erneut gestartet werden.\n\nKürzlich erstellte Logs des Dienstes: {logs}", + "app_manifest_install_ask_password": "Wählen Sie ein Verwaltungspasswort für diese Applikation", + "app_manifest_install_ask_domain": "Wählen Sie die Domäne, auf welcher die Applikation installiert werden soll", + "log_letsencrypt_cert_renew": "Erneuern des Let's Encrypt-Zeritifikates von '{}'", + "log_selfsigned_cert_install": "Das selbstsignierte Zertifikat auf der Domäne '{}' installieren", + "log_letsencrypt_cert_install": "Das Let’s Encrypt auf der Domäne '{}' installieren", + "diagnosis_mail_fcrdns_nok_details": "Sie sollten zuerst versuchen, in Ihrer Internet-Router-Oberfläche oder in Ihrer Hosting-Anbieter-Oberfläche den Reverse-DNS-Eintrag mit {ehlo_domain}zu konfigurieren. (Gewisse Hosting-Anbieter können dafür möglicherweise verlangen, dass Sie dafür ein Support-Ticket erstellen).", + "diagnosis_mail_fcrdns_dns_missing": "Es wurde kein Reverse-DNS-Eintrag definiert für IPv{ipversion}. Einige E-Mails könnten möglicherweise zurückgewiesen oder als Spam markiert werden.", + "diagnosis_mail_fcrdns_ok": "Ihr Reverse-DNS-Eintrag ist korrekt konfiguriert!", + "diagnosis_mail_ehlo_could_not_diagnose_details": "Fehler: {error}", + "diagnosis_mail_ehlo_could_not_diagnose": "Konnte nicht überprüfen, ob der Postfix-Mail-Server von aussen per IPv{ipversion} erreichbar ist.", + "diagnosis_mail_ehlo_wrong_details": "Die vom Remote-Diagnose-Server per IPv{ipversion} empfangene EHLO weicht von der Domäne Ihres Servers ab.
Empfangene EHLO: {wrong_ehlo}
Erwartet: {right_ehlo}
Die geläufigste Ursache für dieses Problem ist, dass der Port 25 nicht korrekt auf Ihren Server weitergeleitet wird. Sie können sich zusätzlich auch versichern, dass keine Firewall oder Reverse-Proxy interferiert.", + "diagnosis_mail_ehlo_bad_answer_details": "Das könnte daran liegen, dass anstelle Ihres Servers eine andere Maschine antwortet.", + "ask_user_domain": "Domäne, welche für die E-Mail-Adresse und den XMPP-Account des Benutzers verwendet werden soll", + "app_manifest_install_ask_is_public": "Soll diese Applikation für anonyme Benutzer:innen sichtbar sein?", + "app_manifest_install_ask_admin": "Wählen Sie einen Administrator für diese Applikation", + "app_manifest_install_ask_path": "Wählen Sie den Pfad, in welchem die Applikation installiert werden soll", + "diagnosis_mail_blacklist_listed_by": "Ihre IP-Adresse oder Domäne {item} ist auf der Blacklist auf {blacklist_name}", + "diagnosis_mail_blacklist_ok": "Die IP-Adressen und die Domänen, welche von diesem Server verwendet werden, scheinen nicht auf einer Blacklist zu sein", + "diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "Aktueller Reverse-DNS-Eintrag: {rdns_domain}
Erwarteter Wert: {ehlo_domain}", + "diagnosis_mail_fcrdns_different_from_ehlo_domain": "Der Reverse-DNS-Eintrag für IPv{ipversion} ist nicht korrekt konfiguriert. Einige E-Mails könnten abgewiesen oder als Spam markiert werden.", + "diagnosis_mail_fcrdns_nok_alternatives_6": "Einige Provider werden es Ihnen nicht erlauben, Ihren Reverse-DNS-Eintrag zu konfigurieren (oder ihre Funktionalität könnte defekt sein ...). Falls Ihr Reverse-DNS-Eintrag für IPv4 korrekt konfiguiert ist, können Sie versuchen, die Verwendung von IPv6 für das Versenden von E-Mails auszuschalten, indem Sie den Befehl yunohost settings set smtp.allow_ipv6 -v off ausführen. Bemerkung: Die Folge dieser letzten Lösung ist, dass Sie mit Servern, welche ausschliesslich über IPv6 verfügen, keine E-Mails mehr versenden oder empfangen können.", + "diagnosis_mail_fcrdns_nok_alternatives_4": "Einige Anbieter werden es dir nicht erlauben, deinen Reverse-DNS zu konfigurieren (oder deren Funktionalität ist defekt...). Falls du deswegen auf Probleme stoßen solltest, ziehe folgende Lösungen in Betracht:
- Manche ISPs stellen als Alternative die Benutzung eines Mail-Server-Relays zur Verfügung, was jedoch mit sich zieht, dass das Relay Ihren E-Mail-Verkehr ausspionieren kann.
- Eine privatsphärenfreundlichere Alternative ist die Benutzung eines VPN *mit einer dedizierten öffentlichen IP* um Einschränkungen dieser Art zu umgehen. Schaue hier nach https://yunohost.org/#/vpn_advantage
- Schließlich ist es auch möglich zu einem anderen Anbieter zu wechseln", + "diagnosis_mail_queue_unavailable_details": "Fehler: {error}", + "diagnosis_mail_queue_unavailable": "Die Anzahl der anstehenden Nachrichten in der Warteschlange kann nicht abgefragt werden", + "diagnosis_mail_queue_ok": "{nb_pending} anstehende E-Mails in der Warteschlange", + "diagnosis_mail_blacklist_reason": "Der Grund für die Blacklist ist: {reason}", + "app_argument_password_no_default": "Fehler beim Verarbeiten des Passwortarguments '{name}': Passwortargument kann aus Sicherheitsgründen keinen Standardwert enthalten", + "log_regen_conf": "Systemkonfiguration neu generieren '{}'", + "diagnosis_http_partially_unreachable": "Die Domäne {domain} scheint von aussen via HTTP per IPv{failed} nicht erreichbar zu sein, obwohl es per IPv{passed} funktioniert.", + "diagnosis_http_unreachable": "Die Domäne {domain} scheint von aussen per HTTP nicht erreichbar zu sein.", + "diagnosis_http_connection_error": "Verbindungsfehler: konnte nicht zur angeforderten Domäne verbinden, es ist sehr wahrscheinlich, dass sie nicht erreichbat ist.", + "diagnosis_http_could_not_diagnose_details": "Fehler: {error}", + "diagnosis_http_could_not_diagnose": "Konnte nicht diagnostizieren, ob die Domäne von aussen per IPv{ipversion} erreichbar ist.", + "diagnosis_ports_partially_unreachable": "Port {port} ist von aussen per IPv{failed} nicht erreichbar.", + "diagnosis_ports_unreachable": "Port {port} ist von aussen nicht erreichbar.", + "diagnosis_ports_could_not_diagnose_details": "Fehler: {error}", + "diagnosis_security_vulnerable_to_meltdown_details": "Um dieses Problem zu beheben, sollten Sie Ihr System upgraden und neustarten um den neuen Linux-Kernel zu laden (oder Ihren Server-Anbieter kontaktieren, falls das nicht funktionieren sollte). Besuchen Sie https://meltdownattack.com/ für weitere Informationen.", + "diagnosis_ports_could_not_diagnose": "Konnte nicht diagnostizieren, ob die Ports von aussen per IPv{ipversion} erreichbar sind.", + "diagnosis_description_regenconf": "Systemkonfiguration", + "diagnosis_description_mail": "E-Mail", + "diagnosis_description_web": "Web", + "diagnosis_description_systemresources": "Systemressourcen", + "diagnosis_description_services": "Dienste-Status", + "diagnosis_description_dnsrecords": "DNS-Einträge", + "diagnosis_description_ip": "Internetkonnektivität", + "diagnosis_description_basesystem": "Grundsystem", + "diagnosis_security_vulnerable_to_meltdown": "Es scheint, als ob Sie durch die kritische Meltdown-Sicherheitslücke verwundbar sind", + "diagnosis_regenconf_manually_modified": "Die Konfigurationsdatei {file} scheint manuell verändert worden zu sein.", + "diagnosis_regenconf_allgood": "Alle Konfigurationsdateien stimmen mit der empfohlenen Konfiguration überein!", + "diagnosis_package_installed_from_sury": "Einige System-Pakete sollten gedowngradet werden", + "diagnosis_ports_forwarding_tip": "Um dieses Problem zu beheben, müssen Sie höchst wahrscheinlich die Port-Weiterleitung auf Ihrem Internet-Router einrichten wie in https://yunohost.org/isp_box_config beschrieben", + "diagnosis_regenconf_manually_modified_details": "Das ist wahrscheinlich OK wenn Sie wissen, was Sie tun! YunoHost wird in Zukunft diese Datei nicht mehr automatisch updaten... Aber seien Sie bitte vorsichtig, da die zukünftigen Upgrades von YunoHost wichtige empfohlene Änderungen enthalten könnten. Falls Sie möchten, können Sie die Unterschiede mit yunohost tools regen-conf {category} --dry-run --with-diff inspizieren und mit yunohost tools regen-conf {category} --force auf das Zurücksetzen die empfohlene Konfiguration erzwingen", + "diagnosis_mail_blacklist_website": "Nachdem Sie herausgefunden haben, weshalb Sie auf die Blacklist gesetzt wurden und dies behoben haben, zögern Sie nicht, nachzufragen, ob Ihre IP-Adresse oder Ihre Domäne von auf {blacklist_website} entfernt wird", + "diagnosis_unknown_categories": "Folgende Kategorien sind unbekannt: {categories}", + "diagnosis_http_hairpinning_issue": "In Ihrem lokalen Netzwerk scheint Hairpinning nicht aktiviert zu sein.", + "diagnosis_ports_needed_by": "Diesen Port zu öffnen ist nötig, um die Funktionalität des Typs {category} (service {service}) zu gewährleisten", + "diagnosis_mail_queue_too_big": "Zu viele anstehende Nachrichten in der Warteschlange ({nb_pending} emails)", + "diagnosis_package_installed_from_sury_details": "Einige Pakete wurden unbeabsichtigterweise aus einem Drittanbieter-Repository, genannt Sury, installiert. Das YunoHost-Team hat die Strategie, um diese Pakete zu handhaben, verbessert, aber es wird erwartet, dass einige Setups, welche PHP7.3-Applikationen installiert haben und immer noch auf Strech laufen, ein paar Inkonsistenzen aufweisen. Um diese Situation zu beheben, sollten Sie versuchen, den folgenden Befehl auszuführen: {cmd_to_fix}", + "domain_cannot_add_xmpp_upload": "Eine hinzugefügte Domain darf nicht mit 'xmpp-upload.' beginnen. Dieser Name ist für das XMPP-Upload-Feature von YunoHost reserviert.", + "group_cannot_be_deleted": "Die Gruppe {group} kann nicht manuell entfernt werden.", + "group_cannot_edit_primary_group": "Die Gruppe '{group}' kann nicht manuell bearbeitet werden. Es ist die primäre Gruppe, welche dazu gedacht ist, nur einen spezifischen Benutzer zu enthalten.", + "diagnosis_processes_killed_by_oom_reaper": "Das System hat einige Prozesse beendet, weil ihm der Arbeitsspeicher ausgegangen ist. Das passiert normalerweise, wenn das System ingesamt nicht genügend Arbeitsspeicher zur Verfügung hat oder wenn ein einzelner Prozess zu viel Speicher verbraucht. Zusammenfassung der beendeten Prozesse: \n{kills_summary}", + "diagnosis_description_ports": "Offene Ports", + "additional_urls_already_added": "Zusätzliche URL '{url}' bereits hinzugefügt in der zusätzlichen URL für Berechtigung '{permission}'", + "additional_urls_already_removed": "Zusätzliche URL '{url}' bereits entfernt in der zusätzlichen URL für Berechtigung '{permission}'", + "app_label_deprecated": "Dieser Befehl ist veraltet! Bitte nutzen Sie den neuen Befehl 'yunohost user permission update' um das Applabel zu verwalten.", + "diagnosis_http_hairpinning_issue_details": "Das ist wahrscheinlich aufgrund Ihrer ISP Box / Router. Als Konsequenz können Personen von ausserhalb Ihres Netzwerkes aber nicht von innerhalb Ihres lokalen Netzwerkes (wie wahrscheinlich Sie selber?) wie gewohnt auf Ihren Server zugreifen, wenn Sie ihre Domäne oder Ihre öffentliche IP verwenden. Sie können die Situation wahrscheinlich verbessern, indem Sie ein einen Blick in https://yunohost.org/dns_local_network werfen", + "diagnosis_http_nginx_conf_not_up_to_date": "Jemand hat anscheinend die Konfiguration von Nginx manuell geändert. Diese Änderung verhindert, dass YunoHost eine Diagnose durchführen kann, wenn er via HTTP erreichbar ist.", + "diagnosis_http_bad_status_code": "Anscheinend beantwortet ein anderes Gerät als Ihr Server die Anfrage (Vielleicht ihr Internetrouter).
1. Die häufigste Ursache ist, dass Port 80 (und 443) nicht richtig auf Ihren Server weitergeleitet wird.
2. Bei komplexeren Setups: Vergewissern Sie sich, dass keine Firewall und keine Reverse-Proxy interferieren.", + "diagnosis_never_ran_yet": "Sie haben kürzlich einen neuen YunoHost-Server installiert aber es gibt davon noch keinen Diagnosereport. Sie sollten eine Diagnose anstossen. Sie können das entweder vom Webadmin aus oder in der Kommandozeile machen. In der Kommandozeile verwenden Sie dafür den Befehl 'yunohost diagnosis run'.", + "diagnosis_http_nginx_conf_not_up_to_date_details": "Um dieses Problem zu beheben, geben Sie in der Kommandozeile yunohost tools regen-conf nginx --dry-run --with-diff ein. Dieses Tool zeigt ihnen den Unterschied an. Wenn Sie damit einverstanden sind, können Sie mit yunohost tools regen-conf nginx --force die Änderungen übernehmen.", + "diagnosis_backports_in_sources_list": "Sie haben anscheinend apt (den Paketmanager) für das Backports-Repository konfiguriert. Wir raten strikte davon ab, Pakete aus dem Backports-Repository zu installieren. Diese würden wahrscheinlich zu Instabilitäten und Konflikten führen. Es sei denn, Sie wissen was Sie tun.", + "diagnosis_basesystem_hardware_model": "Das Servermodell ist {model}", + "domain_name_unknown": "Domäne '{domain}' unbekannt", + "group_user_not_in_group": "Der Benutzer {user} ist nicht in der Gruppe {group}", + "group_user_already_in_group": "Der Benutzer {user} ist bereits in der Gruppe {group}", + "group_cannot_edit_visitors": "Die Gruppe \"Besucher\" kann nicht manuell editiert werden. Sie ist eine Sondergruppe und repräsentiert anonyme Besucher", + "group_cannot_edit_all_users": "Die Gruppe \"all_users\" kann nicht manuell editiert werden. Sie ist eine Sondergruppe die dafür gedacht ist alle Benutzer in YunoHost zu halten", + "group_already_exist_on_system_but_removing_it": "Die Gruppe {group} existiert bereits in den Systemgruppen, aber YunoHost wird sie entfernen...", + "group_already_exist_on_system": "Die Gruppe {group} existiert bereits in den Systemgruppen", + "group_already_exist": "Die Gruppe {group} existiert bereits", + "global_settings_setting_smtp_relay_password": "SMTP Relay Host Passwort", + "global_settings_setting_smtp_relay_user": "SMTP Relay Benutzer Account", + "global_settings_setting_smtp_relay_port": "SMTP Relay Port", + "global_settings_setting_smtp_allow_ipv6": "Erlaube die Nutzung von IPv6 um Mails zu empfangen und zu versenden", + "global_settings_setting_pop3_enabled": "Aktiviere das POP3 Protokoll für den Mailserver", + "domain_cannot_remove_main_add_new_one": "Sie können '{domain}' nicht entfernen, weil es die Hauptdomäne und gleichzeitig Ihre einzige Domäne ist. Zuerst müssen Sie eine andere Domäne hinzufügen, indem Sie \"yunohost domain add another-domain.com>\" eingeben. Bestimmen Sie diese dann als Ihre Hauptdomain indem Sie 'yunohost domain main-domain -n ' eingeben. Nun können Sie die Domäne \"{domain}\" enfernen, indem Sie 'yunohost domain remove {domain}' eingeben.'", + "diagnosis_rootfstotalspace_critical": "Das Root-Filesystem hat noch freien Speicher von {space}. Das ist besorngiserregend! Der Speicher wird schnell aufgebraucht sein. 16 GB für das Root-Filesystem werden empfohlen.", + "diagnosis_rootfstotalspace_warning": "Das Root-Filesystem hat noch freien Speicher von {space}. Möglich, dass das in Ordnung ist. Vielleicht ist er aber auch schneller aufgebraucht. 16 GB für das Root-Filesystem werden empfohlen.", + "global_settings_setting_smtp_relay_host": "Zu verwendender SMTP-Relay-Host um E-Mails zu versenden. Er wird anstelle dieser YunoHost-Instanz verwendet. Nützlich, wenn Sie in einer der folgenden Situationen sind: Ihr ISP- oder VPS-Provider hat Ihren Port 25 geblockt, eine Ihrer residentiellen IPs ist auf DUHL gelistet, Sie können keinen Reverse-DNS konfigurieren oder dieser Server ist nicht direkt mit dem Internet verbunden und Sie möchten einen anderen verwenden, um E-Mails zu versenden.", + "global_settings_setting_backup_compress_tar_archives": "Beim Erstellen von Backups die Archive komprimieren (.tar.gz) anstelle von unkomprimierten Archiven (.tar). N.B. : Diese Option ergibt leichtere Backup-Archive, aber das initiale Backupprozedere wird länger dauern und mehr CPU brauchen.", + "log_remove_on_failed_restore": "'{}' entfernen nach einer fehlerhaften Wiederherstellung aus einem Backup-Archiv", + "log_backup_restore_app": "'{}' aus einem Backup-Archiv wiederherstellen", + "log_backup_restore_system": "System aus einem Backup-Archiv wiederherstellen", + "log_available_on_yunopaste": "Das Protokoll ist nun via {url} verfügbar", + "log_app_action_run": "Führe Aktion der Applikation '{}' aus", + "invalid_regex": "Ungültige Regex:'{regex}'", + "migration_description_0016_php70_to_php73_pools": "Migrieren der php7.0-fpm-Konfigurationsdateien zu php7.3", + "mailbox_disabled": "E-Mail für Benutzer {user} deaktiviert", + "log_tools_reboot": "Server neustarten", + "log_tools_shutdown": "Server ausschalten", + "log_tools_upgrade": "Systempakete aktualisieren", + "log_tools_postinstall": "Post-Installation des YunoHost-Servers durchführen", + "log_tools_migrations_migrate_forward": "Migrationen durchführen", + "log_domain_main_domain": "Mache '{}' zur Hauptdomäne", + "log_user_permission_reset": "Zurücksetzen der Berechtigung '{}'", + "log_user_permission_update": "Aktualisiere Zugriffe für Berechtigung '{}'", + "log_user_update": "Aktualisiere Information für Benutzer '{}'", + "log_user_group_update": "Aktualisiere Gruppe '{}'", + "log_user_group_delete": "Lösche Gruppe '{}'", + "log_user_group_create": "Erstelle Gruppe '{}'", + "log_user_delete": "Lösche Benutzer '{}'", + "log_user_create": "Füge Benutzer '{}' hinzu", + "log_permission_url": "Aktualisiere URL, die mit der Berechtigung '{}' verknüpft ist", + "log_permission_delete": "Lösche Berechtigung '{}'", + "log_permission_create": "Erstelle Berechtigung '{}'", + "log_dyndns_update": "Die IP, die mit der YunoHost-Subdomain '{}' verbunden ist, aktualisieren", + "log_dyndns_subscribe": "Für eine YunoHost-Subdomain registrieren '{}'", + "log_domain_remove": "Entfernen der Domäne '{}' aus der Systemkonfiguration", + "log_domain_add": "Hinzufügen der Domäne '{}' zur Systemkonfiguration", + "log_remove_on_failed_install": "Entfernen von '{}' nach einer fehlgeschlagenen Installation", + "migration_0015_still_on_stretch_after_main_upgrade": "Etwas ist schiefgelaufen während dem Haupt-Upgrade. Das System scheint immer noch auf Debian Stretch zu laufen", + "migration_0015_yunohost_upgrade": "Beginne YunoHost-Core-Upgrade...", + "migration_description_0019_extend_permissions_features": "Erweitern und überarbeiten des Applikationsberechtigungs-Managementsystems", + "migrating_legacy_permission_settings": "Migrieren der Legacy-Berechtigungseinstellungen...", + "migration_description_0017_postgresql_9p6_to_11": "Migrieren der Datenbanken von PostgreSQL 9.6 nach 11", + "migration_0015_main_upgrade": "Beginne Haupt-Upgrade...", + "migration_0015_not_stretch": "Die aktuelle Debian-Distribution ist nicht Stretch!", + "migration_0015_not_enough_free_space": "Der freie Speicher in /var/ ist sehr gering! Sie sollten minimal 1GB frei haben, um diese Migration durchzuführen.", + "domain_remove_confirm_apps_removal": "Wenn Sie diese Domäne löschen, werden folgende Applikationen entfernt:\n{apps}\n\nSind Sie sicher? [{answers}]", + "migration_0015_cleaning_up": "Bereinigung des Cache und der Pakete, welche nicht mehr benötigt werden...", + "migration_0017_postgresql_96_not_installed": "PostgreSQL wurde auf ihrem System nicht installiert. Nichts zu tun.", + "migration_0015_system_not_fully_up_to_date": "Ihr System ist nicht vollständig auf dem neuesten Stand. Bitte führen Sie ein reguläres Upgrade durch, bevor Sie die Migration auf Buster durchführen.", + "migration_0015_modified_files": "Bitte beachten Sie, dass die folgenden Dateien als manuell bearbeitet erkannt wurden und beim nächsten Upgrade überschrieben werden könnten: {manually_modified_files}", + "migration_0015_general_warning": "Bitte beachten Sie, dass diese Migration eine heikle Angelegenheit darstellt. Das YunoHost-Team hat alles unternommen, um sie zu testen und zu überarbeiten. Dennoch ist es möglich, dass diese Migration Teile des Systems oder Applikationen beschädigen könnte.\n\nDeshalb ist folgendes zu empfehlen:\n…- Führen Sie ein Backup aller kritischen Daten und Applikationen durch. Mehr unter https://yunohost.org/backup;\n…- Seien Sie geduldig nachdem Sie die Migration gestartet haben: Abhängig von Ihrer Internetverbindung und Ihrer Hardware kann es einige Stunden dauern, bis das Upgrade fertig ist.", + "migration_0015_problematic_apps_warning": "Bitte beachten Sie, dass folgende möglicherweise problematischen Applikationen auf Ihrer Installation erkannt wurden. Es scheint, als ob sie nicht aus dem YunoHost-Applikationskatalog installiert oder nicht als 'working' gekennzeichnet worden sind. Folglich kann nicht garantiert werden, dass sie nach dem Upgrade immer noch funktionieren: {problematic_apps}", + "migration_0015_specific_upgrade": "Start des Upgrades der Systempakete, deren Upgrade separat durchgeführt werden muss...", + "migration_0015_weak_certs": "Die folgenden Zertifikate verwenden immer noch schwache Signierungsalgorithmen und müssen aktualisiert werden um mit der nächsten Version von nginx kompatibel zu sein: {certs}", + "migrations_pending_cant_rerun": "Diese Migrationen sind immer noch anstehend und können deshalb nicht erneut durchgeführt werden: {ids}", + "migration_0019_add_new_attributes_in_ldap": "Hinzufügen neuer Attribute für die Berechtigungen in der LDAP-Datenbank", + "migrations_not_pending_cant_skip": "Diese Migrationen sind nicht anstehend und können deshalb nicht übersprungen werden: {ids}", + "migration_0018_failed_to_reset_legacy_rules": "Zurücksetzen der veralteten iptables-Regeln fehlgeschlagen: {error}", + "migration_0019_slapd_config_will_be_overwritten": "Es schaut aus, als ob Sie die slapd-Konfigurationsdatei manuell bearbeitet haben. Für diese kritische Migration muss das Update der slapd-Konfiguration erzwungen werden. Von der Originaldatei wird ein Backup gemacht in {conf_backup_folder}.", + "migrations_success_forward": "Migration {id} abgeschlossen", + "migrations_cant_reach_migration_file": "Die Migrationsdateien konnten nicht aufgerufen werden im Verzeichnis '%s'", + "migrations_dependencies_not_satisfied": "Führen Sie diese Migrationen aus: '{dependencies_id}', vor der Migration {id}.", + "migrations_failed_to_load_migration": "Konnte Migration nicht laden {id}: {error}", + "migrations_list_conflict_pending_done": "Sie können nicht '--previous' und '--done' gleichzeitig benützen.", + "migrations_already_ran": "Diese Migrationen wurden bereits durchgeführt: {ids}", + "migrations_loading_migration": "Lade Migrationen {id}...", + "migrations_migration_has_failed": "Migration {id} gescheitert mit der Ausnahme {exception}: Abbruch", + "migrations_must_provide_explicit_targets": "Sie müssen konkrete Ziele angeben, wenn Sie '--skip' oder '--force-rerun' verwenden", + "migrations_need_to_accept_disclaimer": "Um die Migration {id} durchzuführen, müssen Sie den Disclaimer akzeptieren.\n---\n{disclaimer}\n---\n Wenn Sie bestätigen, dass Sie die Migration durchführen wollen, wiederholen Sie bitte den Befehl mit der Option '--accept-disclaimer'.", + "migrations_no_migrations_to_run": "Keine Migrationen durchzuführen", + "migration_0017_postgresql_11_not_installed": "PostgreSQL 9.6 ist installiert aber nicht postgreSQL 11? Etwas komisches ist Ihrem System zugestossen :(...", + "migration_0017_not_enough_space": "Stellen Siea ausreichend Speicherplatz im Verzeichnis {path} zur Verfügung um die Migration durchzuführen.", + "migration_0018_failed_to_migrate_iptables_rules": "Migration der veralteten iptables-Regeln zu nftables fehlgeschlagen: {error}", + "migrations_exclusive_options": "'--auto', '--skip' und '--force-rerun' sind Optionen, die sich gegenseitig ausschliessen.", + "migrations_no_such_migration": "Es existiert keine Migration genannt '{id}'", + "migrations_running_forward": "Durchführen der Migrationen {id}...", + "migrations_skip_migration": "Überspringe Migrationen {id}...", + "password_too_simple_2": "Das Passwort muss mindestens 8 Zeichen lang sein und Gross- sowie Kleinbuchstaben enthalten", + "password_listed": "Dieses Passwort zählt zu den meistgenutzten Passwörtern der Welt. Bitte wähle ein anderes, einzigartigeres Passwort.", + "operation_interrupted": "Wurde die Operation manuell unterbrochen?", + "invalid_number": "Muss eine Zahl sein", + "migrations_to_be_ran_manually": "Die Migration {id} muss manuell durchgeführt werden. Bitte gehen Sie zu Werkzeuge → Migrationen auf der Webadmin-Seite oder führen Sie 'yunohost tools migrations run' aus.", + "permission_already_up_to_date": "Die Berechtigung wurde nicht aktualisiert, weil die Anfragen für Hinzufügen/Entfernen bereits mit dem aktuellen Status übereinstimmen.", + "permission_already_exist": "Berechtigung '{permission}' existiert bereits", + "permission_already_disallowed": "Für die Gruppe '{group}' wurde die Berechtigung '{permission}' deaktiviert", + "permission_already_allowed": "Die Gruppe '{group}' hat die Berechtigung '{permission}' bereits erhalten", + "pattern_password_app": "Entschuldigen Sie bitte! Passwörter dürfen folgende Zeichen nicht enthalten: {forbidden_chars}", + "pattern_email_forward": "Es muss sich um eine gültige E-Mail-Adresse handeln. Das Symbol '+' wird akzeptiert (zum Beispiel : maxmuster@beispiel.com oder maxmuster+yunohost@beispiel.com)", + "password_too_simple_4": "Das Passwort muss mindestens 12 Zeichen lang sein und Grossbuchstaben, Kleinbuchstaben, Zahlen und Sonderzeichen enthalten", + "password_too_simple_3": "Das Passwort muss mindestens 8 Zeichen lang sein und Grossbuchstaben, Kleinbuchstaben, Zahlen und Sonderzeichen enthalten", + "regenconf_file_manually_removed": "Die Konfigurationsdatei '{conf}' wurde manuell gelöscht und wird nicht erstellt", + "regenconf_file_manually_modified": "Die Konfigurationsdatei '{conf}' wurde manuell bearbeitet und wird nicht aktualisiert", + "regenconf_file_kept_back": "Die Konfigurationsdatei '{conf}' sollte von \"regen-conf\" (Kategorie {category}) gelöscht werden, wurde aber beibehalten.", + "regenconf_file_copy_failed": "Die neue Konfigurationsdatei '{new}' kann nicht nach '{conf}' kopiert werden", + "regenconf_file_backed_up": "Die Konfigurationsdatei '{conf}' wurde unter '{backup}' gespeichert", + "permission_require_account": "Berechtigung {permission} ist nur für Benutzer mit einem Konto sinnvoll und kann daher nicht für Besucher aktiviert werden.", + "permission_protected": "Die Berechtigung ist geschützt. Sie können die Besuchergruppe nicht zu dieser Berechtigung hinzufügen oder daraus entfernen.", + "permission_updated": "Berechtigung '{permission}' aktualisiert", + "permission_update_failed": "Die Berechtigung '{permission}' kann nicht aktualisiert werden : {error}", + "permission_not_found": "Berechtigung '{permission}' nicht gefunden", + "permission_deletion_failed": "Entfernung der Berechtigung nicht möglich '{permission}': {error}", + "permission_deleted": "Berechtigung '{permission}' gelöscht", + "permission_currently_allowed_for_all_users": "Diese Berechtigung wird derzeit allen Benutzern zusätzlich zu anderen Gruppen erteilt. Möglicherweise möchten Sie entweder die Berechtigung 'all_users' entfernen oder die anderen Gruppen entfernen, für die sie derzeit zulässig sind.", + "permission_creation_failed": "Berechtigungserstellung nicht möglich '{permission}' : {error}", + "permission_created": "Berechtigung '{permission}' erstellt", + "permission_cannot_remove_main": "Entfernung einer Hauptberechtigung nicht genehmigt", + "regenconf_file_updated": "Konfigurationsdatei '{conf}' aktualisiert", + "regenconf_file_removed": "Konfigurationsdatei '{conf}' entfernt", + "regenconf_file_remove_failed": "Konnte die Konfigurationsdatei '{conf}' nicht entfernen", + "postinstall_low_rootfsspace": "Das Root-Filesystem hat insgesamt weniger als 10GB freien Speicherplatz zur Verfügung, was ziemlich besorgniserregend ist! Sie werden sehr bald keinen freien Speicherplatz mehr haben! Für das Root-Filesystem werden mindestens 16GB empfohlen. Wenn Sie YunoHost trotz dieser Warnung installieren wollen, wiederholen Sie den Befehl mit --force-diskspace", + "regenconf_up_to_date": "Die Konfiguration ist bereits aktuell für die Kategorie '{category}'", + "regenconf_now_managed_by_yunohost": "Die Konfigurationsdatei '{conf}' wird jetzt von YunoHost (Kategorie {category}) verwaltet.", + "regenconf_updated": "Konfiguration aktualisiert für '{category}'", + "regenconf_pending_applying": "Wende die anstehende Konfiguration für die Kategorie {category} an...", + "regenconf_failed": "Konnte die Konfiguration für die Kategorie(n) {categories} nicht neu erstellen", + "regenconf_dry_pending_applying": "Überprüfe die anstehende Konfiguration, welche für die Kategorie {category}' aktualisiert worden wäre...", + "regenconf_would_be_updated": "Die Konfiguration wäre für die Kategorie '{category}' aktualisiert worden", + "restore_system_part_failed": "Die Systemteile '{part}' konnten nicht wiederhergestellt werden", + "restore_removing_tmp_dir_failed": "Ein altes, temporäres Directory konnte nicht entfernt werden", + "restore_not_enough_disk_space": "Nicht genug Speicher (Speicher: {free_space} B, benötigter Speicher: {needed_space} B, Sicherheitspuffer: {margin} B)", + "restore_may_be_not_enough_disk_space": "Ihr System scheint nicht genug Speicherplatz zu haben (frei: {free_space} B, benötigter Platz: {needed_space} B, Sicherheitspuffer: {margin} B)", + "restore_extracting": "Packe die benötigten Dateien aus dem Archiv aus...", + "restore_already_installed_apps": "Folgende Apps können nicht wiederhergestellt werden, weil sie schon installiert sind: {apps}", + "regex_with_only_domain": "Du kannst regex nicht als Domain verwenden, sondern nur als Pfad", + "root_password_desynchronized": "Das Admin-Passwort wurde verändert, aber das Root-Passwort ist immer noch das alte!", + "regenconf_need_to_explicitly_specify_ssh": "Die SSH-Konfiguration wurde manuell modifiziert, aber Sie müssen explizit die Kategorie 'SSH' mit --force spezifizieren, um die Änderungen tatsächlich anzuwenden.", + "migration_update_LDAP_schema": "Aktualisiere das LDAP-Schema...", + "log_backup_create": "Erstelle ein Backup-Archiv", + "diagnosis_sshd_config_inconsistent": "Es sieht aus, als ob der SSH-Port manuell geändert wurde in /etc/ssh/ssh_config. Seit YunoHost 4.2 ist eine neue globale Einstellung 'security.ssh.port' verfügbar um zu verhindern, dass die Konfiguration manuell verändert wird.", + "diagnosis_sshd_config_insecure": "Die SSH-Konfiguration wurde scheinbar manuell geändert und ist unsicher, weil sie keine 'AllowGroups'- oder 'AllowUsers' -Direktiven für die Beschränkung des Zugriffs durch autorisierte Benutzer enthält.", + "backup_create_size_estimation": "Das Archiv wird etwa {size} an Daten enthalten.", + "app_restore_script_failed": "Im Wiederherstellungsskript der Applikation ist ein Fehler aufgetreten", + "app_restore_failed": "Konnte {app} nicht wiederherstellen: {error}", + "migration_ldap_rollback_success": "System-Rollback erfolgreich.", + "migration_ldap_migration_failed_trying_to_rollback": "Migrieren war nicht möglich... Versuch, ein Rollback des Systems durchzuführen.", + "migration_ldap_backup_before_migration": "Vor der eigentlichen Migration ein Backup der LDAP-Datenbank und der Applikations-Einstellungen erstellen.", + "migration_description_0020_ssh_sftp_permissions": "Unterstützung für SSH- und SFTP-Berechtigungen hinzufügen", + "global_settings_setting_ssowat_panel_overlay_enabled": "Das SSOwat-Overlay-Panel aktivieren", + "global_settings_setting_security_ssh_port": "SSH-Port", + "diagnosis_sshd_config_inconsistent_details": "Bitte führen Sie yunohost settings set security.ssh.port -v YOUR_SSH_PORT aus, um den SSH-Port festzulegen, und prüfen Sie yunohost tools regen-conf ssh --dry-run --with-diff und yunohost tools regen-conf ssh --force um Ihre conf auf die YunoHost-Empfehlung zurückzusetzen.", + "regex_incompatible_with_tile": "/!\\ Packagers! Für Berechtigung '{permission}' ist show_tile auf 'true' gesetzt und deshalb können Sie keine regex-URL als Hauptdomäne setzen", + "permission_cant_add_to_all_users": "Die Berechtigung {permission} konnte nicht allen Benutzern gegeben werden.", + "migration_ldap_can_not_backup_before_migration": "Das System-Backup konnte nicht abgeschlossen werden, bevor die Migration fehlschlug. Fehler: {error}", + "service_description_fail2ban": "Schützt gegen Brute-Force-Angriffe und andere Angriffe aus dem Internet", + "service_description_dovecot": "Ermöglicht es E-Mail-Clients auf Konten zuzugreifen (IMAP und POP3)", + "service_description_dnsmasq": "Verarbeitet die Auflösung des Domainnamens (DNS)", + "restore_backup_too_old": "Dieses Backup kann nicht wieder hergestellt werden, weil es von einer zu alten YunoHost Version stammt.", + "service_description_slapd": "Speichert Benutzer, Domains und verbundene Informationen", + "service_description_rspamd": "Spamfilter und andere E-Mail-Merkmale", + "service_description_redis-server": "Eine spezialisierte Datenbank für den schnellen Datenzugriff, die Aufgabenwarteschlange und die Kommunikation zwischen Programmen", + "service_description_postfix": "Wird benutzt, um E-Mails zu senden und zu empfangen", + "service_description_nginx": "Stellt Daten aller Websiten auf dem Server bereit", + "service_description_mysql": "Speichert die Applikationsdaten (SQL Datenbank)", + "service_description_metronome": "XMPP Sofortnachrichtenkonten verwalten", + "service_description_yunohost-firewall": "Verwaltet offene und geschlossene Ports zur Verbindung mit Diensten", + "service_description_yunohost-api": "Verwaltet die Interaktionen zwischen der Weboberfläche von YunoHost und dem System", + "service_description_ssh": "Ermöglicht die Verbindung zu Ihrem Server über ein Terminal (SSH-Protokoll)", + "service_description_php7.3-fpm": "Führt in PHP geschriebene Apps mit NGINX aus", + "server_reboot_confirm": "Der Server wird sofort heruntergefahren, sind Sie sicher? [{answers}]", + "server_reboot": "Der Server wird neu gestartet", + "server_shutdown_confirm": "Der Server wird sofort heruntergefahren, sind Sie sicher? [{answers}]", + "server_shutdown": "Der Server wird heruntergefahren", + "root_password_replaced_by_admin_password": "Ihr Root Passwort wurde durch Ihr Admin Passwort ersetzt.", + "show_tile_cant_be_enabled_for_regex": "Momentan können Sie 'show_tile' nicht aktivieren, weil die URL für die Berechtigung '{permission}' ein regulärer Ausdruck ist", + "show_tile_cant_be_enabled_for_url_not_defined": "Momentan können Sie 'show_tile' nicht aktivieren, weil Sie zuerst eine URL für die Berechtigung '{permission}' definieren müssen", + "tools_upgrade_regular_packages_failed": "Konnte für die folgenden Pakete das Upgrade nicht durchführen: {packages_list}", + "tools_upgrade_regular_packages": "Momentan werden Upgrades für das System (YunoHost-unabhängige) Pakete durchgeführt...", + "tools_upgrade_cant_unhold_critical_packages": "Konnte für die kritischen Pakete das Flag 'hold' nicht aufheben...", + "tools_upgrade_cant_hold_critical_packages": "Konnte für die kritischen Pakete das Flag 'hold' nicht setzen...", + "tools_upgrade_cant_both": "Kann das Upgrade für das System und die Applikation nicht gleichzeitig durchführen", + "tools_upgrade_at_least_one": "Bitte geben Sie '--apps' oder '--system' an", + "this_action_broke_dpkg": "Diese Aktion hat unkonfigurierte Pakete verursacht, welche durch dpkg/apt (die Paketverwaltungen dieses Systems) zurückgelassen wurden... Sie können versuchen dieses Problem zu lösen, indem Sie 'sudo apt install --fix-broken' und/oder 'sudo dpkg --configure -a' ausführen.", + "update_apt_cache_failed": "Kann den Cache von APT (Debians Paketmanager) nicht aktualisieren. Hier ist ein Auszug aus den sources.list-Zeilen, die helfen könnten, das Problem zu identifizieren:\n{sourceslist}", + "tools_upgrade_special_packages_completed": "YunoHost-Paketupdate beendet.\nDrücke [Enter], um zurück zur Kommandoziele zu kommen", + "tools_upgrade_special_packages_explanation": "Das Upgrade \"special\" wird im Hintergrund ausgeführt. Bitte starten Sie keine anderen Aktionen auf Ihrem Server für die nächsten ~10 Minuten. Die Dauer ist abhängig von der Geschwindigkeit Ihres Servers. Nach dem Upgrade müssen Sie sich eventuell erneut in das Adminportal einloggen. Upgrade-Logs sind im Adminbereich unter Tools → Log verfügbar. Alternativ können Sie in der Befehlszeile 'yunohost log list' eingeben.", + "tools_upgrade_special_packages": "\"special\" (YunoHost-bezogene) Pakete werden jetzt aktualisiert...", + "unknown_main_domain_path": "Unbekannte:r Domain oder Pfad für '{app}'. Du musst eine Domain und einen Pfad setzen, um die URL für Berechtigungen zu setzen.", + "yunohost_postinstall_end_tip": "Post-install ist fertig! Um das Setup abzuschliessen, wird empfohlen:\n - einen ersten Benutzer über den Bereich 'Benutzer*in' im Adminbereich hinzuzufügen (oder mit 'yunohost user create ' in der Kommandezeile);\n - mögliche Fehler zu diagnostizieren über den Bereich 'Diagnose' im Adminbereich (oder mit 'yunohost diagnosis run' in der Kommandozeile;\n - Die Abschnitte 'Install YunoHost' und 'Geführte Tour' im Administratorenhandbuch zu lesen: https://yunohost.org/admindoc.", + "user_already_exists": "Der Benutzer '{user}' ist bereits vorhanden", + "update_apt_cache_warning": "Beim Versuch den Cache für APT (Debians Paketmanager) zu aktualisieren, ist etwas schief gelaufen. Hier ist ein Dump der Zeilen aus sources.list, die Ihnen vielleicht dabei helfen, das Problem zu identifizieren:\n{sourceslist}", + "global_settings_setting_security_webadmin_allowlist": "IP-Adressen, die auf die Verwaltungsseite zugreifen dürfen. Kommasepariert.", + "global_settings_setting_security_webadmin_allowlist_enabled": "Erlaube nur bestimmten IP-Adressen den Zugriff auf die Verwaltungsseite.", + "disk_space_not_sufficient_update": "Es ist nicht genügend Speicherplatz frei, um diese Applikation zu aktuallisieren", + "disk_space_not_sufficient_install": "Es ist nicht genügend Speicherplatz frei, um diese Applikation zu installieren", + "danger": "Warnung:" +} \ No newline at end of file diff --git a/locales/el.json b/locales/el.json index 0967ef424..a85bd0710 100644 --- a/locales/el.json +++ b/locales/el.json @@ -1 +1,4 @@ -{} +{ + "password_too_simple_1": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 8 χαρακτήρες", + "aborting": "Ματαίωση." +} \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index b45739149..cf24cfc09 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,584 +1,708 @@ { "aborting": "Aborting.", - "action_invalid": "Invalid action '{action:s}'", + "action_invalid": "Invalid action '{action}'", + "additional_urls_already_added": "Additionnal URL '{url}' already added in the additional URL for permission '{permission}'", + "additional_urls_already_removed": "Additionnal URL '{url}' already removed in the additional URL for permission '{permission}'", "admin_password": "Administration password", "admin_password_change_failed": "Unable to change password", - "admin_password_changed": "The administration password has been changed", + "admin_password_changed": "The administration password was changed", "admin_password_too_long": "Please choose a password shorter than 127 characters", - "already_up_to_date": "Nothing to do! Everything is already up to date!", - "app_action_cannot_be_ran_because_required_services_down": "This app requires some services which are currently down. Before continuing, you should try to restart the following services (and possibly investigate why they are down) : {services}", - "app_already_installed": "{app:s} is already installed", - "app_already_installed_cant_change_url": "This app is already installed. The url cannot be changed just by this function. Look into `app changeurl` if it's available.", - "app_already_up_to_date": "{app:s} is already up to date", - "app_argument_choice_invalid": "Invalid choice for argument '{name:s}', it must be one of {choices:s}", - "app_argument_invalid": "Invalid value for argument '{name:s}': {error:s}", - "app_argument_required": "Argument '{name:s}' is required", - "app_change_no_change_url_script": "The application {app_name:s} doesn't support changing it's URL yet, you might need to upgrade it.", - "app_change_url_failed_nginx_reload": "Failed to reload nginx. Here is the output of 'nginx -t':\n{nginx_errors:s}", - "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_extraction_failed": "Unable to extract installation files", - "app_id_invalid": "Invalid app id", - "app_incompatible": "The app {app} is incompatible with your YunoHost version", - "app_install_files_invalid": "Invalid installation files", - "app_location_already_used": "The app '{app}' is already installed on that location ({path})", - "app_make_default_location_already_used": "Can't make the app '{app}' the default on the domain {domain} is already used by the other app '{other_app}'", - "app_location_install_failed": "Unable to install the app in this location because it conflit with the app '{other_app}' already installed on '{other_path}'", - "app_location_unavailable": "This url is not available or conflicts with the already installed app(s):\n{apps:s}", - "app_manifest_invalid": "Invalid app manifest: {error}", - "app_no_upgrade": "No apps to upgrade", - "app_not_upgraded": "The following apps were not upgraded: {apps}", - "app_not_correctly_installed": "{app:s} seems to be incorrectly installed", - "app_not_installed": "The application '{app:s}' is not installed. Here is the list of all installed apps: {all_apps}", - "app_not_properly_removed": "{app:s} has not been properly removed", - "app_package_need_update": "The app {app} package needs to be updated to follow YunoHost changes", - "app_removed": "{app:s} has been removed", - "app_requirements_checking": "Checking required packages for {app}…", - "app_requirements_failed": "Unable to meet requirements for {app}: {error}", + "already_up_to_date": "Nothing to do. Everything is already up-to-date.", + "app_action_broke_system": "This action seems to have broken these important services: {services}", + "app_action_cannot_be_ran_because_required_services_down": "These required services should be running to run this action: {services}. Try restarting them to continue (and possibly investigate why they are down).", + "app_already_installed": "{app} is already installed", + "app_already_installed_cant_change_url": "This app is already installed. The URL cannot be changed just by this function. Check in `app changeurl` if it's available.", + "app_already_up_to_date": "{app} is already up-to-date", + "app_argument_choice_invalid": "Pick a valid value for argument '{name}': '{value}' is not among the available choices ({choices})", + "app_argument_invalid": "Pick a valid value for the argument '{name}': {error}", + "app_argument_password_no_default": "Error while parsing password argument '{name}': password argument can't have a default value for security reason", + "app_argument_required": "Argument '{name}' is required", + "app_change_url_identical_domains": "The old and new domain/url_path are identical ('{domain}{path}'), nothing to do.", + "app_change_url_no_script": "The app '{app_name}' doesn't support URL modification yet. Maybe you should upgrade it.", + "app_change_url_success": "{app} URL is now {domain}{path}", + "app_config_unable_to_apply": "Failed to apply config panel values.", + "app_config_unable_to_read": "Failed to read config panel values.", + "app_extraction_failed": "Could not extract the installation files", + "app_full_domain_unavailable": "Sorry, this app must be installed on a domain of its own, but other apps are already installed on the domain '{domain}'. You could use a subdomain dedicated to this app instead.", + "app_id_invalid": "Invalid app ID", + "app_install_failed": "Unable to install {app}: {error}", + "app_install_files_invalid": "These files cannot be installed", + "app_install_script_failed": "An error occurred inside the app installation script", + "app_label_deprecated": "This command is deprecated! Please use the new command 'yunohost user permission update' to manage the app label.", + "app_location_unavailable": "This URL is either unavailable, or conflicts with the already installed app(s):\n{apps}", + "app_make_default_location_already_used": "Unable to make '{app}' the default app on the domain, '{domain}' is already in use by '{other_app}'", + "app_manifest_install_ask_admin": "Choose an administrator user for this app", + "app_manifest_install_ask_domain": "Choose the domain where this app should be installed", + "app_manifest_install_ask_is_public": "Should this app be exposed to anonymous visitors?", + "app_manifest_install_ask_password": "Choose an administration password for this app", + "app_manifest_install_ask_path": "Choose the URL path (after the domain) where this app should be installed", + "app_manifest_invalid": "Something is wrong with the app manifest: {error}", + "app_not_correctly_installed": "{app} seems to be incorrectly installed", + "app_not_installed": "Could not find {app} in the list of installed apps: {all_apps}", + "app_not_properly_removed": "{app} has not been properly removed", + "app_not_upgraded": "The app '{failed_app}' failed to upgrade, and as a consequence the following apps' upgrades have been cancelled: {apps}", + "app_packaging_format_not_supported": "This app cannot be installed because its packaging format is not supported by your YunoHost version. You should probably consider upgrading your system.", + "app_remove_after_failed_install": "Removing the app following the installation failure...", + "app_removed": "{app} uninstalled", + "app_requirements_checking": "Checking required packages for {app}...", "app_requirements_unmeet": "Requirements are not met for {app}, the package {pkgname} ({version}) must be {spec}", - "app_sources_fetch_failed": "Unable to fetch sources files, is the url correct?", - "app_start_install": "Installing application {app}…", - "app_start_remove": "Removing application {app}…", - "app_start_backup": "Collecting files to be backuped for {app}…", - "app_start_restore": "Restoring application {app}…", + "app_restore_failed": "Could not restore {app}: {error}", + "app_restore_script_failed": "An error occured inside the app restore script", + "app_sources_fetch_failed": "Could not fetch sources files, is the URL correct?", + "app_start_backup": "Collecting files to be backed up for {app}...", + "app_start_install": "Installing {app}...", + "app_start_remove": "Removing {app}...", + "app_start_restore": "Restoring {app}...", "app_unknown": "Unknown app", "app_unsupported_remote_type": "Unsupported remote type used for the app", - "app_upgrade_several_apps": "The following apps will be upgraded : {apps}", - "app_upgrade_app_name": "Now upgrading app {app}…", - "app_upgrade_failed": "Unable to upgrade {app:s}", - "app_upgrade_some_app_failed": "Unable to upgrade some applications", - "app_upgraded": "{app:s} has been upgraded", - "apps_permission_not_found": "No permission found for the installed apps", - "apps_permission_restoration_failed": "Permission '{permission:s}' for app {app:s} restoration has failed", - "appslist_corrupted_json": "Could not load the application lists. It looks like {filename:s} is corrupted.", - "appslist_could_not_migrate": "Could not migrate app list {appslist:s}! Unable to parse the url… The old cron job has been kept in {bkp_file:s}.", - "appslist_fetched": "The application list {appslist:s} has been fetched", - "appslist_migrating": "Migrating application list {appslist:s}…", - "appslist_name_already_tracked": "There is already a registered application list with name {name:s}.", - "appslist_removed": "The application list {appslist:s} has been removed", - "appslist_retrieve_bad_format": "Retrieved file for application list {appslist:s} is not valid", - "appslist_retrieve_error": "Unable to retrieve the remote application list {appslist:s}: {error:s}", - "appslist_unknown": "Application list {appslist:s} unknown.", - "appslist_url_already_tracked": "There is already a registered application list with url {url:s}.", - "ask_current_admin_password": "Current administration password", - "ask_email": "Email address", + "app_upgrade_app_name": "Now upgrading {app}...", + "app_upgrade_failed": "Could not upgrade {app}: {error}", + "app_upgrade_script_failed": "An error occurred inside the app upgrade script", + "app_upgrade_several_apps": "The following apps will be upgraded: {apps}", + "app_upgrade_some_app_failed": "Some apps could not be upgraded", + "app_upgraded": "{app} upgraded", + "apps_already_up_to_date": "All apps are already up-to-date", + "apps_catalog_failed_to_download": "Unable to download the {apps_catalog} app catalog: {error}", + "apps_catalog_init_success": "App catalog system initialized!", + "apps_catalog_obsolete_cache": "The app catalog cache is empty or obsolete.", + "apps_catalog_update_success": "The application catalog has been updated!", + "apps_catalog_updating": "Updating application catalog...", "ask_firstname": "First name", "ask_lastname": "Last name", - "ask_list_to_remove": "List to remove", "ask_main_domain": "Main domain", "ask_new_admin_password": "New administration password", "ask_new_domain": "New domain", "ask_new_path": "New path", "ask_password": "Password", - "ask_path": "Path", - "backup_abstract_method": "This backup method hasn't yet been implemented", - "backup_action_required": "You must specify something to save", - "backup_actually_backuping": "Now creating a backup archive from the files collected…", - "backup_app_failed": "Unable to back up the app '{app:s}'", - "backup_applying_method_borg": "Sending all files to backup into borg-backup repository…", - "backup_applying_method_copy": "Copying all files to backup…", - "backup_applying_method_custom": "Calling the custom backup method '{method:s}'…", - "backup_applying_method_tar": "Creating the backup tar archive…", - "backup_archive_app_not_found": "App '{app:s}' not found in the backup archive", - "backup_archive_broken_link": "Unable to access backup archive (broken link to {path:s})", - "backup_archive_mount_failed": "Mounting the backup archive failed", - "backup_archive_name_exists": "The backup's archive name already exists", - "backup_archive_name_unknown": "Unknown local backup archive named '{name:s}'", - "backup_archive_open_failed": "Unable to open the backup archive", - "backup_archive_system_part_not_available": "System part '{part:s}' not available in this backup", - "backup_archive_writing_error": "Unable to add files '{source:s}' (named in the archive: '{dest:s}') to backup into the compressed archive '{archive:s}'", - "backup_ask_for_copying_if_needed": "Some files couldn't be prepared to be backuped using the method that avoid to temporarily waste space on the system. To perform the backup, {size:s}MB should be used temporarily. Do you agree?", - "backup_borg_not_implemented": "Borg backup method is not yet implemented", - "backup_cant_mount_uncompress_archive": "Unable to mount in readonly mode the uncompress archive directory", - "backup_cleaning_failed": "Unable to clean-up the temporary backup directory", - "backup_copying_to_organize_the_archive": "Copying {size:s}MB to organize the archive", - "backup_couldnt_bind": "Couldn't bind {src:s} to {dest:s}.", + "ask_user_domain": "Domain to use for the user's email address and XMPP account", + "backup_abstract_method": "This backup method has yet to be implemented", + "backup_actually_backuping": "Creating a backup archive from the collected files...", + "backup_app_failed": "Could not back up {app}", + "backup_applying_method_copy": "Copying all files to backup...", + "backup_applying_method_custom": "Calling the custom backup method '{method}'...", + "backup_applying_method_tar": "Creating the backup TAR archive...", + "backup_archive_app_not_found": "Could not find {app} in the backup archive", + "backup_archive_broken_link": "Could not access the backup archive (broken link to {path})", + "backup_archive_cant_retrieve_info_json": "Could not load infos for archive '{archive}'... The info.json cannot be retrieved (or is not a valid json).", + "backup_archive_corrupted": "It looks like the backup archive '{archive}' is corrupted : {error}", + "backup_archive_name_exists": "A backup archive with this name already exists.", + "backup_archive_name_unknown": "Unknown local backup archive named '{name}'", + "backup_archive_open_failed": "Could not open the backup archive", + "backup_archive_system_part_not_available": "System part '{part}' unavailable in this backup", + "backup_archive_writing_error": "Could not add the files '{source}' (named in the archive '{dest}') to be backed up into the compressed archive '{archive}'", + "backup_ask_for_copying_if_needed": "Do you want to perform the backup using {size}MB temporarily? (This way is used since some files could not be prepared using a more efficient method.)", + "backup_cant_mount_uncompress_archive": "Could not mount the uncompressed archive as write protected", + "backup_cleaning_failed": "Could not clean up the temporary backup folder", + "backup_copying_to_organize_the_archive": "Copying {size}MB to organize the archive", + "backup_couldnt_bind": "Could not bind {src} to {dest}.", + "backup_create_size_estimation": "The archive will contain about {size} of data.", "backup_created": "Backup created", - "backup_creating_archive": "Creating the backup archive…", - "backup_creation_failed": "Backup creation failed", - "backup_csv_addition_failed": "Unable to add files to backup into the CSV file", - "backup_csv_creation_failed": "Unable to create the CSV file needed for future restore operations", - "backup_custom_backup_error": "Custom backup method failure on 'backup' step", - "backup_custom_mount_error": "Custom backup method failure on 'mount' step", - "backup_custom_need_mount_error": "Custom backup method failure on 'need_mount' step", - "backup_delete_error": "Unable to delete '{path:s}'", - "backup_deleted": "The backup has been deleted", - "backup_extracting_archive": "Extracting the backup archive…", - "backup_hook_unknown": "Backup hook '{hook:s}' unknown", - "backup_invalid_archive": "Invalid backup archive", - "backup_method_borg_finished": "Backup into borg finished", - "backup_method_copy_finished": "Backup copy finished", - "backup_method_custom_finished": "Custom backup method '{method:s}' finished", - "backup_method_tar_finished": "Backup tar archive created", - "backup_mount_archive_for_restore": "Preparing archive for restoration…", - "backup_no_uncompress_archive_dir": "Uncompress archive directory doesn't exist", - "backup_nothings_done": "There is nothing to save", - "backup_output_directory_forbidden": "Forbidden output directory. Backups can't be created in /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var or /home/yunohost.backup/archives sub-folders", - "backup_output_directory_not_empty": "The output directory is not empty", + "backup_creation_failed": "Could not create the backup archive", + "backup_csv_addition_failed": "Could not add files to backup into the CSV file", + "backup_csv_creation_failed": "Could not create the CSV file needed for restoration", + "backup_custom_backup_error": "Custom backup method could not get past the 'backup' step", + "backup_custom_mount_error": "Custom backup method could not get past the 'mount' step", + "backup_delete_error": "Could not delete '{path}'", + "backup_deleted": "Backup deleted", + "backup_hook_unknown": "The backup hook '{hook}' is unknown", + "backup_method_copy_finished": "Backup copy finalized", + "backup_method_custom_finished": "Custom backup method '{method}' finished", + "backup_method_tar_finished": "TAR backup archive created", + "backup_mount_archive_for_restore": "Preparing archive for restoration...", + "backup_no_uncompress_archive_dir": "There is no such uncompressed archive directory", + "backup_nothings_done": "Nothing to save", + "backup_output_directory_forbidden": "Pick a different output directory. Backups cannot be created in /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var or /home/yunohost.backup/archives sub-folders", + "backup_output_directory_not_empty": "You should pick an empty output directory", "backup_output_directory_required": "You must provide an output directory for the backup", - "backup_output_symlink_dir_broken": "You have a broken symlink instead of your archives directory '{path:s}'. You may have a specific setup to backup your data on an other filesystem, in this case you probably forgot to remount or plug your hard dirve or usb key.", - "backup_permission": "Backup permission for app {app:s}", - "backup_php5_to_php7_migration_may_fail": "Could not convert your archive to support php7, your php apps may fail to restore (reason: {error:s})", - "backup_running_hooks": "Running backup hooks…", - "backup_system_part_failed": "Unable to backup the '{part:s}' system part", - "backup_unable_to_organize_files": "Unable to organize files in the archive with the quick method", - "backup_with_no_backup_script_for_app": "App {app:s} has no backup script. Ignoring.", - "backup_with_no_restore_script_for_app": "App {app:s} has no restore script, you won't be able to automatically restore the backup of this app.", - "certmanager_acme_not_configured_for_domain": "Certificate for domain {domain:s} does not appear to be correctly installed. Please run cert-install for this domain first.", - "certmanager_attempt_to_renew_nonLE_cert": "The certificate for domain {domain:s} is not issued by Let's Encrypt. Cannot renew it automatically!", - "certmanager_attempt_to_renew_valid_cert": "The certificate for domain {domain:s} is not about to expire! (You may use --force if you know what you're doing)", - "certmanager_attempt_to_replace_valid_cert": "You are attempting to overwrite a good and valid certificate for domain {domain:s}! (Use --force to bypass)", - "certmanager_cannot_read_cert": "Something wrong happened when trying to open current certificate for domain {domain:s} (file: {file:s}), reason: {reason:s}", - "certmanager_cert_install_success": "Successfully installed Let's Encrypt certificate for domain {domain:s}!", - "certmanager_cert_install_success_selfsigned": "Successfully installed a self-signed certificate for domain {domain:s}!", - "certmanager_cert_renew_success": "Successfully renewed Let's Encrypt certificate for domain {domain:s}!", - "certmanager_cert_signing_failed": "Signing the new certificate failed", - "certmanager_certificate_fetching_or_enabling_failed": "Sounds like enabling the new certificate for {domain:s} failed somehow…", - "certmanager_conflicting_nginx_file": "Unable to prepare domain for ACME challenge: the nginx configuration file {filepath:s} is conflicting and should be removed first", - "certmanager_couldnt_fetch_intermediate_cert": "Timed out when trying to fetch intermediate certificate from Let's Encrypt. Certificate installation/renewal aborted - please try again later.", - "certmanager_domain_cert_not_selfsigned": "The certificate for domain {domain:s} is not self-signed. Are you sure you want to replace it? (Use --force)", - "certmanager_domain_dns_ip_differs_from_public_ip": "The DNS 'A' record for domain {domain:s} is different from this server IP. If you recently modified your A record, please wait for it to propagate (some DNS propagation checkers are available online). (If you know what you are doing, use --no-checks to disable those checks.)", - "certmanager_domain_http_not_working": "It seems that the domain {domain:s} cannot be accessed through HTTP. Please check your DNS and nginx configuration is okay", - "certmanager_domain_not_resolved_locally": "The domain {domain:s} cannot be resolved from inside your Yunohost server. This might happen if you recently modified your DNS record. If so, please wait a few hours for it to propagate. If the issue persists, consider adding {domain:s} to /etc/hosts. (If you know what you are doing, use --no-checks to disable those checks.)", - "certmanager_domain_unknown": "Unknown domain {domain:s}", - "certmanager_error_no_A_record": "No DNS 'A' record found for {domain:s}. You need to make your domain name point to your machine to be able to install a Let's Encrypt certificate! (If you know what you are doing, use --no-checks to disable those checks.)", - "certmanager_hit_rate_limit": "Too many certificates already issued for exact set of domains {domain:s} recently. Please try again later. See https://letsencrypt.org/docs/rate-limits/ for more details", - "certmanager_http_check_timeout": "Timed out when server tried to contact itself through HTTP using public IP address (domain {domain:s} with ip {ip:s}). You may be experiencing hairpinning issue or the firewall/router ahead of your server is misconfigured.", - "certmanager_no_cert_file": "Unable to read certificate file for domain {domain:s} (file: {file:s})", - "certmanager_self_ca_conf_file_not_found": "Configuration file not found for self-signing authority (file: {file:s})", - "certmanager_unable_to_parse_self_CA_name": "Unable to parse name of self-signing authority (file: {file:s})", - "confirm_app_install_warning": "Warning: this application may work but is not well-integrated in YunoHost. Some features such as single sign-on and backup/restore might not be available. Install anyway? [{answers:s}] ", - "confirm_app_install_danger": "WARNING! This application is still experimental (if not explicitly not working) and it is likely to break your system! You should probably NOT install it unless you know what you are doing. Are you willing to take that risk? [{answers:s}] ", - "confirm_app_install_thirdparty": "WARNING! Installing 3rd party applications may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing. Are you willing to take that risk? [{answers:s}] ", - "custom_app_url_required": "You must provide a URL to upgrade your custom app {app:s}", - "custom_appslist_name_required": "You must provide a name for your custom app list", - "diagnosis_debian_version_error": "Can't retrieve the Debian version: {error}", - "diagnosis_kernel_version_error": "Can't retrieve kernel version: {error}", - "diagnosis_monitor_disk_error": "Can't monitor disks: {error}", - "diagnosis_monitor_network_error": "Can't monitor network: {error}", - "diagnosis_monitor_system_error": "Can't monitor system: {error}", - "diagnosis_no_apps": "No installed application", - "dpkg_is_broken": "You cannot do this right now because dpkg/apt (the system package managers) seems to be in a broken state... You can try to solve this issue by connecting through SSH and running `sudo dpkg --configure -a`.", - "dpkg_lock_not_available": "This command can't be ran right now because another program seems to be using the lock of dpkg (the system package manager)", - "dnsmasq_isnt_installed": "dnsmasq does not seem to be installed, please run 'apt-get remove bind9 && apt-get install dnsmasq'", - "domain_cannot_remove_main": "Cannot remove main domain. Set a new main domain first", - "domain_cert_gen_failed": "Unable to generate certificate", - "domain_created": "The domain has been created", - "domain_creation_failed": "Unable to create domain", - "domain_deleted": "The domain has been deleted", - "domain_deletion_failed": "Unable to delete domain", - "domain_dns_conf_is_just_a_recommendation": "This command shows you what is the *recommended* configuration. It does not actually set up the DNS configuration for you. It is your responsability to configure your DNS zone in your registrar according to this recommendation.", - "domain_dyndns_already_subscribed": "You've already subscribed to a DynDNS domain", - "domain_dyndns_dynette_is_unreachable": "Unable to reach YunoHost dynette, either your YunoHost is not correctly connected to the internet or the dynette server is down. Error: {error}", - "domain_dyndns_invalid": "Invalid domain to use with DynDNS", + "backup_output_symlink_dir_broken": "Your archive directory '{path}' is a broken symlink. Maybe you forgot to re/mount or plug in the storage medium it points to.", + "backup_permission": "Backup permission for {app}", + "backup_running_hooks": "Running backup hooks...", + "backup_system_part_failed": "Could not backup the '{part}' system part", + "backup_unable_to_organize_files": "Could not use the quick method to organize files in the archive", + "backup_with_no_backup_script_for_app": "The app '{app}' has no backup script. Ignoring.", + "backup_with_no_restore_script_for_app": "{app} has no restoration script, you will not be able to automatically restore the backup of this app.", + "certmanager_acme_not_configured_for_domain": "The ACME challenge cannot be ran for {domain} right now because its nginx conf lacks the corresponding code snippet... Please make sure that your nginx configuration is up to date using `yunohost tools regen-conf nginx --dry-run --with-diff`.", + "certmanager_attempt_to_renew_nonLE_cert": "The certificate for the domain '{domain}' is not issued by Let's Encrypt. Cannot renew it automatically!", + "certmanager_attempt_to_renew_valid_cert": "The certificate for the domain '{domain}' is not about to expire! (You may use --force if you know what you're doing)", + "certmanager_attempt_to_replace_valid_cert": "You are attempting to overwrite a good and valid certificate for domain {domain}! (Use --force to bypass)", + "certmanager_cannot_read_cert": "Something wrong happened when trying to open current certificate for domain {domain} (file: {file}), reason: {reason}", + "certmanager_cert_install_success": "Let's Encrypt certificate now installed for the domain '{domain}'", + "certmanager_cert_install_success_selfsigned": "Self-signed certificate now installed for the domain '{domain}'", + "certmanager_cert_renew_success": "Let's Encrypt certificate renewed for the domain '{domain}'", + "certmanager_cert_signing_failed": "Could not sign the new certificate", + "certmanager_certificate_fetching_or_enabling_failed": "Trying to use the new certificate for {domain} did not work...", + "certmanager_domain_cert_not_selfsigned": "The certificate for domain {domain} is not self-signed. Are you sure you want to replace it? (Use '--force' to do so.)", + "certmanager_domain_dns_ip_differs_from_public_ip": "The DNS records for domain '{domain}' is different from this server's IP. Please check the 'DNS records' (basic) category in the diagnosis for more info. If you recently modified your A record, please wait for it to propagate (some DNS propagation checkers are available online). (If you know what you are doing, use '--no-checks' to turn off those checks.)", + "certmanager_domain_http_not_working": "Domain {domain} does not seem to be accessible through HTTP. Please check the 'Web' category in the diagnosis for more info. (If you know what you are doing, use '--no-checks' to turn off those checks.)", + "certmanager_domain_not_diagnosed_yet": "There is no diagnosis result for domain {domain} yet. Please re-run a diagnosis for categories 'DNS records' and 'Web' in the diagnosis section to check if the domain is ready for Let's Encrypt. (Or if you know what you are doing, use '--no-checks' to turn off those checks.)", + "certmanager_hit_rate_limit": "Too many certificates already issued for this exact set of domains {domain} recently. Please try again later. See https://letsencrypt.org/docs/rate-limits/ for more details", + "certmanager_no_cert_file": "Could not read the certificate file for the domain {domain} (file: {file})", + "certmanager_self_ca_conf_file_not_found": "Could not find configuration file for self-signing authority (file: {file})", + "certmanager_unable_to_parse_self_CA_name": "Could not parse name of self-signing authority (file: {file})", + "certmanager_warning_subdomain_dns_record": "Subdomain '{subdomain}' does not resolve to the same IP address as '{domain}'. Some features will not be available until you fix this and regenerate the certificate.", + "config_apply_failed": "Applying the new configuration failed: {error}", + "config_cant_set_value_on_section": "You can't set a single value on an entire config section.", + "config_forbidden_keyword": "The keyword '{keyword}' is reserved, you can't create or use a config panel with a question with this id.", + "config_no_panel": "No config panel found.", + "config_unknown_filter_key": "The filter key '{filter_key}' is incorrect.", + "config_validate_color": "Should be a valid RGB hexadecimal color", + "config_validate_date": "Should be a valid date like in the format YYYY-MM-DD", + "config_validate_email": "Should be a valid email", + "config_validate_time": "Should be a valid time like HH:MM", + "config_validate_url": "Should be a valid web URL", + "config_version_not_supported": "Config panel versions '{version}' are not supported.", + "confirm_app_install_danger": "DANGER! This app is known to be still experimental (if not explicitly not working)! You should probably NOT install it unless you know what you are doing. NO SUPPORT will be provided if this app doesn't work or breaks your system... If you are willing to take that risk anyway, type '{answers}'", + "confirm_app_install_thirdparty": "DANGER! This app is not part of YunoHost's app catalog. Installing third-party apps may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing. NO SUPPORT will be provided if this app doesn't work or breaks your system... If you are willing to take that risk anyway, type '{answers}'", + "confirm_app_install_warning": "Warning: This app may work, but is not well-integrated in YunoHost. Some features such as single sign-on and backup/restore might not be available. Install anyway? [{answers}] ", + "custom_app_url_required": "You must provide a URL to upgrade your custom app {app}", + "danger": "Danger:", + "diagnosis_apps_allgood": "All installed apps respect basic packaging practices", + "diagnosis_apps_bad_quality": "This application is currently flagged as broken on YunoHost's application catalog. This may be a temporary issue while the maintainers attempt to fix the issue. In the meantime, upgrading this app is disabled.", + "diagnosis_apps_broken": "This application is currently flagged as broken on YunoHost's application catalog. This may be a temporary issue while the maintainers attempt to fix the issue. In the meantime, upgrading this app is disabled.", + "diagnosis_apps_deprecated_practices": "This app's installed version still uses some super-old deprecated packaging practices. You should really consider upgrading it.", + "diagnosis_apps_issue": "An issue was found for app {app}", + "diagnosis_apps_not_in_app_catalog": "This application is not in YunoHost's application catalog. If it was in the past and got removed, you should consider uninstalling this app as it won't receive upgrade, and may compromise the integrity and security of your system.", + "diagnosis_apps_outdated_ynh_requirement": "This app's installed version only requires yunohost >= 2.x, which tends to indicate that it's not up to date with recommended packaging practices and helpers. You should really consider upgrading it.", + "diagnosis_backports_in_sources_list": "It looks like apt (the package manager) is configured to use the backports repository. Unless you really know what you are doing, we strongly discourage from installing packages from backports, because it's likely to create unstabilities or conflicts on your system.", + "diagnosis_basesystem_hardware": "Server hardware architecture is {virt} {arch}", + "diagnosis_basesystem_hardware_model": "Server model is {model}", + "diagnosis_basesystem_host": "Server is running Debian {debian_version}", + "diagnosis_basesystem_kernel": "Server is running Linux kernel {kernel_version}", + "diagnosis_basesystem_ynh_inconsistent_versions": "You are running inconsistent versions of the YunoHost packages... most probably because of a failed or partial upgrade.", + "diagnosis_basesystem_ynh_main_version": "Server is running YunoHost {main_version} ({repo})", + "diagnosis_basesystem_ynh_single_version": "{package} version: {version} ({repo})", + "diagnosis_cache_still_valid": "(Cache still valid for {category} diagnosis. Won't re-diagnose it yet!)", + "diagnosis_cant_run_because_of_dep": "Can't run diagnosis for {category} while there are important issues related to {dep}.", + "diagnosis_description_apps": "Applications", + "diagnosis_description_basesystem": "Base system", + "diagnosis_description_dnsrecords": "DNS records", + "diagnosis_description_ip": "Internet connectivity", + "diagnosis_description_mail": "Email", + "diagnosis_description_ports": "Ports exposure", + "diagnosis_description_regenconf": "System configurations", + "diagnosis_description_services": "Services status check", + "diagnosis_description_systemresources": "System resources", + "diagnosis_description_web": "Web", + "diagnosis_diskusage_low": "Storage {mountpoint} (on device {device}) has only {free} ({free_percent}%) space remaining (out of {total}). Be careful.", + "diagnosis_diskusage_ok": "Storage {mountpoint} (on device {device}) still has {free} ({free_percent}%) space left (out of {total})!", + "diagnosis_diskusage_verylow": "Storage {mountpoint} (on device {device}) has only {free} ({free_percent}%) space remaining (out of {total}). You should really consider cleaning up some space!", + "diagnosis_display_tip": "To see the issues found, you can go to the Diagnosis section of the webadmin, or run 'yunohost diagnosis show --issues --human-readable' from the command-line.", + "diagnosis_dns_bad_conf": "Some DNS records are missing or incorrect for domain {domain} (category {category})", + "diagnosis_dns_discrepancy": "The following DNS record does not seem to follow the recommended configuration:
Type: {type}
Name: {name}
Current value: {current}
Expected value: {value}", + "diagnosis_dns_good_conf": "DNS records are correctly configured for domain {domain} (category {category})", + "diagnosis_dns_missing_record": "According to the recommended DNS configuration, you should add a DNS record with the following info.
Type: {type}
Name: {name}
Value: {value}", + "diagnosis_dns_point_to_doc": "Please check the documentation at https://yunohost.org/dns_config if you need help about configuring DNS records.", + "diagnosis_dns_specialusedomain": "Domain {domain} is based on a special-use top-level domain (TLD) and is therefore not expected to have actual DNS records.", + "diagnosis_dns_try_dyndns_update_force": "This domain's DNS configuration should automatically be managed by YunoHost. If that's not the case, you can try to force an update using yunohost dyndns update --force.", + "diagnosis_domain_expiration_error": "Some domains will expire VERY SOON!", + "diagnosis_domain_expiration_not_found": "Unable to check the expiration date for some domains", + "diagnosis_domain_expiration_not_found_details": "The WHOIS information for domain {domain} doesn't seem to contain the information about the expiration date?", + "diagnosis_domain_expiration_success": "Your domains are registered and not going to expire anytime soon.", + "diagnosis_domain_expiration_warning": "Some domains will expire soon!", + "diagnosis_domain_expires_in": "{domain} expires in {days} days.", + "diagnosis_domain_not_found_details": "The domain {domain} doesn't exist in WHOIS database or is expired!", + "diagnosis_everything_ok": "Everything looks good for {category}!", + "diagnosis_failed": "Failed to fetch diagnosis result for category '{category}': {error}", + "diagnosis_failed_for_category": "Diagnosis failed for category '{category}': {error}", + "diagnosis_found_errors": "Found {errors} significant issue(s) related to {category}!", + "diagnosis_found_errors_and_warnings": "Found {errors} significant issue(s) (and {warnings} warning(s)) related to {category}!", + "diagnosis_found_warnings": "Found {warnings} item(s) that could be improved for {category}.", + "diagnosis_high_number_auth_failures": "There's been a suspiciously high number of authentication failures recently. You may want to make sure that fail2ban is running and is correctly configured, or use a custom port for SSH as explained in https://yunohost.org/security.", + "diagnosis_http_bad_status_code": "It looks like another machine (maybe your internet router) answered instead of your server.
1. The most common cause for this issue is that port 80 (and 443) are not correctly forwarded to your server.
2. On more complex setups: make sure that no firewall or reverse-proxy is interfering.", + "diagnosis_http_connection_error": "Connection error: could not connect to the requested domain, it's very likely unreachable.", + "diagnosis_http_could_not_diagnose": "Could not diagnose if domains are reachable from outside in IPv{ipversion}.", + "diagnosis_http_could_not_diagnose_details": "Error: {error}", + "diagnosis_http_hairpinning_issue": "Your local network does not seem to have hairpinning enabled.", + "diagnosis_http_hairpinning_issue_details": "This is probably because of your ISP box / router. As a result, people from outside your local network will be able to access your server as expected, but not people from inside the local network (like you, probably?) when using the domain name or global IP. You may be able to improve the situation by having a look at https://yunohost.org/dns_local_network", + "diagnosis_http_localdomain": "Domain {domain}, with a .local TLD, is not expected to be exposed outside the local network.", + "diagnosis_http_nginx_conf_not_up_to_date": "This domain's nginx configuration appears to have been modified manually, and prevents YunoHost from diagnosing if it's reachable on HTTP.", + "diagnosis_http_nginx_conf_not_up_to_date_details": "To fix the situation, inspect the difference with the command line using yunohost tools regen-conf nginx --dry-run --with-diff and if you're ok, apply the changes with yunohost tools regen-conf nginx --force.", + "diagnosis_http_ok": "Domain {domain} is reachable through HTTP from outside the local network.", + "diagnosis_http_partially_unreachable": "Domain {domain} appears unreachable through HTTP from outside the local network in IPv{failed}, though it works in IPv{passed}.", + "diagnosis_http_timeout": "Timed-out while trying to contact your server from outside. It appears to be unreachable.
1. The most common cause for this issue is that port 80 (and 443) are not correctly forwarded to your server.
2. You should also make sure that the service nginx is running
3. On more complex setups: make sure that no firewall or reverse-proxy is interfering.", + "diagnosis_http_unreachable": "Domain {domain} appears unreachable through HTTP from outside the local network.", + "diagnosis_ignored_issues": "(+ {nb_ignored} ignored issue(s))", + "diagnosis_ip_broken_dnsresolution": "Domain name resolution seems to be broken for some reason... Is a firewall blocking DNS requests ?", + "diagnosis_ip_broken_resolvconf": "Domain name resolution seems to be broken on your server, which seems related to /etc/resolv.conf not pointing to 127.0.0.1.", + "diagnosis_ip_connected_ipv4": "The server is connected to the Internet through IPv4!", + "diagnosis_ip_connected_ipv6": "The server is connected to the Internet through IPv6!", + "diagnosis_ip_dnsresolution_working": "Domain name resolution is working!", + "diagnosis_ip_global": "Global IP: {global}", + "diagnosis_ip_local": "Local IP: {local}", + "diagnosis_ip_no_ipv4": "The server does not have working IPv4.", + "diagnosis_ip_no_ipv6": "The server does not have working IPv6.", + "diagnosis_ip_no_ipv6_tip": "Having a working IPv6 is not mandatory for your server to work, but it is better for the health of the Internet as a whole. IPv6 should usually be automatically configured by the system or your provider if it's available. Otherwise, you might need to configure a few things manually as explained in the documentation here: https://yunohost.org/#/ipv6. If you cannot enable IPv6 or if it seems too technical for you, you can also safely ignore this warning.", + "diagnosis_ip_not_connected_at_all": "The server does not seem to be connected to the Internet at all!?", + "diagnosis_ip_weird_resolvconf": "DNS resolution seems to be working, but it looks like you're using a custom /etc/resolv.conf.", + "diagnosis_ip_weird_resolvconf_details": "The file /etc/resolv.conf should be a symlink to /etc/resolvconf/run/resolv.conf itself pointing to 127.0.0.1 (dnsmasq). If you want to manually configure DNS resolvers, please edit /etc/resolv.dnsmasq.conf.", + "diagnosis_mail_blacklist_listed_by": "Your IP or domain {item} is blacklisted on {blacklist_name}", + "diagnosis_mail_blacklist_ok": "The IPs and domains used by this server do not appear to be blacklisted", + "diagnosis_mail_blacklist_reason": "The blacklist reason is: {reason}", + "diagnosis_mail_blacklist_website": "After identifying why you are listed and fixed it, feel free to ask for your IP or domaine to be removed on {blacklist_website}", + "diagnosis_mail_ehlo_bad_answer": "A non-SMTP service answered on port 25 on IPv{ipversion}", + "diagnosis_mail_ehlo_bad_answer_details": "It could be due to an other machine answering instead of your server.", + "diagnosis_mail_ehlo_could_not_diagnose": "Could not diagnose if postfix mail server is reachable from outside in IPv{ipversion}.", + "diagnosis_mail_ehlo_could_not_diagnose_details": "Error: {error}", + "diagnosis_mail_ehlo_ok": "The SMTP mail server is reachable from the outside and therefore is able to receive emails!", + "diagnosis_mail_ehlo_unreachable": "The SMTP mail server is unreachable from the outside on IPv{ipversion}. It won't be able to receive emails.", + "diagnosis_mail_ehlo_unreachable_details": "Could not open a connection on port 25 to your server in IPv{ipversion}. It appears to be unreachable.
1. The most common cause for this issue is that port 25 is not correctly forwarded to your server.
2. You should also make sure that service postfix is running.
3. On more complex setups: make sure that no firewall or reverse-proxy is interfering.", + "diagnosis_mail_ehlo_wrong": "A different SMTP mail server answers on IPv{ipversion}. Your server will probably not be able to receive emails.", + "diagnosis_mail_ehlo_wrong_details": "The EHLO received by the remote diagnoser in IPv{ipversion} is different from your server's domain.
Received EHLO: {wrong_ehlo}
Expected: {right_ehlo}
The most common cause for this issue is that port 25 is not correctly forwarded to your server. Alternatively, make sure that no firewall or reverse-proxy is interfering.", + "diagnosis_mail_fcrdns_different_from_ehlo_domain": "The reverse DNS is not correctly configured in IPv{ipversion}. Some emails may fail to get delivered or may get flagged as spam.", + "diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "Current reverse DNS: {rdns_domain}
Expected value: {ehlo_domain}", + "diagnosis_mail_fcrdns_dns_missing": "No reverse DNS is defined in IPv{ipversion}. Some emails may fail to get delivered or may get flagged as spam.", + "diagnosis_mail_fcrdns_nok_alternatives_4": "Some providers won't let you configure your reverse DNS (or their feature might be broken...). If you are experiencing issues because of this, consider the following solutions:
- Some ISP provide the alternative of using a mail server relay though it implies that the relay will be able to spy on your email traffic.
- A privacy-friendly alternative is to use a VPN *with a dedicated public IP* to bypass this kind of limits. See https://yunohost.org/#/vpn_advantage
- Or it's possible to switch to a different provider", + "diagnosis_mail_fcrdns_nok_alternatives_6": "Some providers won't let you configure your reverse DNS (or their feature might be broken...). If your reverse DNS is correctly configured for IPv4, you can try disabling the use of IPv6 when sending emails by running yunohost settings set smtp.allow_ipv6 -v off. Note: this last solution means that you won't be able to send or receive emails from the few IPv6-only servers out there.", + "diagnosis_mail_fcrdns_nok_details": "You should first try to configure the reverse DNS with {ehlo_domain} in your internet router interface or your hosting provider interface. (Some hosting provider may require you to send them a support ticket for this).", + "diagnosis_mail_fcrdns_ok": "Your reverse DNS is correctly configured!", + "diagnosis_mail_outgoing_port_25_blocked": "The SMTP mail server cannot send emails to other servers because outgoing port 25 is blocked in IPv{ipversion}.", + "diagnosis_mail_outgoing_port_25_blocked_details": "You should first try to unblock outgoing port 25 in your internet router interface or your hosting provider interface. (Some hosting provider may require you to send them a support ticket for this).", + "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "Some providers won't let you unblock outgoing port 25 because they don't care about Net Neutrality.
- Some of them provide the alternative of using a mail server relay though it implies that the relay will be able to spy on your email traffic.
- A privacy-friendly alternative is to use a VPN *with a dedicated public IP* to bypass this kind of limits. See https://yunohost.org/#/vpn_advantage
- You can also consider switching to a more net neutrality-friendly provider", + "diagnosis_mail_outgoing_port_25_ok": "The SMTP mail server is able to send emails (outgoing port 25 is not blocked).", + "diagnosis_mail_queue_ok": "{nb_pending} pending emails in the mail queues", + "diagnosis_mail_queue_too_big": "Too many pending emails in mail queue ({nb_pending} emails)", + "diagnosis_mail_queue_unavailable": "Can not consult number of pending emails in queue", + "diagnosis_mail_queue_unavailable_details": "Error: {error}", + "diagnosis_never_ran_yet": "It looks like this server was setup recently and there's no diagnosis report to show yet. You should start by running a full diagnosis, either from the webadmin or using 'yunohost diagnosis run' from the command line.", + "diagnosis_no_cache": "No diagnosis cache yet for category '{category}'", + "diagnosis_package_installed_from_sury": "Some system packages should be downgraded", + "diagnosis_package_installed_from_sury_details": "Some packages were inadvertendly installed from a third-party repository called Sury. The YunoHost team improved the strategy that handle these packages, but it's expected that some setups that installed PHP7.3 apps while still on Stretch have some remaining inconsistencies. To fix this situation, you should try running the following command: {cmd_to_fix}", + "diagnosis_ports_could_not_diagnose": "Could not diagnose if ports are reachable from outside in IPv{ipversion}.", + "diagnosis_ports_could_not_diagnose_details": "Error: {error}", + "diagnosis_ports_forwarding_tip": "To fix this issue, you most probably need to configure port forwarding on your internet router as described in https://yunohost.org/isp_box_config", + "diagnosis_ports_needed_by": "Exposing this port is needed for {category} features (service {service})", + "diagnosis_ports_ok": "Port {port} is reachable from outside.", + "diagnosis_ports_partially_unreachable": "Port {port} is not reachable from outside in IPv{failed}.", + "diagnosis_ports_unreachable": "Port {port} is not reachable from outside.", + "diagnosis_processes_killed_by_oom_reaper": "Some processes were recently killed by the system because it ran out of memory. This is typically symptomatic of a lack of memory on the system or of a process that ate up to much memory. Summary of the processes killed:\n{kills_summary}", + "diagnosis_ram_low": "The system has {available} ({available_percent}%) RAM available (out of {total}). Be careful.", + "diagnosis_ram_ok": "The system still has {available} ({available_percent}%) RAM available out of {total}.", + "diagnosis_ram_verylow": "The system has only {available} ({available_percent}%) RAM available! (out of {total})", + "diagnosis_regenconf_allgood": "All configurations files are in line with the recommended configuration!", + "diagnosis_regenconf_manually_modified": "Configuration file {file} appears to have been manually modified.", + "diagnosis_regenconf_manually_modified_details": "This is probably OK if you know what you're doing! YunoHost will stop updating this file automatically... But beware that YunoHost upgrades could contain important recommended changes. If you want to, you can inspect the differences with yunohost tools regen-conf {category} --dry-run --with-diff and force the reset to the recommended configuration with yunohost tools regen-conf {category} --force", + "diagnosis_rootfstotalspace_critical": "The root filesystem only has a total of {space} which is quite worrisome! You will likely run out of disk space very quickly! It's recommended to have at least 16 GB for the root filesystem.", + "diagnosis_rootfstotalspace_warning": "The root filesystem only has a total of {space}. This may be okay, but be careful because ultimately you may run out of disk space quickly... It's recommended to have at least 16 GB for the root filesystem.", + "diagnosis_security_vulnerable_to_meltdown": "You appear vulnerable to the Meltdown criticial security vulnerability", + "diagnosis_security_vulnerable_to_meltdown_details": "To fix this, you should upgrade your system and reboot to load the new linux kernel (or contact your server provider if this doesn't work). See https://meltdownattack.com/ for more infos.", + "diagnosis_services_bad_status": "Service {service} is {status} :(", + "diagnosis_services_bad_status_tip": "You can try to restart the service, and if it doesn't work, have a look at the service logs in the webadmin (from the command line, you can do this with yunohost service restart {service} and yunohost service log {service}).", + "diagnosis_services_conf_broken": "Configuration is broken for service {service}!", + "diagnosis_services_running": "Service {service} is running!", + "diagnosis_sshd_config_inconsistent": "It looks like the SSH port was manually modified in /etc/ssh/sshd_config. Since YunoHost 4.2, a new global setting 'security.ssh.port' is available to avoid manually editing the configuration.", + "diagnosis_sshd_config_inconsistent_details": "Please run yunohost settings set security.ssh.port -v YOUR_SSH_PORT to define the SSH port, and check yunohost tools regen-conf ssh --dry-run --with-diff and yunohost tools regen-conf ssh --force to reset your conf to the YunoHost recommendation.", + "diagnosis_sshd_config_insecure": "The SSH configuration appears to have been manually modified, and is insecure because it contains no 'AllowGroups' or 'AllowUsers' directive to limit access to authorized users.", + "diagnosis_swap_none": "The system has no swap at all. You should consider adding at least {recommended} of swap to avoid situations where the system runs out of memory.", + "diagnosis_swap_notsomuch": "The system has only {total} swap. You should consider having at least {recommended} to avoid situations where the system runs out of memory.", + "diagnosis_swap_ok": "The system has {total} of swap!", + "diagnosis_swap_tip": "Please be careful and aware that if the server is hosting swap on an SD card or SSD storage, it may drastically reduce the life expectancy of the device`.", + "diagnosis_unknown_categories": "The following categories are unknown: {categories}", + "disk_space_not_sufficient_install": "There is not enough disk space left to install this application", + "disk_space_not_sufficient_update": "There is not enough disk space left to update this application", + "domain_cannot_add_xmpp_upload": "You cannot add domains starting with 'xmpp-upload.'. This kind of name is reserved for the XMPP upload feature integrated in YunoHost.", + "domain_cannot_remove_main": "You cannot remove '{domain}' since it's the main domain, you first need to set another domain as the main domain using 'yunohost domain main-domain -n '; here is the list of candidate domains: {other_domains}", + "domain_cannot_remove_main_add_new_one": "You cannot remove '{domain}' since it's the main domain and your only domain, you need to first add another domain using 'yunohost domain add ', then set is as the main domain using 'yunohost domain main-domain -n ' and then you can remove the domain '{domain}' using 'yunohost domain remove {domain}'.'", + "domain_cert_gen_failed": "Could not generate certificate", + "domain_created": "Domain created", + "domain_creation_failed": "Unable to create domain {domain}: {error}", + "domain_deleted": "Domain deleted", + "domain_deletion_failed": "Unable to delete domain {domain}: {error}", + "domain_dns_conf_is_just_a_recommendation": "This command shows you the *recommended* configuration. It does not actually set up the DNS configuration for you. It is your responsability to configure your DNS zone in your registrar according to this recommendation.", + "domain_dyndns_already_subscribed": "You have already subscribed to a DynDNS domain", "domain_dyndns_root_unknown": "Unknown DynDNS root domain", - "domain_exists": "Domain already exists", - "domain_hostname_failed": "Failed to set new hostname. This might cause issue later (not sure about it... it might be fine).", - "domain_uninstall_app_first": "One or more apps are installed on this domain. Please uninstall them before proceeding to domain removal", - "domain_unknown": "Unknown domain", - "domain_zone_exists": "DNS zone file already exists", - "domain_zone_not_found": "DNS zone file not found for domain {:s}", + "domain_exists": "The domain already exists", + "domain_hostname_failed": "Unable to set new hostname. This might cause an issue later (it might be fine).", + "domain_name_unknown": "Domain '{domain}' unknown", + "domain_remove_confirm_apps_removal": "Removing this domain will remove those applications:\n{apps}\n\nAre you sure you want to do that? [{answers}]", + "domain_uninstall_app_first": "Those applications are still installed on your domain:\n{apps}\n\nPlease uninstall them using 'yunohost app remove the_app_id' or move them to another domain using 'yunohost app change-url the_app_id' before proceeding to domain removal", + "domain_registrar_is_not_configured": "The registrar is not yet configured for domain {domain}.", + "domain_dns_push_not_applicable": "The automatic DNS configuration feature is not applicable to domain {domain}. You should manually configure your DNS records following the documentation at https://yunohost.org/dns_config.", + "domain_dns_push_managed_in_parent_domain": "The automatic DNS configuration feature is managed in the parent domain {parent_domain}.", + "domain_dns_registrar_managed_in_parent_domain": "This domain is a subdomain of {parent_domain_link}. DNS registrar configuration should be managed in {parent_domain}'s configuration panel.", + "domain_dns_registrar_yunohost": "This domain is a nohost.me / nohost.st / ynh.fr and its DNS configuration is therefore automatically handled by YunoHost without any further configuration. (see the 'yunohost dyndns update' command)", + "domain_dns_registrar_not_supported": "YunoHost could not automatically detect the registrar handling this domain. You should manually configure your DNS records following the documentation at https://yunohost.org/dns.", + "domain_dns_registrar_supported": "YunoHost automatically detected that this domain is handled by the registrar **{registrar}**. If you want, YunoHost will automatically configure this DNS zone, if you provide it with the appropriate API credentials. You can find documentation on how to obtain your API credentials on this page: https://yunohost.org/registar_api_{registrar}. (You can also manually configure your DNS records following the documentation at https://yunohost.org/dns )", + "domain_dns_registrar_experimental": "So far, the interface with **{registrar}**'s API has not been properly tested and reviewed by the YunoHost community. Support is **very experimental** - be careful!", + "domain_dns_push_failed_to_authenticate": "Failed to authenticate on registrar's API for domain '{domain}'. Most probably the credentials are incorrect? (Error: {error})", + "domain_dns_push_failed_to_list": "Failed to list current records using the registrar's API: {error}", + "domain_dns_push_already_up_to_date": "Records already up to date, nothing to do.", + "domain_dns_pushing": "Pushing DNS records...", + "domain_dns_push_record_failed": "Failed to {action} record {type}/{name} : {error}", + "domain_dns_push_success": "DNS records updated!", + "domain_dns_push_failed": "Updating the DNS records failed miserably.", + "domain_dns_push_partial_failure": "DNS records partially updated: some warnings/errors were reported.", + "domain_config_features_disclaimer": "So far, enabling/disabling mail or XMPP features only impact the recommended and automatic DNS configuration, not system configurations!", + "domain_config_mail_in": "Incoming emails", + "domain_config_mail_out": "Outgoing emails", + "domain_config_xmpp": "Instant messaging (XMPP)", + "domain_config_auth_token": "Authentication token", + "domain_config_auth_key": "Authentication key", + "domain_config_auth_secret": "Authentication secret", + "domain_config_api_protocol": "API protocol", + "domain_config_auth_entrypoint": "API entry point", + "domain_config_auth_application_key": "Application key", + "domain_config_auth_application_secret": "Application secret key", + "domain_config_auth_consumer_key": "Consumer key", "domains_available": "Available domains:", "done": "Done", - "downloading": "Downloading…", - "dyndns_could_not_check_provide": "Could not check if {provider:s} can provide {domain:s}.", - "dyndns_could_not_check_available": "Could not check if {domain:s} is available on {provider:s}.", - "dyndns_cron_installed": "The DynDNS cron job has been installed", - "dyndns_cron_remove_failed": "Unable to remove the DynDNS cron job", - "dyndns_cron_removed": "The DynDNS cron job has been removed", - "dyndns_ip_update_failed": "Unable to update IP address on DynDNS", - "dyndns_ip_updated": "Your IP address has been updated on DynDNS", - "dyndns_key_generating": "DNS key is being generated, it may take a while…", + "downloading": "Downloading...", + "dpkg_is_broken": "You cannot do this right now because dpkg/APT (the system package managers) seems to be in a broken state... You can try to solve this issue by connecting through SSH and running `sudo apt install --fix-broken` and/or `sudo dpkg --configure -a`.", + "dpkg_lock_not_available": "This command can't be run right now because another program seems to be using the lock of dpkg (the system package manager)", + "dyndns_could_not_check_available": "Could not check if {domain} is available on {provider}.", + "dyndns_could_not_check_provide": "Could not check if {provider} can provide {domain}.", + "dyndns_domain_not_provided": "DynDNS provider {provider} cannot provide domain {domain}.", + "dyndns_ip_update_failed": "Could not update IP address to DynDNS", + "dyndns_ip_updated": "Updated your IP on DynDNS", + "dyndns_key_generating": "Generating DNS key... It may take a while.", "dyndns_key_not_found": "DNS key not found for the domain", - "dyndns_no_domain_registered": "No domain has been registered with DynDNS", - "dyndns_registered": "The DynDNS domain has been registered", - "dyndns_registration_failed": "Unable to register DynDNS domain: {error:s}", - "dyndns_domain_not_provided": "Dyndns provider {provider:s} cannot provide domain {domain:s}.", - "dyndns_unavailable": "Domain {domain:s} is not available.", - "edit_group_not_allowed": "You are not allowed to edit the group {group:s}", - "edit_permission_with_group_all_users_not_allowed": "You are not allowed to edit permission for group 'all_users', use 'yunohost user permission clear APP' or 'yunohost user permission add APP -u USER' instead.", - "error_when_removing_sftpuser_group": "Error when trying remove sftpusers group", - "executing_command": "Executing command '{command:s}'…", - "executing_script": "Executing script '{script:s}'…", - "extracting": "Extracting…", - "experimental_feature": "Warning: this feature is experimental and not consider stable, you shouldn't be using it except if you know what you are doing.", - "field_invalid": "Invalid field '{:s}'", - "file_does_not_exist": "The file {path:s} does not exists.", - "firewall_reload_failed": "Unable to reload the firewall", - "firewall_reloaded": "The firewall has been reloaded", - "firewall_rules_cmd_failed": "Some firewall rules commands have failed. For more information, see the log.", - "format_datetime_short": "%m/%d/%Y %I:%M %p", - "global_settings_bad_choice_for_enum": "Bad choice for setting {setting:s}, received '{choice:s}' but available choices are : {available_choices:s}", - "global_settings_bad_type_for_setting": "Bad type for setting {setting:s}, received {received_type:s}, except {expected_type:s}", - "global_settings_cant_open_settings": "Failed to open settings file, reason: {reason:s}", - "global_settings_cant_serialize_settings": "Failed to serialize settings data, reason: {reason:s}", - "global_settings_cant_write_settings": "Failed to write settings file, reason: {reason:s}", - "global_settings_key_doesnt_exists": "The key '{settings_key:s}' doesn't exists in the global settings, you can see all the available keys by doing 'yunohost settings list'", - "global_settings_reset_success": "Success. Your previous settings have been backuped in {path:s}", - "global_settings_setting_example_bool": "Example boolean option", - "global_settings_setting_example_enum": "Example enum option", - "global_settings_setting_example_int": "Example int option", - "global_settings_setting_example_string": "Example string option", - "global_settings_setting_security_nginx_compatibility": "Compatibility vs. security tradeoff for the web server nginx. Affects the ciphers (and other security-related aspects)", + "dyndns_no_domain_registered": "No domain registered with DynDNS", + "dyndns_provider_unreachable": "Unable to reach DynDNS provider {provider}: either your YunoHost is not correctly connected to the internet or the dynette server is down.", + "dyndns_registered": "DynDNS domain registered", + "dyndns_registration_failed": "Could not register DynDNS domain: {error}", + "dyndns_unavailable": "The domain '{domain}' is unavailable.", + "experimental_feature": "Warning: This feature is experimental and not considered stable, you should not use it unless you know what you are doing.", + "extracting": "Extracting...", + "field_invalid": "Invalid field '{}'", + "file_does_not_exist": "The file {path} does not exist.", + "firewall_reload_failed": "Could not reload the firewall", + "firewall_reloaded": "Firewall reloaded", + "firewall_rules_cmd_failed": "Some firewall rule commands have failed. More info in log.", + "global_settings_bad_choice_for_enum": "Bad choice for setting {setting}, received '{choice}', but available choices are: {available_choices}", + "global_settings_bad_type_for_setting": "Bad type for setting {setting}, received {received_type}, expected {expected_type}", + "global_settings_cant_open_settings": "Could not open settings file, reason: {reason}", + "global_settings_cant_serialize_settings": "Could not serialize settings data, reason: {reason}", + "global_settings_cant_write_settings": "Could not save settings file, reason: {reason}", + "global_settings_key_doesnt_exists": "The key '{settings_key}' does not exist in the global settings, you can see all the available keys by running 'yunohost settings list'", + "global_settings_reset_success": "Previous settings now backed up to {path}", + "global_settings_setting_backup_compress_tar_archives": "When creating new backups, compress the archives (.tar.gz) instead of uncompressed archives (.tar). N.B. : enabling this option means create lighter backup archives, but the initial backup procedure will be significantly longer and heavy on CPU.", + "global_settings_setting_pop3_enabled": "Enable the POP3 protocol for the mail server", + "global_settings_setting_security_experimental_enabled": "Enable experimental security features (don't enable this if you don't know what you're doing!)", + "global_settings_setting_security_nginx_compatibility": "Compatibility vs. security tradeoff for the web server NGINX. Affects the ciphers (and other security-related aspects)", + "global_settings_setting_security_nginx_redirect_to_https": "Redirect HTTP requests to HTTPs by default (DO NOT TURN OFF unless you really know what you're doing!)", "global_settings_setting_security_password_admin_strength": "Admin password strength", "global_settings_setting_security_password_user_strength": "User password strength", - "global_settings_setting_security_ssh_compatibility": "Compatibility vs. security tradeoff for the SSH server. Affects the ciphers (and other security-related aspects)", "global_settings_setting_security_postfix_compatibility": "Compatibility vs. security tradeoff for the Postfix server. Affects the ciphers (and other security-related aspects)", - "global_settings_unknown_setting_from_settings_file": "Unknown key in settings: '{setting_key:s}', discarding it and save it in /etc/yunohost/settings-unknown.json", + "global_settings_setting_security_ssh_compatibility": "Compatibility vs. security tradeoff for the SSH server. Affects the ciphers (and other security-related aspects)", + "global_settings_setting_security_ssh_port": "SSH port", + "global_settings_setting_security_webadmin_allowlist": "IP adresses allowed to access the webadmin. Comma-separated.", + "global_settings_setting_security_webadmin_allowlist_enabled": "Allow only some IPs to access the webadmin.", "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Allow the use of (deprecated) DSA hostkey for the SSH daemon configuration", - "global_settings_unknown_type": "Unexpected situation, the setting {setting:s} appears to have the type {unknown_type:s} but it's not a type supported by the system.", - "good_practices_about_admin_password": "You are now about to define a new administration password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).", - "good_practices_about_user_password": "You are now about to define a new user password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).", - "group_already_allowed": "Group '{group:s}' already has permission '{permission:s}' enabled for app '{app:s}'", - "group_already_disallowed": "Group '{group:s}' already has permissions '{permission:s}' disabled for app '{app:s}'", - "group_name_already_exist": "Group {name:s} already exist", - "group_created": "Group '{group}' successfully created", - "group_creation_failed": "Group creation failed for group '{group}'", + "global_settings_setting_smtp_allow_ipv6": "Allow the use of IPv6 to receive and send mail", + "global_settings_setting_smtp_relay_host": "SMTP relay host to use in order to send mail instead of this yunohost instance. Useful if you are in one of this situation: your 25 port is blocked by your ISP or VPS provider, you have a residential IP listed on DUHL, you are not able to configure reverse DNS or this server is not directly exposed on the internet and you want use an other one to send mails.", + "global_settings_setting_smtp_relay_password": "SMTP relay host password", + "global_settings_setting_smtp_relay_port": "SMTP relay port", + "global_settings_setting_smtp_relay_user": "SMTP relay user account", + "global_settings_setting_ssowat_panel_overlay_enabled": "Enable SSOwat panel overlay", + "global_settings_unknown_setting_from_settings_file": "Unknown key in settings: '{setting_key}', discard it and save it in /etc/yunohost/settings-unknown.json", + "global_settings_unknown_type": "Unexpected situation, the setting {setting} appears to have the type {unknown_type} but it is not a type supported by the system.", + "good_practices_about_admin_password": "You are now about to define a new administration password. The password should be at least 8 characters long—though it is good practice to use a longer password (i.e. a passphrase) and/or to use a variation of characters (uppercase, lowercase, digits and special characters).", + "good_practices_about_user_password": "You are now about to define a new user password. The password should be at least 8 characters long—though it is good practice to use a longer password (i.e. a passphrase) and/or to a variation of characters (uppercase, lowercase, digits and special characters).", + "group_already_exist": "Group {group} already exists", + "group_already_exist_on_system": "Group {group} already exists in the system groups", + "group_already_exist_on_system_but_removing_it": "Group {group} already exists in the system groups, but YunoHost will remove it...", + "group_cannot_be_deleted": "The group {group} cannot be deleted manually.", + "group_cannot_edit_all_users": "The group 'all_users' cannot be edited manually. It is a special group meant to contain all users registered in YunoHost", + "group_cannot_edit_primary_group": "The group '{group}' cannot be edited manually. It is the primary group meant to contain only one specific user.", + "group_cannot_edit_visitors": "The group 'visitors' cannot be edited manually. It is a special group representing anonymous visitors", + "group_created": "Group '{group}' created", + "group_creation_failed": "Could not create the group '{group}': {error}", "group_deleted": "Group '{group}' deleted", - "group_deletion_failed": "Group '{group} 'deletion failed", - "group_deletion_not_allowed": "The group {group:s} cannot be deleted manually.", - "group_info_failed": "Group info failed", - "group_unknown": "Group {group:s} unknown", + "group_deletion_failed": "Could not delete the group '{group}': {error}", + "group_unknown": "The group '{group}' is unknown", + "group_update_failed": "Could not update the group '{group}': {error}", "group_updated": "Group '{group}' updated", - "group_update_failed": "Group update failed for group '{group}'", - "hook_exec_failed": "Script execution failed: {path:s}", - "hook_exec_not_terminated": "Script execution did not finish properly: {path:s}", - "hook_json_return_error": "Failed to read return from hook {path:s}. Error: {msg:s}. Raw content: {raw_content}", - "hook_list_by_invalid": "Invalid property to list hook by", - "hook_name_unknown": "Unknown hook name '{name:s}'", - "installation_complete": "Installation complete", - "installation_failed": "Installation failed", - "invalid_url_format": "Invalid URL format", + "group_user_already_in_group": "User {user} is already in group {group}", + "group_user_not_in_group": "User {user} is not in group {group}", + "hook_exec_failed": "Could not run script: {path}", + "hook_exec_not_terminated": "Script did not finish properly: {path}", + "hook_json_return_error": "Could not read return from hook {path}. Error: {msg}. Raw content: {raw_content}", + "hook_list_by_invalid": "This property can not be used to list hooks", + "hook_name_unknown": "Unknown hook name '{name}'", + "installation_complete": "Installation completed", + "invalid_number": "Must be a number", + "invalid_number_min": "Must be greater than {min}", + "invalid_number_max": "Must be lesser than {max}", + "invalid_password": "Invalid password", + "invalid_regex": "Invalid regex:'{regex}'", "ip6tables_unavailable": "You cannot play with ip6tables here. You are either in a container or your kernel does not support it", "iptables_unavailable": "You cannot play with iptables here. You are either in a container or your kernel does not support it", - "log_corrupted_md_file": "The yaml metadata file associated with logs is corrupted: '{md_file}\nError: {error}'", - "log_category_404": "The log category '{category}' does not exist", - "log_link_to_log": "Full log of this operation: '{desc}'", - "log_help_to_get_log": "To view the log of the operation '{desc}', use the command 'yunohost log display {name}'", - "log_link_to_failed_log": "The operation '{desc}' has failed! To get help, please provide the full log of this operation by clicking here", - "log_help_to_get_failed_log": "The operation '{desc}' has failed! To get help, please share the full log of this operation using the command 'yunohost log display {name} --share'", - "log_does_exists": "There is not operation log with the name '{log}', use 'yunohost log list to see all available operation logs'", - "log_operation_unit_unclosed_properly": "Operation unit has not been closed properly", - "log_app_addaccess": "Add access to '{}'", - "log_app_removeaccess": "Remove access to '{}'", - "log_app_clearaccess": "Remove all access to '{}'", - "log_app_fetchlist": "Add an application list", - "log_app_removelist": "Remove an application list", - "log_app_change_url": "Change the url of '{}' application", - "log_app_install": "Install '{}' application", - "log_app_remove": "Remove '{}' application", - "log_app_upgrade": "Upgrade '{}' application", - "log_app_makedefault": "Make '{}' as default application", + "ldap_server_down": "Unable to reach LDAP server", + "ldap_server_is_down_restart_it": "The LDAP service is down, attempt to restart it...", + "ldap_attribute_already_exists": "LDAP attribute '{attribute}' already exists with value '{value}'", + "log_app_action_run": "Run action of the '{}' app", + "log_app_change_url": "Change the URL of the '{}' app", + "log_app_config_set": "Apply config to the '{}' app", + "log_app_install": "Install the '{}' app", + "log_app_makedefault": "Make '{}' the default app", + "log_app_remove": "Remove the '{}' app", + "log_app_upgrade": "Upgrade the '{}' app", "log_available_on_yunopaste": "This log is now available via {url}", - "log_backup_restore_system": "Restore system from a backup archive", + "log_backup_create": "Create a backup archive", "log_backup_restore_app": "Restore '{}' from a backup archive", - "log_remove_on_failed_restore": "Remove '{}' after a failed restore from a backup archive", - "log_remove_on_failed_install": "Remove '{}' after a failed installation", + "log_backup_restore_system": "Restore system from a backup archive", + "log_corrupted_md_file": "The YAML metadata file associated with logs is damaged: '{md_file}\nError: {error}'", + "log_does_exists": "There is no operation log with the name '{log}', use 'yunohost log list' to see all available operation logs", "log_domain_add": "Add '{}' domain into system configuration", + "log_domain_config_set": "Update configuration for domain '{}'", + "log_domain_main_domain": "Make '{}' the main domain", "log_domain_remove": "Remove '{}' domain from system configuration", + "log_domain_dns_push": "Push DNS records for domain '{}'", "log_dyndns_subscribe": "Subscribe to a YunoHost subdomain '{}'", - "log_dyndns_update": "Update the ip associated with your YunoHost subdomain '{}'", - "log_letsencrypt_cert_install": "Install Let's encrypt certificate on '{}' domain", - "log_permission_add": "Add permission '{}' for app '{}'", - "log_permission_remove": "Remove permission '{}'", - "log_permission_update": "Update permission '{}' for app '{}'", - "log_selfsigned_cert_install": "Install self signed certificate on '{}' domain", - "log_letsencrypt_cert_renew": "Renew '{}' Let's encrypt certificate", + "log_dyndns_update": "Update the IP associated with your YunoHost subdomain '{}'", + "log_help_to_get_failed_log": "The operation '{desc}' could not be completed. Please share the full log of this operation using the command 'yunohost log share {name}' to get help", + "log_help_to_get_log": "To view the log of the operation '{desc}', use the command 'yunohost log show {name}'", + "log_letsencrypt_cert_install": "Install a Let's Encrypt certificate on '{}' domain", + "log_letsencrypt_cert_renew": "Renew '{}' Let's Encrypt certificate", + "log_link_to_failed_log": "Could not complete the operation '{desc}'. Please provide the full log of this operation by clicking here to get help", + "log_link_to_log": "Full log of this operation: '{desc}'", + "log_operation_unit_unclosed_properly": "Operation unit has not been closed properly", + "log_permission_create": "Create permission '{}'", + "log_permission_delete": "Delete permission '{}'", + "log_permission_url": "Update URL related to permission '{}'", "log_regen_conf": "Regenerate system configurations '{}'", + "log_remove_on_failed_install": "Remove '{}' after a failed installation", + "log_remove_on_failed_restore": "Remove '{}' after a failed restore from a backup archive", + "log_selfsigned_cert_install": "Install self-signed certificate on '{}' domain", + "log_tools_migrations_migrate_forward": "Run migrations", + "log_tools_postinstall": "Postinstall your YunoHost server", + "log_tools_reboot": "Reboot your server", + "log_tools_shutdown": "Shutdown your server", + "log_tools_upgrade": "Upgrade system packages", "log_user_create": "Add '{}' user", "log_user_delete": "Delete '{}' user", - "log_user_group_add": "Add '{}' group", + "log_user_group_create": "Create '{}' group", "log_user_group_delete": "Delete '{}' group", "log_user_group_update": "Update '{}' group", - "log_user_update": "Update information of '{}' user", - "log_user_permission_add": "Update '{}' permission", - "log_user_permission_remove": "Update '{}' permission", - "log_tools_maindomain": "Make '{}' as main domain", - "log_tools_migrations_migrate_forward": "Migrate forward", - "log_tools_migrations_migrate_backward": "Migrate backward", - "log_tools_postinstall": "Postinstall your YunoHost server", - "log_tools_upgrade": "Upgrade system packages", - "log_tools_shutdown": "Shutdown your server", - "log_tools_reboot": "Reboot your server", - "ldap_init_failed_to_create_admin": "LDAP initialization failed to create admin user", - "ldap_initialized": "LDAP has been initialized", - "license_undefined": "undefined", - "mail_alias_remove_failed": "Unable to remove mail alias '{mail:s}'", - "mail_domain_unknown": "Unknown mail address domain '{domain:s}'", - "mail_forward_remove_failed": "Unable to remove mail forward '{mail:s}'", - "mailbox_disabled": "Mailbox disabled for user {user:s}", - "mailbox_used_space_dovecot_down": "Dovecot mailbox service need to be up, if you want to get mailbox used space", - "mail_unavailable": "This email address is reserved and shall be automatically allocated to the very first user", - "maindomain_change_failed": "Unable to change the main domain", - "maindomain_changed": "The main domain has been changed", - "migrate_tsig_end": "Migration to hmac-sha512 finished", - "migrate_tsig_failed": "Migrating the dyndns domain {domain} to hmac-sha512 failed, rolling back. Error: {error_code} - {error}", - "migrate_tsig_start": "Not secure enough key algorithm detected for TSIG signature of domain '{domain}', initiating migration to the more secure one hmac-sha512", - "migrate_tsig_wait": "Let's wait 3min for the dyndns server to take the new key into account…", - "migrate_tsig_wait_2": "2min…", - "migrate_tsig_wait_3": "1min…", - "migrate_tsig_wait_4": "30 secondes…", - "migrate_tsig_not_needed": "You do not appear to use a dyndns domain, so no migration is needed!", - "migration_description_0001_change_cert_group_to_sslcert": "Change certificates group permissions from 'metronome' to 'ssl-cert'", - "migration_description_0002_migrate_to_tsig_sha256": "Improve security of dyndns TSIG by using SHA512 instead of MD5", - "migration_description_0003_migrate_to_stretch": "Upgrade the system to Debian Stretch and YunoHost 3.0", - "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_description_0009_decouple_regenconf_from_services": "Decouple the regen-conf mechanism from services", - "migration_description_0010_migrate_to_apps_json": "Remove deprecated appslists and use the new unified 'apps.json' list instead", - "migration_description_0011_setup_group_permission": "Setup user group and setup permission for apps and services", - "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…", - "migration_0003_main_upgrade": "Starting main upgrade…", - "migration_0003_fail2ban_upgrade": "Starting the fail2ban upgrade…", - "migration_0003_restoring_origin_nginx_conf": "Your file /etc/nginx/nginx.conf was edited somehow. The migration is going to reset back to its original state first… The previous file will be available as {backup_dest}.", - "migration_0003_yunohost_upgrade": "Starting the yunohost package upgrade… The migration will end, but the actual upgrade will happen right after. After the operation is complete, you might have to re-log on the webadmin.", - "migration_0003_not_jessie": "The current debian distribution is not Jessie!", - "migration_0003_system_not_fully_up_to_date": "Your system is not fully up to date. Please perform a regular upgrade before running the migration to stretch.", - "migration_0003_still_on_jessie_after_main_upgrade": "Something wrong happened during the main upgrade: system is still on Jessie!? To investigate the issue, please look at {log}:s…", - "migration_0003_general_warning": "Please note that this migration is a delicate operation. While the YunoHost team did its best to review and test it, the migration might still break parts of the system or apps.\n\nTherefore, we recommend you to:\n - Perform a backup of any critical data or app. More infos on https://yunohost.org/backup;\n - Be patient after launching the migration: depending on your internet connection and hardware, it might take up to a few hours for everything to upgrade.\n\nAdditionally, the port for SMTP, used by external email clients (like Thunderbird or K9-Mail) was changed from 465 (SSL/TLS) to 587 (STARTTLS). The old port 465 will automatically be closed and the new port 587 will be opened in the firewall. You and your users *will* have to adapt the configuration of your email clients accordingly!", - "migration_0003_problematic_apps_warning": "Please note that the following possibly problematic installed apps were detected. It looks like those were not installed from an applist or are not flagged as 'working'. Consequently, we cannot guarantee that they will still work after the upgrade: {problematic_apps}", - "migration_0003_modified_files": "Please note that the following files were found to be manually modified and might be overwritten at the end of the upgrade: {manually_modified_files}", - "migration_0005_postgresql_94_not_installed": "Postgresql was not installed on your system. Nothing to do!", - "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.", - "migration_0009_not_needed": "This migration already happened somehow ? Skipping.", - "migration_0011_backup_before_migration": "Creating a backup of LDAP database and apps settings prior to the actual migration.", - "migration_0011_can_not_backup_before_migration": "The backup of the system before the migration failed. Migration failed. Error: {error:s}", - "migration_0011_create_group": "Creating a group for each user...", - "migration_0011_done": "Migration successful. You are now able to manage groups of users.", - "migration_0011_LDAP_config_dirty": "It look like that you customized your LDAP configuration. For this migration the LDAP configuration need to be updated.\nYou need to save your actual configuration, reintialize the original configuration by the command 'yunohost tools regen-conf -f' and after retry the migration", - "migration_0011_LDAP_update_failed": "LDAP update failed. Error: {error:s}", - "migration_0011_migrate_permission": "Migrating permissions from apps settings to LDAP...", - "migration_0011_migration_failed_trying_to_rollback": "Migration failed ... trying to rollback the system.", - "migration_0011_rollback_success": "Rollback succeeded.", - "migration_0011_update_LDAP_database": "Updating LDAP database...", - "migration_0011_update_LDAP_schema": "Updating LDAP schema...", - "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", - "migrations_current_target": "Migration target is {}", - "migrations_error_failed_to_load_migration": "ERROR: failed to load migration {number} {name}", - "migrations_forward": "Migrating forward", - "migrations_list_conflict_pending_done": "You cannot use both --previous and --done at the same time.", - "migrations_loading_migration": "Loading migration {number} {name}…", - "migrations_migration_has_failed": "Migration {number} {name} has failed with exception {exception}, aborting", + "log_user_import": "Import users", + "log_user_permission_reset": "Reset permission '{}'", + "log_user_permission_update": "Update accesses for permission '{}'", + "log_user_update": "Update info for user '{}'", + "mail_alias_remove_failed": "Could not remove e-mail alias '{mail}'", + "mail_domain_unknown": "Invalid e-mail address for domain '{domain}'. Please, use a domain administrated by this server.", + "mail_forward_remove_failed": "Could not remove e-mail forwarding '{mail}'", + "mail_unavailable": "This e-mail address is reserved and shall be automatically allocated to the very first user", + "mailbox_disabled": "E-mail turned off for user {user}", + "mailbox_used_space_dovecot_down": "The Dovecot mailbox service needs to be up if you want to fetch used mailbox space", + "main_domain_change_failed": "Unable to change the main domain", + "main_domain_changed": "The main domain has been changed", + "migrating_legacy_permission_settings": "Migrating legacy permission settings...", + "migration_0015_cleaning_up": "Cleaning up cache and packages not useful anymore...", + "migration_0015_general_warning": "Please note that this migration is a delicate operation. The YunoHost team did its best to review and test it, but the migration might still break parts of the system or its apps.\n\nTherefore, it is recommended to:\n - Perform a backup of any critical data or app. More info on https://yunohost.org/backup;\n - Be patient after launching the migration: Depending on your Internet connection and hardware, it might take up to a few hours for everything to upgrade.", + "migration_0015_main_upgrade": "Starting main upgrade...", + "migration_0015_modified_files": "Please note that the following files were found to be manually modified and might be overwritten following the upgrade: {manually_modified_files}", + "migration_0015_not_enough_free_space": "Free space is pretty low in /var/! You should have at least 1GB free to run this migration.", + "migration_0015_not_stretch": "The current Debian distribution is not Stretch!", + "migration_0015_patching_sources_list": "Patching the sources.lists...", + "migration_0015_problematic_apps_warning": "Please note that the following possibly problematic installed apps were detected. It looks like those were not installed from the YunoHost app catalog, or are not flagged as 'working'. Consequently, it cannot be guaranteed that they will still work after the upgrade: {problematic_apps}", + "migration_0015_specific_upgrade": "Starting upgrade of system packages that needs to be upgrade independently...", + "migration_0015_start": "Starting migration to Buster", + "migration_0015_still_on_stretch_after_main_upgrade": "Something went wrong during the main upgrade, the system appears to still be on Debian Stretch", + "migration_0015_system_not_fully_up_to_date": "Your system is not fully up-to-date. Please perform a regular upgrade before running the migration to Buster.", + "migration_0015_weak_certs": "The following certificates were found to still use weak signature algorithms and have to be upgraded to be compatible with the next version of nginx: {certs}", + "migration_0015_yunohost_upgrade": "Starting YunoHost core upgrade...", + "migration_0017_not_enough_space": "Make sufficient space available in {path} to run the migration.", + "migration_0017_postgresql_11_not_installed": "PostgreSQL 9.6 is installed, but not postgresql 11‽ Something weird might have happened on your system :(...", + "migration_0017_postgresql_96_not_installed": "PostgreSQL was not installed on your system. Nothing to do.", + "migration_0018_failed_to_migrate_iptables_rules": "Failed to migrate legacy iptables rules to nftables: {error}", + "migration_0018_failed_to_reset_legacy_rules": "Failed to reset legacy iptables rules: {error}", + "migration_0019_add_new_attributes_in_ldap": "Add new attributes for permissions in LDAP database", + "migration_0019_slapd_config_will_be_overwritten": "It looks like you manually edited the slapd configuration. For this critical migration, YunoHost needs to force the update of the slapd configuration. The original files will be backuped in {conf_backup_folder}.", + "migration_description_0015_migrate_to_buster": "Upgrade the system to Debian Buster and YunoHost 4.x", + "migration_description_0016_php70_to_php73_pools": "Migrate php7.0-fpm 'pool' conf files to php7.3", + "migration_description_0017_postgresql_9p6_to_11": "Migrate databases from PostgreSQL 9.6 to 11", + "migration_description_0018_xtable_to_nftable": "Migrate old network traffic rules to the new nftable system", + "migration_description_0019_extend_permissions_features": "Extend/rework the app permission management system", + "migration_description_0020_ssh_sftp_permissions": "Add SSH and SFTP permissions support", + "migration_ldap_backup_before_migration": "Creating a backup of LDAP database and apps settings prior to the actual migration.", + "migration_ldap_can_not_backup_before_migration": "The backup of the system could not be completed before the migration failed. Error: {error}", + "migration_ldap_migration_failed_trying_to_rollback": "Could not migrate... trying to roll back the system.", + "migration_ldap_rollback_success": "System rolled back.", + "migration_update_LDAP_schema": "Updating LDAP schema...", + "migrations_already_ran": "Those migrations are already done: {ids}", + "migrations_cant_reach_migration_file": "Could not access migrations files at the path '%s'", + "migrations_dependencies_not_satisfied": "Run these migrations: '{dependencies_id}', before migration {id}.", + "migrations_exclusive_options": "'--auto', '--skip', and '--force-rerun' are mutually exclusive options.", + "migrations_failed_to_load_migration": "Could not load migration {id}: {error}", + "migrations_list_conflict_pending_done": "You cannot use both '--previous' and '--done' at the same time.", + "migrations_loading_migration": "Loading migration {id}...", + "migrations_migration_has_failed": "Migration {id} did not complete, aborting. Error: {exception}", + "migrations_must_provide_explicit_targets": "You must provide explicit targets when using '--skip' or '--force-rerun'", + "migrations_need_to_accept_disclaimer": "To run the migration {id}, your must accept the following disclaimer:\n---\n{disclaimer}\n---\nIf you accept to run the migration, please re-run the command with the option '--accept-disclaimer'.", "migrations_no_migrations_to_run": "No migrations to run", - "migrations_show_currently_running_migration": "Running migration {number} {name}…", - "migrations_show_last_migration": "Last ran migration is {}", - "migrations_skip_migration": "Skipping migration {number} {name}…", - "migrations_success": "Successfully ran migration {number} {name}!", - "migrations_to_be_ran_manually": "Migration {number} {name} has to be ran manually. Please go to Tools > Migrations on the webadmin, or run `yunohost tools migrations migrate`.", - "migrations_need_to_accept_disclaimer": "To run the migration {number} {name}, your must accept the following disclaimer:\n---\n{disclaimer}\n---\nIf you accept to run the migration, please re-run the command with the option --accept-disclaimer.", - "monitor_disabled": "The server monitoring has been disabled", - "monitor_enabled": "The server monitoring has been enabled", - "monitor_glances_con_failed": "Unable to connect to Glances server", - "monitor_not_enabled": "Server monitoring is not enabled", - "monitor_period_invalid": "Invalid time period", - "monitor_stats_file_not_found": "Statistics file not found", - "monitor_stats_no_update": "No monitoring statistics to update", - "monitor_stats_period_unavailable": "No available statistics for the period", - "mountpoint_unknown": "Unknown mountpoint", - "mysql_db_creation_failed": "MySQL database creation failed", - "mysql_db_init_failed": "MySQL database init failed", - "mysql_db_initialized": "The MySQL database has been initialized", - "need_define_permission_before": "You need to redefine the permission using 'yunohost user permission add -u USER' before removing an allowed group", - "network_check_mx_ko": "DNS MX record is not set", - "network_check_smtp_ko": "Outbound mail (SMTP port 25) seems to be blocked by your network", - "network_check_smtp_ok": "Outbound mail (SMTP port 25) is not blocked", - "new_domain_required": "You must provide the new main domain", - "no_appslist_found": "No app list found", - "no_internet_connection": "Server is not connected to the Internet", - "no_ipv6_connectivity": "IPv6 connectivity is not available", - "no_restore_script": "No restore script found for the app '{app:s}'", - "not_enough_disk_space": "Not enough free disk space on '{path:s}'", - "package_not_installed": "Package '{pkgname}' is not installed", - "package_unexpected_error": "An unexpected error occurred processing the package '{pkgname}'", - "package_unknown": "Unknown package '{pkgname}'", - "packages_upgrade_critical_later": "Critical packages ({packages:s}) will be upgraded later", - "packages_upgrade_failed": "Unable to upgrade all of the packages", - "password_listed": "This password is among the most used password in the world. Please choose something a bit more unique.", - "password_too_simple_1": "Password needs to be at least 8 characters long", - "password_too_simple_2": "Password needs to be at least 8 characters long and contains digit, upper and lower characters", - "password_too_simple_3": "Password needs to be at least 8 characters long and contains digit, upper, lower and special characters", - "password_too_simple_4": "Password needs to be at least 12 characters long and contains digit, upper, lower and special characters", - "path_removal_failed": "Unable to remove path {:s}", - "pattern_backup_archive_name": "Must be a valid filename with max 30 characters, and alphanumeric and -_. characters only", + "migrations_no_such_migration": "There is no migration called '{id}'", + "migrations_not_pending_cant_skip": "Those migrations are not pending, so cannot be skipped: {ids}", + "migrations_pending_cant_rerun": "Those migrations are still pending, so cannot be run again: {ids}", + "migrations_running_forward": "Running migration {id}...", + "migrations_skip_migration": "Skipping migration {id}...", + "migrations_success_forward": "Migration {id} completed", + "migrations_to_be_ran_manually": "Migration {id} has to be run manually. Please go to Tools → Migrations on the webadmin page, or run `yunohost tools migrations run`.", + "not_enough_disk_space": "Not enough free space on '{path}'", + "operation_interrupted": "The operation was manually interrupted?", + "other_available_options": "... and {n} other available options not shown", + "packages_upgrade_failed": "Could not upgrade all the packages", + "password_listed": "This password is among the most used passwords in the world. Please choose something more unique.", + "password_too_simple_1": "The password needs to be at least 8 characters long", + "password_too_simple_2": "The password needs to be at least 8 characters long and contain a digit, upper and lower characters", + "password_too_simple_3": "The password needs to be at least 8 characters long and contain a digit, upper, lower and special characters", + "password_too_simple_4": "The password needs to be at least 12 characters long and contain a digit, upper, lower and special characters", + "pattern_backup_archive_name": "Must be a valid filename with max 30 characters, alphanumeric and -_. characters only", "pattern_domain": "Must be a valid domain name (e.g. my-domain.org)", - "pattern_email": "Must be a valid email address (e.g. someone@domain.org)", + "pattern_email": "Must be a valid e-mail address, without '+' symbol (e.g. someone@example.com)", + "pattern_email_forward": "Must be a valid e-mail address, '+' symbol accepted (e.g. someone+tag@example.com)", "pattern_firstname": "Must be a valid first name", "pattern_lastname": "Must be a valid last name", - "pattern_listname": "Must be alphanumeric and underscore characters only", - "pattern_mailbox_quota": "Must be a size with b/k/M/G/T suffix or 0 to disable the quota", + "pattern_mailbox_quota": "Must be a size with b/k/M/G/T suffix or 0 to not have a quota", "pattern_password": "Must be at least 3 characters long", - "pattern_port": "Must be a valid port number (i.e. 0-65535)", + "pattern_password_app": "Sorry, passwords can not contain the following characters: {forbidden_chars}", "pattern_port_or_range": "Must be a valid port number (i.e. 0-65535) or range of ports (e.g. 100:200)", - "pattern_positive_number": "Must be a positive number", "pattern_username": "Must be lower-case alphanumeric and underscore characters only", - "pattern_password_app": "Sorry, passwords should not contain the following characters: {forbidden_chars}", - "permission_already_clear": "Permission '{permission:s}' already clear for app {app:s}", - "permission_already_exist": "Permission '{permission:s}' for app {app:s} already exist", - "permission_created": "Permission '{permission:s}' for app {app:s} created", - "permission_creation_failed": "Permission creation failed", - "permission_deleted": "Permission '{permission:s}' for app {app:s} deleted", - "permission_deletion_failed": "Permission '{permission:s}' for app {app:s} deletion failed", - "permission_not_found": "Permission '{permission:s}' not found for application {app:s}", - "permission_name_not_valid": "Permission name '{permission:s}' not valid", - "permission_update_failed": "Permission update failed", - "permission_generated": "The permission database has been updated", - "permission_updated": "Permission '{permission:s}' for app {app:s} updated", - "permission_update_nothing_to_do": "No permissions to update", - "port_already_closed": "Port {port:d} is already closed for {ip_version:s} connections", - "port_already_opened": "Port {port:d} is already opened for {ip_version:s} connections", - "port_available": "Port {port:d} is available", - "port_unavailable": "Port {port:d} is not available", - "recommend_to_add_first_user": "The post-install is finished but YunoHost needs at least one user to work correctly, you should add one using 'yunohost user create $username' or the admin interface.", - "remove_main_permission_not_allowed": "Removing the main permission is not allowed", - "remove_user_of_group_not_allowed": "You are not allowed to remove the user {user:s} in the group {group:s}", - "regenconf_file_backed_up": "The configuration file '{conf}' has been backed up to '{backup}'", - "regenconf_file_copy_failed": "Unable to copy the new configuration file '{new}' to '{conf}'", - "regenconf_file_kept_back": "The configuration file '{conf}' is expected to be deleted by regen-conf (category {category}) but has been kept back.", + "permission_already_allowed": "Group '{group}' already has permission '{permission}' enabled", + "permission_already_disallowed": "Group '{group}' already has permission '{permission}' disabled", + "permission_already_exist": "Permission '{permission}' already exists", + "permission_already_up_to_date": "The permission was not updated because the addition/removal requests already match the current state.", + "permission_cannot_remove_main": "Removing a main permission is not allowed", + "permission_cant_add_to_all_users": "The permission {permission} can not be added to all users.", + "permission_created": "Permission '{permission}' created", + "permission_creation_failed": "Could not create permission '{permission}': {error}", + "permission_currently_allowed_for_all_users": "This permission is currently granted to all users in addition to other groups. You probably want to either remove the 'all_users' permission or remove the other groups it is currently granted to.", + "permission_deleted": "Permission '{permission}' deleted", + "permission_deletion_failed": "Could not delete permission '{permission}': {error}", + "permission_not_found": "Permission '{permission}' not found", + "permission_protected": "Permission {permission} is protected. You cannot add or remove the visitors group to/from this permission.", + "permission_require_account": "Permission {permission} only makes sense for users having an account, and therefore cannot be enabled for visitors.", + "permission_update_failed": "Could not update permission '{permission}': {error}", + "permission_updated": "Permission '{permission}' updated", + "port_already_closed": "Port {port} is already closed for {ip_version} connections", + "port_already_opened": "Port {port} is already opened for {ip_version} connections", + "postinstall_low_rootfsspace": "The root filesystem has a total space less than 10 GB, which is quite worrisome! You will likely run out of disk space very quickly! It's recommended to have at least 16GB for the root filesystem. If you want to install YunoHost despite this warning, re-run the postinstall with --force-diskspace", + "regenconf_dry_pending_applying": "Checking pending configuration which would have been applied for category '{category}'...", + "regenconf_failed": "Could not regenerate the configuration for category(s): {categories}", + "regenconf_file_backed_up": "Configuration file '{conf}' backed up to '{backup}'", + "regenconf_file_copy_failed": "Could not copy the new configuration file '{new}' to '{conf}'", + "regenconf_file_kept_back": "The configuration file '{conf}' is expected to be deleted by regen-conf (category {category}) but was kept back.", "regenconf_file_manually_modified": "The configuration file '{conf}' has been manually modified and will not be updated", - "regenconf_file_manually_removed": "The configuration file '{conf}' has been manually removed and will not be created", - "regenconf_file_remove_failed": "Unable to remove the configuration file '{conf}'", - "regenconf_file_removed": "The configuration file '{conf}' has been removed", - "regenconf_file_updated": "The configuration file '{conf}' has been updated", + "regenconf_file_manually_removed": "The configuration file '{conf}' was removed manually, and will not be created", + "regenconf_file_remove_failed": "Could not remove the configuration file '{conf}'", + "regenconf_file_removed": "Configuration file '{conf}' removed", + "regenconf_file_updated": "Configuration file '{conf}' updated", + "regenconf_need_to_explicitly_specify_ssh": "The ssh configuration has been manually modified, but you need to explicitly specify category 'ssh' with --force to actually apply the changes.", "regenconf_now_managed_by_yunohost": "The configuration file '{conf}' is now managed by YunoHost (category {category}).", + "regenconf_pending_applying": "Applying pending configuration for category '{category}'...", "regenconf_up_to_date": "The configuration is already up-to-date for category '{category}'", - "regenconf_updated": "The configuration has been updated for category '{category}'", + "regenconf_updated": "Configuration updated for '{category}'", "regenconf_would_be_updated": "The configuration would have been updated for category '{category}'", - "regenconf_dry_pending_applying": "Checking pending configuration which would have been applied for category '{category}'…", - "regenconf_failed": "Unable to regenerate the configuration for category(s): {categories}", - "regenconf_pending_applying": "Applying pending configuration for category '{category}'…", - "restore_action_required": "You must specify something to restore", - "restore_already_installed_app": "An app is already installed with the id '{app:s}'", - "restore_app_failed": "Unable to restore the app '{app:s}'", - "restore_cleaning_failed": "Unable to clean-up the temporary restoration directory", - "restore_complete": "Restore complete", - "restore_confirm_yunohost_installed": "Do you really want to restore an already installed system? [{answers:s}]", - "restore_extracting": "Extracting needed files from the archive…", - "restore_failed": "Unable to restore the system", - "restore_hook_unavailable": "Restoration script for '{part:s}' not available on your system and not in the archive either", - "restore_may_be_not_enough_disk_space": "Your system seems not to have enough disk space (freespace: {free_space:d} B, needed space: {needed_space:d} B, security margin: {margin:d} B)", - "restore_mounting_archive": "Mounting archive into '{path:s}'", - "restore_not_enough_disk_space": "Not enough disk space (freespace: {free_space:d} B, needed space: {needed_space:d} B, security margin: {margin:d} B)", - "restore_nothings_done": "Nothing has been restored", - "restore_removing_tmp_dir_failed": "Unable to remove an old temporary directory", - "restore_running_app_script": "Running restore script of app '{app:s}'…", - "restore_running_hooks": "Running restoration hooks…", - "restore_system_part_failed": "Unable to restore the '{part:s}' system part", - "root_password_desynchronized": "The admin password has been changed, but YunoHost was unable to propagate this on the root password!", + "regex_incompatible_with_tile": "/!\\ Packagers! Permission '{permission}' has show_tile set to 'true' and you therefore cannot define a regex URL as the main URL", + "regex_with_only_domain": "You can't use a regex for domain, only for path", + "restore_already_installed_app": "An app with the ID '{app}' is already installed", + "restore_already_installed_apps": "The following apps can't be restored because they are already installed: {apps}", + "restore_backup_too_old": "This backup archive can not be restored because it comes from a too-old YunoHost version.", + "restore_cleaning_failed": "Could not clean up the temporary restoration directory", + "restore_complete": "Restoration completed", + "restore_confirm_yunohost_installed": "Do you really want to restore an already installed system? [{answers}]", + "restore_extracting": "Extracting needed files from the archive...", + "restore_failed": "Could not restore system", + "restore_hook_unavailable": "Restoration script for '{part}' not available on your system and not in the archive either", + "restore_may_be_not_enough_disk_space": "Your system does not seem to have enough space (free: {free_space} B, needed space: {needed_space} B, security margin: {margin} B)", + "restore_not_enough_disk_space": "Not enough space (space: {free_space} B, needed space: {needed_space} B, security margin: {margin} B)", + "restore_nothings_done": "Nothing was restored", + "restore_removing_tmp_dir_failed": "Could not remove an old temporary directory", + "restore_running_app_script": "Restoring the app '{app}'...", + "restore_running_hooks": "Running restoration hooks...", + "restore_system_part_failed": "Could not restore the '{part}' system part", + "root_password_desynchronized": "The admin password was changed, but YunoHost could not propagate this to the root password!", "root_password_replaced_by_admin_password": "Your root password have been replaced by your admin password.", - "server_shutdown": "The server will shutdown", - "server_shutdown_confirm": "The server will shutdown immediatly, are you sure? [{answers:s}]", "server_reboot": "The server will reboot", - "server_reboot_confirm": "The server will reboot immediatly, are you sure? [{answers:s}]", - "service_add_failed": "Unable to add service '{service:s}'", - "service_added": "The service '{service:s}' has been added", - "service_already_started": "Service '{service:s}' has already been started", - "service_already_stopped": "Service '{service:s}' has already been stopped", - "service_cmd_exec_failed": "Unable to execute command '{command:s}'", - "service_description_avahi-daemon": "allows to reach your server using yunohost.local on your local network", - "service_description_dnsmasq": "handles domain name resolution (DNS)", - "service_description_dovecot": "allows e-mail client to access/fetch email (via IMAP and POP3)", - "service_description_fail2ban": "protects against bruteforce and other kind of attacks from the Internet", - "service_description_glances": "monitors system information on your server", - "service_description_metronome": "manage XMPP instant messaging accounts", - "service_description_mysql": "stores applications data (SQL database)", - "service_description_nginx": "serves or provides access to all the websites hosted on your server", - "service_description_nslcd": "handles YunoHost user shell connection", - "service_description_php7.0-fpm": "runs applications written in PHP with nginx", - "service_description_postfix": "used to send and receive emails", - "service_description_redis-server": "a specialized database used for rapid data access, task queue and communication between programs", - "service_description_rmilter": "checks various parameters in emails", - "service_description_rspamd": "filters spam, and other email-related features", - "service_description_slapd": "stores users, domains and related information", - "service_description_ssh": "allows you to connect remotely to your server via a terminal (SSH protocol)", - "service_description_yunohost-api": "manages interactions between the YunoHost web interface and the system", - "service_description_yunohost-firewall": "manages open and close connexion ports to services", - "service_disable_failed": "Unable to disable service '{service:s}'\n\nRecent service logs:{logs:s}", - "service_disabled": "The service '{service:s}' has been disabled", - "service_enable_failed": "Unable to enable service '{service:s}'\n\nRecent service logs:{logs:s}", - "service_enabled": "The service '{service:s}' has been enabled", - "service_no_log": "No log to display for service '{service:s}'", + "server_reboot_confirm": "The server will reboot immediatly, are you sure? [{answers}]", + "server_shutdown": "The server will shut down", + "server_shutdown_confirm": "The server will shutdown immediatly, are you sure? [{answers}]", + "service_add_failed": "Could not add the service '{service}'", + "service_added": "The service '{service}' was added", + "service_already_started": "The service '{service}' is running already", + "service_already_stopped": "The service '{service}' has already been stopped", + "service_cmd_exec_failed": "Could not execute the command '{command}'", + "service_description_dnsmasq": "Handles domain name resolution (DNS)", + "service_description_dovecot": "Allows e-mail clients to access/fetch email (via IMAP and POP3)", + "service_description_fail2ban": "Protects against brute-force and other kinds of attacks from the Internet", + "service_description_metronome": "Manage XMPP instant messaging accounts", + "service_description_mysql": "Stores app data (SQL database)", + "service_description_nginx": "Serves or provides access to all the websites hosted on your server", + "service_description_php7.3-fpm": "Runs apps written in PHP with NGINX", + "service_description_postfix": "Used to send and receive e-mails", + "service_description_redis-server": "A specialized database used for rapid data access, task queue, and communication between programs", + "service_description_rspamd": "Filters spam, and other e-mail related features", + "service_description_slapd": "Stores users, domains and related info", + "service_description_ssh": "Allows you to connect remotely to your server via a terminal (SSH protocol)", + "service_description_yunohost-api": "Manages interactions between the YunoHost web interface and the system", + "service_description_yunohost-firewall": "Manages open and close connection ports to services", + "service_description_yunomdns": "Allows you to reach your server using 'yunohost.local' in your local network", + "service_disable_failed": "Could not make the service '{service}' not start at boot.\n\nRecent service logs:{logs}", + "service_disabled": "The service '{service}' will not be started anymore when system boots.", + "service_enable_failed": "Could not make the service '{service}' automatically start at boot.\n\nRecent service logs:{logs}", + "service_enabled": "The service '{service}' will now be automatically started during system boots.", + "service_not_reloading_because_conf_broken": "Not reloading/restarting service '{name}' because its configuration is broken: {errors}", "service_regen_conf_is_deprecated": "'yunohost service regen-conf' is deprecated! Please use 'yunohost tools regen-conf' instead.", - "service_remove_failed": "Unable to remove service '{service:s}'", - "service_removed": "The service '{service:s}' has been removed", - "service_reload_failed": "Unable to reload service '{service:s}'\n\nRecent service logs:{logs:s}", - "service_reloaded": "The service '{service:s}' has been reloaded", - "service_restart_failed": "Unable to restart service '{service:s}'\n\nRecent service logs:{logs:s}", - "service_restarted": "The service '{service:s}' has been restarted", - "service_reload_or_restart_failed": "Unable to reload or restart service '{service:s}'\n\nRecent service logs:{logs:s}", - "service_reloaded_or_restarted": "The service '{service:s}' has been reloaded or restarted", - "service_start_failed": "Unable to start service '{service:s}'\n\nRecent service logs:{logs:s}", - "service_started": "The service '{service:s}' has been started", - "service_status_failed": "Unable to determine status of service '{service:s}'", - "service_stop_failed": "Unable to stop service '{service:s}'\n\nRecent service logs:{logs:s}", - "service_stopped": "The service '{service:s}' has been stopped", - "service_unknown": "Unknown service '{service:s}'", - "ssowat_conf_generated": "The SSOwat configuration has been generated", - "ssowat_conf_updated": "The SSOwat configuration has been updated", - "ssowat_persistent_conf_read_error": "Error while reading SSOwat persistent configuration: {error:s}. Edit /etc/ssowat/conf.json.persistent file to fix the JSON syntax", - "ssowat_persistent_conf_write_error": "Error while saving SSOwat persistent configuration: {error:s}. Edit /etc/ssowat/conf.json.persistent file to fix the JSON syntax", - "system_groupname_exists": "Groupname already exists in the system group", - "system_upgraded": "The system has been upgraded", - "system_username_exists": "Username already exists in the system users", - "this_action_broke_dpkg": "This action broke dpkg/apt (the system package managers)... You can try to solve this issue by connecting through SSH and running `sudo dpkg --configure -a`.", - "tools_update_failed_to_app_fetchlist": "Failed to update YunoHost's applists because: {error}", - "tools_upgrade_at_least_one": "Please specify --apps OR --system", + "service_reload_failed": "Could not reload the service '{service}'\n\nRecent service logs:{logs}", + "service_reload_or_restart_failed": "Could not reload or restart the service '{service}'\n\nRecent service logs:{logs}", + "service_reloaded": "Service '{service}' reloaded", + "service_reloaded_or_restarted": "The service '{service}' was reloaded or restarted", + "service_remove_failed": "Could not remove the service '{service}'", + "service_removed": "Service '{service}' removed", + "service_restart_failed": "Could not restart the service '{service}'\n\nRecent service logs:{logs}", + "service_restarted": "Service '{service}' restarted", + "service_start_failed": "Could not start the service '{service}'\n\nRecent service logs:{logs}", + "service_started": "Service '{service}' started", + "service_stop_failed": "Unable to stop the service '{service}'\n\nRecent service logs:{logs}", + "service_stopped": "Service '{service}' stopped", + "service_unknown": "Unknown service '{service}'", + "show_tile_cant_be_enabled_for_regex": "You cannot enable 'show_tile' right now, because the URL for the permission '{permission}' is a regex", + "show_tile_cant_be_enabled_for_url_not_defined": "You cannot enable 'show_tile' right now, because you must first define an URL for the permission '{permission}'", + "ssowat_conf_generated": "SSOwat configuration regenerated", + "ssowat_conf_updated": "SSOwat configuration updated", + "system_upgraded": "System upgraded", + "system_username_exists": "Username already exists in the list of system users", + "this_action_broke_dpkg": "This action broke dpkg/APT (the system package managers)... You can try to solve this issue by connecting through SSH and running `sudo apt install --fix-broken` and/or `sudo dpkg --configure -a`.", + "tools_upgrade_at_least_one": "Please specify 'apps', or 'system'", "tools_upgrade_cant_both": "Cannot upgrade both system and apps at the same time", - "tools_upgrade_cant_hold_critical_packages": "Unable to hold critical packages ...", - "tools_upgrade_cant_unhold_critical_packages": "Unable to unhold critical packages ...", - "tools_upgrade_regular_packages": "Now upgrading 'regular' (non-yunohost-related) packages ...", - "tools_upgrade_regular_packages_failed": "Unable to upgrade packages: {packages_list}", - "tools_upgrade_special_packages": "Now upgrading 'special' (yunohost-related) packages ...", - "tools_upgrade_special_packages_explanation": "This action will end but the actual special upgrade will continue in background. Please don't start any other action on your server in the next ~10 minutes (depending on your hardware speed). Once it's done, you may have to re-log on the webadmin. The upgrade log will be available in Tools > Log (in the webadmin) or through 'yunohost log list' (in command line).", - "tools_upgrade_special_packages_completed": "YunoHost package upgrade completed !\nPress [Enter] to get the command line back", - "unbackup_app": "App '{app:s}' will not be saved", - "unexpected_error": "An unexpected error occured: {error}", - "unit_unknown": "Unknown unit '{unit:s}'", + "tools_upgrade_cant_hold_critical_packages": "Could not hold critical packages...", + "tools_upgrade_cant_unhold_critical_packages": "Could not unhold critical packages...", + "tools_upgrade_regular_packages": "Now upgrading 'regular' (non-yunohost-related) packages...", + "tools_upgrade_regular_packages_failed": "Could not upgrade packages: {packages_list}", + "tools_upgrade_special_packages": "Now upgrading 'special' (yunohost-related) packages...", + "tools_upgrade_special_packages_completed": "YunoHost package upgrade completed.\nPress [Enter] to get the command line back", + "tools_upgrade_special_packages_explanation": "The special upgrade will continue in the background. Please don't start any other actions on your server for the next ~10 minutes (depending on hardware speed). After this, you may have to re-log in to the webadmin. The upgrade log will be available in Tools → Log (in the webadmin) or using 'yunohost log list' (from the command-line).", + "unbackup_app": "{app} will not be saved", + "unexpected_error": "Something unexpected went wrong: {error}", + "unknown_main_domain_path": "Unknown domain or path for '{app}'. You need to specify a domain and a path to be able to specify a URL for permission.", "unlimit": "No quota", - "unrestore_app": "App '{app:s}' will not be restored", - "update_apt_cache_failed": "Unable to update the cache of APT (Debian's package manager). Here is a dump of the sources.list lines which might help to identify problematic lines : \n{sourceslist}", - "update_apt_cache_warning": "Some errors happened while updating the cache of APT (Debian's package manager). Here is a dump of the sources.list lines which might help to identify problematic lines : \n{sourceslist}", - "updating_apt_cache": "Fetching available upgrades for system packages…", - "updating_app_lists": "Fetching available upgrades for applications…", + "unrestore_app": "{app} will not be restored", + "update_apt_cache_failed": "Unable to update the cache of APT (Debian's package manager). Here is a dump of the sources.list lines, which might help identify problematic lines: \n{sourceslist}", + "update_apt_cache_warning": "Something went wrong while updating the cache of APT (Debian's package manager). Here is a dump of the sources.list lines, which might help identify problematic lines: \n{sourceslist}", + "updating_apt_cache": "Fetching available upgrades for system packages...", "upgrade_complete": "Upgrade complete", - "upgrading_packages": "Upgrading packages…", + "upgrading_packages": "Upgrading packages...", "upnp_dev_not_found": "No UPnP device found", - "upnp_disabled": "UPnP has been disabled", - "upnp_enabled": "UPnP has been enabled", - "upnp_port_open_failed": "Unable to open UPnP ports", - "user_already_in_group": "User {user:} already in group {group:s}", - "user_created": "The user has been created", - "user_creation_failed": "Unable to create user", - "user_deleted": "The user has been deleted", - "user_deletion_failed": "Unable to delete user", - "user_home_creation_failed": "Unable to create user home folder", - "user_info_failed": "Unable to retrieve user information", - "user_not_in_group": "User {user:s} not in group {group:s}", - "user_unknown": "Unknown user: {user:s}", - "user_update_failed": "Unable to update user", - "user_updated": "The user has been updated", - "users_available": "Available users:", + "upnp_disabled": "UPnP turned off", + "upnp_enabled": "UPnP turned on", + "upnp_port_open_failed": "Could not open port via UPnP", + "user_already_exists": "The user '{user}' already exists", + "user_created": "User created", + "user_creation_failed": "Could not create user {user}: {error}", + "user_deleted": "User deleted", + "user_deletion_failed": "Could not delete user {user}: {error}", + "user_home_creation_failed": "Could not create home folder '{home}' for user", + "user_import_bad_file": "Your CSV file is not correctly formatted it will be ignored to avoid potential data loss", + "user_import_bad_line": "Incorrect line {line}: {details}", + "user_import_failed": "The users import operation completely failed", + "user_import_missing_columns": "The following columns are missing: {columns}", + "user_import_nothing_to_do": "No user needs to be imported", + "user_import_partial_failed": "The users import operation partially failed", + "user_import_success": "Users successfully imported", + "user_unknown": "Unknown user: {user}", + "user_update_failed": "Could not update user {user}: {error}", + "user_updated": "User info changed", "yunohost_already_installed": "YunoHost is already installed", - "yunohost_ca_creation_failed": "Unable to create certificate authority", - "yunohost_ca_creation_success": "The local certification authority has been created.", - "yunohost_configured": "YunoHost has been configured", - "yunohost_installing": "Installing YunoHost…", - "yunohost_not_installed": "YunoHost is not or not correctly installed. Please execute 'yunohost tools postinstall'" + "yunohost_configured": "YunoHost is now configured", + "yunohost_installing": "Installing YunoHost...", + "yunohost_not_installed": "YunoHost is not correctly installed. Please run 'yunohost tools postinstall'", + "yunohost_postinstall_end_tip": "The post-install completed! To finalize your setup, please consider:\n - adding a first user through the 'Users' section of the webadmin (or 'yunohost user create ' in command-line);\n - diagnose potential issues through the 'Diagnosis' section of the webadmin (or 'yunohost diagnosis run' in command-line);\n - reading the 'Finalizing your setup' and 'Getting to know YunoHost' parts in the admin documentation: https://yunohost.org/admindoc." } diff --git a/locales/eo.json b/locales/eo.json index b9d973557..8973e6344 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -1,36 +1,549 @@ { - "admin_password_change_failed": "Malebla ŝanĝi pasvorton", - "admin_password_changed": "Pasvorto de la estro estas ŝanĝita", - "app_already_installed": "{app:s} estas jam instalita", - "app_already_up_to_date": "{app:s} estas ĝisdata", - "app_argument_required": "Parametro {name:s} estas bezonata", - "app_change_url_identical_domains": "Malnovaj kaj novaj domajno/URL estas la sama ('{domain:s}{path:s}'), nenio fareblas.", - "app_change_url_success": "URL de appo {app:s} ŝanĝita al {domain:s}{path:s}", - "app_extraction_failed": "Malebla malkompaktigi instaldosierojn", - "app_id_invalid": "Nevalida apo id", - "app_incompatible": "Apo {app} ne estas kongrua kun via YunoHost versio", - "app_install_files_invalid": "Nevalidaj instaldosieroj", - "app_location_already_used": "Apo {app} jam estas instalita al tiu loco ({path})", - "user_updated": "Uzanto estas ĝisdatita", - "users_available": "Uzantoj disponeblaj :", + "admin_password_change_failed": "Ne povis ŝanĝi pasvorton", + "admin_password_changed": "La pasvorto de administrado estis ŝanĝita", + "app_already_installed": "{app} estas jam instalita", + "app_already_up_to_date": "{app} estas jam ĝisdata", + "app_argument_required": "Parametro {name} estas bezonata", + "app_change_url_identical_domains": "Malnovaj kaj novaj domajno/URL estas la sama ('{domain}{path}'), nenio fareblas.", + "app_change_url_success": "{app} URL nun estas {domain} {path}", + "app_extraction_failed": "Ne povis ĉerpi la instalajn dosierojn", + "app_id_invalid": "Nevalida apo ID", + "app_install_files_invalid": "Ĉi tiuj dosieroj ne povas esti instalitaj", + "user_updated": "Uzantinformoj ŝanĝis", "yunohost_already_installed": "YunoHost estas jam instalita", - "yunohost_ca_creation_failed": "Ne eblas krei atestan aŭtoritaton", - "yunohost_ca_creation_success": "Loka atesta aŭtoritato estas kreita.", "yunohost_installing": "Instalante YunoHost…", - "service_description_glances": "monitoras sisteminformojn de via servilo", - "service_description_metronome": "mastrumas XMPP tujmesaĝilon kontojn", - "service_description_mysql": "stokas aplikaĵojn datojn (SQL datumbazo)", - "service_description_nginx": "servas aŭ permesas atingi ĉiujn retejojn gastigita sur via servilo", - "service_description_nslcd": "mastrumas Yunohost uzantojn konektojn per komanda linio", - "service_description_php7.0-fpm": "rulas aplikaĵojn skibita en PHP kun nginx", - "service_description_postfix": "uzita por sendi kaj ricevi retpoŝtojn", - "service_description_redis-server": "specialita datumbazo uzita por rapida datumo atingo, atendovicoj kaj komunikadoj inter programoj", - "service_description_rmilter": "kontrolas diversajn parametrojn en retpoŝtoj", - "service_description_rspamd": "filtras trudmesaĝojn, kaj aliaj funkcioj rilate al retpoŝto", - "service_description_slapd": "stokas uzantojn, domajnojn kaj rilatajn informojn", - "service_description_ssh": "permesas al vi konekti al via servilo kun fora terminalo (SSH protokolo)", - "service_description_yunohost-api": "mastrumas interagojn inter la YunoHost retinterfaco kaj la sistemo", - "service_description_yunohost-firewall": "mastrumas malfermitajn kaj fermitajn konektejojn al servoj", - "service_disable_failed": "Neebla malaktivigi servon '{service:s}'\n\nFreŝaj protokoloj de la servo : {logs:s}", - "service_disabled": "Servo '{service:s}' estas malaktivigita" -} + "service_description_metronome": "Mastrumas XMPP tujmesaĝilon kontojn", + "service_description_mysql": "Butikigas datumojn de programoj (SQL datumbazo)", + "service_description_nginx": "Servas aŭ permesas atingi ĉiujn retejojn gastigita sur via servilo", + "service_description_postfix": "Uzita por sendi kaj ricevi retpoŝtojn", + "service_description_redis-server": "Specialita datumbazo uzita por rapida datumo atingo, atendovicoj kaj komunikadoj inter programoj", + "service_description_rspamd": "Filtras trudmesaĝojn, kaj aliaj funkcioj rilate al retpoŝto", + "service_description_slapd": "Stokas uzantojn, domajnojn kaj rilatajn informojn", + "service_description_ssh": "Permesas al vi konekti al via servilo kun fora terminalo (SSH protokolo)", + "service_description_yunohost-api": "Mastrumas interagojn inter la YunoHost retinterfaco kaj la sistemo", + "service_description_yunohost-firewall": "Administras malfermajn kaj fermajn konektajn havenojn al servoj", + "service_disable_failed": "Ne povis fari la servon '{service}' ne komenci ĉe la ekkuro.\n\nLastatempaj servaj protokoloj: {logs}", + "service_disabled": "La servo '{service}' ne plu komenciĝos kiam sistemo ekos.", + "action_invalid": "Nevalida ago « {action} »", + "admin_password": "Pasvorto de la estro", + "admin_password_too_long": "Bonvolu elekti pasvorton pli mallonga ol 127 signoj", + "already_up_to_date": "Nenio por fari. Ĉio estas jam ĝisdatigita.", + "app_argument_choice_invalid": "Uzu unu el ĉi tiuj elektoj '{choices}' por la argumento '{name}' anstataŭ '{value}'", + "app_argument_invalid": "Elektu validan valoron por la argumento '{name}': {error}", + "ask_new_admin_password": "Nova administrada pasvorto", + "app_action_broke_system": "Ĉi tiu ago ŝajne rompis ĉi tiujn gravajn servojn: {services}", + "app_unsupported_remote_type": "Malkontrolita fora speco uzita por la apliko", + "backup_archive_system_part_not_available": "Sistemo parto '{part}' ne haveblas en ĉi tiu rezervo", + "backup_abstract_method": "Ĉi tiu rezerva metodo ankoraŭ efektiviĝis", + "apps_already_up_to_date": "Ĉiuj aplikoj estas jam ĝisdatigitaj", + "app_location_unavailable": "Ĉi tiu URL aŭ ne haveblas, aŭ konfliktas kun la jam instalita (j) apliko (j):\n{apps}", + "backup_archive_app_not_found": "Ne povis trovi {app} en la rezerva arkivo", + "backup_actually_backuping": "Krei rezervan arkivon de la kolektitaj dosieroj ...", + "app_change_url_no_script": "La app '{app_name}' ankoraŭ ne subtenas URL-modifon. Eble vi devus altgradigi ĝin.", + "app_start_install": "Instali {app}...", + "backup_created": "Sekurkopio kreita", + "app_make_default_location_already_used": "Ne povis fari '{app}' la defaŭltan programon sur la domajno, '{domain}' estas jam uzata de '{other_app}'", + "backup_method_copy_finished": "Rezerva kopio finis", + "app_not_properly_removed": "{app} ne estis ĝuste forigita", + "backup_archive_broken_link": "Ne povis aliri la rezervan ar archiveivon (rompita ligilo al {path})", + "app_requirements_checking": "Kontrolante bezonatajn pakaĵojn por {app} ...", + "app_not_installed": "Ne povis trovi {app} en la listo de instalitaj programoj: {all_apps}", + "ask_new_path": "Nova vojo", + "backup_custom_mount_error": "Propra rezerva metodo ne povis preterpasi la paŝon 'monto'", + "app_upgrade_app_name": "Nun ĝisdatigu {app}...", + "app_manifest_invalid": "Io misas pri la aplika manifesto: {error}", + "backup_cleaning_failed": "Ne povis purigi la provizoran rezervan dosierujon", + "backup_creation_failed": "Ne povis krei la rezervan ar archiveivon", + "backup_hook_unknown": "La rezerva hoko '{hook}' estas nekonata", + "backup_custom_backup_error": "Propra rezerva metodo ne povis preterpasi la paŝon \"sekurkopio\"", + "ask_main_domain": "Ĉefa domajno", + "backup_method_tar_finished": "TAR-rezerva ar archiveivo kreita", + "backup_cant_mount_uncompress_archive": "Ne povis munti la nekompresitan ar archiveivon kiel protektita kontraŭ skribo", + "app_action_cannot_be_ran_because_required_services_down": "Ĉi tiuj postulataj servoj devas funkcii por funkciigi ĉi tiun agon: {services}. Provu rekomenci ilin por daŭrigi (kaj eble esploru, kial ili malsupreniras).", + "backup_copying_to_organize_the_archive": "Kopiante {size} MB por organizi la ar archiveivon", + "backup_output_directory_forbidden": "Elektu malsaman elirejan dosierujon. Sekurkopioj ne povas esti kreitaj en sub-dosierujoj /bin, /boot, /dev, /ktp, /lib, /root, /run, /sbin, /sys, /usr, /var aŭ /home/yunohost.backup/archives", + "backup_no_uncompress_archive_dir": "Ne ekzistas tia nekompremita arkiva dosierujo", + "password_too_simple_1": "Pasvorto devas esti almenaŭ 8 signojn longa", + "app_upgrade_failed": "Ne povis ĝisdatigi {app}: {error}", + "app_upgrade_several_apps": "La sekvaj apliko estos altgradigitaj: {apps}", + "backup_archive_open_failed": "Ne povis malfermi la rezervan ar archiveivon", + "ask_lastname": "Familia nomo", + "app_start_backup": "Kolekti dosierojn por esti subtenata por {app}...", + "backup_archive_name_exists": "Rezerva arkivo kun ĉi tiu nomo jam ekzistas.", + "backup_applying_method_tar": "Krei la rezervon TAR Arkivo...", + "backup_method_custom_finished": "Propra rezerva metodo '{method}' finiĝis", + "app_already_installed_cant_change_url": "Ĉi tiu app estas jam instalita. La URL ne povas esti ŝanĝita nur per ĉi tiu funkcio. Kontrolu en `app changeurl` se ĝi haveblas.", + "app_not_correctly_installed": "{app} ŝajnas esti malĝuste instalita", + "app_removed": "{app} forigita", + "backup_delete_error": "Ne povis forigi '{path}'", + "backup_nothings_done": "Nenio por ŝpari", + "backup_applying_method_custom": "Voki la laŭmendan rezervan metodon '{method}'...", + "backup_app_failed": "Ne povis subteni {app}", + "app_upgrade_some_app_failed": "Iuj aplikoj ne povis esti altgradigitaj", + "app_start_remove": "Forigado {app}...", + "backup_output_directory_not_empty": "Vi devas elekti malplenan eligitan dosierujon", + "backup_archive_writing_error": "Ne povis aldoni la dosierojn '{source}' (nomitaj en la ar theivo '{dest}') por esti rezervitaj en la kunpremita arkivo '{archive}'", + "app_start_restore": "Restarigi {app}...", + "backup_applying_method_copy": "Kopii ĉiujn dosierojn por sekurigi...", + "backup_couldnt_bind": "Ne povis ligi {src} al {dest}.", + "ask_password": "Pasvorto", + "app_requirements_unmeet": "Postuloj ne estas renkontitaj por {app}, la pakaĵo {pkgname} ({version}) devas esti {spec}", + "ask_firstname": "Antaŭnomo", + "backup_ask_for_copying_if_needed": "Ĉu vi volas realigi la sekurkopion uzante {size} MB provizore? (Ĉi tiu maniero estas uzata ĉar iuj dosieroj ne povus esti pretigitaj per pli efika metodo.)", + "backup_mount_archive_for_restore": "Preparante arkivon por restarigo …", + "backup_csv_creation_failed": "Ne povis krei la CSV-dosieron bezonatan por restarigo", + "backup_archive_name_unknown": "Nekonata loka rezerva ar archiveivo nomata '{name}'", + "app_sources_fetch_failed": "Ne povis akiri fontajn dosierojn, ĉu la URL estas ĝusta?", + "ask_new_domain": "Nova domajno", + "app_unknown": "Nekonata apliko", + "app_not_upgraded": "La '{failed_app}' de la programo ne sukcesis ĝisdatigi, kaj sekve la nuntempaj plibonigoj de la sekvaj programoj estis nuligitaj: {apps}", + "aborting": "Aborti.", + "app_upgraded": "{app} altgradigita", + "backup_deleted": "Rezerva forigita", + "backup_csv_addition_failed": "Ne povis aldoni dosierojn al sekurkopio en la CSV-dosiero", + "dpkg_lock_not_available": "Ĉi tiu komando ne povas funkcii nun ĉar alia programo uzas la seruron de dpkg (la administrilo de paka sistemo)", + "domain_dyndns_root_unknown": "Nekonata radika domajno DynDNS", + "field_invalid": "Nevalida kampo '{}'", + "log_app_makedefault": "Faru '{}' la defaŭlta apliko", + "backup_system_part_failed": "Ne eblis sekurkopi la sistemon de '{part}'", + "global_settings_setting_security_postfix_compatibility": "Kongruo vs sekureca kompromiso por la Postfix-servilo. Afektas la ĉifradojn (kaj aliajn aspektojn pri sekureco)", + "group_unknown": "La grupo '{group}' estas nekonata", + "mailbox_disabled": "Retpoŝto malŝaltita por uzanto {user}", + "migrations_dependencies_not_satisfied": "Rulu ĉi tiujn migradojn: '{dependencies_id}', antaŭ migrado {id}.", + "migrations_failed_to_load_migration": "Ne povis ŝarĝi migradon {id}: {error}", + "migrations_exclusive_options": "'--auto', '--skip' kaj '--force-rerun' estas reciproke ekskluzivaj ebloj.", + "migrations_must_provide_explicit_targets": "Vi devas provizi eksplicitajn celojn kiam vi uzas '--skip' aŭ '--force-rerun'", + "permission_update_failed": "Ne povis ĝisdatigi permeson '{permission}': {error}", + "permission_updated": "Ĝisdatigita \"{permission}\" rajtigita", + "tools_upgrade_cant_hold_critical_packages": "Ne povis teni kritikajn pakojn…", + "upnp_dev_not_found": "Neniu UPnP-aparato trovita", + "pattern_password": "Devas esti almenaŭ 3 signoj longaj", + "root_password_desynchronized": "La pasvorta administranto estis ŝanĝita, sed YunoHost ne povis propagandi ĉi tion al la radika pasvorto!", + "service_remove_failed": "Ne povis forigi la servon '{service}'", + "backup_permission": "Rezerva permeso por {app}", + "log_user_group_delete": "Forigi grupon '{}'", + "log_user_group_update": "Ĝisdatigi grupon '{}'", + "dyndns_provider_unreachable": "Ne povas atingi la provizanton DynDNS {provider}: ĉu via YunoHost ne estas ĝuste konektita al la interreto aŭ la dyneta servilo malŝaltiĝas.", + "good_practices_about_user_password": "Vi nun estas por difini novan uzantan pasvorton. La pasvorto devas esti almenaŭ 8 signojn - kvankam estas bone praktiki uzi pli longan pasvorton (t.e. pasfrazon) kaj/aŭ variaĵon de signoj (majuskloj, minuskloj, ciferoj kaj specialaj signoj).", + "group_updated": "Ĝisdatigita \"{group}\" grupo", + "group_already_exist": "Grupo {group} jam ekzistas", + "group_already_exist_on_system": "Grupo {group} jam ekzistas en la sistemaj grupoj", + "group_cannot_be_deleted": "La grupo {group} ne povas esti forigita permane.", + "group_update_failed": "Ne povis ĝisdatigi la grupon '{group}': {error}", + "group_user_already_in_group": "Uzanto {user} jam estas en grupo {group}", + "group_user_not_in_group": "Uzanto {user} ne estas en grupo {group}", + "installation_complete": "Kompleta instalado", + "log_permission_create": "Krei permeson '{}'", + "log_permission_delete": "Forigi permeson '{}'", + "log_user_group_create": "Krei grupon '{}'", + "log_user_permission_update": "Mise à jour des accès pour la permission '{}'", + "log_user_permission_reset": "Restarigi permeson '{}'", + "mail_forward_remove_failed": "Ne povis forigi retpoŝton plusendante '{mail}'", + "migrations_already_ran": "Tiuj migradoj estas jam faritaj: {ids}", + "migrations_no_such_migration": "Estas neniu migrado nomata '{id}'", + "permission_already_allowed": "Grupo '{group}' jam havas rajtigitan permeson '{permission}'", + "permission_already_disallowed": "Grupo '{group}' jam havas permeson '{permission}' malebligita", + "permission_cannot_remove_main": "Forigo de ĉefa permeso ne rajtas", + "permission_creation_failed": "Ne povis krei permeson '{permission}': {error}", + "user_already_exists": "La uzanto '{user}' jam ekzistas", + "migrations_pending_cant_rerun": "Tiuj migradoj ankoraŭ estas pritraktataj, do ne plu rajtas esti ekzekutitaj: {ids}", + "migrations_running_forward": "Kuranta migrado {id}…", + "migrations_success_forward": "Migrado {id} kompletigita", + "operation_interrupted": "La operacio estis permane interrompita?", + "permission_created": "Permesita '{permission}' kreita", + "permission_deleted": "Permesita \"{permission}\" forigita", + "permission_deletion_failed": "Ne povis forigi permeson '{permission}': {error}", + "permission_not_found": "Permesita \"{permission}\" ne trovita", + "restore_not_enough_disk_space": "Ne sufiĉa spaco (spaco: {free_space} B, necesa spaco: {needed_space} B, sekureca marĝeno: {margin} B)", + "tools_upgrade_regular_packages": "Nun ĝisdatigi 'regulajn' (ne-yunohost-rilatajn) pakojn …", + "tools_upgrade_special_packages_explanation": "La speciala ĝisdatigo daŭros en la fono. Bonvolu ne komenci aliajn agojn en via servilo dum la sekvaj ~ 10 minutoj (depende de la aparata rapideco). Post tio, vi eble devos re-ensaluti al la retadreso. La ĝisdatiga registro estos havebla en Iloj → Ensaluto (en la retadreso) aŭ uzante 'yunohost logliston' (el la komandlinio).", + "unrestore_app": "App '{app}' ne restarigos", + "group_created": "Grupo '{group}' kreita", + "group_creation_failed": "Ne povis krei la grupon '{group}': {error}", + "group_deleted": "Grupo '{group}' forigita", + "group_deletion_failed": "Ne povis forigi la grupon '{group}': {error}", + "migrations_not_pending_cant_skip": "Tiuj migradoj ankoraŭ ne estas pritraktataj, do ne eblas preterlasi: {ids}", + "permission_already_exist": "Permesita '{permission}' jam ekzistas", + "domain_created": "Domajno kreita", + "log_user_create": "Aldonu uzanton '{}'", + "ip6tables_unavailable": "Vi ne povas ludi kun ip6tabloj ĉi tie. Vi estas en ujo aŭ via kerno ne subtenas ĝin", + "mail_unavailable": "Ĉi tiu retpoŝta adreso estas rezervita kaj aŭtomate estos atribuita al la unua uzanto", + "certmanager_domain_dns_ip_differs_from_public_ip": "La DNS 'A' rekordo por la domajno '{domain}' diferencas de la IP de ĉi tiu servilo. Se vi lastatempe modifis vian A-registron, bonvolu atendi ĝin propagandi (iuj DNS-disvastigaj kontroliloj estas disponeblaj interrete). (Se vi scias, kion vi faras, uzu '--no-checks' por malŝalti tiujn ĉekojn.)", + "tools_upgrade_special_packages_completed": "Plenumis la ĝisdatigon de pakaĵoj de YunoHost.\nPremu [Enter] por retrovi la komandlinion", + "log_remove_on_failed_install": "Forigu '{}' post malsukcesa instalado", + "regenconf_file_manually_modified": "La agorddosiero '{conf}' estis modifita permane kaj ne estos ĝisdatigita", + "regenconf_would_be_updated": "La agordo estus aktualigita por la kategorio '{category}'", + "certmanager_cert_install_success_selfsigned": "Mem-subskribita atestilo nun instalita por la domajno '{domain}'", + "global_settings_unknown_setting_from_settings_file": "Nekonata ŝlosilo en agordoj: '{setting_key}', forĵetu ĝin kaj konservu ĝin en /etc/yunohost/settings-unknown.json", + "regenconf_file_backed_up": "Agordodosiero '{conf}' estis rezervita al '{backup}'", + "iptables_unavailable": "Vi ne povas ludi kun iptables ĉi tie. Vi estas en ujo aŭ via kerno ne subtenas ĝin", + "global_settings_cant_write_settings": "Ne eblis konservi agordojn, tial: {reason}", + "service_added": "La servo '{service}' estis aldonita", + "upnp_disabled": "UPnP malŝaltis", + "service_started": "Servo '{service}' komenciĝis", + "port_already_opened": "Haveno {port} estas jam malfermita por {ip_version} rilatoj", + "upgrading_packages": "Ĝisdatigi pakojn…", + "custom_app_url_required": "Vi devas provizi URL por altgradigi vian kutimon app {app}", + "service_reload_failed": "Ne povis reŝargi la servon '{service}'\n\nLastatempaj servaj protokoloj: {logs}", + "packages_upgrade_failed": "Ne povis ĝisdatigi ĉiujn pakojn", + "hook_json_return_error": "Ne povis legi revenon de hoko {path}. Eraro: {msg}. Kruda enhavo: {raw_content}", + "dyndns_key_not_found": "DNS-ŝlosilo ne trovita por la domajno", + "tools_upgrade_regular_packages_failed": "Ne povis ĝisdatigi pakojn: {packages_list}", + "service_start_failed": "Ne povis komenci la servon '{service}'\n\nLastatempaj servaj protokoloj: {logs}", + "service_reloaded": "Servo '{service}' reŝargita", + "system_upgraded": "Sistemo ĝisdatigita", + "domain_deleted": "Domajno forigita", + "certmanager_acme_not_configured_for_domain": "Atestilo por la domajno '{domain}' ne ŝajnas esti ĝuste instalita. Bonvolu ekzekuti 'cert-instali' por ĉi tiu regado unue.", + "user_update_failed": "Ne povis ĝisdatigi uzanton {user}: {error}", + "restore_confirm_yunohost_installed": "Ĉu vi vere volas restarigi jam instalitan sistemon? [{answers}]", + "update_apt_cache_failed": "Ne eblis ĝisdatigi la kaŝmemoron de APT (paka administranto de Debian). Jen rubujo de la sources.list-linioj, kiuj povus helpi identigi problemajn liniojn:\n{sourceslist}", + "migrations_no_migrations_to_run": "Neniuj migradoj por funkcii", + "certmanager_attempt_to_renew_nonLE_cert": "La atestilo por la domajno '{domain}' ne estas elsendita de Let's Encrypt. Ne eblas renovigi ĝin aŭtomate!", + "domain_dyndns_already_subscribed": "Vi jam abonis DynDNS-domajnon", + "log_letsencrypt_cert_renew": "Renovigu '{}' Let's Encrypt atestilon", + "backup_output_directory_required": "Vi devas provizi elirejan dosierujon por la sekurkopio", + "tools_upgrade_cant_unhold_critical_packages": "Ne povis malŝalti kritikajn pakojn…", + "log_link_to_log": "Plena ŝtipo de ĉi tiu operacio: '{desc} '", + "global_settings_cant_serialize_settings": "Ne eblis serialigi datumojn pri agordoj, motivo: {reason}", + "backup_running_hooks": "Kurado de apogaj hokoj …", + "unexpected_error": "Io neatendita iris malbone: {error}", + "password_listed": "Ĉi tiu pasvorto estas inter la plej uzataj pasvortoj en la mondo. Bonvolu elekti ion pli unikan.", + "ssowat_conf_generated": "SSOwat-agordo generita", + "log_remove_on_failed_restore": "Forigu '{}' post malsukcesa restarigo de rezerva ar archiveivo", + "dpkg_is_broken": "Vi ne povas fari ĉi tion nun ĉar dpkg/APT (la administrantoj pri pakaĵaj sistemoj) ŝajnas esti rompita stato ... Vi povas provi solvi ĉi tiun problemon per konekto per SSH kaj funkcianta `sudo dpkg --configure -a`.", + "certmanager_cert_signing_failed": "Ne povis subskribi la novan atestilon", + "log_tools_upgrade": "Ĝisdatigu sistemajn pakaĵojn", + "log_available_on_yunopaste": "Ĉi tiu protokolo nun haveblas per {url}", + "pattern_port_or_range": "Devas esti valida haveno-nombro (t.e. 0-65535) aŭ gamo da havenoj (t.e. 100:200)", + "migrations_loading_migration": "Ŝarĝante migradon {id}…", + "pattern_mailbox_quota": "Devas esti grandeco kun la sufikso b/k/M/G/T aŭ 0 por ne havi kvoton", + "user_deletion_failed": "Ne povis forigi uzanton {user}: {error}", + "backup_with_no_backup_script_for_app": "La app '{app}' ne havas sekretan skripton. Ignorante.", + "service_regen_conf_is_deprecated": "'yunohost service regen-conf' malakceptas! Bonvolu uzi anstataŭe 'yunohost tools regen-conf'.", + "global_settings_key_doesnt_exists": "La ŝlosilo '{settings_key}' ne ekzistas en la tutmondaj agordoj, vi povas vidi ĉiujn disponeblajn klavojn per uzado de 'yunohost settings list'", + "dyndns_no_domain_registered": "Neniu domajno registrita ĉe DynDNS", + "dyndns_could_not_check_available": "Ne povis kontroli ĉu {domain} haveblas sur {provider}.", + "hook_exec_not_terminated": "Skripto ne finiĝis ĝuste: {path}", + "service_stopped": "Servo '{service}' ĉesis", + "restore_failed": "Ne povis restarigi sistemon", + "confirm_app_install_danger": "Danĝero! Ĉi tiu apliko estas konata ankoraŭ eksperimenta (se ne eksplicite ne funkcias)! Vi probable ne devas instali ĝin krom se vi scias kion vi faras. NENIU SUBTENO estos provizita se ĉi tiu app ne funkcias aŭ rompas vian sistemon ... Se vi pretas riski ĉiuokaze, tajpu '{answers}'", + "log_operation_unit_unclosed_properly": "Operaciumo ne estis fermita ĝuste", + "upgrade_complete": "Ĝisdatigo kompleta", + "upnp_enabled": "UPnP ŝaltis", + "mailbox_used_space_dovecot_down": "La poŝta servo de Dovecot devas funkcii, se vi volas akcepti uzitan poŝtan keston", + "restore_system_part_failed": "Ne povis restarigi la sisteman parton '{part}'", + "service_stop_failed": "Ne povis maldaŭrigi la servon '{service}'\n\nLastatempaj servaj protokoloj: {logs}", + "unbackup_app": "App '{app}' ne konserviĝos", + "updating_apt_cache": "Akirante haveblajn ĝisdatigojn por sistemaj pakoj…", + "tools_upgrade_at_least_one": "Bonvolu specifi '--apps' aŭ '--system'", + "service_already_stopped": "La servo '{service}' jam ĉesis", + "tools_upgrade_cant_both": "Ne eblas ĝisdatigi ambaŭ sistemon kaj programojn samtempe", + "restore_extracting": "Eltirante bezonatajn dosierojn el la ar theivo…", + "upnp_port_open_failed": "Ne povis malfermi havenon per UPnP", + "log_app_upgrade": "Ĝisdatigu la aplikon '{}'", + "log_help_to_get_failed_log": "La operacio '{desc}' ne povis finiĝi. Bonvolu dividi la plenan ŝtipon de ĉi tiu operacio per la komando 'yunohost log share {name}' por akiri helpon", + "port_already_closed": "Haveno {port} estas jam fermita por {ip_version} rilatoj", + "hook_name_unknown": "Nekonata hoko-nomo '{name}'", + "dyndns_could_not_check_provide": "Ne povis kontroli ĉu {provider} povas provizi {domain}.", + "restore_nothings_done": "Nenio estis restarigita", + "log_tools_postinstall": "Afiŝu vian servilon YunoHost", + "dyndns_unavailable": "La domajno '{domain}' ne haveblas.", + "experimental_feature": "Averto: Ĉi tiu funkcio estas eksperimenta kaj ne konsiderata stabila, vi ne uzu ĝin krom se vi scias kion vi faras.", + "root_password_replaced_by_admin_password": "Via radika pasvorto estis anstataŭigita per via administra pasvorto.", + "global_settings_setting_security_password_user_strength": "Uzanto pasvorta forto", + "restore_may_be_not_enough_disk_space": "Via sistemo ne ŝajnas havi sufiĉe da spaco (libera: {free_space} B, necesa spaco: {needed_space} B, sekureca marĝeno: {margin} B)", + "log_corrupted_md_file": "La YAD-metadata dosiero asociita kun protokoloj estas damaĝita: '{md_file}\nEraro: {error} '", + "downloading": "Elŝutante …", + "user_deleted": "Uzanto forigita", + "service_enable_failed": "Ne povis fari la servon '{service}' aŭtomate komenci ĉe la ekkuro.\n\nLastatempaj servaj protokoloj: {logs}", + "tools_upgrade_special_packages": "Nun ĝisdatigi 'specialajn' (rilatajn al yunohost)…", + "domains_available": "Haveblaj domajnoj:", + "dyndns_registered": "Registrita domajno DynDNS", + "service_description_fail2ban": "Protektas kontraŭ bruta forto kaj aliaj specoj de atakoj de la interreto", + "file_does_not_exist": "La dosiero {path} ne ekzistas.", + "yunohost_not_installed": "YunoHost ne estas ĝuste instalita. Bonvolu prilabori 'yunohost tools postinstall'", + "restore_removing_tmp_dir_failed": "Ne povis forigi malnovan provizoran dosierujon", + "certmanager_cannot_read_cert": "Io malbona okazis, kiam mi provis malfermi aktualan atestilon por domajno {domain} (dosiero: {file}), kialo: {reason}", + "service_removed": "Servo '{service}' forigita", + "certmanager_hit_rate_limit": "Tro multaj atestiloj jam eldonitaj por ĉi tiu ĝusta aro de domajnoj {domain} antaŭ nelonge. Bonvolu reprovi poste. Vidu https://letsencrypt.org/docs/rate-limits/ por pliaj detaloj", + "pattern_firstname": "Devas esti valida antaŭnomo", + "domain_cert_gen_failed": "Ne povis generi atestilon", + "regenconf_file_kept_back": "La agorda dosiero '{conf}' estas atendita forigi per regen-conf (kategorio {category}), sed ĝi estis konservita.", + "backup_with_no_restore_script_for_app": "La apliko \"{app}\" ne havas restarigan skripton, vi ne povos aŭtomate restarigi la sekurkopion de ĉi tiu apliko.", + "log_letsencrypt_cert_install": "Instalu atestilon Let's Encrypt sur '{}' regado", + "log_dyndns_update": "Ĝisdatigu la IP asociita kun via subdominio YunoHost '{}'", + "firewall_reload_failed": "Ne eblis reŝargi la firewall", + "confirm_app_install_warning": "Averto: Ĉi tiu aplikaĵo povas funkcii, sed ne bone integras en YunoHost. Iuj funkcioj kiel ekzemple aliĝilo kaj sekurkopio / restarigo eble ne haveblos. Instali ĉiuokaze? [{answers}] ", + "log_user_delete": "Forigi uzanton '{}'", + "dyndns_ip_updated": "Ĝisdatigis vian IP sur DynDNS", + "regenconf_up_to_date": "La agordo jam estas ĝisdatigita por kategorio '{category}'", + "global_settings_setting_security_ssh_compatibility": "Kongruo vs sekureca kompromiso por la SSH-servilo. Afektas la ĉifradojn (kaj aliajn aspektojn pri sekureco)", + "migrations_need_to_accept_disclaimer": "Por funkciigi la migradon {id}, via devas akcepti la sekvan malakcepton:\n---\n{disclaimer}\n---\nSe vi akceptas funkcii la migradon, bonvolu rekonduki la komandon kun la opcio '--accept-disclaimer'.", + "regenconf_file_remove_failed": "Ne povis forigi la agordodosieron '{conf}'", + "not_enough_disk_space": "Ne sufiĉe libera spaco sur '{path}'", + "dyndns_ip_update_failed": "Ne povis ĝisdatigi IP-adreson al DynDNS", + "ssowat_conf_updated": "SSOwat-agordo ĝisdatigita", + "log_link_to_failed_log": "Ne povis plenumi la operacion '{desc}'. Bonvolu provizi la plenan protokolon de ĉi tiu operacio per alklakante ĉi tie por akiri helpon", + "user_home_creation_failed": "Ne povis krei dosierujon \"home\" por uzanto", + "pattern_backup_archive_name": "Devas esti valida dosiernomo kun maksimume 30 signoj, alfanombraj kaj -_. signoj nur", + "restore_cleaning_failed": "Ne eblis purigi la adresaron de provizora restarigo", + "dyndns_registration_failed": "Ne povis registri DynDNS-domajnon: {error}", + "user_unknown": "Nekonata uzanto: {user}", + "migrations_to_be_ran_manually": "Migrado {id} devas funkcii permane. Bonvolu iri al Iloj → Migradoj en la retpaĝa paĝo, aŭ kuri `yunohost tools migrations run`.", + "certmanager_cert_renew_success": "Ni Ĉifru atestilon renovigitan por la domajno '{domain}'", + "global_settings_reset_success": "Antaŭaj agordoj nun estas rezervitaj al {path}", + "pattern_domain": "Devas esti valida domajna nomo (t.e. mia-domino.org)", + "dyndns_key_generating": "Generi DNS-ŝlosilon ... Eble daŭros iom da tempo.", + "restore_running_app_script": "Restarigi la programon '{app}'…", + "migrations_skip_migration": "Salti migradon {id}…", + "regenconf_file_removed": "Agordodosiero '{conf}' forigita", + "log_tools_shutdown": "Enŝaltu vian servilon", + "password_too_simple_3": "La pasvorto bezonas almenaŭ 8 signojn kaj enhavas ciferon, majusklon, pli malaltan kaj specialajn signojn", + "backup_output_symlink_dir_broken": "Via arkiva dosierujo '{path}' estas rompita ligilo. Eble vi forgesis restarigi aŭ munti aŭ enŝovi la stokadon, al kiu ĝi notas.", + "good_practices_about_admin_password": "Vi nun estas por difini novan administran pasvorton. La pasvorto devas esti almenaŭ 8 signojn - kvankam estas bone praktiki uzi pli longan pasvorton (t.e. pasfrazon) kaj/aŭ uzi variaĵon de signoj (majuskloj, minuskloj, ciferoj kaj specialaj signoj).", + "certmanager_attempt_to_renew_valid_cert": "La atestilo por la domajno '{domain}' ne finiĝos! (Vi eble uzos --force se vi scias kion vi faras)", + "restore_running_hooks": "Kurantaj restarigaj hokoj…", + "regenconf_pending_applying": "Aplikante pritraktata agordo por kategorio '{category}'…", + "service_description_dovecot": "Permesas al retpoŝtaj klientoj aliri / serĉi retpoŝton (per IMAP kaj POP3)", + "domain_dns_conf_is_just_a_recommendation": "Ĉi tiu komando montras al vi la *rekomenditan* agordon. Ĝi efektive ne agordas la DNS-agordon por vi. Via respondeco agordi vian DNS-zonon en via registristo laŭ ĉi tiu rekomendo.", + "log_backup_restore_system": "Restarigi sistemon de rezerva arkivo", + "log_app_change_url": "Ŝanĝu la URL de la apliko '{}'", + "service_already_started": "La servo '{service}' jam funkcias", + "global_settings_setting_security_password_admin_strength": "Admin pasvorta forto", + "service_reload_or_restart_failed": "Ne povis reŝargi aŭ rekomenci la servon '{service}'\n\nLastatempaj servaj protokoloj: {logs}", + "migrations_list_conflict_pending_done": "Vi ne povas uzi ambaŭ '--previous' kaj '--done' samtempe.", + "server_shutdown_confirm": "La servilo haltos tuj, ĉu vi certas? [{answers}]", + "log_backup_restore_app": "Restarigu '{}' de rezerva ar archiveivo", + "log_does_exists": "Ne estas operacio kun la nomo '{log}', uzu 'yunohost log list' por vidi ĉiujn disponeblajn operaciojn", + "service_add_failed": "Ne povis aldoni la servon '{service}'", + "pattern_password_app": "Bedaŭrinde, pasvortoj ne povas enhavi jenajn signojn: {forbidden_chars}", + "this_action_broke_dpkg": "Ĉi tiu ago rompis dpkg / APT (la administrantoj pri la paka sistemo) ... Vi povas provi solvi ĉi tiun problemon per konekto per SSH kaj funkcianta `sudo dpkg --configure -a`.", + "log_regen_conf": "Regeneri sistemajn agordojn '{}'", + "restore_hook_unavailable": "Restariga skripto por '{part}' ne haveblas en via sistemo kaj ankaŭ ne en la ar theivo", + "log_dyndns_subscribe": "Aboni al YunoHost-subdominio '{}'", + "password_too_simple_4": "La pasvorto bezonas almenaŭ 12 signojn kaj enhavas ciferon, majuskle, pli malaltan kaj specialajn signojn", + "regenconf_file_updated": "Agordodosiero '{conf}' ĝisdatigita", + "log_help_to_get_log": "Por vidi la protokolon de la operacio '{desc}', uzu la komandon 'yunohost log show {name}'", + "global_settings_setting_security_nginx_compatibility": "Kongruo vs sekureca kompromiso por la TTT-servilo NGINX. Afektas la ĉifradojn (kaj aliajn aspektojn pri sekureco)", + "restore_complete": "Restarigita", + "hook_exec_failed": "Ne povis funkcii skripto: {path}", + "global_settings_cant_open_settings": "Ne eblis malfermi agordojn, tial: {reason}", + "user_created": "Uzanto kreita", + "certmanager_attempt_to_replace_valid_cert": "Vi provas anstataŭigi bonan kaj validan atestilon por domajno {domain}! (Uzu --forte pretervidi)", + "regenconf_updated": "Agordo ĝisdatigita por '{category}'", + "update_apt_cache_warning": "Io iris malbone dum la ĝisdatigo de la kaŝmemoro de APT (paka administranto de Debian). Jen rubujo de la sources.list-linioj, kiuj povus helpi identigi problemajn liniojn:\n{sourceslist}", + "regenconf_dry_pending_applying": "Kontrolado de pritraktata agordo, kiu estus aplikita por kategorio '{category}'…", + "regenconf_file_copy_failed": "Ne povis kopii la novan agordodosieron '{new}' al '{conf}'", + "restore_already_installed_app": "App kun la ID '{app}' estas jam instalita", + "mail_domain_unknown": "Nevalida retadreso por domajno '{domain}'. Bonvolu uzi domajnon administritan de ĉi tiu servilo.", + "migrations_cant_reach_migration_file": "Ne povis aliri migrajn dosierojn ĉe la vojo '% s'", + "pattern_email": "Devas esti valida retpoŝta adreso (t.e. iu@ekzemple.com)", + "mail_alias_remove_failed": "Ne povis forigi retpoŝton alias '{mail}'", + "regenconf_file_manually_removed": "La dosiero de agordo '{conf}' estis forigita permane, kaj ne estos kreita", + "domain_exists": "La domajno jam ekzistas", + "certmanager_domain_cert_not_selfsigned": "La atestilo por domajno {domain} ne estas mem-subskribita. Ĉu vi certas, ke vi volas anstataŭigi ĝin? (Uzu '--force' por fari tion.)", + "certmanager_unable_to_parse_self_CA_name": "Ne povis trapasi nomon de mem-subskribinta aŭtoritato (dosiero: {file})", + "log_selfsigned_cert_install": "Instalu mem-subskribitan atestilon sur '{}' domajno", + "log_tools_reboot": "Reklamu vian servilon", + "certmanager_cert_install_success": "Ni Ĉifru atestilon nun instalitan por la domajno '{domain}'", + "global_settings_bad_choice_for_enum": "Malbona elekto por agordo {setting}, ricevita '{choice}', sed disponeblaj elektoj estas: {available_choices}", + "server_shutdown": "La servilo haltos", + "log_tools_migrations_migrate_forward": "Kuru migradoj", + "regenconf_now_managed_by_yunohost": "La agorda dosiero '{conf}' nun estas administrata de YunoHost (kategorio {category}).", + "server_reboot_confirm": "Ĉu la servilo rekomencos tuj, ĉu vi certas? [{answers}]", + "log_app_install": "Instalu la aplikon '{}'", + "service_description_dnsmasq": "Traktas rezolucion de domajna nomo (DNS)", + "global_settings_unknown_type": "Neatendita situacio, la agordo {setting} ŝajnas havi la tipon {unknown_type} sed ĝi ne estas tipo subtenata de la sistemo.", + "domain_hostname_failed": "Ne povis agordi novan gastigilon. Ĉi tio eble kaŭzos problemon poste (eble bone).", + "server_reboot": "La servilo rekomenciĝos", + "regenconf_failed": "Ne povis regeneri la agordon por kategorio(j): {categories}", + "domain_uninstall_app_first": "Unu aŭ pluraj programoj estas instalitaj en ĉi tiu domajno. Bonvolu malinstali ilin antaŭ ol daŭrigi la domajnan forigon", + "service_unknown": "Nekonata servo '{service}'", + "domain_deletion_failed": "Ne eblas forigi domajnon {domain}: {error}", + "log_user_update": "Ĝisdatigu uzantinformojn de '{}'", + "user_creation_failed": "Ne povis krei uzanton {user}: {error}", + "migrations_migration_has_failed": "Migrado {id} ne kompletigis, abolis. Eraro: {exception}", + "done": "Farita", + "log_domain_remove": "Forigi domon '{}' de agordo de sistemo", + "hook_list_by_invalid": "Ĉi tiu posedaĵo ne povas esti uzata por listigi hokojn", + "confirm_app_install_thirdparty": "Danĝero! Ĉi tiu apliko ne estas parto de la aplika katalogo de Yunohost. Instali triajn aplikojn povas kompromiti la integrecon kaj sekurecon de via sistemo. Vi probable ne devas instali ĝin krom se vi scias kion vi faras. NENIU SUBTENO estos provizita se ĉi tiu app ne funkcias aŭ rompas vian sistemon ... Se vi pretas riski ĉiuokaze, tajpu '{answers}'", + "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Permesu uzon de (malaktuala) DSA-hostkey por la agordo de daemon SSH", + "dyndns_domain_not_provided": "Provizanto DynDNS {provider} ne povas provizi domajnon {domain}.", + "backup_unable_to_organize_files": "Ne povis uzi la rapidan metodon por organizi dosierojn en la ar archiveivo", + "password_too_simple_2": "La pasvorto bezonas almenaŭ 8 signojn kaj enhavas ciferon, majusklojn kaj minusklojn", + "service_cmd_exec_failed": "Ne povis plenumi la komandon '{command}'", + "pattern_lastname": "Devas esti valida familinomo", + "service_enabled": "La servo '{service}' nun aŭtomate komenciĝos dum sistemaj botoj.", + "certmanager_no_cert_file": "Ne povis legi la atestan dosieron por la domajno {domain} (dosiero: {file})", + "domain_creation_failed": "Ne eblas krei domajnon {domain}: {error}", + "certmanager_domain_http_not_working": "Ŝajnas ke la domajno {domain} ne atingeblas per HTTP. Kontrolu, ke via DNS kaj NGINX-agordo ĝustas", + "domain_cannot_remove_main": "Vi ne povas forigi '{domain}' ĉar ĝi estas la ĉefa domajno, vi bezonas unue agordi alian domajnon kiel la ĉefan domajnon per uzado de 'yunohost domain main-domain -n ', jen la listo de kandidataj domajnoj. : {other_domains}", + "service_reloaded_or_restarted": "La servo '{service}' estis reŝarĝita aŭ rekomencita", + "log_domain_add": "Aldonu '{}' domajnon en sisteman agordon", + "global_settings_bad_type_for_setting": "Malbona tipo por agordo {setting}, ricevita {received_type}, atendata {expected_type}", + "unlimit": "Neniu kvoto", + "system_username_exists": "Uzantnomo jam ekzistas en la listo de uzantoj de sistemo", + "firewall_reloaded": "Fajroŝirmilo reŝarĝis", + "service_restarted": "Servo '{service}' rekomencis", + "pattern_username": "Devas esti minuskulaj literoj kaj minuskloj nur", + "extracting": "Eltirante…", + "app_restore_failed": "Ne povis restarigi la programon '{app}': {error}", + "yunohost_configured": "YunoHost nun estas agordita", + "certmanager_self_ca_conf_file_not_found": "Ne povis trovi agorddosieron por mem-subskriba aŭtoritato (dosiero: {file})", + "log_app_remove": "Forigu la aplikon '{}'", + "service_restart_failed": "Ne povis rekomenci la servon '{service}'\n\nLastatempaj servaj protokoloj: {logs}", + "firewall_rules_cmd_failed": "Iuj komandoj pri fajroŝirmilo malsukcesis. Pliaj informoj en ensaluto.", + "certmanager_certificate_fetching_or_enabling_failed": "Provante uzi la novan atestilon por {domain} ne funkciis …", + "app_full_domain_unavailable": "Bedaŭrinde, ĉi tiu app devas esti instalita sur propra domajno, sed aliaj programoj jam estas instalitaj sur la domajno '{domain}'. Vi povus uzi subdominon dediĉitan al ĉi tiu app anstataŭe.", + "group_cannot_edit_all_users": "La grupo 'all_users' ne povas esti redaktita permane. Ĝi estas speciala grupo celita enhavi ĉiujn uzantojn registritajn en YunoHost", + "group_cannot_edit_visitors": "La grupo 'vizitantoj' ne povas esti redaktita permane. Ĝi estas speciala grupo reprezentanta anonimajn vizitantojn", + "group_cannot_edit_primary_group": "La grupo '{group}' ne povas esti redaktita permane. Ĝi estas la primara grupo celita enhavi nur unu specifan uzanton.", + "log_permission_url": "Ĝisdatigu url-rilataj al permeso '{}'", + "permission_already_up_to_date": "La permeso ne estis ĝisdatigita ĉar la petoj pri aldono/forigo jam kongruas kun la aktuala stato.", + "permission_currently_allowed_for_all_users": "Ĉi tiu permeso estas nuntempe donita al ĉiuj uzantoj aldone al aliaj grupoj. Vi probable volas aŭ forigi la permeson \"all_users\" aŭ forigi la aliajn grupojn, kiujn ĝi nuntempe donas.", + "app_install_failed": "Ne povis instali {app}: {error}", + "app_install_script_failed": "Eraro okazis en la skripto de instalado de la app", + "app_remove_after_failed_install": "Forigado de la programo post la instalado-fiasko ...", + "diagnosis_basesystem_host": "Servilo funkcias Debian {debian_version}", + "apps_catalog_init_success": "Aplikoj katalogsistemo inicializita !", + "apps_catalog_updating": "Ĝisdatigante katalogo de aplikoj …", + "apps_catalog_failed_to_download": "Ne eblas elŝuti la katalogon de {apps_catalog}: {error}", + "apps_catalog_obsolete_cache": "La kaŝmemoro de la aplika katalogo estas malplena aŭ malaktuala.", + "apps_catalog_update_success": "La aplika katalogo estis ĝisdatigita!", + "diagnosis_basesystem_kernel": "Servilo funkcias Linuksan kernon {kernel_version}", + "diagnosis_basesystem_ynh_single_version": "{package} versio: {version} ({repo})", + "diagnosis_basesystem_ynh_main_version": "Servilo funkcias YunoHost {main_version} ({repo})", + "diagnosis_basesystem_ynh_inconsistent_versions": "Vi prizorgas malkonsekvencajn versiojn de la YunoHost-pakoj... plej probable pro malsukcesa aŭ parta ĝisdatigo.", + "diagnosis_cache_still_valid": "(La kaŝmemoro ankoraŭ validas por {category} diagnozo. Vi ankoraŭ ne diagnozas ĝin!)", + "diagnosis_cant_run_because_of_dep": "Ne eblas fari diagnozon por {category} dum estas gravaj problemoj rilataj al {dep}.", + "diagnosis_failed_for_category": "Diagnozo malsukcesis por kategorio '{category}': {error}", + "app_upgrade_script_failed": "Eraro okazis en la skripto pri ĝisdatiga programo", + "diagnosis_diskusage_verylow": "Stokado {mountpoint} (sur aparato {device} ) nur restas {free} ({free_percent}%) spaco restanta (el {total}). Vi vere konsideru purigi iom da spaco !", + "diagnosis_ram_verylow": "La sistemo nur restas {available} ({available_percent}%) RAM! (el {total})", + "diagnosis_mail_outgoing_port_25_blocked": "Eliranta haveno 25 ŝajnas esti blokita. Vi devas provi malŝlosi ĝin en via agorda panelo de provizanto (aŭ gastiganto). Dume la servilo ne povos sendi retpoŝtojn al aliaj serviloj.", + "diagnosis_http_bad_status_code": "Ĝi aspektas kiel alia maŝino (eble via interreta enkursigilo) respondita anstataŭ via servilo.
1. La plej ofta kaŭzo por ĉi tiu afero estas, ke la haveno 80 (kaj 443) ne estas ĝuste senditaj al via servilo .
2. Pri pli kompleksaj agordoj: certigu, ke neniu fajroŝirmilo aŭ reverso-prokuro ne interbatalas.", + "main_domain_changed": "La ĉefa domajno estis ŝanĝita", + "yunohost_postinstall_end_tip": "La post-instalado finiĝis! Por fini vian agordon, bonvolu konsideri:\n - aldonado de unua uzanto tra la sekcio 'Uzantoj' de la retadreso (aŭ 'uzanto de yunohost kreu ' en komandlinio);\n - diagnozi eblajn problemojn per la sekcio 'Diagnozo' de la reteja administrado (aŭ 'diagnoza yunohost-ekzekuto' en komandlinio);\n - legante la partojn 'Finigi vian agordon' kaj 'Ekkoni Yunohost' en la administra dokumentado: https://yunohost.org/admindoc.", + "diagnosis_ip_connected_ipv4": "La servilo estas konektita al la interreto per IPv4 !", + "diagnosis_ip_no_ipv4": "La servilo ne havas funkciantan IPv4.", + "diagnosis_ip_connected_ipv6": "La servilo estas konektita al la interreto per IPv6 !", + "diagnosis_ip_no_ipv6": "La servilo ne havas funkciantan IPv6.", + "diagnosis_ip_not_connected_at_all": "La servilo tute ne ŝajnas esti konektita al la Interreto !?", + "diagnosis_ip_dnsresolution_working": "Rezolucio pri domajna nomo funkcias !", + "diagnosis_ip_weird_resolvconf": "DNS-rezolucio ŝajnas funkcii, sed ŝajnas ke vi uzas kutiman /etc/resolv.conf .", + "diagnosis_ip_weird_resolvconf_details": "La dosiero /etc/resolv.conf devas esti ligilo al /etc/resolvconf/run/resolv.conf indikante 127.0.0.1 (dnsmasq). Se vi volas permane agordi DNS-solvilojn, bonvolu redakti /etc/resolv.dnsmasq.conf .", + "diagnosis_dns_good_conf": "DNS-registroj estas ĝuste agorditaj por domajno {domain} (kategorio {category})", + "diagnosis_dns_bad_conf": "Iuj DNS-registroj mankas aŭ malĝustas por domajno {domain} (kategorio {category})", + "diagnosis_ram_ok": "La sistemo ankoraŭ havas {available} ({available_percent}%) RAM forlasita de {total}.", + "diagnosis_swap_none": "La sistemo tute ne havas interŝanĝon. Vi devus pripensi aldoni almenaŭ {recommended} da interŝanĝo por eviti situaciojn en kiuj la sistemo restas sen memoro.", + "diagnosis_swap_notsomuch": "La sistemo havas nur {total}-interŝanĝon. Vi konsideru havi almenaŭ {recommended} por eviti situaciojn en kiuj la sistemo restas sen memoro.", + "diagnosis_regenconf_manually_modified_details": "Ĉi tio probable estas bona, se vi scias, kion vi faras! YunoHost ĉesigos ĝisdatigi ĉi tiun dosieron aŭtomate ... Sed atentu, ke YunoHost-ĝisdatigoj povus enhavi gravajn rekomendajn ŝanĝojn. Se vi volas, vi povas inspekti la diferencojn per yyunohost tools regen-conf {category} --dry-run --with-diff kaj devigi la reset al la rekomendita agordo per yunohost tools regen-conf {category} --force", + "diagnosis_security_vulnerable_to_meltdown": "Vi ŝajnas vundebla al la kritiko-vundebleco de Meltdown", + "diagnosis_no_cache": "Neniu diagnoza kaŝmemoro por kategorio '{category}'", + "diagnosis_ip_broken_dnsresolution": "Rezolucio pri domajna nomo rompiĝas pro iu kialo... Ĉu fajroŝirmilo blokas DNS-petojn ?", + "diagnosis_ip_broken_resolvconf": "Rezolucio pri domajna nomo estas rompita en via servilo, kiu ŝajnas rilata al /etc/resolv.conf ne montrante al 127.0.0.1 .", + "diagnosis_dns_missing_record": "Laŭ la rekomendita DNS-agordo, vi devas aldoni DNS-registron kun\ntipo: {type}\nnomo: {name}\nvaloro: {value}", + "diagnosis_dns_discrepancy": "La DNS-registro kun tipo {type} kaj nomo {name} ne kongruas kun la rekomendita agordo.\nNuna valoro: {current}\nEsceptita valoro: {value}", + "diagnosis_services_conf_broken": "Agordo estas rompita por servo {service} !", + "diagnosis_services_bad_status": "Servo {service} estas {status} :(", + "diagnosis_ram_low": "La sistemo havas {available} ({available_percent}%) RAM forlasita de {total}. Estu zorgema.", + "diagnosis_swap_ok": "La sistemo havas {total} da interŝanĝoj!", + "diagnosis_regenconf_allgood": "Ĉiuj agordaj dosieroj kongruas kun la rekomendita agordo!", + "diagnosis_regenconf_manually_modified": "Agordodosiero {file} ŝajnas esti permane modifita.", + "diagnosis_description_ip": "Interreta konektebleco", + "diagnosis_description_dnsrecords": "Registroj DNS", + "diagnosis_description_services": "Servo kontrolas staton", + "diagnosis_description_systemresources": "Rimedaj sistemoj", + "diagnosis_ports_could_not_diagnose": "Ne povis diagnozi, ĉu haveblaj havenoj de ekstere.", + "diagnosis_ports_could_not_diagnose_details": "Eraro: {error}", + "diagnosis_services_bad_status_tip": "Vi povas provi rekomenci la servon , kaj se ĝi ne funkcias, rigardu La servaj registroj en reteja (el la komandlinio, vi povas fari tion per yunohost service restart {service} kajyunohost service log {service}).", + "diagnosis_security_vulnerable_to_meltdown_details": "Por ripari tion, vi devas ĝisdatigi vian sistemon kaj rekomenci por ŝarĝi la novan linux-kernon (aŭ kontaktu vian servilan provizanton se ĉi tio ne funkcias). Vidu https://meltdownattack.com/ por pliaj informoj.", + "diagnosis_description_basesystem": "Baza sistemo", + "diagnosis_description_regenconf": "Sistemaj agordoj", + "main_domain_change_failed": "Ne eblas ŝanĝi la ĉefan domajnon", + "log_domain_main_domain": "Faru de '{}' la ĉefa domajno", + "diagnosis_http_timeout": "Tempolimigita dum provado kontakti vian servilon de ekstere. Ĝi ŝajnas esti neatingebla.
1. La plej ofta kaŭzo por ĉi tiu afero estas, ke la haveno 80 (kaj 443) ne estas ĝuste senditaj al via servilo.
2. Vi ankaŭ devas certigi, ke la servo nginx funkcias
3. Pri pli kompleksaj agordoj: certigu, ke neniu fajroŝirmilo aŭ reverso-prokuro ne interbatalas.", + "diagnosis_http_connection_error": "Rilata eraro: ne povis konektiĝi al la petita domajno, tre probable ĝi estas neatingebla.", + "diagnosis_ignored_issues": "(+ {nb_ignored} ignorataj aferoj))", + "diagnosis_found_errors": "Trovis {errors} signifa(j) afero(j) rilata al {category}!", + "diagnosis_found_errors_and_warnings": "Trovis {errors} signifaj problemo (j) (kaj {warnings} averto) rilataj al {category}!", + "diagnosis_diskusage_low": "Stokado {mountpoint} (sur aparato {device}) nur restas {free} ({free_percent}%) spaco restanta (el {total}). Estu zorgema.", + "diagnosis_diskusage_ok": "Stokado {mountpoint} (sur aparato {device}) ankoraŭ restas {free} ({free_percent}%) spaco (el {total})!", + "global_settings_setting_pop3_enabled": "Ebligu la protokolon POP3 por la poŝta servilo", + "diagnosis_unknown_categories": "La jenaj kategorioj estas nekonataj: {categories}", + "diagnosis_services_running": "Servo {service} funkcias!", + "diagnosis_ports_unreachable": "Haveno {port} ne atingeblas de ekstere.", + "diagnosis_ports_ok": "Haveno {port} atingeblas de ekstere.", + "diagnosis_ports_needed_by": "Eksponi ĉi tiun havenon necesas por {category} funkcioj (servo {service})", + "diagnosis_ports_forwarding_tip": "Por solvi ĉi tiun problemon, vi plej verŝajne devas agordi la plusendon de haveno en via interreta enkursigilo kiel priskribite en https://yunohost.org/isp_box_config", + "diagnosis_http_could_not_diagnose": "Ne povis diagnozi, ĉu atingeblas domajno de ekstere.", + "diagnosis_http_could_not_diagnose_details": "Eraro: {error}", + "diagnosis_http_ok": "Domajno {domain} atingebla per HTTP de ekster la loka reto.", + "diagnosis_http_unreachable": "Domajno {domain} ŝajnas neatingebla per HTTP de ekster la loka reto.", + "domain_cannot_remove_main_add_new_one": "Vi ne povas forigi '{domain}' ĉar ĝi estas la ĉefa domajno kaj via sola domajno, vi devas unue aldoni alian domajnon uzante ''yunohost domain add ', tiam agordi kiel ĉefan domajnon uzante 'yunohost domain main-domain -n ' kaj tiam vi povas forigi la domajnon' {domain} 'uzante' yunohost domain remove {domain} '.'", + "permission_require_account": "Permesilo {permission} nur havas sencon por uzantoj, kiuj havas konton, kaj tial ne rajtas esti ebligitaj por vizitantoj.", + "diagnosis_found_warnings": "Trovitaj {warnings} ero (j) kiuj povus esti plibonigitaj por {category}.", + "diagnosis_everything_ok": "Ĉio aspektas bone por {category}!", + "diagnosis_failed": "Malsukcesis preni la diagnozan rezulton por kategorio '{category}': {error}", + "diagnosis_description_ports": "Ekspoziciaj havenoj", + "diagnosis_description_mail": "Retpoŝto", + "log_app_action_run": "Funkciigu agon de la apliko '{}'", + "diagnosis_never_ran_yet": "Ŝajnas, ke ĉi tiu servilo estis instalita antaŭ nelonge kaj estas neniu diagnoza raporto por montri. Vi devas komenci kurante plenan diagnozon, ĉu de la retadministro aŭ uzante 'yunohost diagnosis run' el la komandlinio.", + "certmanager_warning_subdomain_dns_record": "Subdominio '{subdomain}' ne solvas al la sama IP-adreso kiel '{domain}'. Iuj funkcioj ne estos haveblaj ĝis vi riparos ĉi tion kaj regeneros la atestilon.", + "diagnosis_basesystem_hardware": "Arkitekturo de servila aparataro estas {virt} {arch}", + "diagnosis_description_web": "Reta", + "domain_cannot_add_xmpp_upload": "Vi ne povas aldoni domajnojn per 'xmpp-upload'. Ĉi tiu speco de nomo estas rezervita por la XMPP-alŝuta funkcio integrita en YunoHost.", + "group_already_exist_on_system_but_removing_it": "Grupo {group} jam ekzistas en la sistemaj grupoj, sed YunoHost forigos ĝin …", + "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "Iuj provizantoj ne lasos vin malŝlosi elirantan havenon 25 ĉar ili ne zorgas pri Neta Neŭtraleco.
- Iuj el ili provizas la alternativon de uzante retpoŝtan servilon kvankam ĝi implicas, ke la relajso povos spioni vian retpoŝtan trafikon.
- Amika privateco estas uzi VPN * kun dediĉita publika IP * por pretervidi ĉi tiun specon. de limoj. Vidu https://yunohost.org/#/vpn_avantage
- Vi ankaŭ povas konsideri ŝanĝi al pli neta neŭtraleco-amika provizanto", + "diagnosis_mail_fcrdns_nok_details": "Vi unue provu agordi la inversan DNS kun {ehlo_domain} en via interreta enkursigilo aŭ en via retprovizanta interfaco. (Iuj gastigantaj provizantoj eble postulas, ke vi sendu al ili subtenan bileton por ĉi tio).", + "diagnosis_mail_fcrdns_nok_alternatives_4": "Iuj provizantoj ne lasos vin agordi vian inversan DNS (aŭ ilia funkcio povus esti rompita ...). Se vi spertas problemojn pro tio, konsideru jenajn solvojn:
- Iuj ISP provizas la alternativon de uzante retpoŝtan servilon kvankam ĝi implicas, ke la relajso povos spioni vian retpoŝtan trafikon.
- Interreta privateco estas uzi VPN * kun dediĉita publika IP * por preterpasi ĉi tiajn limojn. Vidu https://yunohost.org/#/vpn_avantage
- Finfine eblas ankaŭ ŝanĝo de provizanto", + "diagnosis_display_tip": "Por vidi la trovitajn problemojn, vi povas iri al la sekcio pri Diagnozo de la reteja administrado, aŭ funkcii \"yunohost diagnosis show --issues --human-readable\" el la komandlinio.", + "diagnosis_ip_global": "Tutmonda IP: {global} ", + "diagnosis_ip_local": "Loka IP: {local} ", + "diagnosis_dns_point_to_doc": "Bonvolu kontroli la dokumentaron ĉe https://yunohost.org/dns_config se vi bezonas helpon pri agordo de DNS-registroj.", + "diagnosis_mail_outgoing_port_25_ok": "La SMTP-poŝta servilo kapablas sendi retpoŝtojn (eliranta haveno 25 ne estas blokita).", + "diagnosis_mail_outgoing_port_25_blocked_details": "Vi unue provu malŝlosi elirantan havenon 25 en via interreta enkursigilo aŭ en via retprovizanta interfaco. (Iuj gastigantaj provizantoj eble postulas, ke vi sendu al ili subtenan bileton por ĉi tio).", + "diagnosis_mail_ehlo_unreachable": "La SMTP-poŝta servilo estas neatingebla de ekstere sur IPv {ipversion}. Ĝi ne povos ricevi retpoŝtojn.", + "diagnosis_mail_ehlo_ok": "La SMTP-poŝta servilo atingeblas de ekstere kaj tial kapablas ricevi retpoŝtojn !", + "diagnosis_mail_ehlo_unreachable_details": "Ne povis malfermi rilaton sur la haveno 25 al via servilo en IPv {ipversion}. Ĝi ŝajnas esti neatingebla.
1. La plej ofta kaŭzo por ĉi tiu afero estas, ke la haveno 25 ne estas ĝuste sendita al via servilo .
2. Vi ankaŭ devas certigi, ke servo-prefikso funkcias.
3. Pri pli kompleksaj agordoj: certigu, ke neniu fajroŝirmilo aŭ reverso-prokuro ne interbatalas.", + "diagnosis_mail_ehlo_bad_answer": "Ne-SMTP-servo respondita sur la haveno 25 sur IPv {ipversion}", + "diagnosis_mail_ehlo_bad_answer_details": "Povas esti ke alia maŝino respondas anstataŭ via servilo.", + "diagnosis_mail_ehlo_wrong": "Malsama SMTP-poŝta servilo respondas pri IPv {ipversion}. Via servilo probable ne povos ricevi retpoŝtojn.", + "diagnosis_mail_ehlo_wrong_details": "La EHLO ricevita de la fora diagnozilo en IPv {ipversion} diferencas de la domajno de via servilo.
Ricevita EHLO: {wrong_ehlo}
Atendita: {right_ehlo}
La plej ofta kaŭzo por ĉi tiu afero estas, ke la haveno 25 ne estas ĝuste sendita al via servilo . Alternative, certigu, ke neniu fajroŝirmilo aŭ reverso-prokuro ne interbatalas.", + "diagnosis_mail_ehlo_could_not_diagnose": "Ne povis diagnozi ĉu postfiksa poŝta servilo atingebla de ekstere en IPv {ipversion}.", + "diagnosis_mail_ehlo_could_not_diagnose_details": "Eraro: {error}", + "diagnosis_mail_fcrdns_ok": "Via inversa DNS estas ĝuste agordita!", + "diagnosis_mail_fcrdns_dns_missing": "Neniu inversa DNS estas difinita en IPv {ipversion}. Iuj retpoŝtoj povas malsukcesi liveri aŭ povus esti markitaj kiel spamo.", + "diagnosis_mail_fcrdns_nok_alternatives_6": "Iuj provizantoj ne lasos vin agordi vian inversan DNS (aŭ ilia funkcio povus esti rompita ...). Se via inversa DNS estas ĝuste agordita por IPv4, vi povas provi malebligi la uzon de IPv6 kiam vi sendas retpoŝtojn per funkciado yunohost-agordoj set smtp.allow_ipv6 -v off . Noto: ĉi tiu lasta solvo signifas, ke vi ne povos sendi aŭ ricevi retpoŝtojn de la malmultaj IPv6-nur serviloj tie.", + "diagnosis_mail_fcrdns_different_from_ehlo_domain": "La inversa DNS ne ĝuste agordis en IPv {ipversion}. Iuj retpoŝtoj povas malsukcesi liveri aŭ povus esti markitaj kiel spamo.", + "diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "Aktuala reverso DNS: {rdns_domain}
Atendita valoro: {ehlo_domain}", + "diagnosis_mail_blacklist_ok": "La IP kaj domajnoj uzataj de ĉi tiu servilo ne ŝajnas esti listigitaj nigre", + "diagnosis_mail_blacklist_listed_by": "Via IP aŭ domajno {item} estas listigita en {blacklist_name}", + "diagnosis_mail_blacklist_reason": "La negra listo estas: {reason}", + "diagnosis_mail_blacklist_website": "Post identigi kial vi listigas kaj riparis ĝin, bonvolu peti forigi vian IP aŭ domenion sur {blacklist_website}", + "diagnosis_mail_queue_ok": "{nb_pending} pritraktataj retpoŝtoj en la retpoŝtaj vostoj", + "diagnosis_mail_queue_unavailable": "Ne povas konsulti multajn pritraktitajn retpoŝtojn en vosto", + "diagnosis_mail_queue_unavailable_details": "Eraro: {error}", + "diagnosis_mail_queue_too_big": "Tro multaj pritraktataj retpoŝtoj en retpoŝto ({nb_pending} retpoŝtoj)", + "diagnosis_ports_partially_unreachable": "Haveno {port} ne atingebla de ekstere en IPv {failed}.", + "diagnosis_http_hairpinning_issue": "Via loka reto ŝajne ne havas haŭtadon.", + "diagnosis_http_hairpinning_issue_details": "Ĉi tio probable estas pro via ISP-skatolo / enkursigilo. Rezulte, homoj de ekster via loka reto povos aliri vian servilon kiel atendite, sed ne homoj de interne de la loka reto (kiel vi, probable?) Kiam uzas la domajnan nomon aŭ tutmondan IP. Eble vi povas plibonigi la situacion per rigardado al https://yunohost.org/dns_local_network", + "diagnosis_http_partially_unreachable": "Domajno {domain} ŝajnas neatingebla per HTTP de ekster la loka reto en IPv {failed}, kvankam ĝi funkcias en IPv {passed}.", + "diagnosis_http_nginx_conf_not_up_to_date": "La nginx-agordo de ĉi tiu domajno ŝajnas esti modifita permane, kaj malhelpas YunoHost diagnozi ĉu ĝi atingeblas per HTTP.", + "diagnosis_http_nginx_conf_not_up_to_date_details": "Por solvi la situacion, inspektu la diferencon per la komandlinio per yunohost tools regen-conf nginx --dry-run --with-diff kaj se vi aranĝas, apliku la ŝanĝojn per yunohost tools regen-conf nginx --force.", + "global_settings_setting_smtp_allow_ipv6": "Permesu la uzon de IPv6 por ricevi kaj sendi poŝton", + "backup_archive_corrupted": "I aspektas kiel la rezerva arkivo '{archive}' estas koruptita: {error}", + "backup_archive_cant_retrieve_info_json": "Ne povis ŝarĝi infos por arkivo '{archive}' ... la info.json ne povas esti reprenita (aŭ ne estas valida JSON).", + "ask_user_domain": "Domajno uzi por la retpoŝta adreso de la uzanto kaj XMPP-konto", + "app_packaging_format_not_supported": "Ĉi tiu programo ne povas esti instalita ĉar ĝia pakita formato ne estas subtenata de via Yunohost-versio. Vi probable devas konsideri ĝisdatigi vian sistemon.", + "app_restore_script_failed": "Eraro okazis ene de la App Restarigu Skripton", + "app_manifest_install_ask_is_public": "Ĉu ĉi tiu programo devas esti eksponita al anonimaj vizitantoj?", + "app_manifest_install_ask_admin": "Elektu administran uzanton por ĉi tiu programo", + "app_manifest_install_ask_password": "Elektu administradan pasvorton por ĉi tiu programo", + "app_manifest_install_ask_path": "Elektu la vojon, kie ĉi tiu programo devas esti instalita", + "app_manifest_install_ask_domain": "Elektu la domajnon, kie ĉi tiu programo devas esti instalita", + "app_label_deprecated": "Ĉi tiu komando estas malrekomendita! Bonvolu uzi la novan komandon 'yunohost user permission update' por administri la app etikedo.", + "app_argument_password_no_default": "Eraro dum analiza pasvorta argumento '{name}': pasvorta argumento ne povas havi defaŭltan valoron por sekureca kialo", + "additional_urls_already_removed": "Plia URL '{url}' jam forigita en la aldona URL por permeso '{permission}'", + "additional_urls_already_added": "Plia URL '{url}' jam aldonita en la aldona URL por permeso '{permission}'" +} \ No newline at end of file diff --git a/locales/es.json b/locales/es.json index 8861c15b8..688db4546 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1,327 +1,584 @@ { - "action_invalid": "Acción no válida '{action:s} 1'", + "action_invalid": "Acción no válida '{action} 1'", "admin_password": "Contraseña administrativa", - "admin_password_change_failed": "No se puede cambiar la contraseña", - "admin_password_changed": "La contraseña administrativa ha sido cambiada", - "app_already_installed": "{app:s} 2 ya está instalada", - "app_argument_choice_invalid": "Opción no válida para el argumento '{name:s} 3', deber una de {choices:s} 4", - "app_argument_invalid": "Valor no válido para el argumento '{name:s} 5': {error:s} 6", - "app_argument_required": "Se requiere el argumento '{name:s} 7'", + "admin_password_change_failed": "No se pudo cambiar la contraseña", + "admin_password_changed": "La contraseña de administración fue cambiada", + "app_already_installed": "{app} ya está instalada", + "app_argument_choice_invalid": "Use una de estas opciones «{choices}» para el argumento «{name}»", + "app_argument_invalid": "Elija un valor válido para el argumento «{name}»: {error}", + "app_argument_required": "Se requiere el argumento '{name} 7'", "app_extraction_failed": "No se pudieron extraer los archivos de instalación", - "app_id_invalid": "Id de la aplicación no válida", - "app_incompatible": "La aplicación {app} no es compatible con su versión de YunoHost", - "app_install_files_invalid": "Los archivos de instalación no son válidos", - "app_location_already_used": "La aplicación {app} ya está instalada en esta localización ({path})", - "app_location_install_failed": "No se puede instalar la aplicación en esta localización porque entra en conflicto con la aplicación '{other_app}' ya instalada en '{other_path}'", - "app_manifest_invalid": "El manifiesto de la aplicación no es válido: {error}", - "app_no_upgrade": "No hay aplicaciones para actualizar", - "app_not_correctly_installed": "La aplicación {app:s} 8 parece estar incorrectamente instalada", - "app_not_installed": "{app:s} 9 no está instalada", - "app_not_properly_removed": "La {app:s} 0 no ha sido desinstalada correctamente", - "app_package_need_update": "El paquete de la aplicación {app} necesita ser actualizada debido a los cambios en YunoHost", - "app_recent_version_required": "{:s} requiere una versión más reciente de moulinette ", - "app_removed": "{app:s} ha sido eliminada", - "app_requirements_checking": "Comprobando los paquetes requeridos por {app}...", - "app_requirements_failed": "No se cumplen los requisitos para {app}: {error}", + "app_id_invalid": "ID de la aplicación no válida", + "app_install_files_invalid": "Estos archivos no se pueden instalar", + "app_manifest_invalid": "Algo va mal con el manifiesto de la aplicación: {error}", + "app_not_correctly_installed": "La aplicación {app} 8 parece estar incorrectamente instalada", + "app_not_installed": "No se pudo encontrar «{app}» en la lista de aplicaciones instaladas: {all_apps}", + "app_not_properly_removed": "La {app} 0 no ha sido desinstalada correctamente", + "app_removed": "Eliminado {app}", + "app_requirements_checking": "Comprobando los paquetes necesarios para {app}…", "app_requirements_unmeet": "No se cumplen los requisitos para {app}, el paquete {pkgname} ({version}) debe ser {spec}", - "app_sources_fetch_failed": "No se pudieron descargar los archivos del código fuente", + "app_sources_fetch_failed": "No se pudieron obtener los archivos con el código fuente, ¿es el URL correcto?", "app_unknown": "Aplicación desconocida", "app_unsupported_remote_type": "Tipo remoto no soportado por la aplicación", - "app_upgrade_failed": "No se pudo actualizar la aplicación {app:s}", - "app_upgraded": "{app:s} ha sido actualizada", - "appslist_fetched": "La lista de aplicaciones {appslist:s} ha sido descargada", - "appslist_removed": "La lista de aplicaciones {appslist:s} ha sido eliminada", - "appslist_retrieve_error": "No se pudo recuperar la lista remota de aplicaciones {appslist:s} : {error:s}", - "appslist_unknown": "Lista de aplicaciones {appslist:s} desconocida.", - "ask_current_admin_password": "Contraseña administrativa actual", - "ask_email": "Dirección de correo electrónico", + "app_upgrade_failed": "No se pudo actualizar {app}: {error}", + "app_upgraded": "Actualizado {app}", "ask_firstname": "Nombre", "ask_lastname": "Apellido", - "ask_list_to_remove": "Lista para desinstalar", "ask_main_domain": "Dominio principal", "ask_new_admin_password": "Nueva contraseña administrativa", "ask_password": "Contraseña", - "backup_action_required": "Debe especificar algo que guardar", - "backup_app_failed": "No es posible realizar la copia de seguridad de la aplicación '{app:s}'", - "backup_archive_app_not_found": "La aplicación '{app:s}' no ha sido encontrada en la copia de seguridad", - "backup_archive_hook_not_exec": "El hook {hook:s} no ha sido ejecutado en esta copia de seguridad", - "backup_archive_name_exists": "Ya existe una copia de seguridad con ese nombre", - "backup_archive_name_unknown": "Copia de seguridad local desconocida '{name:s}'", - "backup_archive_open_failed": "No se pudo abrir la copia de seguridad", - "backup_cleaning_failed": "No se puede limpiar el directorio temporal de copias de seguridad", + "backup_app_failed": "No se pudo respaldar «{app}»", + "backup_archive_app_not_found": "No se pudo encontrar «{app}» en el archivo de respaldo", + "backup_archive_name_exists": "Ya existe un archivo de respaldo con este nombre.", + "backup_archive_name_unknown": "Copia de seguridad local desconocida '{name}'", + "backup_archive_open_failed": "No se pudo abrir el archivo de respaldo", + "backup_cleaning_failed": "No se pudo limpiar la carpeta de respaldo temporal", "backup_created": "Se ha creado la copia de seguridad", - "backup_creating_archive": "Creando copia de seguridad...", - "backup_creation_failed": "No se pudo crear la copia de seguridad", - "backup_delete_error": "No se puede eliminar '{path:s}'", - "backup_deleted": "La copia de seguridad ha sido eliminada", - "backup_extracting_archive": "Extrayendo la copia de seguridad...", - "backup_hook_unknown": "Hook de copia de seguridad desconocido '{hook:s}'", - "backup_invalid_archive": "La copia de seguridad no es válida", - "backup_nothings_done": "No hay nada que guardar", - "backup_output_directory_forbidden": "Directorio de salida no permitido. Las copias de seguridad no pueden ser creadas en /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var o en los subdirectorios de /home/yunohost.backup/archives", - "backup_output_directory_not_empty": "El directorio de salida no está vacío", + "backup_creation_failed": "No se pudo crear el archivo de respaldo", + "backup_delete_error": "No se pudo eliminar «{path}»", + "backup_deleted": "Eliminada la copia de seguridad", + "backup_hook_unknown": "El gancho «{hook}» de la copia de seguridad es desconocido", + "backup_nothings_done": "Nada que guardar", + "backup_output_directory_forbidden": "Elija un directorio de salida diferente. Las copias de seguridad no se pueden crear en /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var o /home/yunohost.backup/archives subcarpetas", + "backup_output_directory_not_empty": "Debe elegir un directorio de salida vacío", "backup_output_directory_required": "Debe proporcionar un directorio de salida para la copia de seguridad", - "backup_running_app_script": "Ejecutando la script de copia de seguridad de la aplicación '{app:s}'...", - "backup_running_hooks": "Ejecutando los hooks de copia de seguridad...", - "custom_app_url_required": "Debe proporcionar una URL para actualizar su aplicación personalizada {app:s}", - "custom_appslist_name_required": "Debe proporcionar un nombre para su lista de aplicaciones personalizadas", - "diagnosis_debian_version_error": "No se puede obtener la versión de Debian: {error}", - "diagnosis_kernel_version_error": "No se puede obtener la versión del kernel: {error}", - "diagnosis_monitor_disk_error": "No se pueden monitorizar los discos: {error}", - "diagnosis_monitor_network_error": "No se puede monitorizar la red: {error}", - "diagnosis_monitor_system_error": "No se puede monitorizar el sistema: {error}", - "diagnosis_no_apps": "Aplicación no instalada", - "dnsmasq_isnt_installed": "Parece que dnsmasq no está instalado, ejecute 'apt-get remove bind9 && apt-get install dnsmasq'", - "domain_cert_gen_failed": "No se pudo crear el certificado", - "domain_created": "El dominio ha sido creado", - "domain_creation_failed": "No se pudo crear el dominio", - "domain_deleted": "El dominio ha sido eliminado", - "domain_deletion_failed": "No se pudo borrar el dominio", - "domain_dyndns_already_subscribed": "Ya está suscrito a un dominio DynDNS", - "domain_dyndns_invalid": "Dominio no válido para usar con DynDNS", + "backup_running_hooks": "Ejecutando los hooks de copia de respaldo...", + "custom_app_url_required": "Debe proporcionar una URL para actualizar su aplicación personalizada {app}", + "domain_cert_gen_failed": "No se pudo generar el certificado", + "domain_created": "Dominio creado", + "domain_creation_failed": "No se puede crear el dominio {domain}: {error}", + "domain_deleted": "Dominio eliminado", + "domain_deletion_failed": "No se puede eliminar el dominio {domain}: {error}", + "domain_dyndns_already_subscribed": "Ya se ha suscrito a un dominio de DynDNS", "domain_dyndns_root_unknown": "Dominio raíz de DynDNS desconocido", "domain_exists": "El dominio ya existe", - "domain_uninstall_app_first": "Una o más aplicaciones están instaladas en este dominio. Debe desinstalarlas antes de eliminar el dominio", - "domain_unknown": "Dominio desconocido", - "domain_zone_exists": "El archivo de zona del DNS ya existe", - "domain_zone_not_found": "No se ha encontrado el archivo de zona del DNS para el dominio [:s]", + "domain_uninstall_app_first": "Estas aplicaciones están todavía instaladas en tu dominio:\n{apps}\n\nPor favor desinstálalas utilizando yunohost app remove the_app_id o cambialas a otro dominio usando yunohost app change-url the_app_id antes de continuar con el borrado del dominio.", "done": "Hecho.", - "downloading": "Descargando...", - "dyndns_cron_installed": "La tarea cron para DynDNS ha sido instalada", - "dyndns_cron_remove_failed": "No se pudo eliminar la tarea cron DynDNS", - "dyndns_cron_removed": "La tarea cron DynDNS ha sido eliminada", - "dyndns_ip_update_failed": "No se pudo actualizar la dirección IP en el DynDNS", - "dyndns_ip_updated": "Su dirección IP ha sido actualizada en el DynDNS", - "dyndns_key_generating": "Se está generando la clave del DNS. Esto podría tardar unos minutos...", + "downloading": "Descargando…", + "dyndns_ip_update_failed": "No se pudo actualizar la dirección IP en DynDNS", + "dyndns_ip_updated": "Actualizada su IP en DynDNS", + "dyndns_key_generating": "Generando la clave del DNS. Esto podría tardar un rato.", "dyndns_key_not_found": "No se ha encontrado la clave DNS para el dominio", - "dyndns_no_domain_registered": "Ningún dominio ha sido registrado con DynDNS", - "dyndns_registered": "El dominio DynDNS ha sido registrado", - "dyndns_registration_failed": "No se pudo registrar el dominio DynDNS: {error:s}", - "dyndns_unavailable": "El dominio {domain:s} no está disponible.", - "executing_command": "Ejecutando el comando '{command:s}'...", - "executing_script": "Ejecutando el script '{script:s}'...", - "extracting": "Extrayendo...", - "field_invalid": "Campo no válido '{:s}'", + "dyndns_no_domain_registered": "Ningún dominio registrado con DynDNS", + "dyndns_registered": "Registrado dominio de DynDNS", + "dyndns_registration_failed": "No se pudo registrar el dominio de DynDNS: {error}", + "dyndns_unavailable": "El dominio «{domain}» no está disponible.", + "extracting": "Extrayendo…", + "field_invalid": "Campo no válido '{}'", "firewall_reload_failed": "No se pudo recargar el cortafuegos", - "firewall_reloaded": "El cortafuegos ha sido recargado", - "firewall_rules_cmd_failed": "No se pudieron aplicar algunas reglas del cortafuegos. Para más información consulte el registro.", - "format_datetime_short": "%d/%m/%Y %I:%M %p", - "hook_argument_missing": "Falta un parámetro '{:s}'", - "hook_choice_invalid": "Selección inválida '{:s}'", - "hook_exec_failed": "No se puede ejecutar el script: {path:s}", - "hook_exec_not_terminated": "La ejecución del script no ha terminado: {path:s}", - "hook_list_by_invalid": "Enumerar los hooks por validez", - "hook_name_unknown": "Nombre de hook desconocido '{name:s}'", + "firewall_reloaded": "Cortafuegos recargado", + "firewall_rules_cmd_failed": "Algunos comandos para aplicar reglas del cortafuegos han fallado. Más información en el registro.", + "hook_exec_failed": "No se pudo ejecutar el guión: {path}", + "hook_exec_not_terminated": "El guión no terminó correctamente:{path}", + "hook_list_by_invalid": "Esta propiedad no se puede usar para enumerar ganchos («hooks»)", + "hook_name_unknown": "Nombre de hook desconocido '{name}'", "installation_complete": "Instalación finalizada", - "installation_failed": "No se pudo realizar la instalación", "ip6tables_unavailable": "No puede modificar ip6tables aquí. O bien está en un 'container' o su kernel no soporta esta opción", "iptables_unavailable": "No puede modificar iptables aquí. O bien está en un 'container' o su kernel no soporta esta opción", - "ldap_initialized": "Se ha inicializado LDAP", - "license_undefined": "indefinido", - "mail_alias_remove_failed": "No se pudo eliminar el alias de correo '{mail:s}'", - "mail_domain_unknown": "El dominio de correo '{domain:s}' es desconocido", - "mail_forward_remove_failed": "No se pudo eliminar el reenvío de correo '{mail:s}'", - "maindomain_change_failed": "No se pudo cambiar el dominio principal", - "maindomain_changed": "Se ha cambiado el dominio principal", - "monitor_disabled": "La monitorización del sistema ha sido deshabilitada", - "monitor_enabled": "La monitorización del sistema ha sido habilitada", - "monitor_glances_con_failed": "No se pudo conectar al servidor Glances", - "monitor_not_enabled": "La monitorización del sistema no está habilitada", - "monitor_period_invalid": "Período de tiempo no válido", - "monitor_stats_file_not_found": "No se pudo encontrar el archivo de estadísticas", - "monitor_stats_no_update": "No hay estadísticas de monitorización para actualizar", - "monitor_stats_period_unavailable": "No hay estadísticas para el período", - "mountpoint_unknown": "Punto de montaje desconocido", - "mysql_db_creation_failed": "No se pudo crear la base de datos MySQL", - "mysql_db_init_failed": "No se pudo iniciar la base de datos MySQL", - "mysql_db_initialized": "La base de datos MySQL ha sido inicializada", - "network_check_mx_ko": "El registro DNS MX no está configurado", - "network_check_smtp_ko": "El puerto 25 (SMTP) para el correo saliente parece estar bloqueado por su red", - "network_check_smtp_ok": "El puerto de salida del correo electrónico (25, SMTP) no está bloqueado", - "new_domain_required": "Debe proporcionar el nuevo dominio principal", - "no_appslist_found": "No se ha encontrado ninguna lista de aplicaciones", - "no_internet_connection": "El servidor no está conectado a Internet", - "no_ipv6_connectivity": "La conexión por IPv6 no está disponible", - "no_restore_script": "No se ha encontrado un script de restauración para la aplicación '{app:s}'", - "not_enough_disk_space": "No hay suficiente espacio en '{path:s}'", - "package_not_installed": "El paquete '{pkgname}' no está instalado", - "package_unexpected_error": "Ha ocurrido un error inesperado procesando el paquete '{pkgname}'", - "package_unknown": "Paquete desconocido '{pkgname}'", - "packages_no_upgrade": "No hay paquetes para actualizar", - "packages_upgrade_critical_later": "Los paquetes críticos ({packages:s}) serán actualizados más tarde", + "mail_alias_remove_failed": "No se pudo eliminar el alias de correo «{mail}»", + "mail_domain_unknown": "Dirección de correo no válida para el dominio «{domain}». Use un dominio administrado por este servidor.", + "mail_forward_remove_failed": "No se pudo eliminar el reenvío de correo «{mail}»", + "main_domain_change_failed": "No se pudo cambiar el dominio principal", + "main_domain_changed": "El dominio principal ha cambiado", + "not_enough_disk_space": "No hay espacio libre suficiente en «{path}»", "packages_upgrade_failed": "No se pudieron actualizar todos los paquetes", - "path_removal_failed": "No se pudo eliminar la ruta {:s}", - "pattern_backup_archive_name": "Debe ser un nombre de archivo válido con un máximo de 30 caracteres, solo se admiten caracteres alfanuméricos, los guiones -_ y el punto", + "pattern_backup_archive_name": "Debe ser un nombre de archivo válido con un máximo de 30 caracteres, solo se admiten caracteres alfanuméricos y los caracteres -_. (guiones y punto)", "pattern_domain": "El nombre de dominio debe ser válido (por ejemplo mi-dominio.org)", - "pattern_email": "Debe ser una dirección de correo electrónico válida (por ejemplo, alguien@dominio.org)", + "pattern_email": "Debe ser una dirección de correo electrónico válida (p.ej. alguien@example.com)", "pattern_firstname": "Debe ser un nombre válido", "pattern_lastname": "Debe ser un apellido válido", - "pattern_listname": "Solo se pueden usar caracteres alfanuméricos y el guion bajo", - "pattern_mailbox_quota": "El tamaño de cuota debe tener uno de los sufijos b/k/M/G/T. Usar 0 para cuota ilimitada", + "pattern_mailbox_quota": "Debe ser un tamaño con el sufijo «b/k/M/G/T» o «0» para no tener una cuota", "pattern_password": "Debe contener al menos 3 caracteres", - "pattern_port": "Debe ser un número de puerto válido (es decir, entre 0-65535)", "pattern_port_or_range": "Debe ser un número de puerto válido (es decir entre 0-65535) o un intervalo de puertos (por ejemplo 100:200)", - "pattern_positive_number": "Deber ser un número positivo", "pattern_username": "Solo puede contener caracteres alfanuméricos o el guión bajo", - "port_already_closed": "El puerto {port:d} ya está cerrado para las conexiones {ip_version:s}", - "port_already_opened": "El puerto {port:d} ya está abierto para las conexiones {ip_version:s}", - "port_available": "El puerto {port:d} está disponible", - "port_unavailable": "El puerto {port:d} no está disponible", - "restore_action_required": "Debe especificar algo que restaurar", - "restore_already_installed_app": "Una aplicación con la id '{app:s}' ya está instalada", - "restore_app_failed": "No se puede restaurar la aplicación '{app:s}'", - "restore_cleaning_failed": "No se puede limpiar el directorio temporal de restauración", - "restore_complete": "Restauración finalizada", - "restore_confirm_yunohost_installed": "¿Realmente desea restaurar un sistema ya instalado? [{answers:s}]", + "port_already_closed": "El puerto {port} ya está cerrado para las conexiones {ip_version}", + "port_already_opened": "El puerto {port} ya está abierto para las conexiones {ip_version}", + "restore_already_installed_app": "Una aplicación con el ID «{app}» ya está instalada", + "app_restore_failed": "No se pudo restaurar la aplicación «{app}»: {error}", + "restore_cleaning_failed": "No se pudo limpiar el directorio temporal de restauración", + "restore_complete": "Restaurada", + "restore_confirm_yunohost_installed": "¿Realmente desea restaurar un sistema ya instalado? [{answers}]", "restore_failed": "No se pudo restaurar el sistema", - "restore_hook_unavailable": "El script de restauración '{part:s}' no está disponible en su sistema y tampoco en el archivo", + "restore_hook_unavailable": "El script de restauración para «{part}» no está disponible en su sistema y tampoco en el archivo", "restore_nothings_done": "No se ha restaurado nada", - "restore_running_app_script": "Ejecutando el script de restauración de la aplicación '{app:s}'...", - "restore_running_hooks": "Ejecutando los hooks de restauración...", - "service_add_failed": "No se pudo añadir el servicio '{service:s}'", - "service_added": "Servicio '{service:s}' ha sido añadido", - "service_already_started": "El servicio '{service:s}' ya ha sido inicializado", - "service_already_stopped": "El servicio '{service:s}' ya ha sido detenido", - "service_cmd_exec_failed": "No se pudo ejecutar el comando '{command:s}'", - "service_conf_file_backed_up": "Se ha realizado una copia de seguridad del archivo de configuración '{conf}' en '{backup}'", - "service_conf_file_copy_failed": "No se puede copiar el nuevo archivo de configuración '{new}' a {conf}", - "service_conf_file_manually_modified": "El archivo de configuración '{conf}' ha sido modificado manualmente y no será actualizado", - "service_conf_file_manually_removed": "El archivo de configuración '{conf}' ha sido eliminado manualmente y no será creado", - "service_conf_file_not_managed": "El archivo de configuración '{conf}' no está gestionado y no será actualizado", - "service_conf_file_remove_failed": "No se puede eliminar el archivo de configuración '{conf}'", - "service_conf_file_removed": "El archivo de configuración '{conf}' ha sido eliminado", - "service_conf_file_updated": "El archivo de configuración '{conf}' ha sido actualizado", - "service_conf_up_to_date": "La configuración del servicio '{service}' ya está actualizada", - "service_conf_updated": "La configuración ha sido actualizada para el servicio '{service}'", - "service_conf_would_be_updated": "La configuración podría haber sido actualizada para el servicio '{service} 1'", - "service_disable_failed": "No se pudo deshabilitar el servicio '{service:s}'", - "service_disabled": "El servicio '{service:s}' ha sido deshabilitado", - "service_enable_failed": "No se pudo habilitar el servicio '{service:s}'", - "service_enabled": "El servicio '{service:s}' ha sido habilitado", - "service_no_log": "No hay ningún registro para el servicio '{service:s}'", - "service_regenconf_dry_pending_applying": "Comprobando configuración pendiente que podría haber sido aplicada al servicio '{service}'...", - "service_regenconf_failed": "No se puede regenerar la configuración para el servicio(s): {services}", - "service_regenconf_pending_applying": "Aplicando la configuración pendiente para el servicio '{service}'...", - "service_remove_failed": "No se pudo desinstalar el servicio '{service:s}'", - "service_removed": "El servicio '{service:s}' ha sido desinstalado", - "service_start_failed": "No se pudo iniciar el servicio '{service:s}'\n\nRegistros de servicio recientes : {logs:s}", - "service_started": "El servicio '{service:s}' ha sido iniciado", - "service_status_failed": "No se pudo determinar el estado del servicio '{service:s}'", - "service_stop_failed": "No se pudo detener el servicio '{service:s}'", - "service_stopped": "El servicio '{service:s}' ha sido detenido", - "service_unknown": "Servicio desconocido '{service:s}'", - "ssowat_conf_generated": "Se ha generado la configuración de SSOwat", - "ssowat_conf_updated": "La configuración de SSOwat ha sido actualizada", - "system_upgraded": "El sistema ha sido actualizado", - "system_username_exists": "El nombre de usuario ya existe en el sistema", - "unbackup_app": "La aplicación '{app:s}' no se guardará", - "unexpected_error": "Ha ocurrido un error inesperado", - "unit_unknown": "Unidad desconocida '{unit:s}'", + "restore_running_app_script": "Restaurando la aplicación «{app}»…", + "restore_running_hooks": "Ejecutando los ganchos de restauración…", + "service_add_failed": "No se pudo añadir el servicio «{service}»", + "service_added": "Se agregó el servicio '{service}'", + "service_already_started": "El servicio «{service}» ya está funcionando", + "service_already_stopped": "El servicio «{service}» ya ha sido detenido", + "service_cmd_exec_failed": "No se pudo ejecutar la orden «{command}»", + "service_disable_failed": "No se pudo hacer que el servicio '{service}' no se iniciara en el arranque.\n\nRegistros de servicio recientes: {logs}", + "service_disabled": "El servicio '{service}' ya no se iniciará cuando se inicie el sistema.", + "service_enable_failed": "No se pudo hacer que el servicio '{service}' se inicie automáticamente en el arranque.\n\nRegistros de servicio recientes: {logs s}", + "service_enabled": "El servicio '{service}' ahora se iniciará automáticamente durante el arranque del sistema.", + "service_remove_failed": "No se pudo eliminar el servicio «{service}»", + "service_removed": "Servicio '{service}' eliminado", + "service_start_failed": "No se pudo iniciar el servicio «{service}»\n\nRegistro de servicios recientes:{logs}", + "service_started": "El servicio '{service}' comenzó", + "service_stop_failed": "No se pudo detener el servicio «{service}»\n\nRegistro de servicios recientes:{logs}", + "service_stopped": "Servicio '{service}' detenido", + "service_unknown": "Servicio desconocido '{service}'", + "ssowat_conf_generated": "Generada la configuración de SSOwat", + "ssowat_conf_updated": "Actualizada la configuración de SSOwat", + "system_upgraded": "Sistema actualizado", + "system_username_exists": "El nombre de usuario ya existe en la lista de usuarios del sistema", + "unbackup_app": "La aplicación '{app}' no se guardará", + "unexpected_error": "Algo inesperado salió mal: {error}", "unlimit": "Sin cuota", - "unrestore_app": "La aplicación '{app:s}' no será restaurada", - "update_cache_failed": "No se pudo actualizar la caché de APT", - "updating_apt_cache": "Actualizando lista de paquetes disponibles...", + "unrestore_app": "La aplicación '{app}' no será restaurada", + "updating_apt_cache": "Obteniendo las actualizaciones disponibles para los paquetes del sistema…", "upgrade_complete": "Actualización finalizada", - "upgrading_packages": "Actualizando paquetes...", + "upgrading_packages": "Actualizando paquetes…", "upnp_dev_not_found": "No se encontró ningún dispositivo UPnP", - "upnp_disabled": "UPnP ha sido deshabilitado", - "upnp_enabled": "UPnP ha sido habilitado", - "upnp_port_open_failed": "No se pudieron abrir puertos por UPnP", - "user_created": "El usuario ha sido creado", - "user_creation_failed": "No se pudo crear el usuario", - "user_deleted": "El usuario ha sido eliminado", - "user_deletion_failed": "No se pudo eliminar el usuario", - "user_home_creation_failed": "No se puede crear el directorio de usuario 'home'", - "user_info_failed": "No se pudo extraer la información del usuario", - "user_unknown": "Usuario desconocido: {user:s}", - "user_update_failed": "No se pudo actualizar el usuario", - "user_updated": "El usuario ha sido actualizado", + "upnp_disabled": "UPnP desactivado", + "upnp_enabled": "UPnP activado", + "upnp_port_open_failed": "No se pudo abrir el puerto vía UPnP", + "user_created": "Usuario creado", + "user_creation_failed": "No se pudo crear el usuario {user}: {error}", + "user_deleted": "Usuario eliminado", + "user_deletion_failed": "No se pudo eliminar el usuario {user}: {error}", + "user_home_creation_failed": "No se pudo crear la carpeta «home» para el usuario", + "user_unknown": "Usuario desconocido: {user}", + "user_update_failed": "No se pudo actualizar el usuario {user}: {error}", + "user_updated": "Cambiada la información de usuario", "yunohost_already_installed": "YunoHost ya está instalado", - "yunohost_ca_creation_failed": "No se pudo crear el certificado de autoridad", - "yunohost_configured": "YunoHost ha sido configurado", - "yunohost_installing": "Instalando YunoHost...", - "yunohost_not_installed": "YunoHost no está instalado o ha habido errores en la instalación. Ejecute 'yunohost tools postinstall'", - "ldap_init_failed_to_create_admin": "La inicialización de LDAP falló al crear el usuario administrador", - "mailbox_used_space_dovecot_down": "El servicio de e-mail Dovecot debe estar funcionando si desea obtener el espacio utilizado por el buzón de correo", - "ssowat_persistent_conf_read_error": "Error al leer la configuración persistente de SSOwat: {error:s}. Edite el archivo /etc/ssowat/conf.json.persistent para corregir la sintaxis de JSON", - "ssowat_persistent_conf_write_error": "Error al guardar la configuración persistente de SSOwat: {error:s}. Edite el archivo /etc/ssowat/conf.json.persistent para corregir la sintaxis de JSON", - "certmanager_attempt_to_replace_valid_cert": "Está intentando sobrescribir un certificado correcto y válido para el dominio {domain:s}! (Use --force para omitir este mensaje)", - "certmanager_domain_unknown": "Dominio desconocido {domain:s}", - "certmanager_domain_cert_not_selfsigned": "El certificado para el dominio {domain:s} no es un certificado autofirmado. ¿Está seguro de que quiere reemplazarlo? (Use --force para omitir este mensaje)", - "certmanager_certificate_fetching_or_enabling_failed": "Parece que al habilitar el nuevo certificado para el dominio {domain:s} ha fallado de alguna manera...", - "certmanager_attempt_to_renew_nonLE_cert": "El certificado para el dominio {domain:s} no ha sido emitido por Let's Encrypt. ¡No se puede renovar automáticamente!", - "certmanager_attempt_to_renew_valid_cert": "El certificado para el dominio {domain:s} no está a punto de expirar! Utilice --force para omitir este mensaje", - "certmanager_domain_http_not_working": "Parece que no se puede acceder al dominio {domain:s} a través de HTTP. Compruebe que la configuración del DNS y de nginx es correcta", - "certmanager_error_no_A_record": "No se ha encontrado un registro DNS 'A' para el dominio {domain:s}. Debe hacer que su nombre de dominio apunte a su máquina para poder instalar un certificado Let's Encrypt. (Si sabe lo que está haciendo, use --no-checks para desactivar esas comprobaciones.)", - "certmanager_domain_dns_ip_differs_from_public_ip": "El registro DNS 'A' para el dominio {domain:s} es diferente de la IP de este servidor. Si recientemente modificó su registro A, espere a que se propague (existen algunos controladores de propagación DNS disponibles en línea). (Si sabe lo que está haciendo, use --no-checks para desactivar esas comprobaciones.)", - "certmanager_cannot_read_cert": "Se ha producido un error al intentar abrir el certificado actual para el dominio {domain:s} (archivo: {file:s}), razón: {reason:s}", - "certmanager_cert_install_success_selfsigned": "¡Se ha instalado correctamente un certificado autofirmado para el dominio {domain:s}!", - "certmanager_cert_install_success": "¡Se ha instalado correctamente un certificado Let's Encrypt para el dominio {domain:s}!", - "certmanager_cert_renew_success": "¡Se ha renovado correctamente el certificado Let's Encrypt para el dominio {domain:s}!", - "certmanager_old_letsencrypt_app_detected": "\nYunohost ha detectado que la aplicación 'letsencrypt' está instalada, esto produce conflictos con las nuevas funciones de administración de certificados integradas en Yunohost. Si desea utilizar las nuevas funciones integradas, ejecute los siguientes comandos para migrar su instalación:\n\n Yunohost app remove letsencrypt\n Yunohost domain cert-install\n\nP.D.: esto intentará reinstalar los certificados para todos los dominios con un certificado Let's Encrypt o con un certificado autofirmado", - "certmanager_hit_rate_limit": "Se han emitido demasiados certificados recientemente para el conjunto de dominios {domain:s}. Por favor, inténtelo de nuevo más tarde. Consulte https://letsencrypt.org/docs/rate-limits/ para obtener más detalles", + "yunohost_configured": "YunoHost está ahora configurado", + "yunohost_installing": "Instalando YunoHost…", + "yunohost_not_installed": "YunoHost no está correctamente instalado. Ejecute «yunohost tools postinstall»", + "mailbox_used_space_dovecot_down": "El servicio de buzón Dovecot debe estar activo si desea recuperar el espacio usado del buzón", + "certmanager_attempt_to_replace_valid_cert": "Está intentando sobrescribir un certificado correcto y válido para el dominio {domain}! (Use --force para omitir este mensaje)", + "certmanager_domain_cert_not_selfsigned": "El certificado para el dominio {domain} no es un certificado autofirmado. ¿Está seguro de que quiere reemplazarlo? (Use «--force» para hacerlo)", + "certmanager_certificate_fetching_or_enabling_failed": "El intento de usar el nuevo certificado para {domain} no ha funcionado…", + "certmanager_attempt_to_renew_nonLE_cert": "El certificado para el dominio «{domain}» no ha sido emitido por Let's Encrypt. ¡No se puede renovar automáticamente!", + "certmanager_attempt_to_renew_valid_cert": "¡El certificado para el dominio «{domain}» no está a punto de expirar! (Puede usar --force si sabe lo que está haciendo)", + "certmanager_domain_http_not_working": "Parece que no se puede acceder al dominio {domain} a través de HTTP. Por favor compruebe en los diagnósticos la categoría 'Web'para más información. (Si sabe lo que está haciendo, utilice '--no-checks' para no realizar estas comprobaciones.)", + "certmanager_domain_dns_ip_differs_from_public_ip": "El registro DNS 'A' para el dominio '{domain}' es diferente de la IP de este servidor. Por favor comprueba los 'registros DNS' (básicos) la categoría de diagnósticos para mayor información. Si recientemente modificó su registro 'A', espere a que se propague (algunos verificadores de propagación de DNS están disponibles en línea). (Si sabe lo que está haciendo, use '--no-checks' para desactivar esos cheques)", + "certmanager_cannot_read_cert": "Se ha producido un error al intentar abrir el certificado actual para el dominio {domain} (archivo: {file}), razón: {reason}", + "certmanager_cert_install_success_selfsigned": "Instalado correctamente un certificado autofirmado para el dominio «{domain}»", + "certmanager_cert_install_success": "Instalado correctamente un certificado de Let's Encrypt para el dominio «{domain}»", + "certmanager_cert_renew_success": "Renovado correctamente el certificado de Let's Encrypt para el dominio «{domain}»", + "certmanager_hit_rate_limit": "Se han emitido demasiados certificados recientemente para este conjunto exacto de dominios {domain}. Pruebe de nuevo más tarde. Vea para más detalles https://letsencrypt.org/docs/rate-limits/", "certmanager_cert_signing_failed": "No se pudo firmar el nuevo certificado", - "certmanager_no_cert_file": "No se puede leer el certificado para el dominio {domain:s} (archivo: {file:s})", - "certmanager_conflicting_nginx_file": "No se puede preparar el dominio para el desafío ACME: el archivo de configuración nginx {filepath:s} está en conflicto y debe ser eliminado primero", - "domain_cannot_remove_main": "No se puede eliminar el dominio principal. Primero debe establecer un nuevo dominio principal", - "certmanager_self_ca_conf_file_not_found": "No se ha encontrado el archivo de configuración para la autoridad de autofirma (file: {file:s})", - "certmanager_unable_to_parse_self_CA_name": "No se puede procesar el nombre de la autoridad de autofirma (file: {file:s} 1)", + "certmanager_no_cert_file": "No se pudo leer el certificado para el dominio {domain} (archivo: {file})", + "domain_cannot_remove_main": "No puede eliminar '{domain}' ya que es el dominio principal, primero debe configurar otro dominio como el dominio principal usando 'yunohost domain main-domain -n '; Aquí está la lista de dominios candidatos: {other_domains}", + "certmanager_self_ca_conf_file_not_found": "No se pudo encontrar el archivo de configuración para la autoridad de autofirma (archivo: {file})", + "certmanager_unable_to_parse_self_CA_name": "No se pudo procesar el nombre de la autoridad de autofirma (archivo: {file})", "domains_available": "Dominios disponibles:", - "backup_archive_broken_link": "Imposible acceder a la copia de seguridad (enlace roto {path:s})", - "certmanager_domain_not_resolved_locally": "Su servidor Yunohost no consigue resolver el dominio {domain:s}. Esto puede suceder si ha modificado su registro DNS. Si es el caso, espere unas horas hasta que se propague la modificación. Si el problema persiste, considere añadir {domain:s} a /etc/hosts. (Si sabe lo que está haciendo, use --no-checks para deshabilitar estas verificaciones.)", - "certmanager_acme_not_configured_for_domain": "El certificado para el dominio {domain:s} no parece instalado correctamente. Ejecute primero cert-install para este dominio.", - "certmanager_http_check_timeout": "Plazo expirado, el servidor no ha podido contactarse a si mismo a través de HTTP usando su dirección IP pública (dominio {domain:s} con ip {ip:s}). Puede ser debido a hairpinning o a una mala configuración del cortafuego/router al que está conectado su servidor.", - "certmanager_couldnt_fetch_intermediate_cert": "Plazo expirado, no se ha podido descargar el certificado intermedio de Let's Encrypt. La instalación/renovación del certificado ha sido cancelada - vuelva a intentarlo más tarde.", - "appslist_retrieve_bad_format": "El archivo obtenido para la lista de aplicaciones {appslist:s} no es válido", - "domain_hostname_failed": "Error al establecer nuevo nombre de host", - "yunohost_ca_creation_success": "Se ha creado la autoridad de certificación local.", - "app_already_installed_cant_change_url": "Esta aplicación ya está instalada. No se puede cambiar el URL únicamente mediante esta función. Compruebe si está disponible la opción 'app changeurl'.", - "app_change_no_change_url_script": "La aplicacion {app_name:s} aún no permite cambiar su URL, es posible que deba actualizarla.", - "app_change_url_failed_nginx_reload": "No se pudo recargar nginx. Compruebe la salida de 'nginx -t':\n{nginx_errors:s}", - "app_change_url_identical_domains": "El antiguo y nuevo dominio/url_path son idénticos ('{domain:s} {path:s}'), no se realizarán cambios.", - "app_change_url_no_script": "Esta aplicación '{app_name:s}' aún no permite modificar su URL. Quizás debería actualizar la aplicación.", - "app_change_url_success": "El URL de la aplicación {app:s} ha sido cambiado correctamente a {domain:s} {path:s}", - "app_location_unavailable": "Este URL no está disponible o está en conflicto con otra aplicación instalada:\n{apps:s}", - "app_already_up_to_date": "La aplicación {app:s} ya está actualizada", - "appslist_name_already_tracked": "Ya existe una lista de aplicaciones registrada con el nombre {name:s}.", - "appslist_url_already_tracked": "Ya existe una lista de aplicaciones registrada con el URL {url:s}.", - "appslist_migrating": "Migrando la lista de aplicaciones {appslist:s} ...", - "appslist_could_not_migrate": "No se pudo migrar la lista de aplicaciones {appslist:s}! No se pudo analizar el URL ... El antiguo cronjob se ha mantenido en {bkp_file:s}.", - "appslist_corrupted_json": "No se pudieron cargar las listas de aplicaciones. Parece que {filename:s} está dañado.", - "invalid_url_format": "Formato de URL no válido", + "backup_archive_broken_link": "No se pudo acceder al archivo de respaldo (enlace roto a {path})", + "certmanager_acme_not_configured_for_domain": "El reto ACME no ha podido ser realizado para {domain} porque su configuración de nginx no tiene el el código correcto... Por favor, asegurate que la configuración de nginx es correcta ejecutando en el terminal `yunohost tools regen-conf nginx --dry-run --with-diff`.", + "domain_hostname_failed": "No se pudo establecer un nuevo nombre de anfitrión («hostname»). Esto podría causar problemas más tarde (no es seguro... podría ir bien).", + "app_already_installed_cant_change_url": "Esta aplicación ya está instalada. La URL no se puede cambiar solo con esta función. Marque `app changeurl` si está disponible.", + "app_change_url_identical_domains": "El antiguo y nuevo dominio/url_path son idénticos ('{domain} {path}'), no se realizarán cambios.", + "app_change_url_no_script": "La aplicación «{app_name}» aún no permite la modificación de URLs. Quizás debería actualizarla.", + "app_change_url_success": "El URL de la aplicación {app} es ahora {domain} {path}", + "app_location_unavailable": "Este URL o no está disponible o está en conflicto con otra(s) aplicación(es) instalada(s):\n{apps}", + "app_already_up_to_date": "La aplicación {app} ya está actualizada", "app_upgrade_some_app_failed": "No se pudieron actualizar algunas aplicaciones", - "app_make_default_location_already_used": "No puede hacer la aplicación '{app}' por defecto en el dominio {domain} dado que está siendo usado por otra aplicación '{other_app}'", - "app_upgrade_app_name": "Actualizando la aplicación {app}...", - "ask_path": "Camino", - "backup_abstract_method": "Este método de backup no ha sido implementado aún", - "backup_applying_method_borg": "Enviando todos los ficheros al backup en el repositorio borg-backup...", - "backup_applying_method_copy": "Copiado todos los ficheros al backup...", - "backup_applying_method_custom": "Llamando el método de backup {method:s} ...", - "backup_applying_method_tar": "Creando el archivo tar de backup...", - "backup_archive_mount_failed": "Fallo en el montado del archivo de backup", - "backup_archive_system_part_not_available": "La parte del sistema {part:s} no está disponible en este backup", - "backup_archive_writing_error": "No se pueden añadir archivos de backup en el archivo comprimido", - "backup_ask_for_copying_if_needed": "Algunos ficheros no pudieron ser preparados para hacer backup usando el método que evita el gasto de espacio temporal en el sistema. Para hacer el backup, {size:s} MB deberían ser usados temporalmente. ¿Está de acuerdo?", - "backup_borg_not_implemented": "Método de backup Borg no está implementado aún", - "backup_cant_mount_uncompress_archive": "No se puede montar en modo solo lectura el directorio del archivo descomprimido", - "backup_copying_to_organize_the_archive": "Copiando {size:s}MB para organizar el archivo", - "backup_couldnt_bind": "No puede enlazar {src:s} con {dest:s}", - "backup_csv_addition_failed": "No puede añadir archivos al backup en el archivo CSV", - "backup_csv_creation_failed": "No se puede crear el archivo CSV necesario para futuras operaciones de restauración", - "backup_custom_mount_error": "Fracaso del método de copia de seguridad personalizada en la etapa \"mount\"", - "backup_custom_need_mount_error": "Fracaso del método de copia de seguridad personalizada en la étapa \"need_mount\"", - "backup_no_uncompress_archive_dir": "El directorio del archivo descomprimido no existe", - "backup_php5_to_php7_migration_may_fail": "No se ha podido convertir su archivo para soportar php7, la restauración de sus aplicaciones php puede fallar (razón : {error:s})", - "backup_system_part_failed": "No se puede hacer una copia de seguridad de la parte \"{part:s}\" del sistema", - "backup_with_no_backup_script_for_app": "La aplicación {app:s} no tiene script de respaldo. Se ha ignorado.", - "backup_with_no_restore_script_for_app": "La aplicación {app:s} no tiene script de restauración, no podrá restaurar automáticamente la copia de seguridad de esta aplicación.", - "dyndns_could_not_check_provide": "No se pudo verificar si {provider:s} puede ofrecer {domain:s}.", - "dyndns_domain_not_provided": "El proveedor Dyndns {provider:s} no puede proporcionar el dominio {domain:s}.", - "experimental_feature": "Cuidado : esta funcionalidad es experimental y no es considerada estable, no debería usarla excepto si sabe lo que hace.", - "good_practices_about_user_password": "Está a punto de establecer una nueva contraseña de usuario. La contraseña debería de ser de al menos 8 caracteres, aunque es una buena práctica usar una contraseña más larga (es decir, una frase de paso) y/o usar varias clases de caracteres (mayúsculas, minúsculas, dígitos y caracteres especiales).", - "password_listed": "Esta contraseña es una de las más usadas en el mundo. Elija algo un poco más único.", + "app_make_default_location_already_used": "No pudo hacer que la aplicación «{app}» sea la predeterminada en el dominio, «{domain}» ya está siendo usado por la aplicación «{other_app}»", + "app_upgrade_app_name": "Ahora actualizando {app}…", + "backup_abstract_method": "Este método de respaldo aún no se ha implementado", + "backup_applying_method_copy": "Copiando todos los archivos en la copia de respaldo…", + "backup_applying_method_custom": "Llamando al método de copia de seguridad personalizado «{method}»…", + "backup_applying_method_tar": "Creando el archivo TAR de respaldo…", + "backup_archive_system_part_not_available": "La parte del sistema «{part}» no está disponible en esta copia de seguridad", + "backup_archive_writing_error": "No se pudieron añadir los archivos «{source}» (llamados en el archivo «{dest}») para ser respaldados en el archivo comprimido «{archive}»", + "backup_ask_for_copying_if_needed": "¿Quiere realizar la copia de seguridad usando {size}MB temporalmente? (Se usa este modo ya que algunos archivos no se pudieron preparar usando un método más eficiente.)", + "backup_cant_mount_uncompress_archive": "No se pudo montar el archivo descomprimido como protegido contra escritura", + "backup_copying_to_organize_the_archive": "Copiando {size}MB para organizar el archivo", + "backup_couldnt_bind": "No se pudo enlazar {src} con {dest}.", + "backup_csv_addition_failed": "No se pudo añadir archivos para respaldar en el archivo CSV", + "backup_csv_creation_failed": "No se pudo crear el archivo CSV necesario para la restauración", + "backup_custom_mount_error": "El método de respaldo personalizado no pudo superar el paso «mount»", + "backup_no_uncompress_archive_dir": "No existe tal directorio de archivos sin comprimir", + "backup_system_part_failed": "No se pudo respaldar la parte del sistema «{part}»", + "backup_with_no_backup_script_for_app": "La aplicación «{app}» no tiene un guión de respaldo. Omitiendo.", + "backup_with_no_restore_script_for_app": "«{app}» no tiene un script de restauración, no podá restaurar automáticamente la copia de seguridad de esta aplicación.", + "dyndns_could_not_check_provide": "No se pudo verificar si {provider} puede ofrecer {domain}.", + "dyndns_domain_not_provided": "El proveedor de DynDNS {provider} no puede proporcionar el dominio {domain}.", + "experimental_feature": "Aviso : esta funcionalidad es experimental y no se considera estable, no debería usarla a menos que sepa lo que está haciendo.", + "good_practices_about_user_password": "Ahora está a punto de definir una nueva contraseña de usuario. La contraseña debe tener al menos 8 caracteres, aunque es una buena práctica usar una contraseña más larga (es decir, una frase de contraseña) y / o una variación de caracteres (mayúsculas, minúsculas, dígitos y caracteres especiales).", + "password_listed": "Esta contraseña se encuentra entre las contraseñas más utilizadas en el mundo. Por favor, elija algo más único.", "password_too_simple_1": "La contraseña debe tener al menos 8 caracteres de longitud", - "password_too_simple_2": "La contraseña debe tener al menos 8 caracteres de longitud y contiene dígitos, mayúsculas y minúsculas", - "password_too_simple_3": "La contraseña debe tener al menos 8 caracteres de longitud y contiene dígitos, mayúsculas, minúsculas y caracteres especiales", - "password_too_simple_4": "La contraseña debe tener al menos 12 caracteres de longitud y contiene dígitos, mayúsculas, minúsculas y caracteres especiales" -} + "password_too_simple_2": "La contraseña tiene que ser de al menos 8 caracteres de longitud e incluir un número y caracteres en mayúsculas y minúsculas", + "password_too_simple_3": "La contraseña tiene que ser de al menos 8 caracteres de longitud e incluir un número, mayúsculas, minúsculas y caracteres especiales", + "password_too_simple_4": "La contraseña tiene que ser de al menos 12 caracteres de longitud e incluir un número, mayúsculas, minúsculas y caracteres especiales", + "update_apt_cache_warning": "Algo fue mal durante la actualización de la caché de APT (gestor de paquetes de Debian). Aquí tiene un volcado de las líneas de sources.list que podría ayudarle a identificar las líneas problemáticas:\n{sourceslist}", + "update_apt_cache_failed": "No se pudo actualizar la caché de APT (gestor de paquetes de Debian). Aquí tiene un volcado de las líneas de sources.list que podría ayudarle a identificar las líneas problemáticas:\n{sourceslist}", + "tools_upgrade_special_packages_completed": "Actualización de paquetes de YunoHost completada.\nPulse [Intro] para regresar a la línea de órdenes", + "tools_upgrade_special_packages_explanation": "La actualización especial continuará en segundo plano. No inicie ninguna otra acción en su servidor durante los próximos 10 minutos (dependiendo de la velocidad del hardware). Después de esto, es posible que deba volver a iniciar sesión en el administrador web. El registro de actualización estará disponible en Herramientas → Registro (en el webadmin) o usando 'yunohost log list' (desde la línea de comandos).", + "tools_upgrade_special_packages": "Actualizando ahora paquetes «especiales» (relacionados con YunoHost)…", + "tools_upgrade_regular_packages_failed": "No se pudieron actualizar los paquetes: {packages_list}", + "tools_upgrade_regular_packages": "Actualizando ahora paquetes «normales» (no relacionados con YunoHost)…", + "tools_upgrade_cant_unhold_critical_packages": "No se pudo liberar los paquetes críticos…", + "tools_upgrade_cant_hold_critical_packages": "No se pudieron retener los paquetes críticos…", + "tools_upgrade_cant_both": "No se puede actualizar el sistema y las aplicaciones al mismo tiempo", + "tools_upgrade_at_least_one": "Especifique «--apps», o «--system»", + "this_action_broke_dpkg": "Esta acción rompió dpkg/APT(los gestores de paquetes del sistema)… Puede tratar de solucionar este problema conectando mediante SSH y ejecutando `sudo dpkg --configure -a`.", + "service_reloaded_or_restarted": "El servicio '{service}' fue recargado o reiniciado", + "service_reload_or_restart_failed": "No se pudo recargar o reiniciar el servicio «{service}»\n\nRegistro de servicios recientes:{logs}", + "service_restarted": "Servicio '{service}' reiniciado", + "service_restart_failed": "No se pudo reiniciar el servicio «{service}»\n\nRegistro de servicios recientes:{logs}", + "service_reloaded": "Servicio '{service}' recargado", + "service_reload_failed": "No se pudo recargar el servicio «{service}»\n\nRegistro de servicios recientes:{logs}", + "service_regen_conf_is_deprecated": "¡«yunohost service regen-conf» está obsoleto! Use «yunohost tools regen-conf» en su lugar.", + "service_description_yunohost-firewall": "Gestiona los puertos de conexiones abiertos y cerrados a los servicios", + "service_description_yunohost-api": "Gestiona las interacciones entre la interfaz web de YunoHost y el sistema", + "service_description_ssh": "Permite conectar a su servidor remotamente mediante un terminal (protocolo SSH)", + "service_description_slapd": "Almacena usuarios, dominios e información relacionada", + "service_description_rspamd": "Filtra correo no deseado y otras características relacionadas con el correo", + "service_description_redis-server": "Una base de datos especializada usada para el acceso rápido de datos, cola de tareas y comunicación entre programas", + "service_description_postfix": "Usado para enviar y recibir correos", + "service_description_nginx": "Sirve o proporciona acceso a todos los sitios web alojados en su servidor", + "service_description_mysql": "Almacena los datos de la aplicación (base de datos SQL)", + "service_description_metronome": "Gestionar las cuentas XMPP de mensajería instantánea", + "service_description_fail2ban": "Protege contra ataques de fuerza bruta y otras clases de ataques desde Internet", + "service_description_dovecot": "Permite a los clientes de correo acceder/obtener correo (vía IMAP y POP3)", + "service_description_dnsmasq": "Maneja la resolución de nombres de dominio (DNS)", + "server_reboot_confirm": "El servidor se reiniciará inmediatamente ¿está seguro? [{answers}]", + "server_reboot": "El servidor se reiniciará", + "server_shutdown_confirm": "El servidor se apagará inmediatamente ¿está seguro? [{answers}]", + "server_shutdown": "El servidor se apagará", + "root_password_replaced_by_admin_password": "Su contraseña de root ha sido sustituida por su contraseña de administración.", + "root_password_desynchronized": "La contraseña de administración ha sido cambiada pero ¡YunoHost no pudo propagar esto a la contraseña de root!", + "restore_system_part_failed": "No se pudo restaurar la parte del sistema «{part}»", + "restore_removing_tmp_dir_failed": "No se pudo eliminar un directorio temporal antiguo", + "restore_not_enough_disk_space": "Espacio insuficiente (espacio: {free_space} B, espacio necesario: {needed_space} B, margen de seguridad: {margin} B)", + "restore_may_be_not_enough_disk_space": "Parece que su sistema no tiene suficiente espacio libre (libre: {free_space} B, espacio necesario: {needed_space} B, margen de seguridad: {margin} B)", + "restore_extracting": "Extrayendo los archivos necesarios para el archivo…", + "regenconf_pending_applying": "Aplicando la configuración pendiente para la categoría «{category}»…", + "regenconf_failed": "No se pudo regenerar la configuración para la(s) categoría(s): {categories}", + "regenconf_dry_pending_applying": "Comprobando la configuración pendiente que habría sido aplicada para la categoría «{category}»…", + "regenconf_would_be_updated": "La configuración habría sido actualizada para la categoría «{category}»", + "regenconf_updated": "Configuración actualizada para '{category}'", + "regenconf_up_to_date": "Ya está actualizada la configuración para la categoría «{category}»", + "regenconf_now_managed_by_yunohost": "El archivo de configuración «{conf}» está gestionado ahora por YunoHost (categoría {category}).", + "regenconf_file_updated": "Actualizado el archivo de configuración «{conf}»", + "regenconf_file_removed": "Eliminado el archivo de configuración «{conf}»", + "regenconf_file_remove_failed": "No se pudo eliminar el archivo de configuración «{conf}»", + "regenconf_file_manually_removed": "El archivo de configuración «{conf}» ha sido eliminado manualmente y no se creará", + "regenconf_file_manually_modified": "El archivo de configuración «{conf}» ha sido modificado manualmente y no será actualizado", + "regenconf_file_kept_back": "Se espera que el archivo de configuración «{conf}» sea eliminado por regen-conf (categoría {category}) pero ha sido retenido.", + "regenconf_file_copy_failed": "No se pudo copiar el nuevo archivo de configuración «{new}» a «{conf}»", + "regenconf_file_backed_up": "Archivo de configuración «{conf}» respaldado en «{backup}»", + "permission_updated": "Actualizado el permiso «{permission}»", + "permission_update_failed": "No se pudo actualizar el permiso '{permission}': {error}", + "permission_not_found": "No se encontró el permiso «{permission}»", + "permission_deletion_failed": "No se pudo eliminar el permiso «{permission}»: {error}", + "permission_deleted": "Eliminado el permiso «{permission}»", + "permission_creation_failed": "No se pudo crear el permiso «{permission}»: {error}", + "permission_created": "Creado el permiso «{permission}»", + "permission_already_exist": "El permiso «{permission}» ya existe", + "pattern_password_app": "Las contraseñas no pueden incluir los siguientes caracteres: {forbidden_chars}", + "migrations_to_be_ran_manually": "La migración {id} hay que ejecutarla manualmente. Vaya a Herramientas → Migraciones en la página web de administración o ejecute `yunohost tools migrations run`.", + "migrations_success_forward": "Migración {id} completada", + "migrations_skip_migration": "Omitiendo migración {id}…", + "migrations_running_forward": "Ejecutando migración {id}…", + "migrations_pending_cant_rerun": "Esas migraciones están aún pendientes, así que no se pueden volver a ejecutar: {ids}", + "migrations_not_pending_cant_skip": "Esas migraciones no están pendientes, así que no pueden ser omitidas: {ids}", + "migrations_no_such_migration": "No hay ninguna migración llamada «{id}»", + "migrations_no_migrations_to_run": "No hay migraciones que ejecutar", + "migrations_need_to_accept_disclaimer": "Para ejecutar la migración {id} debe aceptar el siguiente descargo de responsabilidad:\n---\n{disclaimer}\n---\nSi acepta ejecutar la migración, vuelva a ejecutar la orden con la opción «--accept-disclaimer».", + "migrations_must_provide_explicit_targets": "Necesita proporcionar objetivos explícitos al usar «--skip» or «--force-rerun»", + "migrations_migration_has_failed": "La migración {id} no se ha completado, cancelando. Error: {exception}", + "migrations_loading_migration": "Cargando migración {id}…", + "migrations_list_conflict_pending_done": "No puede usar «--previous» y «--done» al mismo tiempo.", + "migrations_exclusive_options": "«--auto», «--skip», and «--force-rerun» son opciones mutuamente excluyentes.", + "migrations_failed_to_load_migration": "No se pudo cargar la migración {id}: {error}", + "migrations_dependencies_not_satisfied": "Ejecutar estas migraciones: «{dependencies_id}» antes de migrar {id}.", + "migrations_cant_reach_migration_file": "No se pudo acceder a los archivos de migración en la ruta «%s»", + "migrations_already_ran": "Esas migraciones ya se han realizado: {ids}", + "mail_unavailable": "Esta dirección de correo está reservada y será asignada automáticamente al primer usuario", + "mailbox_disabled": "Correo desactivado para usuario {user}", + "log_tools_reboot": "Reiniciar el servidor", + "log_tools_shutdown": "Apagar el servidor", + "log_tools_upgrade": "Actualizar paquetes del sistema", + "log_tools_postinstall": "Posinstalación del servidor YunoHost", + "log_tools_migrations_migrate_forward": "Inicializa la migración", + "log_user_update": "Actualizar la información de usuario de «{}»", + "log_user_group_update": "Actualizar grupo «{}»", + "log_user_group_delete": "Eliminar grupo «{}»", + "log_user_delete": "Eliminar usuario «{}»", + "log_user_create": "Añadir usuario «{}»", + "log_regen_conf": "Regenerar la configuración del sistema «{}»", + "log_letsencrypt_cert_renew": "Renovar el certificado «{}» de Let's Encrypt", + "log_selfsigned_cert_install": "Instalar el certificado auto-firmado en el dominio '{}'", + "log_letsencrypt_cert_install": "Instalar un certificado de Let's Encrypt en el dominio «{}»", + "log_dyndns_update": "Actualizar la IP asociada con su subdominio de YunoHost «{}»", + "log_dyndns_subscribe": "Subscribirse a un subdomino de YunoHost «{}»", + "log_domain_remove": "Eliminar el dominio «{}» de la configuración del sistema", + "log_domain_add": "Añadir el dominio «{}» a la configuración del sistema", + "log_remove_on_failed_install": "Eliminar «{}» después de una instalación fallida", + "log_remove_on_failed_restore": "Eliminar «{}» después de una restauración fallida desde un archivo de respaldo", + "log_backup_restore_app": "Restaurar «{}» desde un archivo de respaldo", + "log_backup_restore_system": "Restaurar sistema desde un archivo de respaldo", + "log_available_on_yunopaste": "Este registro está ahora disponible vía {url}", + "log_app_makedefault": "Convertir «{}» en aplicación predeterminada", + "log_app_upgrade": "Actualizar la aplicación «{}»", + "log_app_remove": "Eliminar la aplicación «{}»", + "log_app_install": "Instalar la aplicación «{}»", + "log_app_change_url": "Cambiar el URL de la aplicación «{}»", + "log_operation_unit_unclosed_properly": "La unidad de operación no se ha cerrado correctamente", + "log_does_exists": "No existe ningún registro de actividades con el nombre '{log}', ejecute 'yunohost log list' para ver todos los registros de actividades disponibles", + "log_help_to_get_failed_log": "No se pudo completar la operación «{desc}». Para obtener ayuda, comparta el registro completo de esta operación ejecutando la orden «yunohost log share {name}»", + "log_link_to_failed_log": "No se pudo completar la operación «{desc}». Para obtener ayuda, proporcione el registro completo de esta operación pulsando aquí", + "log_help_to_get_log": "Para ver el registro de la operación «{desc}», ejecute la orden «yunohost log show {name}»", + "log_link_to_log": "Registro completo de esta operación: «{desc}»", + "log_corrupted_md_file": "El archivo de metadatos YAML asociado con el registro está dañado: «{md_file}\nError: {error}»", + "hook_json_return_error": "No se pudo leer la respuesta del gancho {path}. Error: {msg}. Contenido sin procesar: {raw_content}", + "group_update_failed": "No se pudo actualizar el grupo «{group}»: {error}", + "group_updated": "Grupo «{group}» actualizado", + "group_unknown": "El grupo «{group}» es desconocido", + "group_deletion_failed": "No se pudo eliminar el grupo «{group}»: {error}", + "group_deleted": "Eliminado el grupo «{group}»", + "group_creation_failed": "No se pudo crear el grupo «{group}»: {error}", + "group_created": "Creado el grupo «{group}»", + "good_practices_about_admin_password": "Ahora está a punto de definir una nueva contraseña de administración. La contraseña debe tener al menos 8 caracteres, aunque es una buena práctica usar una contraseña más larga (es decir, una frase de contraseña) y / o usar una variación de caracteres (mayúsculas, minúsculas, dígitos y caracteres especiales).", + "global_settings_unknown_type": "Situación imprevista, la configuración {setting} parece tener el tipo {unknown_type} pero no es un tipo compatible con el sistema.", + "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Permitir el uso de la llave (obsoleta) DSA para la configuración del demonio SSH", + "global_settings_unknown_setting_from_settings_file": "Clave desconocida en la configuración: «{setting_key}», desechada y guardada en /etc/yunohost/settings-unknown.json", + "global_settings_setting_security_postfix_compatibility": "Compromiso entre compatibilidad y seguridad para el servidor Postfix. Afecta al cifrado (y otros aspectos relacionados con la seguridad)", + "global_settings_setting_security_ssh_compatibility": "Compromiso entre compatibilidad y seguridad para el servidor SSH. Afecta al cifrado (y otros aspectos relacionados con la seguridad)", + "global_settings_setting_security_password_user_strength": "Seguridad de la contraseña de usuario", + "global_settings_setting_security_password_admin_strength": "Seguridad de la contraseña del administrador", + "global_settings_setting_security_nginx_compatibility": "Compromiso entre compatibilidad y seguridad para el servidor web NGINX. Afecta al cifrado (y otros aspectos relacionados con la seguridad)", + "global_settings_reset_success": "Respaldada la configuración previa en {path}", + "global_settings_key_doesnt_exists": "La clave «{settings_key}» no existe en la configuración global, puede ver todas las claves disponibles ejecutando «yunohost settings list»", + "global_settings_cant_write_settings": "No se pudo guardar el archivo de configuración, motivo: {reason}", + "global_settings_cant_serialize_settings": "No se pudo seriar los datos de configuración, motivo: {reason}", + "global_settings_cant_open_settings": "No se pudo abrir el archivo de configuración, motivo: {reason}", + "global_settings_bad_type_for_setting": "Tipo erróneo para la configuración {setting}, obtuvo {received_type}, esperado {expected_type}", + "global_settings_bad_choice_for_enum": "Opción errónea para la configuración {setting}, obtuvo «{choice}» pero las opciones disponibles son: {available_choices}", + "file_does_not_exist": "El archivo {path} no existe.", + "dyndns_could_not_check_available": "No se pudo comprobar si {domain} está disponible en {provider}.", + "domain_dns_conf_is_just_a_recommendation": "Esta orden muestra la configuración *recomendada*. No configura el DNS en realidad. Es su responsabilidad configurar la zona de DNS en su registrador según esta recomendación.", + "dpkg_lock_not_available": "Esta orden no se puede ejecutar en este momento ,parece que programa está usando el bloqueo de dpkg (el gestor de paquetes del sistema)", + "dpkg_is_broken": "No puede hacer esto en este momento porque dpkg/APT (los gestores de paquetes del sistema) parecen estar mal configurados... Puede tratar de solucionar este problema conectando a través de SSH y ejecutando `sudo apt install --fix-broken` y/o `sudo dpkg --configure -a`.", + "confirm_app_install_thirdparty": "¡PELIGRO! Esta aplicación no forma parte del catálogo de aplicaciones de Yunohost. La instalación de aplicaciones de terceros puede comprometer la integridad y la seguridad de su sistema. Probablemente NO debería instalarlo a menos que sepa lo que está haciendo. NO se proporcionará SOPORTE si esta aplicación no funciona o rompe su sistema ... Si de todos modos está dispuesto a correr ese riesgo, escriba '{answers}'", + "confirm_app_install_danger": "¡PELIGRO! ¡Se sabe que esta aplicación sigue siendo experimental (si no explícitamente no funciona)! Probablemente NO debería instalarlo a menos que sepa lo que está haciendo. NO se proporcionará SOPORTE si esta aplicación no funciona o rompe su sistema ... Si de todos modos está dispuesto a correr ese riesgo, escriba '{answers}'", + "confirm_app_install_warning": "Aviso: esta aplicación puede funcionar pero no está bien integrada en YunoHost. Algunas herramientas como la autentificación única y respaldo/restauración podrían no estar disponibles. ¿Instalar de todos modos? [{answers}] ", + "backup_unable_to_organize_files": "No se pudo usar el método rápido de organización de los archivos en el archivo", + "backup_permission": "Permiso de respaldo para {app}", + "backup_output_symlink_dir_broken": "El directorio de su archivo «{path}» es un enlace simbólico roto. Tal vez olvidó (re)montarlo o conectarlo al medio de almacenamiento al que apunta.", + "backup_mount_archive_for_restore": "Preparando el archivo para restaurarlo…", + "backup_method_tar_finished": "Creado el archivo TAR de respaldo", + "backup_method_custom_finished": "Terminado el método «{method}» de respaldo personalizado", + "backup_method_copy_finished": "Terminada la copia de seguridad", + "backup_custom_backup_error": "El método de respaldo personalizado no pudo superar el paso de «copia de seguridad»", + "backup_actually_backuping": "Creando un archivo de respaldo de los archivos obtenidos…", + "ask_new_path": "Nueva ruta", + "ask_new_domain": "Nuevo dominio", + "app_upgrade_several_apps": "Las siguientes aplicaciones se actualizarán: {apps}", + "app_start_restore": "Restaurando «{app}»…", + "app_start_backup": "Obteniendo archivos para el respaldo de «{app}»…", + "app_start_remove": "Eliminando «{app}»…", + "app_start_install": "Instalando «{app}»…", + "app_not_upgraded": "La aplicación '{failed_app}' no se pudo actualizar y, como consecuencia, se cancelaron las actualizaciones de las siguientes aplicaciones: {apps}", + "app_action_cannot_be_ran_because_required_services_down": "Estos servicios necesarios deberían estar funcionando para ejecutar esta acción: {services}. Pruebe a reiniciarlos para continuar (y posiblemente investigar por qué están caídos).", + "already_up_to_date": "Nada que hacer. Todo está actualizado.", + "admin_password_too_long": "Elija una contraseña de menos de 127 caracteres", + "aborting": "Cancelando.", + "app_action_broke_system": "Esta acción parece que ha roto estos servicios importantes: {services}", + "operation_interrupted": "¿La operación fue interrumpida manualmente?", + "apps_already_up_to_date": "Todas las aplicaciones están ya actualizadas", + "dyndns_provider_unreachable": "No se puede conectar con el proveedor de Dyndns {provider}: o su YunoHost no está correctamente conectado a Internet o el servidor dynette está caído.", + "group_already_exist": "El grupo {group} ya existe", + "group_already_exist_on_system": "El grupo {group} ya existe en los grupos del sistema", + "group_cannot_be_deleted": "El grupo {group} no se puede eliminar manualmente.", + "group_user_already_in_group": "El usuario {user} ya está en el grupo {group}", + "group_user_not_in_group": "El usuario {user} no está en el grupo {group}", + "log_permission_create": "Crear permiso «{}»", + "log_permission_delete": "Eliminar permiso «{}»", + "log_user_group_create": "Crear grupo «{}»", + "log_user_permission_update": "Actualizar los accesos para el permiso «{}»", + "log_user_permission_reset": "Restablecer permiso «{}»", + "permission_already_allowed": "El grupo «{group}» ya tiene el permiso «{permission}» activado", + "permission_already_disallowed": "El grupo '{group}' ya tiene el permiso '{permission}' deshabilitado", + "permission_cannot_remove_main": "No está permitido eliminar un permiso principal", + "user_already_exists": "El usuario «{user}» ya existe", + "app_full_domain_unavailable": "Lamentablemente esta aplicación tiene que instalarse en un dominio propio pero ya hay otras aplicaciones instaladas en el dominio «{domain}». Podría usar un subdomino dedicado a esta aplicación en su lugar.", + "app_install_failed": "No se pudo instalar {app}: {error}", + "app_install_script_failed": "Ha ocurrido un error en el guión de instalación de la aplicación", + "group_cannot_edit_all_users": "El grupo «all_users» no se puede editar manualmente. Es un grupo especial destinado a contener todos los usuarios registrados en YunoHost", + "group_cannot_edit_visitors": "El grupo «visitors» no se puede editar manualmente. Es un grupo especial que representa a los visitantes anónimos", + "group_cannot_edit_primary_group": "El grupo «{group}» no se puede editar manualmente. Es el grupo primario destinado a contener solo un usuario específico.", + "log_permission_url": "Actualizar la URL relacionada con el permiso «{}»", + "permission_already_up_to_date": "El permiso no se ha actualizado porque las peticiones de incorporación o eliminación ya coinciden con el estado actual.", + "permission_currently_allowed_for_all_users": "Este permiso se concede actualmente a todos los usuarios además de los otros grupos. Probablemente quiere o eliminar el permiso de «all_users» o eliminar los otros grupos a los que está otorgado actualmente.", + "permission_require_account": "El permiso {permission} solo tiene sentido para usuarios con una cuenta y, por lo tanto, no se puede activar para visitantes.", + "app_remove_after_failed_install": "Eliminando la aplicación tras el fallo de instalación…", + "diagnosis_basesystem_host": "El servidor está ejecutando Debian {debian_version}", + "diagnosis_basesystem_kernel": "El servidor está ejecutando el núcleo de Linux {kernel_version}", + "diagnosis_basesystem_ynh_single_version": "{package} versión: {version} ({repo})", + "diagnosis_basesystem_ynh_main_version": "El servidor está ejecutando YunoHost {main_version} ({repo})", + "diagnosis_basesystem_ynh_inconsistent_versions": "Está ejecutando versiones inconsistentes de los paquetes de YunoHost ... probablemente debido a una actualización parcial o fallida.", + "diagnosis_failed_for_category": "Error de diagnóstico para la categoría '{category}': {error}", + "diagnosis_cache_still_valid": "(Caché aún válida para el diagnóstico de {category}. ¡No se volvera a comprobar de momento!)", + "diagnosis_found_errors_and_warnings": "¡Encontrado(s) error(es) significativo(s) {errors} (y aviso(s) {warnings}) relacionado(s) con {category}!", + "apps_catalog_init_success": "¡Sistema de catálogo de aplicaciones inicializado!", + "apps_catalog_updating": "Actualizando el catálogo de aplicaciones…", + "apps_catalog_failed_to_download": "No se puede descargar el catálogo de aplicaciones {apps_catalog}: {error}", + "apps_catalog_obsolete_cache": "El caché del catálogo de aplicaciones está vacío u obsoleto.", + "apps_catalog_update_success": "¡El catálogo de aplicaciones ha sido actualizado!", + "diagnosis_cant_run_because_of_dep": "No se puede ejecutar el diagnóstico para {category} mientras haya problemas importantes relacionados con {dep}.", + "diagnosis_ignored_issues": "(+ {nb_ignored} problema(s) ignorado(s))", + "diagnosis_found_errors": "¡Encontrado(s) error(es) significativo(s) {errors} relacionado(s) con {category}!", + "diagnosis_found_warnings": "Encontrado elemento(s) {warnings} que puede(n) ser mejorado(s) para {category}.", + "diagnosis_everything_ok": "¡Todo se ve bien para {category}!", + "app_upgrade_script_failed": "Ha ocurrido un error en el script de actualización de la app", + "diagnosis_no_cache": "Todavía no hay una caché de diagnóstico para la categoría '{category}'", + "diagnosis_ip_no_ipv4": "El servidor no cuenta con ipv4 funcional.", + "diagnosis_ip_not_connected_at_all": "¿¡Está conectado el servidor a internet!?", + "diagnosis_ip_broken_resolvconf": "La resolución de nombres de dominio parece no funcionar en tu servidor, lo que parece estar relacionado con que /etc/resolv.conf no apunta a 127.0.0.1.", + "diagnosis_dns_missing_record": "Según la configuración DNS recomendada, deberías añadir un registro DNS\ntipo: {type}\nnombre: {name}\nvalor: {value}", + "diagnosis_diskusage_low": "El almacenamiento {mountpoint} (en dispositivo {device}) solo tiene {free} ({free_percent}%) de espacio disponible. Ten cuidado.", + "diagnosis_services_bad_status_tip": "Puedes intentar reiniciar el servicio, y si no funciona, echar un vistazo a los logs del servicio usando 'yunohost service log {service}' o a través de la sección 'Servicios' en webadmin.", + "diagnosis_ip_connected_ipv6": "¡El servidor está conectado a internet a través de IPv6!", + "diagnosis_ip_no_ipv6": "El servidor no cuenta con IPv6 funcional.", + "diagnosis_ip_dnsresolution_working": "¡DNS no está funcionando!", + "diagnosis_ip_broken_dnsresolution": "Parece que no funciona la resolución de nombre de dominio por alguna razón... ¿Hay algún firewall bloqueando peticiones DNS?", + "diagnosis_ip_weird_resolvconf": "La resolución de nombres de dominio DNS funciona, aunque parece que estás utilizando /etc/resolv.conf personalizada.", + "diagnosis_ip_weird_resolvconf_details": "El fichero /etc/resolv.conf debería ser un enlace simbólico a /etc/resolvconf/run/resolv.conf a su vez debe apuntar a 127.0.0.1 (dnsmasq). Si lo que quieres es configurar la resolución DNS manualmente, porfavor modifica /etc/resolv.dnsmasq.conf.", + "diagnosis_dns_good_conf": "La configuración de registros DNS es correcta para {domain} (categoría {category})", + "diagnosis_dns_bad_conf": "Algunos registros DNS faltan o están mal cofigurados para el dominio {domain} (categoría {category})", + "diagnosis_dns_discrepancy": "El siguiente registro DNS parace que no sigue la configuración recomendada
Tipo: {type}
Nombre: {name}
Valor Actual: {current}
Valor esperado: {value}", + "diagnosis_services_bad_status": "El servicio {service} está {status} :(", + "diagnosis_diskusage_verylow": "El almacenamiento {mountpoint} (en el dispositivo {device}) sólo tiene {free} ({free_percent}%) de espacio disponible. Deberías considerar la posibilidad de limpiar algo de espacio.", + "diagnosis_diskusage_ok": "¡El almacenamiento {mountpoint} (en el dispositivo {device}) todavía tiene {free} ({free_percent}%) de espacio libre!", + "diagnosis_services_conf_broken": "¡Mala configuración para el servicio {service}!", + "diagnosis_services_running": "¡El servicio {service} está en ejecución!", + "diagnosis_failed": "Error al obtener el resultado del diagnóstico para la categoría '{category}': {error}", + "diagnosis_ip_connected_ipv4": "¡El servidor está conectado a internet a través de IPv4!", + "diagnosis_security_vulnerable_to_meltdown_details": "Para corregir esto, debieras actualizar y reiniciar tu sistema para cargar el nuevo kernel de Linux (o contacta tu proveedor si esto no funciona). Mas información en https://meltdownattack.com/ .", + "diagnosis_ram_verylow": "Al sistema le queda solamente {available} ({available_percent}%) de RAM! (De un total de {total})", + "diagnosis_ram_low": "Al sistema le queda {available} ({available_percent}%) de RAM de un total de {total}. Cuidado.", + "diagnosis_ram_ok": "El sistema aun tiene {available} ({available_percent}%) de RAM de un total de {total}.", + "diagnosis_swap_none": "El sistema no tiene mas espacio de intercambio. Considera agregar por lo menos {recommended} de espacio de intercambio para evitar que el sistema se quede sin memoria.", + "diagnosis_swap_notsomuch": "Al sistema le queda solamente {total} de espacio de intercambio. Considera agregar al menos {recommended} para evitar que el sistema se quede sin memoria.", + "diagnosis_mail_outgoing_port_25_blocked": "El puerto de salida 25 parece estar bloqueado. Intenta desbloquearlo con el panel de configuración de tu proveedor de servicios de Internet (o proveedor de halbergue). Mientras tanto, el servidor no podrá enviar correos electrónicos a otros servidores.", + "diagnosis_regenconf_allgood": "Todos los archivos de configuración están en linea con la configuración recomendada!", + "diagnosis_regenconf_manually_modified": "El archivo de configuración {file} parece que ha sido modificado manualmente.", + "diagnosis_regenconf_manually_modified_details": "¡Esto probablemente esta BIEN si sabes lo que estás haciendo! YunoHost dejará de actualizar este fichero automáticamente... Pero ten en cuenta que las actualizaciones de YunoHost pueden contener importantes cambios que están recomendados. Si quieres puedes comprobar las diferencias mediante yunohost tools regen-conf {category} --dry-run --with-diff o puedes forzar el volver a las opciones recomendadas mediante el comando yunohost tools regen-conf {category} --force", + "diagnosis_security_vulnerable_to_meltdown": "Pareces vulnerable a el colapso de vulnerabilidad critica de seguridad", + "diagnosis_description_basesystem": "Sistema de base", + "diagnosis_description_ip": "Conectividad a Internet", + "diagnosis_description_dnsrecords": "Registro DNS", + "diagnosis_description_services": "Comprobación del estado de los servicios", + "diagnosis_description_ports": "Exposición de puertos", + "diagnosis_description_systemresources": "Recursos del sistema", + "diagnosis_swap_ok": "El sistema tiene {total} de espacio de intercambio!", + "diagnosis_ports_needed_by": "La apertura de este puerto es requerida para la funcionalidad {category} (service {service})", + "diagnosis_ports_ok": "El puerto {port} es accesible desde internet.", + "diagnosis_ports_unreachable": "El puerto {port} no es accesible desde internet.", + "diagnosis_ports_could_not_diagnose": "No se puede comprobar si los puertos están accesibles desde el exterior.", + "diagnosis_ports_could_not_diagnose_details": "Error: {error}", + "diagnosis_description_regenconf": "Configuraciones de sistema", + "diagnosis_description_mail": "Correo electrónico", + "diagnosis_description_web": "Web", + "diagnosis_basesystem_hardware": "La arquitectura material del servidor es {virt} {arch}", + "log_domain_main_domain": "Hacer de '{}' el dominio principal", + "log_app_action_run": "Inicializa la acción de la aplicación '{}'", + "group_already_exist_on_system_but_removing_it": "El grupo {group} ya existe en los grupos del sistema, pero YunoHost lo suprimirá …", + "global_settings_setting_pop3_enabled": "Habilita el protocolo POP3 para el servidor de correo electrónico", + "domain_cannot_remove_main_add_new_one": "No se puede remover '{domain}' porque es su principal y único dominio. Primero debe agregar un nuevo dominio con la linea de comando 'yunohost domain add ', entonces configurarlo como dominio principal con 'yunohost domain main-domain -n ' y finalmente borrar el dominio '{domain}' con 'yunohost domain remove {domain}'.'", + "diagnosis_never_ran_yet": "Este servidor todavía no tiene reportes de diagnostico. Puede iniciar un diagnostico completo desde la interface administrador web o con la linea de comando 'yunohost diagnosis run'.", + "diagnosis_unknown_categories": "Las siguientes categorías están desconocidas: {categories}", + "diagnosis_http_unreachable": "El dominio {domain} esta fuera de alcance desde internet y a través de HTTP.", + "diagnosis_http_bad_status_code": "Parece que otra máquina (quizás el router de conexión a internet) haya respondido en vez de tu servidor.
1. La causa más común es que el puerto 80 (y el 443) no hayan sido redirigidos a tu servidor.
2. En situaciones más complejas: asegurate de que ni el cortafuegos ni el proxy inverso están interfiriendo.", + "diagnosis_http_connection_error": "Error de conexión: Ne se pudo conectar al dominio solicitado.", + "diagnosis_http_timeout": "Tiempo de espera agotado al intentar contactar tu servidor desde el exterior. Parece que no sea alcanzable.
1. La causa más común es que el puerto 80 (y el 443) no estén correctamente redirigidos a tu servidor.
2. Deberías asegurarte que el servicio nginx está en marcha.
3. En situaciones más complejas: asegurate de que ni el cortafuegos ni el proxy inverso estén interfiriendo.", + "diagnosis_http_ok": "El Dominio {domain} es accesible desde internet a través de HTTP.", + "diagnosis_http_could_not_diagnose": "No se pudo verificar si el dominio es accesible desde internet.", + "diagnosis_http_could_not_diagnose_details": "Error: {error}", + "diagnosis_ports_forwarding_tip": "Para solucionar este incidente, lo más seguro deberías configurar la redirección de los puertos en el router como se especifica en https://yunohost.org/isp_box_config", + "certmanager_warning_subdomain_dns_record": "El subdominio '{subdomain}' no se resuelve en la misma dirección IP que '{domain}'. Algunas funciones no estarán disponibles hasta que solucione esto y regenere el certificado.", + "domain_cannot_add_xmpp_upload": "No puede agregar dominios que comiencen con 'xmpp-upload'. Este tipo de nombre está reservado para la función de carga XMPP integrada en YunoHost.", + "yunohost_postinstall_end_tip": "¡La post-instalación completada! Para finalizar su configuración, considere:\n - agregar un primer usuario a través de la sección 'Usuarios' del webadmin (o 'yunohost user create ' en la línea de comandos);\n - diagnostique problemas potenciales a través de la sección 'Diagnóstico' de webadmin (o 'ejecución de diagnóstico yunohost' en la línea de comandos);\n - leyendo las partes 'Finalizando su configuración' y 'Conociendo a Yunohost' en la documentación del administrador: https://yunohost.org/admindoc.", + "diagnosis_dns_point_to_doc": "Por favor, consulta la documentación en https://yunohost.org/dns_config si necesitas ayuda para configurar los registros DNS.", + "diagnosis_ip_global": "IP Global: {global}", + "diagnosis_mail_outgoing_port_25_ok": "El servidor de email SMTP puede mandar emails (puerto saliente 25 no está bloqueado).", + "diagnosis_mail_outgoing_port_25_blocked_details": "Primeramente deberías intentar desbloquear el puerto de salida 25 en la interfaz de control de tu router o en la interfaz de tu provedor de hosting. (Algunos hosting pueden necesitar que les abras un ticket de soporte para esto).", + "diagnosis_swap_tip": "Por favor tenga cuidado y sepa que si el servidor contiene swap en una tarjeta SD o un disco duro de estado sólido, esto reducirá drásticamente la vida útil del dispositivo.", + "diagnosis_domain_expires_in": "{domain} expira en {days} días.", + "diagnosis_domain_expiration_error": "¡Algunos dominios expirarán MUY PRONTO!", + "diagnosis_domain_expiration_warning": "¡Algunos dominios expirarán pronto!", + "diagnosis_domain_expiration_success": "Sus dominios están registrados y no expirarán pronto.", + "diagnosis_domain_expiration_not_found_details": "¿Parece que la información de WHOIS para el dominio {domain} no contiene información sobre la fecha de expiración?", + "diagnosis_domain_not_found_details": "¡El dominio {domain} no existe en la base de datos WHOIS o ha expirado!", + "diagnosis_domain_expiration_not_found": "No se pudo revisar la fecha de expiración para algunos dominios", + "diagnosis_dns_try_dyndns_update_force": "La configuración DNS de este dominio debería ser administrada automáticamente por Yunohost. Si no es el caso, puede intentar forzar una actualización ejecutando yunohost dyndns update --force.", + "diagnosis_ip_local": "IP Local: {local}", + "diagnosis_ip_no_ipv6_tip": "Tener IPv6 funcionando no es obligatorio para que su servidor funcione, pero es mejor para la salud del Internet en general. IPv6 debería ser configurado automáticamente por el sistema o su proveedor si está disponible. De otra manera, es posible que tenga que configurar varias cosas manualmente, tal y como se explica en esta documentación https://yunohost.org/#/ipv6. Si no puede habilitar IPv6 o si parece demasiado técnico, puede ignorar esta advertencia con toda seguridad.", + "diagnosis_display_tip": "Para ver los problemas encontrados, puede ir a la sección de diagnóstico del webadmin, o ejecutar 'yunohost diagnosis show --issues --human-readable' en la línea de comandos.", + "diagnosis_package_installed_from_sury_details": "Algunos paquetes fueron accidentalmente instalados de un repositorio de terceros llamado Sury. El equipo Yunohost ha mejorado la estrategia para manejar estos pquetes, pero es posible que algunas instalaciones con aplicaciones de PHP7.3 en Stretch puedan tener algunas inconsistencias. Para solucionar esta situación, debería intentar ejecutar el siguiente comando: {cmd_to_fix}", + "diagnosis_package_installed_from_sury": "Algunos paquetes del sistema deberían ser devueltos a una versión anterior", + "certmanager_domain_not_diagnosed_yet": "Aún no hay resultado del diagnóstico para el dominio {domain}. Por favor ejecute el diagnóstico para las categorías 'Registros DNS' y 'Web' en la sección de diagnóstico para verificar si el dominio está listo para Let's Encrypt. (O si sabe lo que está haciendo, utilice '--no-checks' para deshabilitar esos chequeos.)", + "backup_archive_corrupted": "Parece que el archivo de respaldo '{archive}' está corrupto : {error}", + "backup_archive_cant_retrieve_info_json": "No se pudieron cargar informaciones para el archivo '{archive}'... El archivo info.json no se puede cargar (o no es un json válido).", + "ask_user_domain": "Dominio a usar para la dirección de correo del usuario y cuenta XMPP", + "app_packaging_format_not_supported": "Esta aplicación no se puede instalar porque su formato de empaque no está soportado por su versión de YunoHost. Considere actualizar su sistema.", + "app_manifest_install_ask_is_public": "¿Debería exponerse esta aplicación a visitantes anónimos?", + "app_manifest_install_ask_admin": "Elija un usuario administrativo para esta aplicación", + "app_manifest_install_ask_password": "Elija una contraseña de administración para esta aplicación", + "app_manifest_install_ask_path": "Seleccione el path donde esta aplicación debería ser instalada", + "app_manifest_install_ask_domain": "Seleccione el dominio donde esta app debería ser instalada", + "app_label_deprecated": "Este comando está depreciado! Favor usar el nuevo comando 'yunohost user permission update' para administrar la etiqueta de app.", + "app_argument_password_no_default": "Error al interpretar argumento de contraseña'{name}': El argumento de contraseña no puede tener un valor por defecto por razón de seguridad", + "migration_0015_not_enough_free_space": "¡El espacio es muy bajo en `/var/`! Deberías tener almenos 1Gb de espacio libre para ejecutar la migración.", + "migration_0015_not_stretch": "¡La distribución actual de Debian no es Stretch!", + "migration_0015_yunohost_upgrade": "Iniciando la actualización del núcleo de YunoHost...", + "migration_0015_still_on_stretch_after_main_upgrade": "Algo fue mal durante la actualización principal, el sistema parece que está todavía en Debian Stretch", + "migration_0015_main_upgrade": "Comenzando la actualización principal...", + "migration_0015_patching_sources_list": "Adaptando las sources.lists...", + "migration_0015_start": "Comenzando la migración a Buster", + "migration_description_0019_extend_permissions_features": "Extiende/rehaz el sistema de gestión de permisos de la aplicación", + "migration_description_0018_xtable_to_nftable": "Migra las viejas reglas de tráfico de red al nuevo sistema nftable", + "migration_description_0017_postgresql_9p6_to_11": "Migra las bases de datos de PostgreSQL 9.6 a 11", + "migration_description_0016_php70_to_php73_pools": "Migra el «pool» de ficheros php7.0-fpm a php7.3", + "migration_description_0015_migrate_to_buster": "Actualiza el sistema a Debian Buster y YunoHost 4.x", + "migrating_legacy_permission_settings": "Migrando los antiguos parámetros de permisos...", + "invalid_regex": "Regex no valido: «{regex}»", + "global_settings_setting_backup_compress_tar_archives": "Cuando se creen nuevas copias de respaldo, comprimir los archivos (.tar.gz) en lugar de descomprimir los archivos (.tar). N.B.: activar esta opción quiere decir que los archivos serán más pequeños pero que el proceso tardará más y utilizará más CPU.", + "global_settings_setting_smtp_relay_password": "Clave de uso del SMTP", + "global_settings_setting_smtp_relay_user": "Cuenta de uso de SMTP", + "global_settings_setting_smtp_relay_port": "Puerto de envio / relay SMTP", + "global_settings_setting_smtp_relay_host": "El servidor relay de SMTP para enviar correo en lugar de esta instalación YunoHost. Útil si estás en una de estas situaciones: tu puerto 25 esta bloqueado por tu ISP o VPS, si estás en usado una IP marcada como residencial o DUHL, si no puedes configurar un DNS inverso o si el servidor no está directamente expuesto a internet y quieres utilizar otro servidor para enviar correos.", + "global_settings_setting_smtp_allow_ipv6": "Permitir el uso de IPv6 para enviar y recibir correo", + "domain_name_unknown": "Dominio «{domain}» desconocido", + "diagnosis_processes_killed_by_oom_reaper": "Algunos procesos fueron terminados por el sistema recientemente porque se quedó sin memoria. Típicamente es sintoma de falta de memoria o de un proceso que se adjudicó demasiada memoria.
Resumen de los procesos terminados:
\n{kills_summary}", + "diagnosis_http_nginx_conf_not_up_to_date_details": "Para arreglar este asunto, estudia las diferencias mediante el comando yunohost tools regen-conf nginx --dry-run --with-diff y si te parecen bien aplica los cambios mediante yunohost tools regen-conf nginx --force.", + "diagnosis_http_nginx_conf_not_up_to_date": "Parece que la configuración nginx de este dominio haya sido modificada manualmente, esto no deja que YunoHost pueda diagnosticar si es accesible mediante HTTP.", + "diagnosis_http_partially_unreachable": "El dominio {domain} parece que no es accesible mediante HTTP desde fuera de la red local mediante IPv{failed}, aunque si que funciona mediante IPv{passed}.", + "diagnosis_http_hairpinning_issue_details": "Esto quizás es debido a tu router o máquina en el ISP. Como resultado, la gente fuera de tu red local podrá acceder a tu servidor como es de esperar, pero no así las persona que estén dentro de la red local (como tu probablemente) o cuando usen el nombre de dominio o la IP global. Quizás puedes mejorar o arreglar esta situación leyendo https://yunohost.org/dns_local_network", + "diagnosis_http_hairpinning_issue": "Parece que tu red local no tiene la opción hairpinning activada.", + "diagnosis_ports_partially_unreachable": "El port {port} no es accesible desde el exterior mediante IPv{failed}.", + "diagnosis_mail_queue_too_big": "Demasiados correos electrónicos pendientes en la cola ({nb_pending} correos electrónicos)", + "diagnosis_mail_queue_unavailable_details": "Error: {error}", + "diagnosis_mail_queue_unavailable": "No se ha podido consultar el número de correos electrónicos pendientes en la cola", + "diagnosis_mail_queue_ok": "{nb_pending} correos esperando e la cola de correos electrónicos", + "diagnosis_mail_blacklist_website": "Cuando averigües y arregles el motivo por el que aprareces en la lista maligna, no dudes en solicitar que tu IP o dominio sea retirado de la {blacklist_website}", + "diagnosis_mail_blacklist_reason": "El motivo de estar en la lista maligna es: {reason}", + "diagnosis_mail_blacklist_listed_by": "Tu IP o dominio {item} está marcado como maligno en {blacklist_name}", + "diagnosis_mail_blacklist_ok": "Las IP y los dominios utilizados en este servidor no parece que estén en ningún listado maligno (blacklist)", + "diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "El DNS inverso actual es: {rdns_domain}
Valor esperado: {ehlo_domain}", + "diagnosis_mail_fcrdns_different_from_ehlo_domain": "La resolución de DNS inverso no está correctamente configurada mediante IPv{ipversion}. Algunos correos pueden fallar al ser enviados o pueden ser marcados como basura.", + "diagnosis_mail_fcrdns_nok_alternatives_6": "Algunos proveedores no permiten configurar el DNS inverso (o su funcionalidad puede estar rota...). Si tu DNS inverso está configurado correctamente para IPv4, puedes intentar deshabilitarlo para IPv6 cuando envies correos mediante el comando yunohost settings set smtp.allow_ipv6 -v off. Nota: esta solución quiere decir que no podrás enviar ni recibir correos con los pocos servidores que utilizan exclusivamente IPv6.", + "diagnosis_mail_fcrdns_nok_alternatives_4": "Algunos proveedores no te permitirán que configures un DNS inverso (o puede que esta opción esté rota...). Si estás sufriendo problemas por este asunto, quizás te sirvan las siguientes soluciones:
- Algunos ISP proporcionan una alternativa mediante el uso de un relay de servidor de correo aunque esto implica que el relay podrá espiar tu tráfico de correo electrónico.
- Una solución amigable con la privacidad es utilizar una VPN con una *IP pública dedicada* para evitar este tipo de limitaciones. Mira en https://yunohost.org/#/vpn_advantage
- Quizás tu solución sea cambiar de proveedor de internet", + "diagnosis_mail_fcrdns_nok_details": "Primero deberías intentar configurar el DNS inverso mediante {ehlo_domain} en la interfaz de internet de tu router o en la de tu proveedor de internet. (Algunos proveedores de internet en ocasiones necesitan que les solicites un ticket de soporte para ello).", + "diagnosis_mail_fcrdns_dns_missing": "No hay definida ninguna DNS inversa mediante IPv{ipversion}. Algunos correos puede que fallen al enviarse o puede que se marquen como basura.", + "diagnosis_mail_fcrdns_ok": "¡Las DNS inversas están bien configuradas!", + "diagnosis_mail_ehlo_could_not_diagnose_details": "Error: {error}", + "diagnosis_mail_ehlo_could_not_diagnose": "No pudimos diagnosticar si el servidor de correo postfix es accesible desde el exterior utilizando IPv{ipversion}.", + "diagnosis_mail_ehlo_wrong_details": "El EHLO recibido por el diagnosticador remoto de IPv{ipversion} es diferente del dominio de tu servidor.
EHLO recibido: {wrong_ehlo}
EHLO esperado: {right_ehlo}
La causa más común de este error suele ser que el puerto 25 no está correctamente enrutado hacia tu servidor. Así mismo asegurate que ningún firewall ni reverse-proxy está interfiriendo.", + "diagnosis_mail_ehlo_wrong": "Un servidor diferente de SMTP está respondiendo mediante IPv{ipversion}. Es probable que tu servidor no pueda recibir correos.", + "diagnosis_mail_ehlo_bad_answer_details": "Podría ser debido a otra máquina en lugar de tu servidor.", + "diagnosis_mail_ehlo_bad_answer": "Un servicio que no es SMTP respondió en el puerto 25 mediante IPv{ipversion}", + "diagnosis_mail_ehlo_unreachable_details": "No pudo abrirse la conexión en el puerto 25 de tu servidor mediante IPv{ipversion}. Parece que no se puede contactar.
1. La causa más común en estos casos suele ser que el puerto 25 no está correctamente redireccionado a tu servidor.
2. También deberías asegurarte que el servicio postfix está en marcha.
3. En casos más complejos: asegurate que no estén interfiriendo ni el firewall ni el reverse-proxy.", + "diagnosis_mail_ehlo_unreachable": "El servidor de correo SMTP no puede contactarse desde el exterior mediante IPv{ipversion}. No puede recibir correos", + "diagnosis_mail_ehlo_ok": "¡El servidor de correo SMTP puede contactarse desde el exterior por lo que puede recibir correos!", + "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "Algunos proveedores de internet no le permitirán desbloquear el puerto 25 porque no les importa la Neutralidad de la Red.
- Algunos proporcionan una alternativa usando un relay como servidor de correo lo que implica que el relay podrá espiar tu trafico de correo.
- Una alternativa buena para la privacidad es utilizar una VPN *con una IP pública dedicada* para evitar estas limitaciones. Mira en https://yunohost.org/#/vpn_advantage
- Otra alternativa es cambiar de proveedor de inteernet a uno más amable con la Neutralidad de la Red", + "diagnosis_backports_in_sources_list": "Parece que apt (el gestor de paquetes) está configurado para usar el repositorio backports. A menos que realmente sepas lo que estás haciendo, desaconsejamos absolutamente instalar paquetes desde backports, ya que pueden provocar comportamientos intestables o conflictos en el sistema.", + "diagnosis_basesystem_hardware_model": "El modelo de servidor es {model}", + "additional_urls_already_removed": "La URL adicional «{url}» ya se ha eliminado para el permiso «{permission}»", + "additional_urls_already_added": "La URL adicional «{url}» ya se ha añadido para el permiso «{permission}»" +} \ No newline at end of file diff --git a/locales/eu.json b/locales/eu.json index 0967ef424..1891e00a3 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -1 +1,3 @@ -{} +{ + "password_too_simple_1": "Pasahitzak gutxienez 8 karaktere izan behar ditu" +} \ No newline at end of file diff --git a/locales/fa.json b/locales/fa.json new file mode 100644 index 000000000..f566fed90 --- /dev/null +++ b/locales/fa.json @@ -0,0 +1,640 @@ +{ + "action_invalid": "اقدام نامعتبر '{action}'", + "aborting": "رها کردن.", + "app_argument_required": "استدلال '{name}' الزامی است", + "app_argument_password_no_default": "خطا هنگام تجزیه گذرواژه '{name}': به دلایل امنیتی استدلال رمز عبور نمی تواند مقدار پیش فرض داشته باشد", + "app_argument_invalid": "یک مقدار معتبر انتخاب کنید برای استدلال '{name}':{error}", + "app_argument_choice_invalid": "برای آرگومان '{name}' از یکی از این گزینه ها '{choices}' استفاده کنید", + "app_already_up_to_date": "{app} در حال حاضر به روز است", + "app_already_installed_cant_change_url": "این برنامه قبلاً نصب شده است. URL فقط با این عملکرد قابل تغییر نیست. در صورت موجود بودن برنامه `app changeurl` را بررسی کنید.", + "app_already_installed": "{app} قبلاً نصب شده است", + "app_action_broke_system": "این اقدام به نظر می رسد سرویس های مهمی را خراب کرده است: {services}", + "app_action_cannot_be_ran_because_required_services_down": "برای اجرای این عملیات سرویس هایی که مورد نیازاند و باید اجرا شوند: {services}. سعی کنید آنها را مجدداً راه اندازی کنید (و علت خرابی احتمالی آنها را بررسی کنید).", + "already_up_to_date": "کاری برای انجام دادن نیست. همه چیز در حال حاضر به روز است.", + "admin_password_too_long": "لطفاً گذرواژه ای کوتاهتر از 127 کاراکتر انتخاب کنید", + "admin_password_changed": "رمز مدیریت تغییر کرد", + "admin_password_change_failed": "تغییر رمز امکان پذیر نیست", + "admin_password": "رمز عبور مدیریت", + "additional_urls_already_removed": "نشانی اینترنتی اضافی '{url}' قبلاً در نشانی اینترنتی اضافی برای اجازه '{permission}'حذف شده است", + "additional_urls_already_added": "نشانی اینترنتی اضافی '{url}' قبلاً در نشانی اینترنتی اضافی برای اجازه '{permission}' اضافه شده است", + "diagnosis_diskusage_low": "‏ذخیره سازی {mountpoint} (روی دستگاه {device}) فقط {free} ({free_percent}%) فضا باقی مانده(از {total}). مراقب باشید.", + "diagnosis_diskusage_verylow": "‏ذخیره سازی {mountpoint} (روی دستگاه {device}) فقط {free} ({free_percent}%) فضا باقی مانده (از {total}). شما واقعاً باید پاکسازی فضای ذخیره ساز را در نظر بگیرید!", + "diagnosis_services_bad_status_tip": "می توانید سعی کنید سرویس را راه اندازی مجدد کنید، و اگر کار نمی کند ، نگاهی داشته باشید بهسرویس در webadmin ثبت می شود (از خط فرمان ، می توانید این کار را انجام دهید با yunohost service restart {service} و yunohost service log {service}).", + "diagnosis_services_bad_status": "سرویس {service} {status} است :(", + "diagnosis_services_conf_broken": "پیکربندی سرویس {service} خراب است!", + "diagnosis_services_running": "سرویس {service} در حال اجرا است!", + "diagnosis_domain_expires_in": "{domain} در {days} روز منقضی می شود.", + "diagnosis_domain_expiration_error": "برخی از دامنه ها به زودی منقضی می شوند!", + "diagnosis_domain_expiration_warning": "برخی از دامنه ها به زودی منقضی می شوند!", + "diagnosis_domain_expiration_success": "دامنه های شما ثبت شده است و به این زودی منقضی نمی شود.", + "diagnosis_domain_expiration_not_found_details": "به نظر می رسد اطلاعات WHOIS برای دامنه {domain} حاوی اطلاعات مربوط به تاریخ انقضا نیست؟", + "diagnosis_domain_not_found_details": "دامنه {domain} در پایگاه داده WHOIS وجود ندارد یا منقضی شده است!", + "diagnosis_domain_expiration_not_found": "بررسی تاریخ انقضا برخی از دامنه ها امکان پذیر نیست", + "diagnosis_dns_specialusedomain": "دامنه {domain} بر اساس یک دامنه سطح بالا (TLD) مخصوص استفاده است و بنابراین انتظار نمی رود که دارای سوابق DNS واقعی باشد.", + "diagnosis_dns_try_dyndns_update_force": "پیکربندی DNS این دامنه باید به طور خودکار توسط YunoHost مدیریت شود. اگر اینطور نیست ، می توانید سعی کنید به زور یک به روز رسانی را با استفاده از yunohost dyndns update --force.", + "diagnosis_dns_point_to_doc": "لطفاً اسناد را در https://yunohost.org/dns_config برسی و مطالعه کنید، اگر در مورد پیکربندی سوابق DNS به کمک نیاز دارید.", + "diagnosis_dns_discrepancy": "به نظر می رسد پرونده DNS زیر از پیکربندی توصیه شده پیروی نمی کند:
نوع: {type}
نام: {name}
ارزش فعلی: {current}
مقدار مورد انتظار: {value}", + "diagnosis_dns_missing_record": "با توجه به پیکربندی DNS توصیه شده ، باید یک رکورد DNS با اطلاعات زیر اضافه کنید.
نوع: {type}
نام: {name}
ارزش: {value}", + "diagnosis_dns_bad_conf": "برخی از سوابق DNS برای دامنه {domain} (دسته {category}) وجود ندارد یا نادرست است", + "diagnosis_dns_good_conf": "سوابق DNS برای دامنه {domain} (دسته {category}) به درستی پیکربندی شده است", + "diagnosis_ip_weird_resolvconf_details": "پرونده /etc/resolv.conf باید یک پیوند همراه برای /etc/resolvconf/run/resolv.conf خود اشاره می کند به 127.0.0.1 (dnsmasq). اگر می خواهید راه حل های DNS را به صورت دستی پیکربندی کنید ، لطفاً ویرایش کنید /etc/resolv.dnsmasq.conf.", + "diagnosis_ip_weird_resolvconf": "اینطور که پیداست تفکیک پذیری DNS کار می کند ، اما به نظر می رسد از سفارشی استفاده می کنید /etc/resolv.conf.", + "diagnosis_ip_broken_resolvconf": "به نظر می رسد تفکیک پذیری نام دامنه در سرور شما شکسته شده است ، که به نظر می رسد مربوط به /etc/resolv.conf و اشاره نکردن به 127.0.0.1 میباشد.", + "diagnosis_ip_broken_dnsresolution": "به نظر می رسد تفکیک پذیری نام دامنه به دلایلی خراب شده است... آیا فایروال درخواست های DNS را مسدود می کند؟", + "diagnosis_ip_dnsresolution_working": "تفکیک پذیری نام دامنه کار می کند!", + "diagnosis_ip_not_connected_at_all": "به نظر می رسد سرور اصلا به اینترنت متصل نیست !؟", + "diagnosis_ip_local": "IP محلی: {local}", + "diagnosis_ip_global": "IP جهانی: {global}", + "diagnosis_ip_no_ipv6_tip": "داشتن یک IPv6 فعال برای کار سرور شما اجباری نیست ، اما برای سلامت اینترنت به طور کلی بهتر است. IPv6 معمولاً باید در صورت موجود بودن توسط سیستم یا ارائه دهنده اینترنت شما به طور خودکار پیکربندی شود. در غیر این صورت ، ممکن است لازم باشد چند مورد را به صورت دستی پیکربندی کنید ، همانطور که در اسناد اینجا توضیح داده شده است: https://yunohost.org/#/ipv6.اگر نمی توانید IPv6 را فعال کنید یا اگر برای شما بسیار فنی به نظر می رسد ، می توانید با خیال راحت این هشدار را نادیده بگیرید.", + "diagnosis_ip_no_ipv6": "سرور IPv6 کار نمی کند.", + "diagnosis_ip_connected_ipv6": "سرور از طریق IPv6 به اینترنت متصل است!", + "diagnosis_ip_no_ipv4": "سرور IPv4 کار نمی کند.", + "diagnosis_ip_connected_ipv4": "سرور از طریق IPv4 به اینترنت متصل است!", + "diagnosis_no_cache": "هنوز هیچ حافظه نهانی معاینه و عیب یابی برای دسته '{category}' وجود ندارد", + "diagnosis_failed": "نتیجه معاینه و عیب یابی برای دسته '{category}' واکشی نشد: {error}", + "diagnosis_everything_ok": "همه چیز برای {category} خوب به نظر می رسد!", + "diagnosis_found_warnings": "مورد (های) {warnings} یافت شده که می تواند دسته {category} را بهبود بخشد.", + "diagnosis_found_errors_and_warnings": "{errors} مسائل مهم (و {warnings} هشدارها) مربوط به {category} پیدا شد!", + "diagnosis_found_errors": "{errors} مشکلات مهم مربوط به {category} پیدا شد!", + "diagnosis_ignored_issues": "(+ {nb_ignored} مسئله (ها) نادیده گرفته شده)", + "diagnosis_cant_run_because_of_dep": "در حالی که مشکلات مهمی در ارتباط با {dep} وجود دارد ، نمی توان عیب یابی را برای {category} اجرا کرد.", + "diagnosis_cache_still_valid": "(حافظه پنهان هنوز برای عیب یابی {category} معتبر است. هنوز دوباره تشخیص داده نمی شود!)", + "diagnosis_failed_for_category": "عیب یابی برای دسته '{category}' ناموفق بود: {error}", + "diagnosis_display_tip": "برای مشاهده مسائل پیدا شده ، می توانید به بخش تشخیص webadmin بروید یا از خط فرمان 'yunohost diagnosis show --issues --human-readable' را اجرا کنید.", + "diagnosis_package_installed_from_sury_details": "برخی از بسته ها ناخواسته از مخزن شخص ثالث به نام Sury نصب شده اند. تیم YunoHost استراتژی مدیریت این بسته ها را بهبود بخشیده ، اما انتظار می رود برخی از تنظیماتی که برنامه های PHP7.3 را در حالی که هنوز بر روی Stretch نصب شده اند نصب کرده اند ، ناسازگاری های باقی مانده ای داشته باشند. برای رفع این وضعیت ، باید دستور زیر را اجرا کنید: {cmd_to_fix}", + "diagnosis_package_installed_from_sury": "برخی از بسته های سیستمی باید کاهش یابد", + "diagnosis_backports_in_sources_list": "به نظر می رسد apt (مدیریت بسته) برای استفاده از مخزن پشتیبان پیکربندی شده است. مگر اینکه واقعاً بدانید چه کار می کنید ، ما به شدت از نصب بسته های پشتیبان خودداری می کنیم، زیرا به احتمال زیاد باعث ایجاد ناپایداری یا تداخل در سیستم شما می شود.", + "diagnosis_basesystem_ynh_inconsistent_versions": "شما نسخه های ناسازگار از بسته های YunoHost را اجرا می کنید... به احتمال زیاد به دلیل ارتقاء ناموفق یا جزئی است.", + "diagnosis_basesystem_ynh_main_version": "سرور نسخه YunoHost {main_version} ({repo}) را اجرا می کند", + "diagnosis_basesystem_ynh_single_version": "{package} نسخه: {version} ({repo})", + "diagnosis_basesystem_kernel": "سرور نسخه {kernel_version} هسته لینوکس را اجرا می کند", + "diagnosis_basesystem_host": "سرور نسخه {debian_version} دبیان را اجرا می کند", + "diagnosis_basesystem_hardware_model": "مدل سرور {model} میباشد", + "diagnosis_basesystem_hardware": "معماری سخت افزاری سرور {virt} {arch} است", + "custom_app_url_required": "برای ارتقاء سفارشی برنامه {app} خود باید نشانی اینترنتی ارائه دهید", + "confirm_app_install_thirdparty": "خطرناک! این برنامه بخشی از فهرست برنامه YunoHost نیست. نصب برنامه های شخص ثالث ممکن است یکپارچگی و امنیت سیستم شما را به خطر بیندازد. احتمالاً نباید آن را نصب کنید مگر اینکه بدانید در حال انجام چه کاری هستید. اگر این برنامه کار نکرد یا سیستم شما را خراب کرد ، هیچ پشتیبانی ارائه نخواهدشد... به هر حال اگر مایل به پذیرش این خطر هستید ، '{answers}' را تایپ کنید", + "confirm_app_install_danger": "خطرناک! این برنامه هنوز آزمایشی است (اگر صراحتاً کار نکند)! احتمالاً نباید آن را نصب کنید مگر اینکه بدانید در حال انجام چه کاری هستید. اگر این برنامه کار نکرد یا سیستم شما را خراب کرد، هیچ پشتیبانی ارائه نخواهد شد... اگر به هر حال مایل به پذیرش این خطر هستید ، '{answers}' را تایپ کنید", + "confirm_app_install_warning": "هشدار: این برنامه ممکن است کار کند ، اما در YunoHost یکپارچه نشده است. برخی از ویژگی ها مانند ورود به سیستم و پشتیبان گیری/بازیابی ممکن است در دسترس نباشد. به هر حال نصب شود؟ [{answers}] ", + "certmanager_unable_to_parse_self_CA_name": "نتوانست نام مرجع خودامضائی را تجزیه و تحلیل کند (فایل: {file})", + "certmanager_self_ca_conf_file_not_found": "فایل پیکربندی برای اجازه خود امضائی پیدا نشد (فایل: {file})", + "certmanager_no_cert_file": "فایل گواهینامه برای دامنه {domain} خوانده نشد (فایل: {file})", + "certmanager_hit_rate_limit": "اخیراً تعداد زیادی گواهی برای این مجموعه دقیق از دامنه ها {domain} صادر شده است. لطفاً بعداً دوباره امتحان کنید. برای جزئیات بیشتر به https://letsencrypt.org/docs/rate-limits/ مراجعه کنید", + "certmanager_warning_subdomain_dns_record": "آدرس زیر دامنه '{subdomain}' به آدرس IP مشابه '{domain}' تبدیل نمی شود. تا زمانی که این مشکل را برطرف نکنید و گواهی را دوباره ایجاد نکنید ، برخی از ویژگی ها در دسترس نخواهند بود.", + "certmanager_domain_http_not_working": "به نظر می رسد دامنه {domain} از طریق HTTP قابل دسترسی نیست. لطفاً برای اطلاعات بیشتر ، دسته \"وب\" را در عیب یابی بررسی کنید. (اگر می دانید چه کار می کنید ، از '--no-checks' برای خاموش کردن این چک ها استفاده کنید.)", + "certmanager_domain_dns_ip_differs_from_public_ip": "سوابق DNS برای دامنه '{domain}' با IP این سرور متفاوت است. لطفاً برای اطلاعات بیشتر ، دسته 'DNS records' (پایه) را در عیب یابی بررسی کنید. اگر اخیراً رکورد A خود را تغییر داده اید ، لطفاً منتظر انتشار آن باشید (برخی از چکرهای انتشار DNS بصورت آنلاین در دسترس هستند). (اگر می دانید چه کار می کنید ، از '--no-checks' برای خاموش کردن این چک ها استفاده کنید.)", + "certmanager_domain_cert_not_selfsigned": "گواهی دامنه {domain} خود امضا نشده است. آیا مطمئن هستید که می خواهید آن را جایگزین کنید؟ (برای این کار از '--force' استفاده کنید.)", + "certmanager_domain_not_diagnosed_yet": "هنوز هیچ نتیجه تشخیصی و عیب یابی دامنه {domain} وجود ندارد. لطفاً در بخش عیب یابی ، دسته های 'DNS records' و 'Web'مجدداً عیب یابی را اجرا کنید تا بررسی شود که آیا دامنه ای برای گواهی اجازه رمزنگاری آماده است. (یا اگر می دانید چه کار می کنید ، از '--no-checks' برای خاموش کردن این بررسی ها استفاده کنید.)", + "certmanager_certificate_fetching_or_enabling_failed": "تلاش برای استفاده از گواهینامه جدید برای {domain} جواب نداد...", + "certmanager_cert_signing_failed": "گواهی جدید امضا نشده است", + "certmanager_cert_renew_success": "گواهی اجازه رمزنگاری برای دامنه '{domain}' تمدید شد", + "certmanager_cert_install_success_selfsigned": "گواهی خود امضا شده اکنون برای دامنه '{domain}' نصب شده است", + "certmanager_cert_install_success": "هم اینک گواهی اجازه رمزگذاری برای دامنه '{domain}' نصب شده است", + "certmanager_cannot_read_cert": "هنگام باز کردن گواهینامه فعلی مشکلی پیش آمده است برای دامنه {domain} (فایل: {file}) ، علّت: {reason}", + "certmanager_attempt_to_replace_valid_cert": "شما در حال تلاش برای بازنویسی یک گواهی خوب و معتبر برای دامنه {domain} هستید! (استفاده از --force برای bypass)", + "certmanager_attempt_to_renew_valid_cert": "گواهی دامنه '{domain}' در حال انقضا نیست! (اگر می دانید چه کار می کنید می توانید از --force استفاده کنید)", + "certmanager_attempt_to_renew_nonLE_cert": "گواهی دامنه '{domain}' توسط Let's Encrypt صادر نشده است. به طور خودکار تمدید نمی شود!", + "certmanager_acme_not_configured_for_domain": "در حال حاضر نمی توان چالش ACME را برای {domain} اجرا کرد زیرا nginx conf آن فاقد قطعه کد مربوطه است... لطفاً مطمئن شوید که پیکربندی nginx شما به روز است با استفاده از دستور `yunohost tools regen-conf nginx --dry-run --with-diff`.", + "backup_with_no_restore_script_for_app": "{app} فاقد اسکریپت بازگردانی است ، نمی توانید پشتیبان گیری این برنامه را به طور خودکار بازیابی کنید.", + "backup_with_no_backup_script_for_app": "برنامه '{app}' فاقد اسکریپت پشتیبان است. نادیده گرفتن.", + "backup_unable_to_organize_files": "نمی توان از روش سریع برای سازماندهی فایل ها در بایگانی استفاده کرد", + "backup_system_part_failed": "از بخش سیستم '{part}' پشتیبان گیری نشد", + "backup_running_hooks": "درحال اجرای قلاب پشتیبان گیری...", + "backup_permission": "مجوز پشتیبان گیری برای {app}", + "backup_output_symlink_dir_broken": "فهرست بایگانی شما '{path}' یک پیوند symlink خراب است. شاید فراموش کرده اید که مجدداً محل ذخیره سازی که به آن اشاره می کند را دوباره نصب یا وصل کنید.", + "backup_output_directory_required": "شما باید یک پوشه خروجی برای نسخه پشتیبان تهیه کنید", + "backup_output_directory_not_empty": "شما باید یک دایرکتوری خروجی خالی انتخاب کنید", + "backup_output_directory_forbidden": "دایرکتوری خروجی دیگری را انتخاب کنید. پشتیبان گیری نمی تواند در /bin، /boot، /dev ، /etc ، /lib ، /root ، /run ، /sbin ، /sys ، /usr ، /var یا /home/yunohost.backup/archives ایجاد شود", + "backup_nothings_done": "چیزی برای ذخیره کردن وجود ندارد", + "backup_no_uncompress_archive_dir": "چنین فهرست بایگانی فشرده نشده ایی وجود ندارد", + "backup_mount_archive_for_restore": "در حال آماده سازی بایگانی برای بازگردانی...", + "backup_method_tar_finished": "بایگانی پشتیبان TAR ایجاد شد", + "backup_method_custom_finished": "روش پشتیبان گیری سفارشی '{method}' به پایان رسید", + "backup_method_copy_finished": "نسخه پشتیبان نهایی شد", + "backup_hook_unknown": "قلاب پشتیبان '{hook}' ناشناخته است", + "backup_deleted": "نسخه پشتیبان حذف شد", + "backup_delete_error": "'{path}' حذف نشد", + "backup_custom_mount_error": "روش پشتیبان گیری سفارشی نمی تواند از مرحله 'mount' عبور کند", + "backup_custom_backup_error": "روش پشتیبان گیری سفارشی نمی تواند مرحله 'backup' را پشت سر بگذارد", + "backup_csv_creation_failed": "فایل CSV مورد نیاز برای بازیابی ایجاد نشد", + "backup_csv_addition_failed": "فایلهای پشتیبان به فایل CSV اضافه نشد", + "backup_creation_failed": "نسخه پشتیبان بایگانی ایجاد نشد", + "backup_create_size_estimation": "بایگانی حاوی حدود {size} داده است.", + "backup_created": "نسخه پشتیبان ایجاد شد", + "backup_couldnt_bind": "نمی توان {src} را به {dest} متصل کرد.", + "backup_copying_to_organize_the_archive": "در حال کپی {size} مگابایت برای سازماندهی بایگانی", + "backup_cleaning_failed": "پوشه موقت پشتیبان گیری پاکسازی نشد", + "backup_cant_mount_uncompress_archive": "بایگانی فشرده سازی نشده را نمی توان به عنوان حفاظت از نوشتن مستقر کرد", + "backup_ask_for_copying_if_needed": "آیا می خواهید پشتیبان گیری را با استفاده از {size} مگابایت به طور موقت انجام دهید؟ (این روش استفاده می شود زیرا برخی از پرونده ها با استفاده از روش کارآمدتری تهیه نمی شوند.)", + "backup_archive_writing_error": "فایل های '{source}' (که در بایگانی '{dest}' نامگذاری شده اند) برای پشتیبان گیری به بایگانی فشرده '{archive}' اضافه نشد", + "backup_archive_system_part_not_available": "بخش سیستم '{part}' در این نسخه پشتیبان در دسترس نیست", + "backup_archive_corrupted": "به نظر می رسد بایگانی پشتیبان '{archive}' خراب است: {error}", + "backup_archive_cant_retrieve_info_json": "اطلاعات مربوط به بایگانی '{archive}' بارگیری نشد... info.json بازیابی نمی شود (یا json معتبری نیست).", + "backup_archive_open_failed": "بایگانی پشتیبان باز نشد", + "backup_archive_name_unknown": "بایگانی پشتیبان محلی ناشناخته با نام '{name}'", + "backup_archive_name_exists": "بایگانی پشتیبان با این نام در حال حاضر وجود دارد.", + "backup_archive_broken_link": "دسترسی به بایگانی پشتیبان امکان پذیر نیست (پیوند خراب به {path})", + "backup_archive_app_not_found": "در بایگانی پشتیبان {app} پیدا نشد", + "backup_applying_method_tar": "ایجاد آرشیو پشتیبان TAR...", + "backup_applying_method_custom": "فراخوانی روش پشتیبان گیری سفارشی '{method}'...", + "backup_applying_method_copy": "در حال کپی تمام فایل ها برای پشتیبان گیری...", + "backup_app_failed": "{app} پشتیبان گیری نشد", + "backup_actually_backuping": "ایجاد آرشیو پشتیبان از پرونده های جمع آوری شده...", + "backup_abstract_method": "این روش پشتیبان گیری هنوز اجرا نشده است", + "ask_password": "رمز عبور", + "ask_new_path": "مسیر جدید", + "ask_new_domain": "دامنه جدید", + "ask_new_admin_password": "رمز جدید مدیریت", + "ask_main_domain": "دامنه اصلی", + "ask_lastname": "نام خانوادگی", + "ask_firstname": "نام کوچک", + "ask_user_domain": "دامنه ای که برای آدرس ایمیل کاربر و حساب XMPP استفاده می شود", + "apps_catalog_update_success": "کاتالوگ برنامه به روز شد!", + "apps_catalog_obsolete_cache": "حافظه پنهان کاتالوگ برنامه خالی یا منسوخ شده است.", + "apps_catalog_failed_to_download": "بارگیری کاتالوگ برنامه {apps_catalog} امکان پذیر نیست: {error}", + "apps_catalog_updating": "در حال به روز رسانی کاتالوگ برنامه...", + "apps_catalog_init_success": "سیستم کاتالوگ برنامه راه اندازی اولیه شد!", + "apps_already_up_to_date": "همه برنامه ها در حال حاضر به روز هستند", + "app_packaging_format_not_supported": "این برنامه قابل نصب نیست زیرا قالب بسته بندی آن توسط نسخه YunoHost شما پشتیبانی نمی شود. احتمالاً باید ارتقاء سیستم خود را در نظر بگیرید.", + "app_upgraded": "{app} ارتقا یافت", + "app_upgrade_some_app_failed": "برخی از برنامه ها را نمی توان ارتقا داد", + "app_upgrade_script_failed": "خطایی در داخل اسکریپت ارتقاء برنامه رخ داده است", + "app_upgrade_failed": "{app} ارتقاء نیافت: {error}", + "app_upgrade_app_name": "در حال ارتقاء {app}...", + "app_upgrade_several_apps": "برنامه های زیر ارتقا می یابند: {apps}", + "app_unsupported_remote_type": "نوع راه دور پشتیبانی نشده برای برنامه استفاده می شود", + "app_unknown": "برنامه ناشناخته", + "app_start_restore": "درحال بازیابی {app}...", + "app_start_backup": "در حال جمع آوری فایل ها برای پشتیبان گیری {app}...", + "app_start_remove": "در حال حذف {app}...", + "app_start_install": "در حال نصب {app}...", + "app_sources_fetch_failed": "نمی توان فایل های منبع را واکشی کرد ، آیا URL درست است؟", + "app_restore_script_failed": "خطایی در داخل اسکریپت بازیابی برنامه رخ داده است", + "app_restore_failed": "{app} بازیابی نشد: {error}", + "app_remove_after_failed_install": "حذف برنامه در پی شکست نصب...", + "app_requirements_unmeet": "شرایط مورد نیاز برای {app} برآورده نمی شود ، بسته {pkgname} ({version}) باید {spec} باشد", + "app_requirements_checking": "در حال بررسی بسته های مورد نیاز برای {app}...", + "app_removed": "{app} حذف نصب شد", + "app_not_properly_removed": "{app} به درستی حذف نشده است", + "app_not_installed": "{app} در لیست برنامه های نصب شده یافت نشد: {all_apps}", + "app_not_correctly_installed": "به نظر می رسد {app} به اشتباه نصب شده است", + "app_not_upgraded": "برنامه '{failed_app}' ارتقا پیدا نکرد و در نتیجه ارتقا برنامه های زیر لغو شد: {apps}", + "app_manifest_install_ask_is_public": "آیا این برنامه باید در معرض دید بازدیدکنندگان ناشناس قرار گیرد؟", + "app_manifest_install_ask_admin": "برای این برنامه یک کاربر سرپرست انتخاب کنید", + "app_manifest_install_ask_password": "گذرواژه مدیریتی را برای این برنامه انتخاب کنید", + "app_manifest_install_ask_path": "مسیر URL (بعد از دامنه) را انتخاب کنید که این برنامه باید در آن نصب شود", + "app_manifest_install_ask_domain": "دامنه ای را انتخاب کنید که این برنامه باید در آن نصب شود", + "app_manifest_invalid": "مشکلی در مانیفست برنامه وجود دارد: {error}", + "app_location_unavailable": "این نشانی وب یا در دسترس نیست یا با برنامه (هایی) که قبلاً نصب شده در تعارض است:\n{apps}", + "app_label_deprecated": "این دستور منسوخ شده است! لطفاً برای مدیریت برچسب برنامه از فرمان جدید'yunohost به روز رسانی مجوز کاربر' استفاده کنید.", + "app_make_default_location_already_used": "نمی توان '{app}' را برنامه پیش فرض در دامنه قرار داد ، '{domain}' قبلاً توسط '{other_app}' استفاده می شود", + "app_install_script_failed": "خطایی در درون اسکریپت نصب برنامه رخ داده است", + "app_install_failed": "نصب {app} امکان پذیر نیست: {error}", + "app_install_files_invalid": "این فایل ها قابل نصب نیستند", + "app_id_invalid": "شناسه برنامه نامعتبر است", + "app_full_domain_unavailable": "متأسفیم ، این برنامه باید در دامنه خود نصب شود ، اما سایر برنامه ها قبلاً در دامنه '{domain}' نصب شده اند.شما به جای آن می توانید از یک زیر دامنه اختصاص داده شده به این برنامه استفاده کنید.", + "app_extraction_failed": "فایل های نصبی استخراج نشد", + "app_change_url_success": "{app} URL اکنون {domain} {path} است", + "app_change_url_no_script": "برنامه '{app_name}' هنوز از تغییر URL پشتیبانی نمی کند. شاید باید آن را ارتقا دهید.", + "app_change_url_identical_domains": "دامنه /url_path قدیمی و جدیدیکسان هستند ('{domain}{path}') ، کاری برای انجام دادن نیست.", + "diagnosis_http_connection_error": "خطای اتصال: ارتباط با دامنه درخواست شده امکان پذیر نیست، به احتمال زیاد غیرقابل دسترسی است.", + "diagnosis_http_timeout": "زمان تلاش برای تماس با سرور از خارج به پایان رسید. به نظر می رسد غیرقابل دسترسی است.
1. شایع ترین علت برای این مشکل ، پورت 80 است (و 443) به درستی به سرور شما ارسال نمی شوند.
2. همچنین باید مطمئن شوید که سرویس nginx در حال اجرا است
3. در تنظیمات پیچیده تر: مطمئن شوید که هیچ فایروال یا پروکسی معکوسی تداخل نداشته باشد.", + "diagnosis_http_ok": "دامنه {domain} از طریق HTTP از خارج از شبکه محلی قابل دسترسی است.", + "diagnosis_http_localdomain": "انتظار نمی رود که دامنه {domain} ، با TLD محلی. از خارج از شبکه محلی به آن دسترسی پیدا کند.", + "diagnosis_http_could_not_diagnose_details": "خطا: {error}", + "diagnosis_http_could_not_diagnose": "نمی توان تشخیص داد که در IPv{ipversion} دامنه ها از خارج قابل دسترسی هستند یا خیر.", + "diagnosis_http_hairpinning_issue_details": "این احتمالاً به دلیل جعبه / روتر ISP شما است. در نتیجه ، افراد خارج از شبکه محلی شما می توانند به سرور شما مطابق انتظار دسترسی پیدا کنند ، اما افراد داخل شبکه محلی (احتمالاً مثل شما؟) هنگام استفاده از نام دامنه یا IP جهانی. ممکن است بتوانید وضعیت را بهبود بخشید با نگاهی به https://yunohost.org/dns_local_network", + "diagnosis_http_hairpinning_issue": "به نظر می رسد در شبکه محلی شما hairpinning فعال نشده است.", + "diagnosis_ports_forwarding_tip": "برای رفع این مشکل، به احتمال زیاد باید انتقال پورت را در روتر اینترنت خود پیکربندی کنید همانطور که شرح داده شده در https://yunohost.org/isp_box_config", + "diagnosis_ports_needed_by": "افشای این پورت برای ویژگی های {category} (سرویس {service}) مورد نیاز است", + "diagnosis_ports_ok": "پورت {port} از خارج قابل دسترسی است.", + "diagnosis_ports_partially_unreachable": "پورت {port} از خارج در {failed}IPv قابل دسترسی نیست.", + "diagnosis_ports_unreachable": "پورت {port} از خارج قابل دسترسی نیست.", + "diagnosis_ports_could_not_diagnose_details": "خطا: {error}", + "diagnosis_ports_could_not_diagnose": "نمی توان تشخیص داد پورت ها از خارج در IPv{ipversion} قابل دسترسی هستند یا خیر.", + "diagnosis_description_regenconf": "تنظیمات سیستم", + "diagnosis_description_mail": "ایمیل", + "diagnosis_description_web": "وب", + "diagnosis_description_ports": "ارائه پورت ها", + "diagnosis_description_systemresources": "منابع سیستم", + "diagnosis_description_services": "بررسی وضعیّت سرویس ها", + "diagnosis_description_dnsrecords": "رکورد DNS", + "diagnosis_description_ip": "اتصال به اینترنت", + "diagnosis_description_basesystem": "سیستم پایه", + "diagnosis_security_vulnerable_to_meltdown_details": "برای رفع این مشکل ، باید سیستم خود را ارتقا دهید و مجدداً راه اندازی کنید تا هسته لینوکس جدید بارگیری شود (یا در صورت عدم کارکرد با ارائه دهنده سرور خود تماس بگیرید). برای اطلاعات بیشتر به https://meltdownattack.com/ مراجعه کنید.", + "diagnosis_security_vulnerable_to_meltdown": "به نظر می رسد شما در برابر آسیب پذیری امنیتی بحرانی Meltdown آسیب پذیر هستید", + "diagnosis_rootfstotalspace_critical": "کل سیستم فایل فقط دارای {space} است که بسیار نگران کننده است! احتمالاً خیلی زود فضای دیسک شما تمام می شود! توصیه می شود حداقل 16 گیگابایت و بیشتر فضا برای سیستم فایل ریشه داشته باشید.", + "diagnosis_rootfstotalspace_warning": "سیستم فایل ریشه در مجموع فقط {space} دارد. ممکن است اشکالی نداشته باشد ، اما مراقب باشید زیرا در نهایت ممکن است فضای دیسک شما به سرعت تمام شود... توصیه می شود حداقل 16 گیگابایت و بیشتر فضا برای سیستم فایل ریشه داشته باشید.", + "diagnosis_regenconf_manually_modified_details": "اگر بدانید چه کار می کنید ، احتمالاً خوب است! YunoHost به روز رسانی خودکار این فایل را متوقف می کند... اما مراقب باشید که ارتقاء YunoHost می تواند شامل تغییرات مهم توصیه شده باشد. اگر می خواهید ، می توانید تفاوت ها را با yunohost tools regen-conf {category} --dry-run --with-diff و تنظیم مجدد پیکربندی توصیه شده به زور با فرمان yunohost tools regen-conf {category} --force", + "diagnosis_regenconf_manually_modified": "به نظر می رسد فایل پیکربندی {file} به صورت دستی اصلاح شده است.", + "diagnosis_regenconf_allgood": "همه فایلهای پیکربندی مطابق با تنظیمات توصیه شده است!", + "diagnosis_mail_queue_too_big": "تعداد زیادی ایمیل معلق در صف پست ({nb_pending} ایمیل)", + "diagnosis_mail_queue_unavailable_details": "خطا: {error}", + "diagnosis_mail_queue_unavailable": "نمی توان با تعدادی از ایمیل های معلق در صف مشورت کرد", + "diagnosis_mail_queue_ok": "{nb_pending} ایمیل های معلق در صف های ایمیل", + "diagnosis_mail_blacklist_website": "پس از شناسایی دلیل لیست شدن و رفع آن، با خیال راحت درخواست کنید IP یا دامنه شما حذف شود از {blacklist_website}", + "diagnosis_mail_blacklist_reason": "دلیل لیست سیاه: {reason}", + "diagnosis_mail_blacklist_listed_by": "IP یا دامنه شما {item}در لیست سیاه {blacklist_name} قرار دارد", + "diagnosis_mail_blacklist_ok": "به نظر می رسد IP ها و دامنه های مورد استفاده این سرور در لیست سیاه قرار ندارند", + "diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "DNS معکوس فعلی: {rdns_domain}
مقدار مورد انتظار: {ehlo_domain}", + "diagnosis_mail_fcrdns_different_from_ehlo_domain": "DNS معکوس به درستی در IPv{ipversion} پیکربندی نشده است. ممکن است برخی از ایمیل ها تحویل داده نشوند یا به عنوان هرزنامه پرچم گذاری شوند.", + "diagnosis_mail_fcrdns_nok_alternatives_6": "برخی از ارائه دهندگان به شما اجازه نمی دهند DNS معکوس خود را پیکربندی کنید (یا ممکن است ویژگی آنها شکسته شود...). اگر DNS معکوس شما به درستی برای IPv4 پیکربندی شده است، با استفاده از آن می توانید هنگام ارسال ایمیل، استفاده از IPv6 را غیرفعال کنید. yunohost settings set smtp.allow_ipv6 -v off. توجه: این راه حل آخری به این معنی است که شما نمی توانید از چند سرور IPv6 موجود ایمیل ارسال یا دریافت کنید.", + "diagnosis_mail_fcrdns_nok_alternatives_4": "برخی از ارائه دهندگان به شما اجازه نمی دهند DNS معکوس خود را پیکربندی کنید (یا ممکن است ویژگی آنها شکسته شود...). اگر به همین دلیل مشکلاتی را تجربه می کنید ، راه حل های زیر را در نظر بگیرید: - برخی از ISP ها جایگزین ارائه می دهند با استفاده از رله سرور ایمیل اگرچه به این معنی است که رله می تواند از ترافیک ایمیل شما جاسوسی کند.
- یک جایگزین دوستدار حریم خصوصی استفاده از VPN * با IP عمومی اختصاصی * برای دور زدن این نوع محدودیت ها است. ببینید https://yunohost.org/#/vpn_advantage
- یا ممکن است به ارائه دهنده دیگری بروید", + "diagnosis_mail_fcrdns_nok_details": "ابتدا باید DNS معکوس را پیکربندی کنید با {ehlo_domain} در رابط روتر اینترنت یا رابط ارائه دهنده میزبانی تان. (ممکن است برخی از ارائه دهندگان میزبانی از شما بخواهند که برای این کار تیکت پشتیبانی ارسال کنید).", + "diagnosis_mail_fcrdns_dns_missing": "در IPv{ipversion} هیچ DNS معکوسی تعریف نشده است. ممکن است برخی از ایمیل ها تحویل داده نشوند یا به عنوان هرزنامه پرچم گذاری شوند.", + "diagnosis_mail_fcrdns_ok": "DNS معکوس شما به درستی پیکربندی شده است!", + "diagnosis_mail_ehlo_could_not_diagnose_details": "خطا: {error}", + "diagnosis_mail_ehlo_could_not_diagnose": "نمی توان تشخیص داد که آیا سرور ایمیل postfix از خارج در IPv{ipversion} قابل دسترسی است یا خیر.", + "diagnosis_mail_ehlo_wrong_details": "EHLO دریافت شده توسط تشخیص دهنده از راه دور در IPv{ipversion} با دامنه سرور شما متفاوت است.
EHLO دریافت شده: {wrong_ehlo}
انتظار می رود: {right_ehlo}
شایع ترین علت این مشکل ، پورت 25 است به درستی به سرور شما ارسال نشده است. از سوی دیگر اطمینان حاصل کنید که هیچ فایروال یا پروکسی معکوسی تداخل ایجاد نمی کند.", + "diagnosis_mail_ehlo_wrong": "یک سرور ایمیل SMTP متفاوت در IPv{ipversion} پاسخ می دهد. سرور شما احتمالاً نمی تواند ایمیل دریافت کند.", + "diagnosis_mail_ehlo_bad_answer_details": "ممکن است به دلیل پاسخ دادن دستگاه دیگری به جای سرور شما باشد.", + "diagnosis_mail_ehlo_bad_answer": "یک سرویس غیر SMTP در پورت 25 در IPv{ipversion} پاسخ داد", + "diagnosis_mail_ehlo_unreachable_details": "اتصال روی پورت 25 سرور شما در IPv{ipversion} باز نشد. به نظر می رسد غیرقابل دسترس است.
1. شایع ترین علت این مشکل ، پورت 25 است به درستی به سرور شما ارسال نشده است.
2. همچنین باید مطمئن شوید که سرویس postfix در حال اجرا است.
3. در تنظیمات پیچیده تر: مطمئن شوید که هیچ فایروال یا پروکسی معکوسی تداخل نداشته باشد.", + "diagnosis_mail_ehlo_unreachable": "سرور ایمیل SMTP از خارج در IPv {ipversion} غیرقابل دسترسی است. قادر به دریافت ایمیل نخواهد بود.", + "diagnosis_mail_ehlo_ok": "سرور ایمیل SMTP از خارج قابل دسترسی است و بنابراین می تواند ایمیل دریافت کند!", + "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "برخی از ارائه دهندگان به شما اجازه نمی دهند پورت خروجی 25 را رفع انسداد کنید زیرا به بی طرفی شبکه اهمیتی نمی دهند.
- برخی از آنها جایگزین را ارائه می دهند با استفاده از رله سرور ایمیل اگرچه به این معنی است که رله می تواند از ترافیک ایمیل شما جاسوسی کند.
- یک جایگزین دوستدار حریم خصوصی استفاده از VPN * با IP عمومی اختصاصی * برای دور زدن این نوع محدودیت ها است. ببینید https://yunohost.org/#/vpn_advantage
- همچنین می توانید تغییر را در نظر بگیرید به یک ارائه دهنده بی طرف خالص تر", + "diagnosis_mail_outgoing_port_25_blocked_details": "ابتدا باید سعی کنید پورت خروجی 25 را در رابط اینترنت روتر یا رابط ارائه دهنده میزبانی خود باز کنید. (ممکن است برخی از ارائه دهندگان میزبانی از شما بخواهند که برای این کار تیکت پشتیبانی ارسال کنید).", + "diagnosis_mail_outgoing_port_25_blocked": "سرور ایمیل SMTP نمی تواند به سرورهای دیگر ایمیل ارسال کند زیرا درگاه خروجی 25 در IPv {ipversion} مسدود شده است.", + "diagnosis_mail_outgoing_port_25_ok": "سرور ایمیل SMTP قادر به ارسال ایمیل است (پورت خروجی 25 مسدود نشده است).", + "diagnosis_swap_tip": "لطفاً مراقب و آگاه باشید، اگر سرور میزبانی swap را روی کارت SD یا حافظه SSD انجام دهد ، ممکن است طول عمر دستگاه را به شدت کاهش دهد.", + "diagnosis_swap_ok": "سیستم {total} swap دارد!", + "diagnosis_swap_notsomuch": "سیستم فقط {total} swap دارد. برای جلوگیری از شرایطی که حافظه سیستم شما تمام می شود ، باید حداقل {recommended} را در نظر بگیرید.", + "diagnosis_swap_none": "این سیستم به هیچ وجه swap ندارد. برای جلوگیری از شرایطی که حافظه سیستم شما تمام می شود ، باید حداقل {recommended} swap را در نظر بگیرید.", + "diagnosis_ram_ok": "این سیستم هنوز {available} ({available_percent}٪) حافظه در دسترس دارد از مجموع {total}.", + "diagnosis_ram_low": "این سیستم فقط {available} ({available_percent}٪) حافظه در دسترس دارد! (از {total}). مراقب باشید.", + "diagnosis_ram_verylow": "این سیستم فقط {available} ({available_percent}٪) حافظه در دسترس دارد! (از {total})", + "diagnosis_diskusage_ok": "‏ذخیره سازی {mountpoint} (روی دستگاه {device}) هنوز {free} فضا در دسترس دارد ({free_percent}%) فضای باقی مانده (از {total})!", + "diagnosis_http_nginx_conf_not_up_to_date": "به نظر می رسد که پیکربندی nginx این دامنه به صورت دستی تغییر کرده است و از تشخیص YunoHost در صورت دسترسی به HTTP جلوگیری می کند.", + "diagnosis_http_partially_unreachable": "به نظر می رسد که دامنه {domain} از طریق HTTP از خارج از شبکه محلی در IPv{failed} غیرقابل دسترسی است، اگرچه در IPv{passed} کار می کند.", + "diagnosis_http_unreachable": "به نظر می رسد دامنه {domain} از خارج از شبکه محلی از طریق HTTP قابل دسترسی نیست.", + "diagnosis_http_bad_status_code": "به نظر می رسد دستگاه دیگری (شاید روتر اینترنتی شما) به جای سرور شما پاسخ داده است.
1. شایع ترین علت برای این مشکل ، پورت 80 است (و 443) به درستی به سرور شما ارسال نمی شوند.
2. در تنظیمات پیچیده تر: مطمئن شوید که هیچ فایروال یا پروکسی معکوسی تداخل نداشته باشد.", + "disk_space_not_sufficient_update": "برای به روزرسانی این برنامه فضای دیسک کافی باقی نمانده است", + "disk_space_not_sufficient_install": "فضای کافی برای نصب این برنامه در دیسک باقی نمانده است", + "diagnosis_sshd_config_inconsistent_details": "لطفاً اجراکنید yunohost settings set security.ssh.port -v YOUR_SSH_PORT برای تعریف پورت SSH و بررسی کنید yunohost tools regen-conf ssh --dry-run --with-diff و yunohost tools regen-conf ssh --force برای تنظیم مجدد تنظیمات خود به توصیه YunoHost.", + "diagnosis_sshd_config_inconsistent": "به نظر می رسد که پورت SSH به صورت دستی در/etc/ssh/sshd_config تغییر یافته است. از زمان YunoHost 4.2 ، یک تنظیم جهانی جدید 'security.ssh.port' برای جلوگیری از ویرایش دستی پیکربندی در دسترس است.", + "diagnosis_sshd_config_insecure": "به نظر می رسد که پیکربندی SSH به صورت دستی تغییر یافته است و مطمئن نیست زیرا هیچ دستورالعمل 'AllowGroups' یا 'AllowUsers' برای محدود کردن دسترسی به کاربران مجاز ندارد.", + "diagnosis_processes_killed_by_oom_reaper": "برخی از فرآیندها اخیراً توسط سیستم از بین رفته اند زیرا حافظه آن تمام شده است. این به طور معمول نشانه کمبود حافظه در سیستم یا فرآیندی است که حافظه زیادی را از بین می برد. خلاصه فرآیندهای کشته شده:\n{kills_summary}", + "diagnosis_never_ran_yet": "به نظر می رسد این سرور به تازگی راه اندازی شده است و هنوز هیچ گزارش تشخیصی برای نمایش وجود ندارد. شما باید با اجرای یک عیب یابی و تشخیص کامل، از طریق رابط مدیریت تحت وب webadmin یا با استفاده از 'yunohost diagnosis run' از خط فرمان معاینه و تشخیص عیب یابی را شروع کنید.", + "diagnosis_unknown_categories": "دسته های زیر ناشناخته است: {categories}", + "diagnosis_http_nginx_conf_not_up_to_date_details": "برای برطرف کردن وضعیّت ، تفاوت را با استفاده از خط فرمان بررسی کنیدyunohost tools regen-conf nginx --dry-run --with-diff و اگر خوب است ، تغییرات را اعمال کنید با استفاده از فرمان yunohost tools regen-conf nginx --force.", + "domain_cannot_remove_main_add_new_one": "شما نمی توانید '{domain}' را حذف کنید زیرا دامنه اصلی و تنها دامنه شما است، ابتدا باید دامنه دیگری را با 'yunohost domain add ' اضافه کنید، سپس با استفاده از 'yunohost domain main-domain -n ' به عنوان دامنه اصلی تنظیم شده. و بعد از آن می توانید'{domain}' را حذف کنید با استفاده از'yunohost domain remove {domain}'.'", + "domain_cannot_add_xmpp_upload": "شما نمی توانید دامنه هایی را که با \"xmpp-upload\" شروع می شوند اضافه کنید. این نوع نام مختص ویژگی بارگذاری XMPP است که در YunoHost یکپارچه شده است.", + "domain_cannot_remove_main": "شما نمی توانید '{domain}' را حذف کنید زیرا دامنه اصلی است ، ابتدا باید با استفاده از 'yunohost domain main-domain -n ' دامنه دیگری را به عنوان دامنه اصلی تعیین کنید. در اینجا لیست دامنه های کاندید وجود دارد: {other_domains}", + "installation_complete": "عملیّات نصب کامل شد", + "hook_name_unknown": "نام قلاب ناشناخته '{name}'", + "hook_list_by_invalid": "از این ویژگی نمی توان برای فهرست قلاب ها استفاده کرد", + "hook_json_return_error": "بازگشت از قلاب {path} خوانده نشد. خطا: {msg}. محتوای خام: {raw_content}", + "hook_exec_not_terminated": "اسکریپت به درستی به پایان نرسید: {path}", + "hook_exec_failed": "اسکریپت اجرا نشد: {path}", + "group_user_not_in_group": "کاربر {user} در گروه {group} نیست", + "group_user_already_in_group": "کاربر {user} در حال حاضر در گروه {group} است", + "group_update_failed": "گروه '{group}' به روز نشد: {error}", + "group_updated": "گروه '{group}' به روز شد", + "group_unknown": "گروه '{group}' ناشناخته است", + "group_deletion_failed": "گروه '{group}' حذف نشد: {error}", + "group_deleted": "گروه '{group}' حذف شد", + "group_cannot_be_deleted": "گروه {group} را نمی توان به صورت دستی حذف کرد.", + "group_cannot_edit_primary_group": "گروه '{group}' را نمی توان به صورت دستی ویرایش کرد. این گروه اصلی شامل تنها یک کاربر خاص است.", + "group_cannot_edit_visitors": "ویرایش گروه 'visitors' بازدیدکنندگان به صورت دستی امکان پذیر نیست. این گروه ویژه، نمایانگر بازدیدکنندگان ناشناس است", + "group_cannot_edit_all_users": "گروه 'all_users' را نمی توان به صورت دستی ویرایش کرد. این یک گروه ویژه است که شامل همه کاربران ثبت شده در YunoHost میباشد", + "group_creation_failed": "گروه '{group}' ایجاد نشد: {error}", + "group_created": "گروه '{group}' ایجاد شد", + "group_already_exist_on_system_but_removing_it": "گروه {group} از قبل در گروه های سیستم وجود دارد ، اما YunoHost آن را حذف می کند...", + "group_already_exist_on_system": "گروه {group} از قبل در گروه های سیستم وجود دارد", + "group_already_exist": "گروه {group} از قبل وجود دارد", + "good_practices_about_user_password": "گذرواژه باید حداقل 8 کاراکتر باشد - اگرچه استفاده از گذرواژه طولانی تر تمرین خوبی است (به عنوان مثال عبارت عبور) و/یا استفاده از تنوع کاراکترها (بزرگ ، کوچک ، رقم و کاراکتر های خاص).", + "good_practices_about_admin_password": "اکنون می خواهید گذرواژه جدیدی برای مدیریت تعریف کنید. گذرواژه باید حداقل 8 کاراکتر باشد - اگرچه استفاده از گذرواژه طولانی تر تمرین خوبی است (به عنوان مثال عبارت عبور) و/یا استفاده از تنوع کاراکترها (بزرگ ، کوچک ، رقم و کاراکتر های خاص).", + "global_settings_unknown_type": "وضعیت غیرمنتظره ، به نظر می رسد که تنظیمات {setting} دارای نوع {unknown_type} است اما از نوع پشتیبانی شده توسط سیستم نیست.", + "global_settings_setting_backup_compress_tar_archives": "هنگام ایجاد پشتیبان جدید ، بایگانی های فشرده (.tar.gz) را به جای بایگانی های فشرده نشده (.tar) انتخاب کنید. N.B. : فعال کردن این گزینه به معنای ایجاد آرشیوهای پشتیبان سبک تر است ، اما روش پشتیبان گیری اولیه به طور قابل توجهی طولانی تر و سنگین تر بر روی CPU خواهد بود.", + "global_settings_setting_security_experimental_enabled": "فعال کردن ویژگی های امنیتی آزمایشی (اگر نمی دانید در حال انجام چه کاری هستید این کار را انجام ندهید!)", + "global_settings_setting_security_webadmin_allowlist": "آدرس های IP که مجاز به دسترسی مدیر وب هستند. جدا شده با ویرگول.", + "global_settings_setting_security_webadmin_allowlist_enabled": "فقط به برخی از IP ها اجازه دسترسی به مدیریت وب را بدهید.", + "global_settings_setting_smtp_relay_password": "رمز عبور میزبان رله SMTP", + "global_settings_setting_smtp_relay_user": "حساب کاربری رله SMTP", + "global_settings_setting_smtp_relay_port": "پورت رله SMTP", + "global_settings_setting_smtp_relay_host": "میزبان رله SMTP برای ارسال نامه به جای این نمونه yunohost استفاده می شود. اگر در یکی از این شرایط قرار دارید مفید است: پورت 25 شما توسط ارائه دهنده ISP یا VPS شما مسدود شده است، شما یک IP مسکونی دارید که در DUHL ذکر شده است، نمی توانید DNS معکوس را پیکربندی کنید یا این سرور مستقیماً در اینترنت نمایش داده نمی شود و می خواهید از یکی دیگر برای ارسال ایمیل استفاده کنید.", + "global_settings_setting_smtp_allow_ipv6": "اجازه دهید از IPv6 برای دریافت و ارسال نامه استفاده شود", + "global_settings_setting_ssowat_panel_overlay_enabled": "همپوشانی پانل SSOwat را فعال کنید", + "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "اجازه دهید از کلید میزبان DSA (منسوخ شده) برای پیکربندی SH daemon استفاده شود", + "global_settings_unknown_setting_from_settings_file": "کلید ناشناخته در تنظیمات: '{setting_key}'، آن را کنار گذاشته و در /etc/yunohost/settings-unknown.json ذخیره کنید", + "global_settings_setting_security_ssh_port": "درگاه SSH", + "global_settings_setting_security_postfix_compatibility": "سازگاری در مقابل مبادله امنیتی برای سرور Postfix. روی رمزها (و سایر جنبه های مرتبط با امنیت) تأثیر می گذارد", + "global_settings_setting_security_ssh_compatibility": "سازگاری در مقابل مبادله امنیتی برای سرور SSH. روی رمزها (و سایر جنبه های مرتبط با امنیت) تأثیر می گذارد", + "global_settings_setting_security_password_user_strength": "قدرت رمز عبور کاربر", + "global_settings_setting_security_password_admin_strength": "قدرت رمز عبور مدیر", + "global_settings_setting_security_nginx_compatibility": "سازگاری در مقابل مبادله امنیتی برای وب سرور NGINX. روی رمزها (و سایر جنبه های مرتبط با امنیت) تأثیر می گذارد", + "global_settings_setting_pop3_enabled": "پروتکل POP3 را برای سرور ایمیل فعال کنید", + "global_settings_reset_success": "تنظیمات قبلی اکنون در {path} پشتیبان گیری شده است", + "global_settings_key_doesnt_exists": "کلید '{settings_key}' در تنظیمات جهانی وجود ندارد ، با اجرای 'لیست تنظیمات yunohost' می توانید همه کلیدهای موجود را مشاهده کنید", + "global_settings_cant_write_settings": "فایل تنظیمات ذخیره نشد، به دلیل: {reason}", + "global_settings_cant_serialize_settings": "سریال سازی داده های تنظیمات انجام نشد، به دلیل: {reason}", + "global_settings_cant_open_settings": "فایل تنظیمات باز نشد ، به دلیل: {reason}", + "global_settings_bad_type_for_setting": "نوع نادرست برای تنظیم {setting} ، دریافت شده {received_type}، مورد انتظار {expected_type}", + "global_settings_bad_choice_for_enum": "انتخاب نادرست برای تنظیم {setting} ، '{choice}' دریافت شد ، اما گزینه های موجود عبارتند از: {available_choices}", + "firewall_rules_cmd_failed": "برخی از دستورات قانون فایروال شکست خورده است. اطلاعات بیشتر در گزارش.", + "firewall_reloaded": "فایروال بارگیری مجدد شد", + "firewall_reload_failed": "بارگیری مجدد فایروال امکان پذیر نیست", + "file_does_not_exist": "فایل {path} وجود ندارد.", + "field_invalid": "فیلد نامعتبر '{}'", + "experimental_feature": "هشدار: این ویژگی آزمایشی است و پایدار تلقی نمی شود ، نباید از آن استفاده کنید مگر اینکه بدانید در حال انجام چه کاری هستید.", + "extracting": "استخراج...", + "dyndns_unavailable": "دامنه '{domain}' در دسترس نیست.", + "dyndns_domain_not_provided": "ارائه دهنده DynDNS {provider} نمی تواند دامنه {domain} را ارائه دهد.", + "dyndns_registration_failed": "دامنه DynDNS ثبت نشد: {error}", + "dyndns_registered": "دامنه DynDNS ثبت شد", + "dyndns_provider_unreachable": "دسترسی به ارائه دهنده DynDNS {provider} امکان پذیر نیست: یا YunoHost شما به درستی به اینترنت متصل نیست یا سرور dynette خراب است.", + "dyndns_no_domain_registered": "هیچ دامنه ای با DynDNS ثبت نشده است", + "dyndns_key_not_found": "کلید DNS برای دامنه یافت نشد", + "dyndns_key_generating": "ایجاد کلید DNS... ممکن است مدتی طول بکشد.", + "dyndns_ip_updated": "IP خود را در DynDNS به روز کرد", + "dyndns_ip_update_failed": "آدرس IP را به DynDNS به روز نکرد", + "dyndns_could_not_check_available": "بررسی نشد که آیا {domain} در {provider} در دسترس است یا خیر.", + "dyndns_could_not_check_provide": "بررسی نشد که آیا {provider} می تواند {domain} را ارائه دهد یا خیر.", + "dpkg_lock_not_available": "این دستور در حال حاضر قابل اجرا نیست زیرا به نظر می رسد برنامه دیگری از قفل dpkg (مدیر بسته سیستم) استفاده می کند", + "dpkg_is_broken": "شما نمی توانید این کار را در حال حاضر انجام دهید زیرا dpkg/APT (اداره کنندگان سیستم بسته ها) به نظر می رسد در وضعیت خرابی است… می توانید با اتصال از طریق SSH و اجرا این فرمان `sudo apt install --fix-broken` and/or `sudo dpkg --configure -a` مشکل را حل کنید.", + "downloading": "در حال بارگیری...", + "done": "انجام شد", + "domains_available": "دامنه های موجود:", + "domain_name_unknown": "دامنه '{domain}' ناشناخته است", + "domain_uninstall_app_first": "این برنامه ها هنوز روی دامنه شما نصب هستند:\n{apps}\n\nلطفاً قبل از اقدام به حذف دامنه ، آنها را با استفاده از 'برنامه yunohost remove the_app_id' حذف کرده یا با استفاده از 'yunohost app change-url the_app_id' به دامنه دیگری منتقل کنید", + "domain_remove_confirm_apps_removal": "حذف این دامنه برنامه های زیر را حذف می کند:\n{apps}\n\nآیا طمئن هستید که میخواهید انجام دهید؟ [{answers}]", + "domain_hostname_failed": "نام میزبان جدید قابل تنظیم نیست. این ممکن است بعداً مشکلی ایجاد کند (ممکن هم هست خوب باشد).", + "domain_exists": "دامنه از قبل وجود دارد", + "domain_dyndns_root_unknown": "دامنه ریشه DynDNS ناشناخته", + "domain_dyndns_already_subscribed": "شما قبلاً در یک دامنه DynDNS مشترک شده اید", + "domain_dns_conf_is_just_a_recommendation": "این دستور پیکربندی * توصیه شده * را به شما نشان می دهد. این وظیفه شماست که مطابق این توصیه ، منطقه DNS خود را در ثبت کننده خود پیکربندی کنید. در واقع پیکربندی DNS را برای شما تنظیم نمی کند.", + "domain_deletion_failed": "حذف دامنه {domain} امکان پذیر نیست: {error}", + "domain_deleted": "دامنه حذف شد", + "domain_creation_failed": "ایجاد دامنه {domain} امکان پذیر نیست: {error}", + "domain_created": "دامنه ایجاد شد", + "domain_cert_gen_failed": "گواهی تولید نشد", + "permission_creation_failed": "مجوز '{permission}' را نمیتوان ایجاد کرد: {error}", + "permission_created": "مجوز '{permission}' ایجاد شد", + "permission_cannot_remove_main": "حذف مجوز اصلی مجاز نیست", + "permission_already_up_to_date": "مجوز به روز نشد زیرا درخواست های افزودن/حذف هم اینک با وضعیّت فعلی مطابقت دارد.", + "permission_already_exist": "مجوز '{permission}' در حال حاضر وجود دارد", + "permission_already_disallowed": "گروه '{group}' قبلاً مجوز '{permission}' را غیرفعال کرده است", + "permission_already_allowed": "گروه '{group}' قبلاً مجوز '{permission}' را فعال کرده است", + "pattern_password_app": "متأسفیم ، گذرواژه ها نمی توانند شامل کاراکترهای زیر باشند: {forbidden_chars}", + "pattern_username": "باید فقط حروف الفبایی کوچک و خط زیر باشد", + "pattern_port_or_range": "باید یک شماره پورت معتبر (یعنی 0-65535) یا محدوده پورت (به عنوان مثال 100: 200) باشد", + "pattern_password": "باید حداقل 3 کاراکتر داشته باشد", + "pattern_mailbox_quota": "باید اندازه ای با پسوند b / k / M / G / T یا 0 داشته باشد تا سهمیه نداشته باشد", + "pattern_lastname": "باید نام خانوادگی معتبر باشد", + "pattern_firstname": "باید یک نام کوچک معتبر باشد", + "pattern_email": "باید یک آدرس ایمیل معتبر باشد ، بدون نماد '+' (به عنوان مثال someone@example.com)", + "pattern_email_forward": "باید یک آدرس ایمیل معتبر باشد ، نماد '+' پذیرفته شده است (به عنوان مثال someone+tag@example.com)", + "pattern_domain": "باید یک نام دامنه معتبر باشد (به عنوان مثال my-domain.org)", + "pattern_backup_archive_name": "باید یک نام فایل معتبر با حداکثر 30 کاراکتر حرف و عدد و -_ باشد. فقط کاراکترها", + "password_too_simple_4": "گذرواژه باید حداقل 12 کاراکتر طول داشته باشد و شامل عدد ، حروف الفبائی کوچک و بزرگ و کاراکترهای خاص باشد", + "password_too_simple_3": "گذرواژه باید حداقل 8 کاراکتر طول داشته باشد و شامل عدد ، حروف الفبائی کوچک و بزرگ و کاراکترهای خاص باشد", + "password_too_simple_2": "گذرواژه باید حداقل 8 کاراکتر طول داشته باشد و شامل عدد ، حروف الفبائی کوچک و بزرگ باشد", + "password_too_simple_1": "رمز عبور باید حداقل 8 کاراکتر باشد", + "password_listed": "این رمز در بین پر استفاده ترین رمزهای عبور در جهان قرار دارد. لطفاً چیزی منحصر به فرد تر انتخاب کنید.", + "packages_upgrade_failed": "همه بسته ها را نمی توان ارتقا داد", + "operation_interrupted": "عملیات به صورت دستی قطع شد؟", + "invalid_password": "رمز عبور نامعتبر", + "invalid_number": "باید یک عدد باشد", + "not_enough_disk_space": "فضای آزاد کافی در '{path}' وجود ندارد", + "migrations_to_be_ran_manually": "مهاجرت {id} باید به صورت دستی اجرا شود. لطفاً به صفحه Tools → Migrations در صفحه webadmin بروید، یا `yunohost tools migrations run` را اجرا کنید.", + "migrations_success_forward": "مهاجرت {id} تکمیل شد", + "migrations_skip_migration": "رد کردن مهاجرت {id}...", + "migrations_running_forward": "مهاجرت در حال اجرا {id}»...", + "migrations_pending_cant_rerun": "این مهاجرت ها هنوز در انتظار هستند ، بنابراین نمی توان آنها را دوباره اجرا کرد: {ids}", + "migrations_not_pending_cant_skip": "این مهاجرت ها معلق نیستند ، بنابراین نمی توان آنها را رد کرد: {ids}", + "migrations_no_such_migration": "مهاجرتی به نام '{id}' وجود ندارد", + "migrations_no_migrations_to_run": "مهاجرتی برای اجرا وجود ندارد", + "migrations_need_to_accept_disclaimer": "برای اجرای مهاجرت {id} ، باید سلب مسئولیت زیر را بپذیرید:\n---\n{disclaimer}\n---\nاگر می خواهید مهاجرت را اجرا کنید ، لطفاً فرمان را با گزینه '--accept-disclaimer' دوباره اجرا کنید.", + "migrations_must_provide_explicit_targets": "هنگام استفاده '--skip' یا '--force-rerun' باید اهداف مشخصی را ارائه دهید", + "migrations_migration_has_failed": "مهاجرت {id} کامل نشد ، لغو شد. خطا: {exception}", + "migrations_loading_migration": "بارگیری مهاجرت {id}...", + "migrations_list_conflict_pending_done": "شما نمیتوانید از هر دو انتخاب '--previous' و '--done' به طور همزمان استفاده کنید.", + "migrations_exclusive_options": "'--auto', '--skip'، و '--force-rerun' گزینه های متقابل هستند.", + "migrations_failed_to_load_migration": "مهاجرت بار نشد {id}: {error}", + "migrations_dependencies_not_satisfied": "این مهاجرت ها را اجرا کنید: '{dependencies_id}' ، قبل از مهاجرت {id}.", + "migrations_cant_reach_migration_file": "دسترسی به پرونده های مهاجرت در مسیر '٪ s' امکان پذیر نیست", + "migrations_already_ran": "این مهاجرت ها قبلاً انجام شده است: {ids}", + "migration_0019_slapd_config_will_be_overwritten": "به نظر می رسد که شما پیکربندی slapd را به صورت دستی ویرایش کرده اید. برای این مهاجرت بحرانی ، YunoHost باید به روز رسانی پیکربندی slapd را مجبور کند. فایلهای اصلی در {conf_backup_folder} پشتیبان گیری می شوند.", + "migration_0019_add_new_attributes_in_ldap": "اضافه کردن ویژگی های جدید برای مجوزها در پایگاه داده LDAP", + "migration_0018_failed_to_reset_legacy_rules": "تنظیم مجدد قوانین iptables قدیمی انجام نشد: {error}", + "migration_0018_failed_to_migrate_iptables_rules": "انتقال قوانین قدیمی iptables به nftables انجام نشد: {error}", + "migration_0017_not_enough_space": "فضای کافی در {path} برای اجرای مهاجرت در دسترس قرار دهید.", + "migration_0017_postgresql_11_not_installed": "PostgreSQL 9.6 نصب شده است ، اما postgresql 11 نه؟ ممکن است اتفاق عجیبی در سیستم شما رخ داده باشد:(...", + "migration_0017_postgresql_96_not_installed": "PostgreSQL روی سیستم شما نصب نشده است. کاری برای انجام دادن نیست.", + "migration_0015_weak_certs": "گواهینامه های زیر هنوز از الگوریتم های امضای ضعیف استفاده می کنند و برای سازگاری با نسخه بعدی nginx باید ارتقاء یابند: {certs}", + "migration_0015_cleaning_up": "پاک کردن حافظه پنهان و بسته ها دیگر مفید نیست...", + "migration_0015_specific_upgrade": "شروع به روزرسانی بسته های سیستم که باید به طور مستقل ارتقا یابد...", + "migration_0015_modified_files": "لطفاً توجه داشته باشید که فایل های زیر به صورت دستی اصلاح شده اند و ممکن است پس از ارتقاء رونویسی شوند: {manually_modified_files}", + "migration_0015_problematic_apps_warning": "لطفاً توجه داشته باشید که احتمالاً برنامه های نصب شده مشکل ساز تشخیص داده شده. به نظر می رسد که آنها از فهرست برنامه YunoHost نصب نشده اند یا به عنوان 'working' علامت گذاری نشده اند. در نتیجه ، نمی توان تضمین کرد که پس از ارتقاء همچنان کار خواهند کرد: {problematic_apps}", + "migration_0015_general_warning": "لطفاً توجه داشته باشید که این مهاجرت یک عملیات ظریف است. تیم YunoHost تمام تلاش خود را برای بررسی و آزمایش آن انجام داد ، اما مهاجرت ممکن است بخشهایی از سیستم یا برنامه های آن را خراب کند.\n\nبنابراین ، توصیه می شود:\n- پشتیبان گیری از هرگونه داده یا برنامه حیاتی را انجام دهید. اطلاعات بیشتر در https://yunohost.org/backup ؛\n- پس از راه اندازی مهاجرت صبور باشید: بسته به اتصال به اینترنت و سخت افزار شما ، ممکن است چند ساعت طول بکشد تا همه چیز ارتقا یابد.", + "migration_0015_system_not_fully_up_to_date": "سیستم شما کاملاً به روز نیست. لطفاً قبل از اجرای مهاجرت به Buster ، یک ارتقاء منظم انجام دهید.", + "migration_0015_not_enough_free_space": "فضای آزاد در /var /بسیار کم است! برای اجرای این مهاجرت باید حداقل 1 گیگابایت فضای آزاد داشته باشید.", + "migration_0015_not_stretch": "توزیع دبیان فعلی استرچ نیست!", + "migration_0015_yunohost_upgrade": "شروع به روز رسانی اصلی YunoHost...", + "migration_0015_still_on_stretch_after_main_upgrade": "هنگام ارتقاء اصلی مشکلی پیش آمد ، به نظر می رسد سیستم هنوز در Debian Stretch است", + "migration_0015_main_upgrade": "شروع به روزرسانی اصلی...", + "migration_0015_patching_sources_list": "وصله منابع. لیست ها...", + "migration_0015_start": "شروع مهاجرت به باستر", + "migration_update_LDAP_schema": "در حال به روزرسانی طرح وشمای LDAP...", + "migration_ldap_rollback_success": "سیستم برگردانده شد.", + "migration_ldap_migration_failed_trying_to_rollback": "نمی توان مهاجرت کرد... تلاش برای بازگرداندن سیستم.", + "migration_ldap_can_not_backup_before_migration": "نمی توان پشتیبان گیری سیستم را قبل از شکست مهاجرت تکمیل کرد. خطا: {error}", + "migration_ldap_backup_before_migration": "ایجاد پشتیبان از پایگاه داده LDAP و تنظیمات برنامه ها قبل از مهاجرت واقعی.", + "migration_description_0020_ssh_sftp_permissions": "پشتیبانی مجوزهای SSH و SFTP را اضافه کنید", + "migration_description_0019_extend_permissions_features": "سیستم مدیریت مجوز برنامه را تمدید / دوباره کار بندازید", + "migration_description_0018_xtable_to_nftable": "مهاجرت از قوانین قدیمی ترافیک شبکه به سیستم جدید nftable", + "migration_description_0017_postgresql_9p6_to_11": "مهاجرت پایگاه های داده از PostgreSQL 9.6 به 11", + "migration_description_0016_php70_to_php73_pools": "انتقال فایلهای conf php7.0-fpm 'pool' به php7.3", + "migration_description_0015_migrate_to_buster": "سیستم را به Debian Buster و YunoHost 4.x ارتقا دهید", + "migrating_legacy_permission_settings": "در حال انتقال تنظیمات مجوز قدیمی...", + "main_domain_changed": "دامنه اصلی تغییر کرده است", + "main_domain_change_failed": "تغییر دامنه اصلی امکان پذیر نیست", + "mail_unavailable": "این آدرس ایمیل محفوظ است و باید به طور خودکار به اولین کاربر اختصاص داده شود", + "mailbox_used_space_dovecot_down": "اگر می خواهید فضای صندوق پستی استفاده شده را واکشی کنید ، سرویس صندوق پستی Dovecot باید فعال باشد", + "mailbox_disabled": "ایمیل برای کاربر {user} خاموش است", + "mail_forward_remove_failed": "ارسال ایمیل '{mail}' حذف نشد", + "mail_domain_unknown": "آدرس ایمیل نامعتبر برای دامنه '{domain}'. لطفاً از دامنه ای که توسط این سرور اداره می شود استفاده کنید.", + "mail_alias_remove_failed": "نام مستعار ایمیل '{mail}' حذف نشد", + "log_tools_reboot": "سرور خود را راه اندازی مجدد کنید", + "log_tools_shutdown": "سرور خود را خاموش کنید", + "log_tools_upgrade": "بسته های سیستم را ارتقا دهید", + "log_tools_postinstall": "اسکریپت پس از نصب سرور YunoHost خود را نصب کنید", + "log_tools_migrations_migrate_forward": "اجرای مهاجرت ها", + "log_domain_main_domain": "'{}' را دامنه اصلی کنید", + "log_user_permission_reset": "بازنشانی مجوز '{}'", + "log_user_permission_update": "دسترسی ها را برای مجوزهای '{}' به روز کنید", + "log_user_update": "به روزرسانی اطلاعات کاربر '{}'", + "log_user_group_update": "به روزرسانی گروه '{}'", + "log_user_group_delete": "حذف گروه '{}'", + "log_user_group_create": "ایجاد گروه '{}'", + "log_user_delete": "کاربر '{}' را حذف کنید", + "log_user_create": "کاربر '{}' را اضافه کنید", + "log_regen_conf": "بازسازی تنظیمات سیستم '{}'", + "log_letsencrypt_cert_renew": "تمدید '{}' گواهی اجازه رمزگذاری", + "log_selfsigned_cert_install": "گواهی خود امضا شده را در دامنه '{}' نصب کنید", + "log_permission_url": "به روزرسانی نشانی اینترنتی مربوط به مجوز دسترسی '{}'", + "log_permission_delete": "حذف مجوز دسترسی '{}'", + "log_permission_create": "ایجاد مجوز دسترسی '{}'", + "log_letsencrypt_cert_install": "گواهی اجازه رمزگذاری را در دامنه '{}' نصب کنید", + "log_dyndns_update": "IP مرتبط با '{}' زیر دامنه YunoHost خود را به روز کنید", + "log_dyndns_subscribe": "مشترک شدن در زیر دامنه YunoHost '{}'", + "log_domain_remove": "دامنه '{}' را از پیکربندی سیستم حذف کنید", + "log_domain_add": "دامنه '{}' را به پیکربندی سیستم اضافه کنید", + "log_remove_on_failed_install": "پس از نصب ناموفق '{}' را حذف کنید", + "log_remove_on_failed_restore": "پس از بازیابی ناموفق از بایگانی پشتیبان، '{}' را حذف کنید", + "log_backup_restore_app": "بازیابی '{}' از بایگانی پشتیبان", + "log_backup_restore_system": "بازیابی سیستم بوسیله آرشیو پشتیبان", + "log_backup_create": "بایگانی پشتیبان ایجاد کنید", + "log_available_on_yunopaste": "این گزارش اکنون از طریق {url} در دسترس است", + "log_app_action_run": "عملکرد برنامه '{}' را اجرا کنید", + "log_app_makedefault": "\"{}\" را برنامه پیش فرض قرار دهید", + "log_app_upgrade": "برنامه '{}' را ارتقاء دهید", + "log_app_remove": "برنامه '{}' را حذف کنید", + "log_app_install": "برنامه '{}' را نصب کنید", + "log_app_change_url": "نشانی وب برنامه '{}' را تغییر دهید", + "log_operation_unit_unclosed_properly": "واحد عملیّات به درستی بسته نشده است", + "log_does_exists": "هیچ گزارش عملیاتی با نام '{log}' وجود ندارد ، برای مشاهده همه گزارش عملیّات های موجود در خط فرمان از دستور 'yunohost log list' استفاده کنید", + "log_help_to_get_failed_log": "عملیات '{desc}' کامل نشد. لطفاً برای دریافت راهنمایی و کمک ، گزارش کامل این عملیات را با استفاده از دستور 'yunohost log share {name}' به اشتراک بگذارید", + "log_link_to_failed_log": "عملیّات '{desc}' کامل نشد. لطفاً گزارش کامل این عملیات را ارائه دهید بواسطه اینجا را کلیک کنید برای دریافت کمک", + "log_help_to_get_log": "برای مشاهده گزارش عملیات '{desc}'، از دستور 'yunohost log show {name}' استفاده کنید", + "log_link_to_log": "گزارش کامل این عملیات: {desc}'", + "log_corrupted_md_file": "فایل فوق داده YAML مربوط به گزارش ها آسیب دیده است: '{md_file}\nخطا: {error} '", + "ldap_server_is_down_restart_it": "سرویس LDAP خاموش است ، سعی کنید آن را دوباره راه اندازی کنید...", + "ldap_server_down": "دسترسی به سرور LDAP امکان پذیر نیست", + "iptables_unavailable": "در اینجا نمی توانید با iptables بازی کنید. شما یا در ظرفی هستید یا هسته شما آن را پشتیبانی نمی کند", + "ip6tables_unavailable": "در اینجا نمی توانید با جدول های ipv6 کار کنید. شما یا در کانتینتر هستید یا هسته شما آن را پشتیبانی نمی کند", + "invalid_regex": "عبارت منظم نامعتبر: '{regex}'", + "yunohost_postinstall_end_tip": "پس از نصب کامل شد! برای نهایی کردن تنظیمات خود ، لطفاً موارد زیر را در نظر بگیرید:\n - افزودن اولین کاربر از طریق بخش \"کاربران\" webadmin (یا 'yunohost user create ' در خط فرمان) ؛\n - تشخیص مشکلات احتمالی از طریق بخش \"عیب یابی\" webadmin (یا 'yunohost diagnosis run' در خط فرمان) ؛\n - خواندن قسمت های \"نهایی کردن راه اندازی خود\" و \"آشنایی با YunoHost\" در اسناد مدیریت: https://yunohost.org/admindoc.", + "yunohost_not_installed": "YunoHost به درستی نصب نشده است. لطفا 'yunohost tools postinstall' را اجرا کنید", + "yunohost_installing": "در حال نصب YunoHost...", + "yunohost_configured": "YunoHost اکنون پیکربندی شده است", + "yunohost_already_installed": "YunoHost قبلاً نصب شده است", + "user_updated": "اطلاعات کاربر تغییر کرد", + "user_update_failed": "کاربر {user} به روز نشد: {error}", + "user_unknown": "کاربر ناشناس: {user}", + "user_home_creation_failed": "پوشه 'home' برای کاربر ایجاد نشد", + "user_deletion_failed": "کاربر {user} حذف نشد: {error}", + "user_deleted": "کاربر حذف شد", + "user_creation_failed": "کاربر {user} ایجاد نشد: {error}", + "user_created": "کاربر ایجاد شد", + "user_already_exists": "کاربر '{user}' در حال حاضر وجود دارد", + "upnp_port_open_failed": "پورت از طریق UPnP باز نشد", + "upnp_enabled": "UPnP روشن شد", + "upnp_disabled": "UPnP خاموش شد", + "upnp_dev_not_found": "هیچ دستگاه UPnP یافت نشد", + "upgrading_packages": "در حال ارتقاء بسته ها...", + "upgrade_complete": "ارتقا کامل شد", + "updating_apt_cache": "در حال واکشی و دریافت ارتقاء موجود برای بسته های سیستم...", + "update_apt_cache_warning": "هنگام به روز رسانی حافظه پنهان APT (مدیر بسته دبیان) مشکلی پیش آمده. در اینجا مجموعه ای از خطوط source.list موجود میباشد که ممکن است به شناسایی خطوط مشکل ساز کمک کند:\n{sourceslist}", + "update_apt_cache_failed": "امکان بروزرسانی حافظه پنهان APT (مدیر بسته دبیان) وجود ندارد. در اینجا مجموعه ای از خطوط source.list هست که ممکن است به شناسایی خطوط مشکل ساز کمک کند:\n{sourceslist}", + "unrestore_app": "{app} بازیابی نمی شود", + "unlimit": "بدون سهمیه", + "unknown_main_domain_path": "دامنه یا مسیر ناشناخته برای '{app}'. شما باید یک دامنه و یک مسیر را مشخص کنید تا بتوانید یک آدرس اینترنتی برای مجوز تعیین کنید.", + "unexpected_error": "مشکل غیر منتظره ای پیش آمده: {error}", + "unbackup_app": "{app} ذخیره نمی شود", + "tools_upgrade_special_packages_completed": "ارتقاء بسته YunoHost به پایان رسید\nبرای بازگرداندن خط فرمان [Enter] را فشار دهید", + "tools_upgrade_special_packages_explanation": "ارتقاء ویژه در پس زمینه ادامه خواهد یافت. لطفاً تا 10 دقیقه دیگر (بسته به سرعت سخت افزار) هیچ اقدام دیگری را روی سرور خود شروع نکنید. پس از این کار ، ممکن است مجبور شوید دوباره وارد webadmin شوید. گزارش ارتقاء در Tools → Log (در webadmin) یا با استفاده از 'yunohost log list' (در خط فرمان) در دسترس خواهد بود.", + "tools_upgrade_special_packages": "در حال ارتقاء بسته های 'special' (مربوط به yunohost)...", + "tools_upgrade_regular_packages_failed": "بسته ها را نمی توان ارتقا داد: {packages_list}", + "tools_upgrade_regular_packages": "در حال ارتقاء بسته های 'regular' (غیر مرتبط با yunohost)...", + "tools_upgrade_cant_unhold_critical_packages": "بسته های مهم و حیاتی را نمی توان نگه نداشت...", + "tools_upgrade_cant_hold_critical_packages": "بسته های مهم و حیاتی را نمی توان نگه داشت...", + "tools_upgrade_cant_both": "نمی توان سیستم و برنامه ها را به طور همزمان ارتقا داد", + "tools_upgrade_at_least_one": "لطفاً مشخص کنید 'apps' ، یا 'system'", + "this_action_broke_dpkg": "این اقدام dpkg/APT (مدیران بسته های سیستم) را خراب کرد... می توانید با اتصال از طریق SSH و اجرای فرمان `sudo apt install --fix -break` و/یا` sudo dpkg --configure -a` این مشکل را حل کنید.", + "system_username_exists": "نام کاربری قبلاً در لیست کاربران سیستم وجود دارد", + "system_upgraded": "سیستم ارتقا یافت", + "ssowat_conf_updated": "پیکربندی SSOwat به روزرسانی شد", + "ssowat_conf_generated": "پیکربندی SSOwat بازسازی شد", + "show_tile_cant_be_enabled_for_regex": "شما نمی توانید \"show_tile\" را درست فعال کنید ، چرا که آدرس اینترنتی مجوز '{permission}' یک عبارت منظم است", + "show_tile_cant_be_enabled_for_url_not_defined": "شما نمی توانید \"show_tile\" را در حال حاضر فعال کنید ، زیرا ابتدا باید یک آدرس اینترنتی برای مجوز '{permission}' تعریف کنید", + "service_unknown": "سرویس ناشناخته '{service}'", + "service_stopped": "سرویس '{service}' متوقف شد", + "service_stop_failed": "سرویس '{service}' متوقف نمی شود\n\nگزارشات اخیر سرویس: {logs}", + "service_started": "سرویس '{service}' شروع شد", + "service_start_failed": "سرویس '{service}' شروع نشد\n\nگزارشات اخیر سرویس: {logs}", + "service_reloaded_or_restarted": "سرویس '{service}' بارگیری یا راه اندازی مجدد شد", + "service_reload_or_restart_failed": "سرویس \"{service}\" بارگیری یا راه اندازی مجدد نشد\n\nگزارشات اخیر سرویس: {logs}", + "service_restarted": "سرویس '{service}' راه اندازی مجدد شد", + "service_restart_failed": "سرویس \"{service}\" راه اندازی مجدد نشد\n\nگزارشات اخیر سرویس: {logs}", + "service_reloaded": "سرویس '{service}' بارگیری مجدد شد", + "service_reload_failed": "سرویس '{service}' بارگیری نشد\n\nگزارشات اخیر سرویس: {logs}", + "service_removed": "سرویس '{service}' حذف شد", + "service_remove_failed": "سرویس '{service}' حذف نشد", + "service_regen_conf_is_deprecated": "فرمان 'yunohost service regen-conf' منسوخ شده است! لطفاً به جای آن از 'yunohost tools regen-conf' استفاده کنید.", + "service_enabled": "سرویس '{service}' اکنون بطور خودکار در هنگام بوت شدن سیستم راه اندازی می شود.", + "service_enable_failed": "انجام سرویس '{service}' به طور خودکار در هنگام راه اندازی امکان پذیر نیست.\n\nگزارشات اخیر سرویس: {logs}", + "service_disabled": "هنگام راه اندازی سیستم ، سرویس '{service}' دیگر راه اندازی نمی شود.", + "service_disable_failed": "نتوانست باعث شود سرویس '{service}' در هنگام راه اندازی شروع نشود.\n\nگزارشات سرویس اخیر: {logs}", + "service_description_yunohost-firewall": "باز و بسته شدن پورت های اتصال به سرویس ها را مدیریت می کند", + "service_description_yunohost-api": "تعاملات بین رابط وب YunoHost و سیستم را مدیریت می کند", + "service_description_ssh": "به شما امکان می دهد از راه دور از طریق ترمینال (پروتکل SSH) به سرور خود متصل شوید", + "service_description_slapd": "کاربران ، دامنه ها و اطلاعات مرتبط را ذخیره می کند", + "service_description_rspamd": "هرزنامه ها و سایر ویژگی های مربوط به ایمیل را فیلتر می کند", + "service_description_redis-server": "یک پایگاه داده تخصصی برای دسترسی سریع به داده ها ، صف وظیفه و ارتباط بین برنامه ها استفاده می شود", + "service_description_postfix": "برای ارسال و دریافت ایمیل استفاده می شود", + "service_description_php7.3-fpm": "برنامه های نوشته شده با PHP را با NGINX اجرا می کند", + "service_description_nginx": "به همه وب سایت هایی که روی سرور شما میزبانی شده اند سرویس می دهد یا دسترسی به آنها را فراهم می کند", + "service_description_mysql": "ذخیره داده های برنامه (پایگاه داده SQL)", + "service_description_metronome": "مدیریت حساب های پیام رسانی فوری XMPP", + "service_description_fail2ban": "در برابر حملات وحشیانه و انواع دیگر حملات از طریق اینترنت محافظت می کند", + "service_description_dovecot": "به کلاینت های ایمیل اجازه می دهد تا به ایمیل دسترسی/واکشی داشته باشند (از طریق IMAP و POP3)", + "service_description_dnsmasq": "کنترل تفکیک پذیری نام دامنه (DNS)", + "service_description_yunomdns": "به شما امکان می دهد با استفاده از 'yunohost.local' در شبکه محلی به سرور خود برسید", + "service_cmd_exec_failed": "نمی توان دستور '{command}' را اجرا کرد", + "service_already_stopped": "سرویس '{service}' قبلاً متوقف شده است", + "service_already_started": "سرویس '{service}' در حال اجرا است", + "service_added": "سرویس '{service}' اضافه شد", + "service_add_failed": "سرویس '{service}' اضافه نشد", + "server_reboot_confirm": "سرور بلافاصله راه اندازی مجدد می شود، آیا مطمئن هستید؟ [{answers}]", + "server_reboot": "سرور راه اندازی مجدد می شود", + "server_shutdown_confirm": "آیا مطمئن هستید که سرور بلافاصله خاموش می شود؟ [{answers}]", + "server_shutdown": "سرور خاموش می شود", + "root_password_replaced_by_admin_password": "گذرواژه ریشه شما با رمز مدیریت جایگزین شده است.", + "root_password_desynchronized": "گذرواژه مدیریت تغییر کرد ، اما YunoHost نتوانست این را به رمز عبور ریشه منتقل کند!", + "restore_system_part_failed": "بخش سیستم '{part}' بازیابی و ترمیم نشد", + "restore_running_hooks": "در حال اجرای قلاب های ترمیم و بازیابی...", + "restore_running_app_script": "ترمیم و بازیابی برنامه '{app}'...", + "restore_removing_tmp_dir_failed": "پوشه موقت قدیمی حذف نشد", + "restore_nothings_done": "هیچ چیز ترمیم و بازسازی نشد", + "restore_not_enough_disk_space": "فضای کافی موجود نیست (فضا: {free_space} B ، فضای مورد نیاز: {needed_space} B ، حاشیه امنیتی: {margin} B)", + "restore_may_be_not_enough_disk_space": "به نظر می رسد سیستم شما فضای کافی ندارد (فضای آزاد: {free_space} B ، فضای مورد نیاز: {needed_space} B ، حاشیه امنیتی: {margin} B)", + "restore_hook_unavailable": "اسکریپت ترمیم و بازسازی برای '{part}' در سیستم شما در دسترس نیست و همچنین در بایگانی نیز وجود ندارد", + "restore_failed": "سیستم بازیابی نشد", + "restore_extracting": "استخراج فایل های مورد نیاز از بایگانی...", + "restore_confirm_yunohost_installed": "آیا واقعاً می خواهید سیستمی که هم اکنون نصب شده را بازیابی کنید؟ [{answers}]", + "restore_complete": "مرمت به پایان رسید", + "restore_cleaning_failed": "فهرست بازسازی موقت پاک نشد", + "restore_backup_too_old": "این بایگانی پشتیبان را نمی توان بازیابی کرد زیرا با نسخه خیلی قدیمی YunoHost تهیه شده است.", + "restore_already_installed_apps": "برنامه های زیر به دلیل نصب بودن قابل بازیابی نیستند: {apps}", + "restore_already_installed_app": "برنامه ای با شناسه '{app}' در حال حاضر نصب شده است", + "regex_with_only_domain": "شما نمی توانید از عبارات منظم برای دامنه استفاده کنید، فقط برای مسیر قابل استفاده است", + "regex_incompatible_with_tile": "/!\\ بسته بندی کنندگان! مجوز '{permission}' show_tile را روی 'true' تنظیم کرده اند و بنابراین نمی توانید عبارت منظم آدرس اینترنتی را به عنوان URL اصلی تعریف کنید", + "regenconf_need_to_explicitly_specify_ssh": "پیکربندی ssh به صورت دستی تغییر یافته است ، اما شما باید صراحتاً دسته \"ssh\" را با --force برای اعمال تغییرات در واقع مشخص کنید.", + "regenconf_pending_applying": "در حال اعمال پیکربندی معلق برای دسته '{category}'...", + "regenconf_failed": "پیکربندی برای دسته (ها) بازسازی نشد: {categories}", + "regenconf_dry_pending_applying": "در حال بررسی پیکربندی معلق که برای دسته '{category}' اعمال می شد...", + "regenconf_would_be_updated": "پیکربندی برای دسته '{category}' به روز می شد", + "regenconf_updated": "پیکربندی برای دسته '{category}' به روز شد", + "regenconf_up_to_date": "پیکربندی در حال حاضر برای دسته '{category}' به روز است", + "regenconf_now_managed_by_yunohost": "فایل پیکربندی '{conf}' اکنون توسط YunoHost (دسته {category}) مدیریت می شود.", + "regenconf_file_updated": "فایل پیکربندی '{conf}' به روز شد", + "regenconf_file_removed": "فایل پیکربندی '{conf}' حذف شد", + "regenconf_file_remove_failed": "فایل پیکربندی '{conf}' حذف نشد", + "regenconf_file_manually_removed": "فایل پیکربندی '{conf}' به صورت دستی حذف شد، و ایجاد نخواهد شد", + "regenconf_file_manually_modified": "فایل پیکربندی '{conf}' به صورت دستی اصلاح شده است و به روز نمی شود", + "regenconf_file_kept_back": "انتظار میرفت که فایل پیکربندی '{conf}' توسط regen-conf (دسته {category}) حذف شود ، اما پس گرفته شد.", + "regenconf_file_copy_failed": "فایل پیکربندی جدید '{new}' در '{conf}' کپی نشد", + "regenconf_file_backed_up": "فایل پیکربندی '{conf}' در '{backup}' پشتیبان گیری شد", + "postinstall_low_rootfsspace": "فضای فایل سیستم اصلی کمتر از 10 گیگابایت است که بسیار نگران کننده است! به احتمال زیاد خیلی زود فضای دیسک شما تمام می شود! توصیه می شود حداقل 16 گیگابایت برای سیستم فایل ریشه داشته باشید. اگر می خواهید YunoHost را با وجود این هشدار نصب کنید ، فرمان نصب را مجدد با این آپشن --force-diskspace اجرا کنید", + "port_already_opened": "پورت {port} قبلاً برای اتصالات {ip_version} باز شده است", + "port_already_closed": "پورت {port} قبلاً برای اتصالات {ip_version} بسته شده است", + "permission_require_account": "مجوز {permission} فقط برای کاربران دارای حساب کاربری منطقی است و بنابراین نمی تواند برای بازدیدکنندگان فعال شود.", + "permission_protected": "مجوز {permission} محافظت می شود. شما نمی توانید گروه بازدیدکنندگان را از/به این مجوز اضافه یا حذف کنید.", + "permission_updated": "مجوز '{permission}' به روز شد", + "permission_update_failed": "مجوز '{permission}' به روز نشد: {error}", + "permission_not_found": "مجوز '{permission}' پیدا نشد", + "permission_deletion_failed": "اجازه '{permission}' حذف نشد: {error}", + "permission_deleted": "مجوز '{permission}' حذف شد", + "permission_cant_add_to_all_users": "مجوز {permission} را نمی توان به همه کاربران اضافه کرد.", + "permission_currently_allowed_for_all_users": "این مجوز در حال حاضر به همه کاربران علاوه بر آن گروه های دیگر نیز اعطا شده. احتمالاً بخواهید مجوز 'all_users' را حذف کنید یا سایر گروه هایی را که در حال حاضر مجوز به آنها اعطا شده است را هم حذف کنید." +} \ No newline at end of file diff --git a/locales/fi.json b/locales/fi.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/locales/fi.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/locales/fr.json b/locales/fr.json index cce49c7a3..46535719e 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1,443 +1,264 @@ { - "action_invalid": "Action '{action:s}' incorrecte", - "admin_password": "Mot de passe d’administration", + "action_invalid": "Action '{action}' incorrecte", + "admin_password": "Mot de passe d'administration", "admin_password_change_failed": "Impossible de changer le mot de passe", - "admin_password_changed": "Le mot de passe d’administration a été modifié", - "app_already_installed": "{app:s} est déjà installé", - "app_argument_choice_invalid": "Choix invalide pour le paramètre '{name:s}', il doit être l’un de {choices:s}", - "app_argument_invalid": "Valeur invalide pour le paramètre '{name:s}' : {error:s}", - "app_argument_missing": "Paramètre manquant « {:s} »", - "app_argument_required": "Le paramètre '{name:s}' est requis", - "app_extraction_failed": "Impossible d’extraire les fichiers d’installation", - "app_id_invalid": "Identifiant d’application invalide", - "app_incompatible": "L’application {app} est incompatible avec votre version de YunoHost", - "app_install_files_invalid": "Fichiers d’installation incorrects", - "app_location_already_used": "L’application '{app}' est déjà installée à cet emplacement ({path})", - "app_location_install_failed": "Impossible d’installer l’application à cet emplacement pour cause de conflit avec l’application '{other_app}' déjà installée sur '{other_path}'", - "app_manifest_invalid": "Manifeste d’application incorrect : {error}", - "app_no_upgrade": "Aucune application à mettre à jour", - "app_not_correctly_installed": "{app:s} semble être mal installé", - "app_not_installed": "{app:s} n’est pas installé", - "app_not_properly_removed": "{app:s} n’a pas été supprimé correctement", - "app_package_need_update": "Le paquet de l’application {app} doit être mis à jour pour être en adéquation avec les changements de YunoHost", - "app_recent_version_required": "{app:s} nécessite une version plus récente de YunoHost", - "app_removed": "{app:s} a été supprimé", - "app_requirements_checking": "Vérification des paquets requis pour {app} …", - "app_requirements_failed": "Impossible de satisfaire les pré-requis pour {app} : {error}", + "admin_password_changed": "Le mot de passe d'administration a été modifié", + "app_already_installed": "{app} est déjà installé", + "app_argument_choice_invalid": "Choix invalide pour le paramètre '{name}'. Les valeurs acceptées sont {choices}, au lieu de '{value}'", + "app_argument_invalid": "Valeur invalide pour le paramètre '{name}' : {error}", + "app_argument_required": "Le paramètre '{name}' est requis", + "app_extraction_failed": "Impossible d'extraire les fichiers d'installation", + "app_id_invalid": "Identifiant d'application invalide", + "app_install_files_invalid": "Fichiers d'installation incorrects", + "app_manifest_invalid": "Manifeste d'application incorrect : {error}", + "app_not_correctly_installed": "{app} semble être mal installé", + "app_not_installed": "Nous n'avons pas trouvé {app} dans la liste des applications installées : {all_apps}", + "app_not_properly_removed": "{app} n'a pas été supprimé correctement", + "app_removed": "{app} désinstallé", + "app_requirements_checking": "Vérification des paquets requis pour {app}...", "app_requirements_unmeet": "Les pré-requis de {app} ne sont pas satisfaits, le paquet {pkgname} ({version}) doit être {spec}", "app_sources_fetch_failed": "Impossible de récupérer les fichiers sources, l'URL est-elle correcte ?", "app_unknown": "Application inconnue", "app_unsupported_remote_type": "Ce type de commande à distance utilisé pour cette application n'est pas supporté", - "app_upgrade_failed": "Impossible de mettre à jour {app:s}", - "app_upgraded": "{app:s} a été mis à jour", - "appslist_fetched": "La liste d’applications {appslist:s} a été récupérée", - "appslist_removed": "La liste d’applications {appslist:s} a été supprimée", - "appslist_retrieve_error": "Impossible de récupérer la liste d’applications distante {appslist:s} : {error:s}", - "appslist_unknown": "La liste d’applications {appslist:s} est inconnue.", - "ask_current_admin_password": "Mot de passe d’administration actuel", - "ask_email": "Adresse de courriel", + "app_upgrade_failed": "Impossible de mettre à jour {app} : {error}", + "app_upgraded": "{app} mis à jour", "ask_firstname": "Prénom", "ask_lastname": "Nom", - "ask_list_to_remove": "Liste à supprimer", "ask_main_domain": "Domaine principal", - "ask_new_admin_password": "Nouveau mot de passe d’administration", + "ask_new_admin_password": "Nouveau mot de passe d'administration", "ask_password": "Mot de passe", - "backup_action_required": "Vous devez préciser ce qui est à sauvegarder", - "backup_app_failed": "Impossible de sauvegarder l’application '{app:s}'", - "backup_archive_app_not_found": "L’application '{app:s}' n’a pas été trouvée dans l’archive de la sauvegarde", - "backup_archive_hook_not_exec": "Le script « {hook:s} » n'a pas été exécuté dans cette sauvegarde", - "backup_archive_name_exists": "Une archive de sauvegarde avec ce nom existe déjà", - "backup_archive_name_unknown": "L’archive locale de sauvegarde nommée '{name:s}' est inconnue", - "backup_archive_open_failed": "Impossible d’ouvrir l’archive de sauvegarde", + "backup_app_failed": "Impossible de sauvegarder {app}", + "backup_archive_app_not_found": "{app} n'a pas été trouvée dans l'archive de la sauvegarde", + "backup_archive_name_exists": "Une archive de sauvegarde avec ce nom existe déjà.", + "backup_archive_name_unknown": "L'archive locale de sauvegarde nommée '{name}' est inconnue", + "backup_archive_open_failed": "Impossible d'ouvrir l'archive de la sauvegarde", "backup_cleaning_failed": "Impossible de nettoyer le dossier temporaire de sauvegarde", "backup_created": "Sauvegarde terminée", - "backup_creating_archive": "Création de l’archive de sauvegarde …", - "backup_creation_failed": "Impossible de créer la sauvegarde", - "backup_delete_error": "Impossible de supprimer '{path:s}'", + "backup_creation_failed": "Impossible de créer l'archive de la sauvegarde", + "backup_delete_error": "Impossible de supprimer '{path}'", "backup_deleted": "La sauvegarde a été supprimée", - "backup_extracting_archive": "Extraction de l’archive de sauvegarde …", - "backup_hook_unknown": "Script de sauvegarde '{hook:s}' inconnu", - "backup_invalid_archive": "Archive de sauvegarde invalide", - "backup_nothings_done": "Il n’y a rien à sauvegarder", - "backup_output_directory_forbidden": "Dossier de destination interdit. Les sauvegardes ne peuvent être créées dans les sous-dossiers /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var ou /home/yunohost.backup/archives", + "backup_hook_unknown": "Script de sauvegarde '{hook}' inconnu", + "backup_nothings_done": "Il n'y a rien à sauvegarder", + "backup_output_directory_forbidden": "Choisissez un répertoire de destination différent. Les sauvegardes ne peuvent pas être créées dans les sous-dossiers /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var ou /home/yunohost.backup/archives", "backup_output_directory_not_empty": "Le répertoire de destination n'est pas vide", "backup_output_directory_required": "Vous devez spécifier un dossier de destination pour la sauvegarde", - "backup_running_app_script": "Lancement du script de sauvegarde de l’application « {app:s} »...", - "backup_running_hooks": "Exécution des scripts de sauvegarde …", - "custom_app_url_required": "Vous devez spécifier une URL pour mettre à jour votre application personnalisée {app:s}", - "custom_appslist_name_required": "Vous devez spécifier un nom pour votre liste d’applications personnalisées", - "diagnosis_debian_version_error": "Impossible de déterminer la version de Debian : {error}", - "diagnosis_kernel_version_error": "Impossible de récupérer la version du noyau : {error}", - "diagnosis_monitor_disk_error": "Impossible de superviser les disques : {error}", - "diagnosis_monitor_network_error": "Impossible de superviser le réseau : {error}", - "diagnosis_monitor_system_error": "Impossible de superviser le système : {error}", - "diagnosis_no_apps": "Aucune application installée", - "dnsmasq_isnt_installed": "dnsmasq ne semble pas être installé, veuillez lancer 'apt-get remove bind9 && apt-get install dnsmasq'", + "backup_running_hooks": "Exécution des scripts de sauvegarde ...", + "custom_app_url_required": "Vous devez spécifier une URL pour mettre à jour votre application personnalisée {app}", + "disk_space_not_sufficient_install": "Il ne reste pas assez d'espace disque pour installer cette application", + "disk_space_not_sufficient_update": "Il ne reste pas assez d'espace disque pour mettre à jour cette application", "domain_cert_gen_failed": "Impossible de générer le certificat", "domain_created": "Le domaine a été créé", - "domain_creation_failed": "Impossible de créer le domaine", + "domain_creation_failed": "Impossible de créer le domaine {domain} : {error}", "domain_deleted": "Le domaine a été supprimé", - "domain_deletion_failed": "Impossible de supprimer le domaine", + "domain_deletion_failed": "Impossible de supprimer le domaine {domain} : {error}", "domain_dyndns_already_subscribed": "Vous avez déjà souscris à un domaine DynDNS", - "domain_dyndns_invalid": "Domaine incorrect pour un usage avec DynDNS", "domain_dyndns_root_unknown": "Domaine DynDNS principal inconnu", "domain_exists": "Le domaine existe déjà", - "domain_uninstall_app_first": "Une ou plusieurs applications sont installées sur ce domaine. Veuillez d’abord les désinstaller avant de supprimer ce domaine", - "domain_unknown": "Domaine inconnu", - "domain_zone_exists": "Le fichier de zone DNS existe déjà", - "domain_zone_not_found": "Fichier de zone DNS introuvable pour le domaine {:s}", + "domain_uninstall_app_first": "Ces applications sont toujours installées sur votre domaine :\n{apps}\n\nVeuillez les désinstaller avec la commande 'yunohost app remove nom-de-l-application' ou les déplacer vers un autre domaine avec la commande 'yunohost app change-url nom-de-l-application' avant de procéder à la suppression du domaine", "done": "Terminé", - "downloading": "Téléchargement en cours …", - "dyndns_cron_installed": "La tâche cron pour le domaine DynDNS a été installée", - "dyndns_cron_remove_failed": "Impossible de supprimer la tâche cron pour le domaine DynDNS", - "dyndns_cron_removed": "La tâche cron pour le domaine DynDNS a été enlevée", - "dyndns_ip_update_failed": "Impossible de mettre à jour l’adresse IP sur le domaine DynDNS", - "dyndns_ip_updated": "Votre adresse IP a été mise à jour pour le domaine DynDNS", - "dyndns_key_generating": "La clé DNS est en cours de génération, cela peut prendre un certain temps …", + "downloading": "Téléchargement en cours...", + "dyndns_ip_update_failed": "Impossible de mettre à jour l'adresse IP sur le domaine DynDNS", + "dyndns_ip_updated": "Mise à jour de votre IP pour le domaine DynDNS", + "dyndns_key_generating": "Génération de la clé DNS..., cela peut prendre un certain temps.", "dyndns_key_not_found": "Clé DNS introuvable pour le domaine", - "dyndns_no_domain_registered": "Aucun domaine n’a été enregistré avec DynDNS", - "dyndns_registered": "Le domaine DynDNS a été enregistré", - "dyndns_registration_failed": "Impossible d’enregistrer le domaine DynDNS : {error:s}", - "dyndns_unavailable": "Le domaine {domain:s} est indisponible.", - "executing_command": "Exécution de la commande '{command:s}' …", - "executing_script": "Exécution du script '{script:s}' …", - "extracting": "Extraction en cours …", - "field_invalid": "Champ incorrect : '{:s}'", + "dyndns_no_domain_registered": "Aucun domaine n'a été enregistré avec DynDNS", + "dyndns_registered": "Domaine DynDNS enregistré", + "dyndns_registration_failed": "Impossible d'enregistrer le domaine DynDNS : {error}", + "dyndns_unavailable": "Le domaine {domain} est indisponible.", + "extracting": "Extraction en cours...", + "field_invalid": "Champ incorrect : '{}'", "firewall_reload_failed": "Impossible de recharger le pare-feu", - "firewall_reloaded": "Le pare-feu a été rechargé", - "firewall_rules_cmd_failed": "Certaines règles du pare-feu n’ont pas pu être appliquées. Pour plus d’informations, consultez le journal.", - "format_datetime_short": "%d/%m/%Y %H:%M", - "hook_argument_missing": "Argument manquant : '{:s}'", - "hook_choice_invalid": "Choix incorrect : '{:s}'", - "hook_exec_failed": "Échec de l’exécution du script : {path:s}", - "hook_exec_not_terminated": "L’exécution du script {path:s} ne s’est pas terminée correctement", + "firewall_reloaded": "Pare-feu rechargé", + "firewall_rules_cmd_failed": "Certaines commandes de règles de pare-feu ont échoué. Plus d'informations dans le journal.", + "hook_exec_failed": "Échec de l'exécution du script : {path}", + "hook_exec_not_terminated": "L'exécution du script {path} ne s'est pas terminée correctement", "hook_list_by_invalid": "Propriété invalide pour lister les actions par celle-ci", - "hook_name_unknown": "Nom de l'action '{name:s}' inconnu", + "hook_name_unknown": "Nom de l'action '{name}' inconnu", "installation_complete": "Installation terminée", - "installation_failed": "Échec de l’installation", "ip6tables_unavailable": "Vous ne pouvez pas jouer avec ip6tables ici. Vous êtes soit dans un conteneur, soit votre noyau ne le prend pas en charge", "iptables_unavailable": "Vous ne pouvez pas jouer avec iptables ici. Vous êtes soit dans un conteneur, soit votre noyau ne le prend pas en charge", - "ldap_initialized": "L’annuaire LDAP a été initialisé", - "license_undefined": "indéfinie", - "mail_alias_remove_failed": "Impossible de supprimer l’alias de courriel '{mail:s}'", - "mail_domain_unknown": "Le domaine d'adresse du courriel '{domain:s}' est inconnu", - "mail_forward_remove_failed": "Impossible de supprimer le courriel de transfert '{mail:s}'", - "maindomain_change_failed": "Impossible de modifier le domaine principal", - "maindomain_changed": "Le domaine principal a été modifié", - "monitor_disabled": "La supervision du serveur a été désactivé", - "monitor_enabled": "La supervision du serveur a été activé", - "monitor_glances_con_failed": "Impossible de se connecter au serveur Glances", - "monitor_not_enabled": "Le suivi de l’état du serveur n’est pas activé", - "monitor_period_invalid": "Période de temps incorrecte", - "monitor_stats_file_not_found": "Le fichier de statistiques est introuvable", - "monitor_stats_no_update": "Aucune donnée de l’état du serveur à mettre à jour", - "monitor_stats_period_unavailable": "Aucune statistique n’est disponible pour la période", - "mountpoint_unknown": "Point de montage inconnu", - "mysql_db_creation_failed": "Impossible de créer la base de données MySQL", - "mysql_db_init_failed": "Impossible d’initialiser la base de données MySQL", - "mysql_db_initialized": "La base de données MySQL a été initialisée", - "network_check_mx_ko": "L’enregistrement DNS MX n’est pas défini", - "network_check_smtp_ko": "Le trafic courriel sortant (port 25 SMTP) semble bloqué par votre réseau", - "network_check_smtp_ok": "Le trafic courriel sortant (port 25 SMTP) n’est pas bloqué", - "new_domain_required": "Vous devez spécifier le nouveau domaine principal", - "no_appslist_found": "Aucune liste d’applications n’a été trouvée", - "no_internet_connection": "Le serveur n’est pas connecté à Internet", - "no_ipv6_connectivity": "La connectivité IPv6 n’est pas disponible", - "no_restore_script": "Le script de sauvegarde n’a pas été trouvé pour l’application '{app:s}'", - "no_such_conf_file": "Le fichier {file:s} n’existe pas, il ne peut pas être copié", - "not_enough_disk_space": "L’espace disque est insuffisant sur '{path:s}'", - "package_not_installed": "Le paquet '{pkgname}' n’est pas installé", - "package_unexpected_error": "Une erreur inattendue s'est produite lors du traitement du paquet '{pkgname}'", - "package_unknown": "Le paquet '{pkgname}' est inconnu", - "packages_no_upgrade": "Il n’y a aucun paquet à mettre à jour", - "packages_upgrade_critical_later": "Les paquets critiques ({packages:s}) seront mis à jour ultérieurement", + "mail_alias_remove_failed": "Impossible de supprimer l'alias mail '{mail}'", + "mail_domain_unknown": "Le domaine '{domain}' de cette adresse email n'est pas valide. Merci d'utiliser un domaine administré par ce serveur.", + "mail_forward_remove_failed": "Impossible de supprimer l'email de transfert '{mail}'", + "main_domain_change_failed": "Impossible de modifier le domaine principal", + "main_domain_changed": "Le domaine principal a été modifié", + "not_enough_disk_space": "L'espace disque est insuffisant sur '{path}'", "packages_upgrade_failed": "Impossible de mettre à jour tous les paquets", - "path_removal_failed": "Impossible de supprimer le chemin {:s}", "pattern_backup_archive_name": "Doit être un nom de fichier valide avec un maximum de 30 caractères, et composé de caractères alphanumériques et -_. uniquement", "pattern_domain": "Doit être un nom de domaine valide (ex : mon-domaine.fr)", - "pattern_email": "Doit être une adresse de courriel valide (ex. : pseudo@domaine.fr)", + "pattern_email": "Il faut une adresse électronique valide, sans le symbole '+' (par exemple johndoe@exemple.com)", "pattern_firstname": "Doit être un prénom valide", "pattern_lastname": "Doit être un nom valide", - "pattern_listname": "Doit être composé uniquement de caractères alphanumériques et de tirets bas (aussi appelé tiret du 8 ou underscore)", "pattern_mailbox_quota": "Doit avoir une taille suffixée avec b/k/M/G/T ou 0 pour désactiver le quota", - "pattern_password": "Doit être composé d’au moins 3 caractères", - "pattern_port": "Doit être un numéro de port valide compris entre 0 et 65535", + "pattern_password": "Doit être composé d'au moins 3 caractères", "pattern_port_or_range": "Doit être un numéro de port valide compris entre 0 et 65535, ou une gamme de ports (exemple : 100:200)", - "pattern_positive_number": "Doit être un nombre positif", "pattern_username": "Doit être composé uniquement de caractères alphanumériques minuscules et de tirets bas (aussi appelé tiret du 8 ou underscore)", - "port_already_closed": "Le port {port:d} est déjà fermé pour les connexions {ip_version:s}", - "port_already_opened": "Le port {port:d} est déjà ouvert pour les connexions {ip_version:s}", - "port_available": "Le port {port:d} est disponible", - "port_unavailable": "Le port {port:d} n’est pas disponible", - "restore_action_required": "Vous devez préciser ce qui est à restaurer", - "restore_already_installed_app": "Une application est déjà installée avec l’identifiant '{app:s}'", - "restore_app_failed": "Impossible de restaurer l’application '{app:s}'", + "port_already_closed": "Le port {port} est déjà fermé pour les connexions {ip_version}", + "port_already_opened": "Le port {port} est déjà ouvert pour les connexions {ip_version}", + "restore_already_installed_app": "Une application est déjà installée avec l'identifiant '{app}'", + "app_restore_failed": "Impossible de restaurer {app} : {error}", "restore_cleaning_failed": "Impossible de nettoyer le dossier temporaire de restauration", "restore_complete": "Restauration terminée", - "restore_confirm_yunohost_installed": "Voulez-vous vraiment restaurer un système déjà installé ? [{answers:s}]", + "restore_confirm_yunohost_installed": "Voulez-vous vraiment restaurer un système déjà installé ? [{answers}]", "restore_failed": "Impossible de restaurer le système", - "restore_hook_unavailable": "Le script de restauration '{part:s}' n’est pas disponible sur votre système, et ne l'est pas non plus dans l’archive", - "restore_nothings_done": "Rien n’a été restauré", - "restore_running_app_script": "Exécution du script de restauration de l'application '{app:s}' .…", - "restore_running_hooks": "Exécution des scripts de restauration …", - "service_add_configuration": "Ajout du fichier de configuration {file:s}", - "service_add_failed": "Impossible d’ajouter le service '{service:s}'", - "service_added": "Le service '{service:s}' a été ajouté", - "service_already_started": "Le service '{service:s}' est déjà démarré", - "service_already_stopped": "Le service '{service:s}' est déjà arrêté", - "service_cmd_exec_failed": "Impossible d’exécuter la commande '{command:s}'", - "service_conf_file_backed_up": "Le fichier de configuration '{conf}' a été sauvegardé dans '{backup}'", - "service_conf_file_copy_failed": "Impossible de copier le nouveau fichier de configuration '{new}' vers '{conf}'", - "service_conf_file_manually_modified": "Le fichier de configuration '{conf}' a été modifié manuellement et ne sera pas mis à jour", - "service_conf_file_manually_removed": "Le fichier de configuration '{conf}' a été supprimé manuellement et ne sera pas créé", - "service_conf_file_not_managed": "Le fichier de configuration « {conf} » n'est pas géré pour l'instant et ne sera pas mis à jour", - "service_conf_file_remove_failed": "Impossible de supprimer le fichier de configuration '{conf}'", - "service_conf_file_removed": "Le fichier de configuration '{conf}' a été supprimé", - "service_conf_file_updated": "Le fichier de configuration '{conf}' a été mis à jour", - "service_conf_up_to_date": "La configuration du service '{service}' est déjà à jour", - "service_conf_updated": "La configuration a été mise à jour pour le service '{service}'", - "service_conf_would_be_updated": "La configuration du service '{service}' aurait été mise à jour", - "service_configuration_conflict": "Le fichier {file:s} a été modifié depuis sa dernière génération. Veuillez y appliquer les modifications manuellement ou utiliser l’option --force (ce qui écrasera toutes les modifications effectuées sur le fichier).", - "service_configured": "La configuration du service « {service:s} » a été générée avec succès", - "service_configured_all": "La configuration de tous les services a été générée avec succès", - "service_disable_failed": "Impossible de désactiver le service '{service:s}'\n\nJournaux historisés récents : {logs:s}", - "service_disabled": "Le service '{service:s}' a été désactivé", - "service_enable_failed": "Impossible d’activer le service '{service:s}'\n\nJournaux historisés récents : {logs:s}", - "service_enabled": "Le service '{service:s}' a été activé", - "service_no_log": "Aucun journal historisé à afficher pour le service '{service:s}'", - "service_regenconf_dry_pending_applying": "Vérification des configurations en attentes qui pourraient être appliquées au le service '{service}' …", - "service_regenconf_failed": "Impossible de régénérer la configuration pour les services : {services}", - "service_regenconf_pending_applying": "Application des configurations en attentes pour le service '{service}' …", - "service_remove_failed": "Impossible de supprimer le service '{service:s}'", - "service_removed": "Le service '{service:s}' a été supprimé", - "service_start_failed": "Impossible de démarrer le service '{service:s}'\n\nJournaux historisés récents : {logs:s}", - "service_started": "Le service '{service:s}' a été démarré", - "service_status_failed": "Impossible de déterminer le statut du service '{service:s}'", - "service_stop_failed": "Impossible d’arrêter le service '{service:s}'\n\nJournaux historisés récents : {logs:s}", - "service_stopped": "Le service '{service:s}' a été arrêté", - "service_unknown": "Le service '{service:s}' est inconnu", - "services_configured": "La configuration a été générée avec succès", - "show_diff": "Voici les différences :\n{diff:s}", - "ssowat_conf_generated": "La configuration de SSOwat a été générée", + "restore_hook_unavailable": "Le script de restauration '{part}' n'est pas disponible sur votre système, et ne l'est pas non plus dans l'archive", + "restore_nothings_done": "Rien n'a été restauré", + "restore_running_app_script": "Exécution du script de restauration de l'application '{app}'...", + "restore_running_hooks": "Exécution des scripts de restauration...", + "service_add_failed": "Impossible d'ajouter le service '{service}'", + "service_added": "Le service '{service}' a été ajouté", + "service_already_started": "Le service '{service}' est déjà en cours d'exécution", + "service_already_stopped": "Le service '{service}' est déjà arrêté", + "service_cmd_exec_failed": "Impossible d'exécuter la commande '{command}'", + "service_disable_failed": "Impossible de ne pas lancer le service '{service}' au démarrage.\n\nJournaux récents du service : {logs}", + "service_disabled": "Le service '{service}' ne sera plus lancé au démarrage du système.", + "service_enable_failed": "Impossible de lancer automatiquement le service '{service}' au démarrage.\n\nJournaux récents du service : {logs}", + "service_enabled": "Le service '{service}' sera désormais lancé automatiquement au démarrage du système.", + "service_remove_failed": "Impossible de supprimer le service '{service}'", + "service_removed": "Le service '{service}' a été supprimé", + "service_start_failed": "Impossible de démarrer le service '{service}'\n\nJournaux historisés récents : {logs}", + "service_started": "Le service '{service}' a été démarré", + "service_stop_failed": "Impossible d'arrêter le service '{service}'\n\nJournaux récents de service : {logs}", + "service_stopped": "Le service '{service}' a été arrêté", + "service_unknown": "Le service '{service}' est inconnu", + "ssowat_conf_generated": "La configuration de SSOwat a été regénérée", "ssowat_conf_updated": "La configuration de SSOwat a été mise à jour", - "system_upgraded": "Le système a été mis à jour", - "system_username_exists": "Ce nom d’utilisateur existe déjà dans les utilisateurs système", - "unbackup_app": "L’application '{app:s}' ne sera pas sauvegardée", + "system_upgraded": "Système mis à jour", + "system_username_exists": "Ce nom d'utilisateur existe déjà dans les utilisateurs système", + "unbackup_app": "'{app}' ne sera pas sauvegardée", "unexpected_error": "Une erreur inattendue est survenue : {error}", - "unit_unknown": "L'unité '{unit:s}' est inconnue", "unlimit": "Pas de quota", - "unrestore_app": "L’application '{app:s}' ne sera pas restaurée", - "update_cache_failed": "Impossible de mettre à jour le cache de l'outil de gestion avancée des paquets (APT)", - "updating_apt_cache": "Récupération des mises à jour disponibles pour les paquets du système …", + "unrestore_app": "'{app}' ne sera pas restaurée", + "updating_apt_cache": "Récupération des mises à jour disponibles pour les paquets du système...", "upgrade_complete": "Mise à jour terminée", - "upgrading_packages": "Mise à jour des paquets en cours …", - "upnp_dev_not_found": "Aucun périphérique compatible UPnP n’a été trouvé", - "upnp_disabled": "UPnP a été désactivé", - "upnp_enabled": "UPnP a été activé", - "upnp_port_open_failed": "Impossible d’ouvrir les ports UPnP", - "user_created": "L’utilisateur a été créé", - "user_creation_failed": "Impossible de créer l’utilisateur", - "user_deleted": "L’utilisateur a été supprimé", - "user_deletion_failed": "Impossible de supprimer l’utilisateur", - "user_home_creation_failed": "Impossible de créer le dossier personnel de l’utilisateur", - "user_info_failed": "Impossible de récupérer les informations de l’utilisateur", - "user_unknown": "L'utilisateur {user:s} est inconnu", - "user_update_failed": "Impossible de modifier l’utilisateur", - "user_updated": "L’utilisateur a été modifié", + "upgrading_packages": "Mise à jour des paquets en cours...", + "upnp_dev_not_found": "Aucun périphérique compatible UPnP n'a été trouvé", + "upnp_disabled": "L'UPnP est désactivé", + "upnp_enabled": "L'UPnP est activé", + "upnp_port_open_failed": "Impossible d'ouvrir les ports UPnP", + "user_created": "L'utilisateur a été créé", + "user_creation_failed": "Impossible de créer l'utilisateur {user} : {error}", + "user_deleted": "L'utilisateur a été supprimé", + "user_deletion_failed": "Impossible de supprimer l'utilisateur {user} : {error}", + "user_home_creation_failed": "Impossible de créer le dossier personnel '{home}' de l'utilisateur", + "user_unknown": "L'utilisateur {user} est inconnu", + "user_update_failed": "Impossible de mettre à jour l'utilisateur {user} : {error}", + "user_updated": "L'utilisateur a été modifié", "yunohost_already_installed": "YunoHost est déjà installé", - "yunohost_ca_creation_failed": "Impossible de créer l’autorité de certification", - "yunohost_configured": "YunoHost a été configuré", - "yunohost_installing": "L'installation de YunoHost est en cours …", - "yunohost_not_installed": "YunoHost n’est pas ou pas correctement installé. Veuillez exécuter 'yunohost tools postinstall'", - "certmanager_attempt_to_replace_valid_cert": "Vous êtes en train de vouloir remplacer un certificat correct et valide pour le domaine {domain:s} ! (Utilisez --force pour contourner cela)", - "certmanager_domain_unknown": "Domaine {domain:s} inconnu", - "certmanager_domain_cert_not_selfsigned": "Le certificat du domaine {domain:s} n’est pas auto-signé. Voulez-vous vraiment le remplacer ? (Utilisez --force pour cela)", - "certmanager_certificate_fetching_or_enabling_failed": "Il semble que l’activation du nouveau certificat pour {domain:s} a échoué …", - "certmanager_attempt_to_renew_nonLE_cert": "Le certificat pour le domaine {domain:s} n’est pas émis par Let’s Encrypt. Impossible de le renouveler automatiquement !", - "certmanager_attempt_to_renew_valid_cert": "Le certificat pour le domaine {domain:s} n'est pas sur le point d’expirer ! (Vous pouvez utiliser --force si vous savez ce que vous faites)", - "certmanager_domain_http_not_working": "Il semble que le domaine {domain:s} ne soit pas accessible via HTTP. Veuillez vérifier que vos configuration DNS et Nginx sont correctes", - "certmanager_error_no_A_record": "Aucun enregistrement DNS 'A' n’a été trouvé pour {domain:s}. Vous devez faire pointer votre nom de domaine vers votre machine pour être en mesure d’installer un certificat Let’s Encrypt ! (Si vous savez ce que vous faites, utilisez --no-checks pour désactiver ces contrôles)", - "certmanager_domain_dns_ip_differs_from_public_ip": "L’enregistrement DNS 'A' du domaine {domain:s} est différent de l’adresse IP de ce serveur. Si vous avez récemment modifié votre enregistrement 'A', veuillez attendre sa propagation (quelques vérificateur de propagation DNS sont disponibles en ligne). (Si vous savez ce que vous faites, utilisez --no-checks pour désactiver ces contrôles)", - "certmanager_cannot_read_cert": "Quelque chose s’est mal passé lors de la tentative d’ouverture du certificat actuel pour le domaine {domain:s} (fichier : {file:s}), la cause est : {reason:s}", - "certmanager_cert_install_success_selfsigned": "Installation avec succès d’un certificat auto-signé pour le domaine {domain:s} !", - "certmanager_cert_install_success": "Installation avec succès d’un certificat Let’s Encrypt pour le domaine {domain:s} !", - "certmanager_cert_renew_success": "Renouvellement avec succès d’un certificat Let’s Encrypt pour le domaine {domain:s} !", - "certmanager_old_letsencrypt_app_detected": "\nYunoHost a détecté que l’application « letsencrypt » est installé, ce qui est en conflit avec les nouvelles fonctionnalités de gestion intégrée de certificats dans YunoHost. Si vous souhaitez utiliser ces nouvelles fonctionnalités intégrées, veuillez lancer les commandes suivantes pour migrer votre installation :\n\n yunohost app remove letsencrypt\n yunohost domain cert-install\n\nN.B. : cela tentera de réinstaller les certificats de tous les domaines avec un certificat Let's Encrypt ou ceux auto-signés", - "certmanager_cert_signing_failed": "La signature du nouveau certificat a échoué", - "certmanager_no_cert_file": "Impossible de lire le fichier du certificat pour le domaine {domain:s} (fichier : {file:s})", - "certmanager_conflicting_nginx_file": "Impossible de préparer le domaine pour le défi ACME : le fichier de configuration Nginx {filepath:s} est en conflit et doit être préalablement retiré", - "certmanager_hit_rate_limit": "Trop de certificats ont déjà été émis récemment pour ce même ensemble de domaines {domain:s}. Veuillez réessayer plus tard. Lisez https://letsencrypt.org/docs/rate-limits/ pour obtenir plus de détails sur les ratios et limitations", - "ldap_init_failed_to_create_admin": "L’initialisation de l'annuaire LDAP n’a pas réussi à créer l’utilisateur admin", - "ssowat_persistent_conf_read_error": "Erreur lors de la lecture de la configuration persistante de SSOwat : {error:s}. Modifiez le fichier /etc/ssowat/conf.json.persistent pour réparer la syntaxe JSON", - "ssowat_persistent_conf_write_error": "Erreur lors de la sauvegarde de la configuration persistante de SSOwat : {error:s}. Modifiez le fichier /etc/ssowat/conf.json.persistent pour réparer la syntaxe JSON", - "domain_cannot_remove_main": "Impossible de supprimer le domaine principal. Définissez d'abord un nouveau domaine principal", - "certmanager_self_ca_conf_file_not_found": "Le fichier de configuration pour l’autorité du certificat auto-signé est introuvable (fichier : {file:s})", - "certmanager_unable_to_parse_self_CA_name": "Impossible d’analyser le nom de l’autorité du certificat auto-signé (fichier : {file:s})", - "mailbox_used_space_dovecot_down": "Le service de courriel Dovecot doit être démarré, si vous souhaitez voir l’espace disque occupé par la messagerie", + "yunohost_configured": "YunoHost est maintenant configuré", + "yunohost_installing": "L'installation de YunoHost est en cours...", + "yunohost_not_installed": "YunoHost n'est pas correctement installé. Veuillez exécuter 'yunohost tools postinstall'", + "certmanager_attempt_to_replace_valid_cert": "Vous êtes en train de vouloir remplacer un certificat correct et valide pour le domaine {domain} ! (Utilisez --force pour contourner cela)", + "certmanager_domain_cert_not_selfsigned": "Le certificat du domaine {domain} n'est pas auto-signé. Voulez-vous vraiment le remplacer ? (Utilisez --force pour cela)", + "certmanager_certificate_fetching_or_enabling_failed": "Il semble que l'activation du nouveau certificat pour {domain} a échoué...", + "certmanager_attempt_to_renew_nonLE_cert": "Le certificat pour le domaine {domain} n'est pas émis par Let's Encrypt. Impossible de le renouveler automatiquement !", + "certmanager_attempt_to_renew_valid_cert": "Le certificat pour le domaine {domain} n'est pas sur le point d'expirer ! (Vous pouvez utiliser --force si vous savez ce que vous faites)", + "certmanager_domain_http_not_working": "Le domaine {domain} ne semble pas être accessible via HTTP. Merci de vérifier la catégorie 'Web' dans le diagnostic pour plus d'informations. (Ou si vous savez ce que vous faites, utilisez '--no-checks' pour désactiver la vérification.)", + "certmanager_domain_dns_ip_differs_from_public_ip": "Les enregistrements DNS du domaine '{domain}' sont différents de l'adresse IP de ce serveur. Pour plus d'informations, veuillez consulter la catégorie \"Enregistrements DNS\" dans la section diagnostic. Si vous avez récemment modifié votre enregistrement A, veuillez attendre sa propagation (des vérificateurs de propagation DNS sont disponibles en ligne). (Si vous savez ce que vous faites, utilisez '--no-checks' pour désactiver ces contrôles)", + "certmanager_cannot_read_cert": "Quelque chose s'est mal passé lors de la tentative d'ouverture du certificat actuel pour le domaine {domain} (fichier : {file}), la cause est : {reason}", + "certmanager_cert_install_success_selfsigned": "Le certificat auto-signé est maintenant installé pour le domaine '{domain}'", + "certmanager_cert_install_success": "Le certificat Let's Encrypt est maintenant installé pour le domaine '{domain}'", + "certmanager_cert_renew_success": "Certificat Let's Encrypt renouvelé pour le domaine '{domain}'", + "certmanager_cert_signing_failed": "Impossible de signer le nouveau certificat", + "certmanager_no_cert_file": "Impossible de lire le fichier du certificat pour le domaine {domain} (fichier : {file})", + "certmanager_hit_rate_limit": "Trop de certificats ont déjà été émis récemment pour ce même ensemble de domaines {domain}. Veuillez réessayer plus tard. Lisez https://letsencrypt.org/docs/rate-limits/ pour obtenir plus de détails sur les ratios et limitations", + "domain_cannot_remove_main": "Vous ne pouvez pas supprimer '{domain}' car il s'agit du domaine principal. Vous devez d'abord définir un autre domaine comme domaine principal à l'aide de 'yunohost domain main-domain -n ', voici la liste des domaines candidats : {other_domains}", + "certmanager_self_ca_conf_file_not_found": "Le fichier de configuration pour l'autorité du certificat auto-signé est introuvable (fichier : {file})", + "certmanager_unable_to_parse_self_CA_name": "Impossible d'analyser le nom de l'autorité du certificat auto-signé (fichier : {file})", + "mailbox_used_space_dovecot_down": "Le service Dovecot doit être démarré si vous souhaitez voir l'espace disque occupé par la messagerie", "domains_available": "Domaines disponibles :", - "backup_archive_broken_link": "Impossible d’accéder à l’archive de sauvegarde (lien invalide vers {path:s})", - "certmanager_acme_not_configured_for_domain": "Le certificat du domaine {domain:s} ne semble pas être correctement installé. Veuillez d'abord exécuter cert-install.", - "certmanager_domain_not_resolved_locally": "Le domaine {domain:s} ne peut être résolu depuis votre serveur YunoHost. Cela peut se produire si vous avez récemment modifié votre enregistrement DNS. Si c'est le cas, merci d’attendre quelques heures qu’il se propage. Si le problème persiste, envisager d’ajouter {domain:s} au fichier /etc/hosts. (Si vous savez ce que vous faites, utilisez --no-checks pour désactiver ces vérifications.)", - "certmanager_http_check_timeout": "Expiration du délai lorsque le serveur a essayé de se contacter lui-même via HTTP en utilisant l'adresse IP public {ip:s} du domaine {domain:s}. Vous rencontrez peut-être un problème d’hairpinning ou alors le pare-feu/routeur en amont de votre serveur est mal configuré.", - "certmanager_couldnt_fetch_intermediate_cert": "Expiration du délai lors de la tentative de récupération du certificat intermédiaire depuis Let’s Encrypt. L’installation ou le renouvellement du certificat a été annulé. Veuillez réessayer plus tard.", - "appslist_retrieve_bad_format": "Le fichier récupéré pour la liste d’applications {appslist:s} n’est pas valide", - "domain_hostname_failed": "Échec de la création d’un nouveau nom d’hôte", - "yunohost_ca_creation_success": "L’autorité de certification locale a été créée.", - "appslist_name_already_tracked": "Il y a déjà une liste d’applications enregistrée avec le nom {name:s}.", - "appslist_url_already_tracked": "Il y a déjà une liste d’applications enregistrée avec l’URL {url:s}.", - "appslist_migrating": "Migration de la liste d’applications {appslist:s} …", - "appslist_could_not_migrate": "Impossible de migrer la liste {appslist:s} ! Impossible d’exploiter l’URL. L’ancienne tâche programmée a été conservée dans {bkp_file:s}.", - "appslist_corrupted_json": "Impossible de charger la liste d’applications. Il semble que {filename:s} soit corrompu.", - "app_already_installed_cant_change_url": "Cette application est déjà installée. L’URL ne peut pas être changé simplement par cette fonction. Regardez si cela est disponible avec `app changeurl`.", - "app_change_no_change_url_script": "L’application {app_name:s} ne prend pas encore en charge le changement d’URL, vous pourriez avoir besoin de la mettre à jour.", - "app_change_url_failed_nginx_reload": "Le redémarrage de Nginx a échoué. Voici la sortie de 'nginx -t' :\n{nginx_errors:s}", - "app_change_url_identical_domains": "L’ancien et le nouveau couple domaine/chemin_de_l'URL sont identiques pour ('{domain:s}{path:s}'), rien à faire.", - "app_change_url_no_script": "L’application '{app_name:s}' ne prend pas encore en charge le changement d’URL. Vous devriez peut-être la mettre à jour.", - "app_change_url_success": "L’URL de l’application {app:s} a été changée en {domain:s}{path:s}", - "app_location_unavailable": "Cette URL n’est pas disponible ou est en conflit avec une application existante :\n{apps:s}", - "app_already_up_to_date": "{app:s} est déjà à jour", - "invalid_url_format": "Format d’URL non valide", - "global_settings_bad_choice_for_enum": "Valeur du paramètre {setting:s} incorrecte. Reçu : {received_type:s}, mais les valeurs possibles sont : {expected_type:s}", - "global_settings_bad_type_for_setting": "Le type du paramètre {setting:s} est incorrect. Reçu {received_type:s} alors que {expected_type:s} était attendu", - "global_settings_cant_open_settings": "Échec de l’ouverture du ficher de configurations car : {reason:s}", - "global_settings_cant_serialize_setings": "Échec de sérialisation des données de configurations, cause : {reason:s}", - "global_settings_cant_write_settings": "Échec d’écriture du fichier de configurations car : {reason:s}", - "global_settings_key_doesnt_exists": "La clef '{settings_key:s}' n’existe pas dans les configurations générales, vous pouvez voir toutes les clefs disponibles en saisissant 'yunohost settings list'", - "global_settings_reset_success": "Super ! Vos configurations précédentes ont été sauvegardées dans {path:s}", - "global_settings_setting_example_bool": "Exemple d’option booléenne", - "global_settings_setting_example_int": "Exemple d’option de type entier", - "global_settings_setting_example_string": "Exemple d’option de type chaîne", - "global_settings_setting_example_enum": "Exemple d’option de type énumération", - "global_settings_unknown_type": "Situation inattendue : la configuration {setting:s} semble avoir le type {unknown_type:s} mais celui-ci n'est pas pris en charge par le système.", - "global_settings_unknown_setting_from_settings_file": "Clé inconnue dans les paramètres : '{setting_key:s}', rejet de cette clé et sauvegarde de celle-ci dans /etc/yunohost/unkown_settings.json", - "service_conf_new_managed_file": "Le fichier de configuration « {conf} » est désormais géré par le service {service}.", - "service_conf_file_kept_back": "Le fichier de configuration '{conf}' devait être supprimé par le service {service} mais a été conservé.", - "backup_abstract_method": "Cette méthode de sauvegarde n’a pas encore été implémentée", - "backup_applying_method_tar": "Création de l’archive tar de la sauvegarde …", - "backup_applying_method_copy": "Copie de tous les fichiers à sauvegarder …", - "backup_applying_method_borg": "Envoi de tous les fichiers à sauvegarder dans le répertoire borg-backup…", - "backup_applying_method_custom": "Appel de la méthode de sauvegarde personnalisée '{method:s}' …", - "backup_archive_system_part_not_available": "La partie '{part:s}' du système n’est pas disponible dans cette sauvegarde", - "backup_archive_mount_failed": "Le montage de l’archive de sauvegarde a échoué", - "backup_archive_writing_error": "Impossible d'ajouter des fichiers '{source:s}' (nommés dans l'archive : '{dest:s}') à sauvegarder dans l'archive compressée '{archive:s}'", - "backup_ask_for_copying_if_needed": "Certains fichiers n’ont pas pu être préparés pour être sauvegardés en utilisant la méthode qui évite temporairement de gaspiller de l’espace sur le système. Pour réaliser la sauvegarde, {size:s} Mo doivent être temporairement utilisés. Acceptez-vous ?", - "backup_borg_not_implemented": "La méthode de sauvegarde Borg n’est pas encore implémentée", - "backup_cant_mount_uncompress_archive": "Impossible de monter en lecture seule le dossier de l’archive décompressée", - "backup_copying_to_organize_the_archive": "Copie de {size:s} Mo pour organiser l’archive", - "backup_csv_creation_failed": "Impossible de créer le fichier CSV nécessaire aux opérations futures de restauration", - "backup_csv_addition_failed": "Impossible d’ajouter des fichiers à sauvegarder dans le fichier CSV", - "backup_custom_need_mount_error": "Échec de la méthode de sauvegarde personnalisée à l’étape 'need_mount'", - "backup_custom_backup_error": "Échec de la méthode de sauvegarde personnalisée à l’étape 'backup'", - "backup_custom_mount_error": "Échec de la méthode de sauvegarde personnalisée à l’étape 'mount'", - "backup_no_uncompress_archive_dir": "Le dossier de l’archive décompressée n’existe pas", - "backup_method_tar_finished": "L’archive tar de la sauvegarde a été créée", + "backup_archive_broken_link": "Impossible d'accéder à l'archive de sauvegarde (lien invalide vers {path})", + "certmanager_acme_not_configured_for_domain": "Le challenge ACME n'a pas pu être validé pour le domaine {domain} pour le moment car le code de la configuration NGINX est manquant... Merci de vérifier que votre configuration NGINX est à jour avec la commande : `yunohost tools regen-conf nginx --dry-run --with-diff`.", + "domain_hostname_failed": "Échec de l'utilisation d'un nouveau nom d'hôte. Cela pourrait causer des soucis plus tard (cela n'en causera peut-être pas).", + "app_already_installed_cant_change_url": "Cette application est déjà installée. L'URL ne peut pas être changé simplement par cette fonction. Vérifiez si cela est disponible avec `app changeurl`.", + "app_change_url_identical_domains": "L'ancien et le nouveau couple domaine/chemin_de_l'URL sont identiques pour ('{domain}{path}'), rien à faire.", + "app_change_url_no_script": "L'application '{app_name}' ne prend pas encore en charge le changement d'URL. Vous devriez peut-être la mettre à jour.", + "app_change_url_success": "L'URL de l'application {app} a été changée en {domain}{path}", + "app_location_unavailable": "Cette URL n'est pas disponible ou est en conflit avec une application existante :\n{apps}", + "app_already_up_to_date": "{app} est déjà à jour", + "global_settings_bad_choice_for_enum": "Valeur du paramètre {setting} incorrecte. Reçu : {choice}, mais les valeurs possibles sont : {available_choices}", + "global_settings_bad_type_for_setting": "Le type du paramètre {setting} est incorrect. Reçu {received_type} alors que {expected_type} était attendu", + "global_settings_cant_open_settings": "Échec de l'ouverture du ficher de configurations car : {reason}", + "global_settings_cant_write_settings": "Échec d'écriture du fichier de configurations car : {reason}", + "global_settings_key_doesnt_exists": "La clef '{settings_key}' n'existe pas dans les configurations générales, vous pouvez voir toutes les clefs disponibles en saisissant 'yunohost settings list'", + "global_settings_reset_success": "Vos configurations précédentes ont été sauvegardées dans {path}", + "global_settings_unknown_type": "Situation inattendue : la configuration {setting} semble avoir le type {unknown_type} mais celui-ci n'est pas pris en charge par le système.", + "global_settings_unknown_setting_from_settings_file": "Clé inconnue dans les paramètres : '{setting_key}', rejet de cette clé et sauvegarde de celle-ci dans /etc/yunohost/unkown_settings.json", + "backup_abstract_method": "Cette méthode de sauvegarde reste à implémenter", + "backup_applying_method_tar": "Création de l'archive TAR de la sauvegarde ...", + "backup_applying_method_copy": "Copie de tous les fichiers à sauvegarder ...", + "backup_applying_method_custom": "Appel de la méthode de sauvegarde personnalisée '{method}' ...", + "backup_archive_system_part_not_available": "La partie '{part}' du système n'est pas disponible dans cette sauvegarde", + "backup_archive_writing_error": "Impossible d'ajouter des fichiers '{source}' (nommés dans l'archive : '{dest}') à sauvegarder dans l'archive compressée '{archive}'", + "backup_ask_for_copying_if_needed": "Voulez-vous effectuer la sauvegarde en utilisant {size}Mo temporairement ? (Cette méthode est utilisée car certains fichiers n'ont pas pu être préparés avec une méthode plus efficace.)", + "backup_cant_mount_uncompress_archive": "Impossible de monter en lecture seule le dossier de l'archive décompressée", + "backup_copying_to_organize_the_archive": "Copie de {size} Mo pour organiser l'archive", + "backup_csv_creation_failed": "Impossible de créer le fichier CSV nécessaire à la restauration", + "backup_csv_addition_failed": "Impossible d'ajouter des fichiers à sauvegarder dans le fichier CSV", + "backup_custom_backup_error": "Échec de la méthode de sauvegarde personnalisée à l'étape 'backup'", + "backup_custom_mount_error": "Échec de la méthode de sauvegarde personnalisée à l'étape 'mount'", + "backup_no_uncompress_archive_dir": "Ce dossier d'archive décompressée n'existe pas", + "backup_method_tar_finished": "L'archive TAR de la sauvegarde a été créée", "backup_method_copy_finished": "La copie de la sauvegarde est terminée", - "backup_method_borg_finished": "La sauvegarde dans Borg est terminée", - "backup_method_custom_finished": "La méthode de sauvegarde personnalisée '{method:s}' est terminée", - "backup_system_part_failed": "Impossible de sauvegarder la partie '{part:s}' du système", - "backup_unable_to_organize_files": "Impossible d’organiser les fichiers dans l’archive avec la méthode rapide", - "backup_with_no_backup_script_for_app": "L’application {app:s} n’a pas de script de sauvegarde. Ignorer.", - "backup_with_no_restore_script_for_app": "L’application {app:s} n’a pas de script de restauration, vous ne pourrez pas restaurer automatiquement la sauvegarde de cette application.", - "global_settings_cant_serialize_settings": "Échec de la sérialisation des données de paramétrage car : {reason:s}", + "backup_method_custom_finished": "La méthode de sauvegarde personnalisée '{method}' est terminée", + "backup_system_part_failed": "Impossible de sauvegarder la partie '{part}' du système", + "backup_unable_to_organize_files": "Impossible d'utiliser la méthode rapide pour organiser les fichiers dans l'archive", + "backup_with_no_backup_script_for_app": "L'application {app} n'a pas de script de sauvegarde. Ignorer.", + "backup_with_no_restore_script_for_app": "{app} n'a pas de script de restauration, vous ne pourrez pas restaurer automatiquement la sauvegarde de cette application.", + "global_settings_cant_serialize_settings": "Échec de la sérialisation des données de paramétrage car : {reason}", "restore_removing_tmp_dir_failed": "Impossible de sauvegarder un ancien dossier temporaire", - "restore_extracting": "Extraction des fichiers nécessaires depuis l’archive …", - "restore_mounting_archive": "Montage de l’archive dans '{path:s}'", - "restore_may_be_not_enough_disk_space": "Votre système semble ne pas avoir suffisamment d’espace disponible (L'espace libre est de {free_space:d} octets. Le besoin d'espace nécessaire est de {needed_space:d} octets. En appliquant une marge de sécurité, la quantité d'espace nécessaire est de {margin:d} octets)", - "restore_not_enough_disk_space": "Espace disponible insuffisant (L'espace libre est de {free_space:d} octets. Le besoin d'espace nécessaire est de {needed_space:d} octets. En appliquant une marge de sécurité, la quantité d'espace nécessaire est de {margin:d} octets)", - "restore_system_part_failed": "Impossible de restaurer la partie '{part:s}' du système", - "backup_couldnt_bind": "Impossible de lier {src:s} avec {dest:s}.", - "domain_dns_conf_is_just_a_recommendation": "Cette page montre la configuration *recommandée*. Elle ne configure *pas* le DNS pour vous. Il est de votre responsabilité que de configurer votre zone DNS chez votre fournisseur/registrar DNS avec cette recommandation.", - "domain_dyndns_dynette_is_unreachable": "Impossible de contacter la dynette YunoHost. Soit YunoHost n’est pas correctement connecté à internet, soit le serveur de dynette est en panne. Erreur : {error}", - "migrations_backward": "Migration en arrière.", - "migrations_bad_value_for_target": "Nombre invalide pour le paramètre target, les numéros de migration sont 0 ou {}", - "migrations_cant_reach_migration_file": "Impossible d’accéder aux fichiers de migrations avec le chemin %s", - "migrations_current_target": "La cible de migration est {}", - "migrations_error_failed_to_load_migration": "ERREUR : échec du chargement de migration {number} {name}", - "migrations_forward": "Migration en avant", - "migrations_loading_migration": "Chargement de la migration {number} {name} …", - "migrations_migration_has_failed": "La migration {number} {name} a échoué avec l’exception {exception} : annulation", + "restore_extracting": "Extraction des fichiers nécessaires depuis l'archive...", + "restore_may_be_not_enough_disk_space": "Votre système ne semble pas avoir suffisamment d'espace (libre : {free_space} B, espace nécessaire : {needed_space} B, marge de sécurité : {margin} B)", + "restore_not_enough_disk_space": "Espace disponible insuffisant (L'espace libre est de {free_space} octets. Le besoin d'espace nécessaire est de {needed_space} octets. En appliquant une marge de sécurité, la quantité d'espace nécessaire est de {margin} octets)", + "restore_system_part_failed": "Impossible de restaurer la partie '{part}' du système", + "backup_couldnt_bind": "Impossible de lier {src} avec {dest}.", + "domain_dns_conf_is_just_a_recommendation": "Cette commande vous montre la configuration *recommandée*. Elle ne configure pas le DNS pour vous. Il est de votre ressort de configurer votre zone DNS chez votre registrar/fournisseur conformément à cette recommandation.", + "migrations_cant_reach_migration_file": "Impossible d'accéder aux fichiers de migration via le chemin '%s'", + "migrations_loading_migration": "Chargement de la migration {id}...", + "migrations_migration_has_failed": "La migration {id} a échoué avec l'exception {exception} : annulation", "migrations_no_migrations_to_run": "Aucune migration à lancer", - "migrations_show_currently_running_migration": "Application de la migration {number} {name} …", - "migrations_show_last_migration": "La dernière migration appliquée est {}", - "migrations_skip_migration": "Ignorer et passer la migration {number} {name}…", - "server_shutdown": "Le serveur va éteindre", - "server_shutdown_confirm": "Le serveur va être éteint immédiatement, le voulez-vous vraiment ? [{answers:s}]", + "migrations_skip_migration": "Ignorer et passer la migration {id}...", + "server_shutdown": "Le serveur va s'éteindre", + "server_shutdown_confirm": "Le serveur va être éteint immédiatement, le voulez-vous vraiment ? [{answers}]", "server_reboot": "Le serveur va redémarrer", - "server_reboot_confirm": "Le serveur va redémarrer immédiatement, le voulez-vous vraiment ? [{answers:s}]", - "app_upgrade_some_app_failed": "Impossible de mettre à jour certaines applications", - "ask_path": "Chemin", - "dyndns_could_not_check_provide": "Impossible de vérifier si {provider:s} peut fournir {domain:s}.", - "dyndns_domain_not_provided": "Le fournisseur DynDNS {provider:s} ne peut pas fournir le domaine {domain:s}.", - "app_make_default_location_already_used": "Impossible de configurer l’application '{app}' par défaut pour le domaine {domain} car il est déjà utilisé par l'application '{other_app}'", - "app_upgrade_app_name": "Mise à jour de l’application {app} …", - "backup_output_symlink_dir_broken": "Vous avez un lien symbolique cassé à la place de votre dossier d’archives « {path:s} ». Vous pourriez avoir une configuration personnalisée pour sauvegarder vos données sur un autre système de fichiers, dans ce cas, vous avez probablement oublié de monter ou de connecter votre disque dur ou votre clé USB.", - "migrate_tsig_end": "La migration à hmac-sha512 est terminée", - "migrate_tsig_failed": "La migration du domaine DynDNS {domain} à hmac-sha512 a échoué. Annulation des modifications. Erreur : {error_code} - {error}", - "migrate_tsig_start": "L’algorithme de génération des clefs n’est pas suffisamment sécurisé pour la signature TSIG du domaine '{domain}', lancement de la migration vers hmac-sha512 qui est plus sécurisé", - "migrate_tsig_wait": "Attendre 3 minutes pour que le serveur DynDNS prenne en compte la nouvelle clef …", - "migrate_tsig_wait_2": "2 minutes …", - "migrate_tsig_wait_3": "1 minute …", - "migrate_tsig_wait_4": "30 secondes …", - "migrate_tsig_not_needed": "Il ne semble pas que vous utilisez un domaine DynDNS, donc aucune migration n’est nécessaire !", - "app_checkurl_is_deprecated": "Packagers /!\\ 'app checkurl' est obsolète ! Utilisez 'app register-url' en remplacement !", - "migration_description_0001_change_cert_group_to_sslcert": "Changement des permissions de groupe des certificats de « metronome » à « ssl-cert »", - "migration_description_0002_migrate_to_tsig_sha256": "Amélioration de la sécurité de DynDNS TSIG en utilisant SHA512 au lieu de MD5", - "migration_description_0003_migrate_to_stretch": "Mise à niveau du système vers Debian Stretch et YunoHost 3.0", - "migration_0003_backward_impossible": "La migration Stretch n’est pas réversible.", - "migration_0003_start": "Démarrage de la migration vers Stretch. Les journaux seront disponibles dans {logfile}.", - "migration_0003_patching_sources_list": "Modification du fichier sources.lists …", - "migration_0003_main_upgrade": "Démarrage de la mise à niveau principale …", - "migration_0003_fail2ban_upgrade": "Démarrage de la mise à niveau de fail2ban …", - "migration_0003_restoring_origin_nginx_conf": "Votre fichier /etc/nginx/nginx.conf a été modifié d’une manière ou d’une autre. La migration va d’abords le réinitialiser à son état initial. Le fichier précédent sera disponible en tant que {backup_dest}.", - "migration_0003_yunohost_upgrade": "Démarrage de la mise à niveau du paquet YunoHost. La migration se terminera, mais la mise à jour réelle aura lieu immédiatement après. Une fois cette opération terminée, vous pourriez avoir à vous reconnecter à l’administration via le panel web.", - "migration_0003_not_jessie": "La distribution Debian actuelle n’est pas Jessie !", - "migration_0003_system_not_fully_up_to_date": "Votre système n’est pas complètement à jour. Veuillez mener une mise à jour classique avant de lancer à migration à Stretch.", - "migration_0003_still_on_jessie_after_main_upgrade": "Quelque chose s’est mal passé pendant la mise à niveau principale : le système est toujours sur Debian Jessie !? Pour investiguer sur le problème, veuillez regarder les journaux {log}:s …", - "migration_0003_general_warning": "Veuillez noter que cette migration est une opération délicate. Si l’équipe YunoHost a fait de son mieux pour la relire et la tester, la migration pourrait tout de même casser des parties de votre système ou de vos applications.\n\nEn conséquence, nous vous recommandons :\n - de lancer une sauvegarde de vos données ou applications critiques. Plus d’informations sur https://yunohost.org/backup ;\n - d’être patient après avoir lancé la migration : selon votre connexion internet et matériel, cela pourrait prendre jusqu’à quelques heures pour que tout soit à niveau.\n\nEn outre, le port SMTP utilisé par les clients de messagerie externes comme (Thunderbird ou K9-Mail) a été changé de 465 (SSL/TLS) à 587 (STARTTLS). L’ancien port 465 sera automatiquement fermé et le nouveau port 587 sera ouvert dans le pare-feu. Vous et vos utilisateurs *devront* adapter la configuration de vos clients de messagerie en conséquence !", - "migration_0003_problematic_apps_warning": "Veuillez noter que des applications possiblement problématiques ont été détectées. Il semble qu’elles n’aient pas été installées depuis une liste d’application ou qu’elles ne soit pas marquées comme « fonctionnelles ». En conséquence, nous ne pouvons pas garantir qu’elles fonctionneront après la mise à niveau : {problematic_apps}", - "migration_0003_modified_files": "Veuillez noter que les fichiers suivants ont été détectés comme modifiés manuellement et pourraient être écrasés à la fin de la mise à niveau : {manually_modified_files}", + "server_reboot_confirm": "Le serveur va redémarrer immédiatement, le voulez-vous vraiment ? [{answers}]", + "app_upgrade_some_app_failed": "Certaines applications n'ont pas été mises à jour", + "dyndns_could_not_check_provide": "Impossible de vérifier si {provider} peut fournir {domain}.", + "dyndns_domain_not_provided": "Le fournisseur DynDNS {provider} ne peut pas fournir le domaine {domain}.", + "app_make_default_location_already_used": "Impossible de configurer l'application '{app}' par défaut pour le domaine '{domain}' car il est déjà utilisé par l'application '{other_app}'", + "app_upgrade_app_name": "Mise à jour de {app}...", + "backup_output_symlink_dir_broken": "Votre répertoire d'archivage '{path}' est un lien symbolique brisé. Peut-être avez-vous oublié de re/monter ou de brancher le support de stockage sur lequel il pointe.", "migrations_list_conflict_pending_done": "Vous ne pouvez pas utiliser --previous et --done simultanément.", - "migrations_to_be_ran_manually": "La migration {number} {name} doit être lancée manuellement. Veuillez aller dans Outils > Migrations dans l’interface admin, ou lancer `yunohost tools migrations migrate`.", - "migrations_need_to_accept_disclaimer": "Pour lancer la migration {number} {name}, vous devez accepter cette clause de non-responsabilité :\n---\n{disclaimer}\n---\nSi vous acceptez de lancer la migration, veuillez relancer la commande avec l’option --accept-disclaimer.", - "service_description_avahi-daemon": "permet d’atteindre votre serveur via yunohost.local sur votre réseau local", - "service_description_dnsmasq": "gère la résolution des noms de domaine (DNS)", - "service_description_dovecot": "permet aux clients de messagerie d’accéder/récupérer les courriels (via IMAP et POP3)", - "service_description_fail2ban": "protège contre les attaques brute-force et autres types d’attaques venant d’Internet", - "service_description_glances": "surveille les informations système de votre serveur", - "service_description_metronome": "gère les comptes de messagerie instantanée XMPP", - "service_description_mysql": "stocke les données des applications (bases de données SQL)", - "service_description_nginx": "sert ou permet l’accès à tous les sites web hébergés sur votre serveur", - "service_description_nslcd": "gère la connexion en ligne de commande des utilisateurs YunoHost", - "service_description_php5-fpm": "exécute des applications écrites en PHP avec nginx", - "service_description_postfix": "utilisé pour envoyer et recevoir des courriels", - "service_description_redis-server": "une base de données spécialisée utilisée pour l’accès rapide aux données, les files d’attentes et la communication entre les programmes", - "service_description_rmilter": "vérifie divers paramètres dans les courriels", - "service_description_rspamd": "filtre le pourriel, et d’autres fonctionnalités liées au courriel", - "service_description_slapd": "stocke les utilisateurs, domaines et leurs informations liées", - "service_description_ssh": "vous permet de vous connecter à distance à votre serveur via un terminal (protocole SSH)", - "service_description_yunohost-api": "permet les interactions entre l’interface web de YunoHost et le système", - "service_description_yunohost-firewall": "gère l'ouverture et la fermeture des ports de connexion aux services", - "experimental_feature": "Attention : cette fonctionnalité est expérimentale et ne doit pas être considérée comme stable, vous ne devriez pas l’utiliser à moins que vous ne sachiez ce que vous faites.", - "log_corrupted_md_file": "Le fichier yaml de metadata associé aux logs est corrompu : '{md_file}'", - "log_category_404": "Le journal de la catégorie '{category}' n’existe pas", - "log_link_to_log": "Journal historisé complet de cette opération : ' {desc} '", - "log_help_to_get_log": "Pour voir le journal historisé de cette opération '{desc}', utilisez la commande 'yunohost log display {name}'", - "log_link_to_failed_log": "L’opération '{desc}' a échouée ! Pour avoir de l’aide, merci de fournir le journal historisé complet de l’opération en cliquant ici", - "backup_php5_to_php7_migration_may_fail": "Impossible de convertir votre archive pour prendre en charge php7, vos applications php pourraient ne pas être restaurées (reason: {error:s})", - "log_help_to_get_failed_log": "L’opération '{desc}' a échouée ! Pour avoir de l’aide, merci de partager le journal historisé de cette opération en utilisant la commande 'yunohost log display {name} --share'", - "log_does_exists": "Il n’existe pas de journal historisé de l’opération ayant pour nom '{log}', utiliser 'yunohost log list pour voir tous les fichiers de journaux historisés disponibles'", - "log_operation_unit_unclosed_properly": "L’opération ne s’est pas terminée correctement", - "log_app_addaccess": "Ajouter l’accès à '{}'", - "log_app_removeaccess": "Enlever l’accès à '{}'", - "log_app_clearaccess": "Retirer tous les accès à '{}'", - "log_app_fetchlist": "Ajouter une liste d’application", - "log_app_removelist": "Enlever une liste d’application", - "log_app_change_url": "Changer l’URL de l’application '{}'", - "log_app_install": "Installer l’application '{}'", - "log_app_remove": "Enlever l’application '{}'", - "log_app_upgrade": "Mettre à jour l’application '{}'", - "log_app_makedefault": "Faire de '{}' l’application par défaut", - "log_available_on_yunopaste": "Le journal historisé est désormais disponible via {url}", + "migrations_to_be_ran_manually": "La migration {id} doit être lancée manuellement. Veuillez aller dans Outils > Migrations dans la webadmin, ou lancer `yunohost tools migrations run`.", + "migrations_need_to_accept_disclaimer": "Pour lancer la migration {id}, vous devez accepter cet avertissement :\n---\n{disclaimer}\n---\nSi vous acceptez de lancer la migration, veuillez relancer la commande avec l'option --accept-disclaimer.", + "service_description_yunomdns": "Vous permet d'atteindre votre serveur en utilisant 'yunohost.local' sur votre réseau local", + "service_description_dnsmasq": "Gère la résolution des noms de domaine (DNS)", + "service_description_dovecot": "Permet aux clients de messagerie d'accéder/récupérer les emails (via IMAP et POP3)", + "service_description_fail2ban": "Protège contre les attaques brute-force et autres types d'attaques venant d'Internet", + "service_description_metronome": "Gère les comptes de messagerie instantanée XMPP", + "service_description_mysql": "Stocke les données des applications (bases de données SQL)", + "service_description_nginx": "Sert ou permet l'accès à tous les sites web hébergés sur votre serveur", + "service_description_postfix": "Utilisé pour envoyer et recevoir des emails", + "service_description_redis-server": "Une base de données spécialisée utilisée pour l'accès rapide aux données, les files d'attentes et la communication entre les programmes", + "service_description_rspamd": "Filtre le pourriel, et d'autres fonctionnalités liées aux emails", + "service_description_slapd": "Stocke les utilisateurs, domaines et leurs informations liées", + "service_description_ssh": "Vous permet de vous connecter à distance à votre serveur via un terminal (protocole SSH)", + "service_description_yunohost-api": "Permet les interactions entre l'interface web de YunoHost et le système", + "service_description_yunohost-firewall": "Gère l'ouverture et la fermeture des ports de connexion aux services", + "experimental_feature": "Attention : cette fonctionnalité est expérimentale et ne doit pas être considérée comme stable, vous ne devriez pas l'utiliser à moins que vous ne sachiez ce que vous faites.", + "log_corrupted_md_file": "Le fichier YAML de métadonnées associé aux logs est corrompu : '{md_file}'\nErreur : {error}", + "log_link_to_log": "Journal complet de cette opération : ' {desc} '", + "log_help_to_get_log": "Pour voir le journal de cette opération '{desc}', utilisez la commande 'yunohost log show {name}'", + "log_link_to_failed_log": "L'opération '{desc}' a échoué ! Pour obtenir de l'aide, merci de partager le journal de l'opération en cliquant ici", + "log_help_to_get_failed_log": "L'opération '{desc}' a échoué ! Pour obtenir de l'aide, merci de partager le journal de l'opération en utilisant la commande 'yunohost log share {name}'", + "log_does_exists": "Il n'y a pas de journal des opérations avec le nom '{log}', utilisez 'yunohost log list' pour voir tous les journaux d'opérations disponibles", + "log_operation_unit_unclosed_properly": "L'opération ne s'est pas terminée correctement", + "log_app_change_url": "Changer l'URL de l'application '{}'", + "log_app_install": "Installer l'application '{}'", + "log_app_remove": "Enlever l'application '{}'", + "log_app_upgrade": "Mettre à jour l'application '{}'", + "log_app_makedefault": "Faire de '{}' l'application par défaut", + "log_available_on_yunopaste": "Le journal est désormais disponible via {url}", "log_backup_restore_system": "Restaurer le système depuis une archive de sauvegarde", "log_backup_restore_app": "Restaurer '{}' depuis une sauvegarde", "log_remove_on_failed_restore": "Retirer '{}' après un échec de restauration depuis une archive de sauvegarde", @@ -445,88 +266,61 @@ "log_domain_add": "Ajouter le domaine '{}' dans la configuration du système", "log_domain_remove": "Enlever le domaine '{}' de la configuration du système", "log_dyndns_subscribe": "Souscrire au sous-domaine YunoHost '{}'", - "log_dyndns_update": "Mettre à jour l’adresse IP associée à votre sous-domaine YunoHost '{}'", - "log_letsencrypt_cert_install": "Installer le certificat Let’s Encrypt sur le domaine '{}'", - "log_selfsigned_cert_install": "Installer le certificat auto-signé sur le domaine '{}'", - "log_letsencrypt_cert_renew": "Renouveler le certificat Let’s Encrypt de '{}'", - "log_service_enable": "Activer le service '{}'", - "log_service_regen_conf": "Régénérer la configuration système de '{}'", - "log_user_create": "Ajouter l’utilisateur '{}'", - "log_user_delete": "Supprimer l’utilisateur '{}'", - "log_user_update": "Mettre à jour les informations de l’utilisateur '{}'", - "log_tools_maindomain": "Faire de '{}' le domaine principal", - "log_tools_migrations_migrate_forward": "Migrer vers", - "log_tools_migrations_migrate_backward": "Revenir en arrière", + "log_dyndns_update": "Mettre à jour l'adresse IP associée à votre sous-domaine YunoHost '{}'", + "log_letsencrypt_cert_install": "Installer le certificat Let's Encrypt sur le domaine '{}'", + "log_selfsigned_cert_install": "Installer un certificat auto-signé sur le domaine '{}'", + "log_letsencrypt_cert_renew": "Renouveler le certificat Let's Encrypt de '{}'", + "log_user_create": "Ajouter l'utilisateur '{}'", + "log_user_delete": "Supprimer l'utilisateur '{}'", + "log_user_update": "Mettre à jour les informations de l'utilisateur '{}'", + "log_domain_main_domain": "Faire de '{}' le domaine principal", + "log_tools_migrations_migrate_forward": "Exécuter les migrations", "log_tools_postinstall": "Faire la post-installation de votre serveur YunoHost", "log_tools_upgrade": "Mettre à jour les paquets du système", "log_tools_shutdown": "Éteindre votre serveur", "log_tools_reboot": "Redémarrer votre serveur", - "mail_unavailable": "Cette adresse de courriel est réservée et doit être automatiquement attribuée au tout premier utilisateur", - "migration_description_0004_php5_to_php7_pools": "Reconfiguration des groupes PHP pour utiliser PHP 7 au lieu de PHP 5", - "migration_description_0005_postgresql_9p4_to_9p6": "Migration des bases de données de PostgreSQL 9.4 vers PostgreSQL 9.6", - "migration_0005_postgresql_94_not_installed": "PostgreSQL n’a pas été installé sur votre système. Rien à faire !", - "migration_0005_postgresql_96_not_installed": "PostgreSQL 9.4 a été trouvé et installé, mais pas PostgreSQL 9.6 !? Quelque chose d’étrange a dû arriver à votre système :( …", - "migration_0005_not_enough_space": "Il n’y a pas assez d’espace libre de disponible sur {path} pour lancer maintenant la migration :(.", - "recommend_to_add_first_user": "La post-installation est terminée mais YunoHost a besoin d’au moins un utilisateur pour fonctionner correctement. Vous devez en ajouter un en utilisant la commande 'yunohost user create $nomdutilisateur' ou bien via l’interface d’administration web.", - "service_description_php7.0-fpm": "exécute des applications écrites en PHP avec Nginx", - "users_available": "Liste des utilisateurs disponibles :", - "good_practices_about_admin_password": "Vous êtes maintenant sur le point de définir un nouveau mot de passe d’administration. Le mot de passe doit comporter au moins 8 caractères – bien qu’il soit recommandé d’utiliser un mot de passe plus long (c’est-à-dire une phrase secrète) et/ou d’utiliser différents types de caractères (majuscules, minuscules, chiffres et caractères spéciaux).", - "good_practices_about_user_password": "Vous êtes maintenant sur le point de définir un nouveau mot de passe utilisateur. Le mot de passe doit comporter au moins 8 caractères - bien qu’il soit recommandé d’utiliser un mot de passe plus long (c’est-à-dire une phrase secrète) et/ou d’utiliser différents types de caractères tels que : majuscules, minuscules, chiffres et caractères spéciaux.", - "migration_description_0006_sync_admin_and_root_passwords": "Synchroniser les mots de passe admin et root", - "migration_0006_disclaimer": "YunoHost s’attendra désormais à ce que les mots de passe admin et root soient synchronisés. En exécutant cette migration, votre mot de passe root sera remplacé par le mot de passe administrateur.", - "migration_0006_done": "Votre mot de passe root a été remplacé par celui de votre adminitrateur.", - "password_listed": "Ce mot de passe est l'un des mots de passe les plus utilisés dans le monde. Veuillez choisir quelque chose d'un peu plus singulier.", + "mail_unavailable": "Cette adresse d'email est réservée et doit être automatiquement attribuée au tout premier utilisateur", + "good_practices_about_admin_password": "Vous êtes sur le point de définir un nouveau mot de passe d'administration. Le mot de passe doit comporter au moins 8 caractères, bien qu'il soit recommandé d'utiliser un mot de passe plus long (c'est-à-dire une phrase secrète) et/ou d'utiliser une combinaison de caractères (majuscules, minuscules, chiffres et caractères spéciaux).", + "good_practices_about_user_password": "Vous êtes sur le point de définir un nouveau mot de passe utilisateur. Le mot de passe doit comporter au moins 8 caractères, bien qu'il soit recommandé d'utiliser un mot de passe plus long (c'est-à-dire une phrase secrète) et/ou une combinaison de caractères (majuscules, minuscules, chiffres et caractères spéciaux).", + "password_listed": "Ce mot de passe fait partie des mots de passe les plus utilisés dans le monde. Veuillez en choisir un autre moins commun et plus robuste.", "password_too_simple_1": "Le mot de passe doit comporter au moins 8 caractères", "password_too_simple_2": "Le mot de passe doit comporter au moins 8 caractères et contenir des chiffres, des majuscules et des minuscules", "password_too_simple_3": "Le mot de passe doit comporter au moins 8 caractères et contenir des chiffres, des majuscules, des minuscules et des caractères spéciaux", "password_too_simple_4": "Le mot de passe doit comporter au moins 12 caractères et contenir des chiffres, des majuscules, des minuscules et des caractères spéciaux", - "root_password_desynchronized": "Le mot de passe administrateur a été changé, mais YunoHost n’a pas pu le propager au mot de passe root !", - "aborting": "Opération annulée.", - "app_not_upgraded": "Les applications suivantes n'ont pas été mises à jour : {apps}", - "app_start_install": "Installation de l'application {app} …", - "app_start_remove": "Suppression de l'application {app} …", - "app_start_backup": "Collecte des fichiers devant être sauvegardés pour {app} …", - "app_start_restore": "Restauration de l'application {app} …", + "root_password_desynchronized": "Le mot de passe administrateur a été changé, mais YunoHost n'a pas pu le propager au mot de passe root !", + "aborting": "Annulation en cours.", + "app_not_upgraded": "L'application {failed_app} n'a pas été mise à jour et par conséquence les applications suivantes n'ont pas été mises à jour : {apps}", + "app_start_install": "Installation de {app}...", + "app_start_remove": "Suppression de {app}...", + "app_start_backup": "Collecte des fichiers devant être sauvegardés pour {app}...", + "app_start_restore": "Restauration de {app}...", "app_upgrade_several_apps": "Les applications suivantes seront mises à jour : {apps}", "ask_new_domain": "Nouveau domaine", "ask_new_path": "Nouveau chemin", - "backup_actually_backuping": "Création d'une archive de sauvegarde à partir des fichiers collectés …", - "backup_mount_archive_for_restore": "Préparation de l'archive pour restauration …", - "confirm_app_install_warning": "Avertissement : cette application peut fonctionner mais n'est pas bien intégrée dans YunoHost. Certaines fonctionnalités telles que l'authentification unique et la sauvegarde/restauration peuvent ne pas être disponibles. L'installer quand même ? [{answers:s}] ", - "confirm_app_install_danger": "AVERTISSEMENT ! Cette application est encore expérimentale (explicitement, elle ne fonctionne pas) et risque de casser votre système ! Vous ne devriez probablement PAS l'installer sans savoir ce que vous faites. Êtes-vous prêt à prendre ce risque ? [{answers:s}] ", - "confirm_app_install_thirdparty": "AVERTISSEMENT ! L'installation d'applications tierces peut compromettre l'intégrité et la sécurité de votre système. Vous ne devriez probablement PAS l'installer si vous ne savez pas ce que vous faites. Êtes-vous prêt à prendre ce risque ? [{answers:s}] ", - "dpkg_is_broken": "Vous ne pouvez pas faire ça maintenant car dpkg/apt (le gestionnaire de paquets du système) semble avoir laissé des choses non configurées. Vous pouvez essayer de résoudre ce problème en vous connectant via SSH et en exécutant `sudo dpkg --configure -a'.", - "dyndns_could_not_check_available": "Impossible de vérifier si {domain:s} est disponible chez {provider:s}.", - "file_does_not_exist": "Le fichier dont le chemin est {path:s} n'existe pas.", + "backup_actually_backuping": "Création d'une archive de sauvegarde à partir des fichiers collectés ...", + "backup_mount_archive_for_restore": "Préparation de l'archive pour restauration...", + "confirm_app_install_warning": "Avertissement : cette application peut fonctionner mais n'est pas bien intégrée dans YunoHost. Certaines fonctionnalités telles que l'authentification unique et la sauvegarde/restauration peuvent ne pas être disponibles. L'installer quand même ? [{answers}] ", + "confirm_app_install_danger": "DANGER ! Cette application est connue pour être encore expérimentale (si elle ne fonctionne pas explicitement) ! Vous ne devriez probablement PAS l'installer à moins de savoir ce que vous faites. AUCUN SUPPORT ne sera fourni si cette application ne fonctionne pas ou casse votre système... Si vous êtes prêt à prendre ce risque de toute façon, tapez '{answers}'", + "confirm_app_install_thirdparty": "DANGER ! Cette application ne fait pas partie du catalogue d'applications de YunoHost. L'installation d'applications tierces peut compromettre l'intégrité et la sécurité de votre système. Vous ne devriez probablement PAS l'installer à moins de savoir ce que vous faites. AUCUN SUPPORT ne sera fourni si cette application ne fonctionne pas ou casse votre système... Si vous êtes prêt à prendre ce risque de toute façon, tapez '{answers}'", + "dpkg_is_broken": "Vous ne pouvez pas faire ça maintenant car dpkg/apt (le gestionnaire de paquets du système) semble avoir laissé des choses non configurées. Vous pouvez essayer de résoudre ce problème en vous connectant via SSH et en exécutant `sudo apt install --fix-broken` et/ou `sudo dpkg --configure -a'.", + "dyndns_could_not_check_available": "Impossible de vérifier si {domain} est disponible chez {provider}.", + "file_does_not_exist": "Le fichier dont le chemin est {path} n'existe pas.", "global_settings_setting_security_password_admin_strength": "Qualité du mot de passe administrateur", "global_settings_setting_security_password_user_strength": "Qualité du mot de passe de l'utilisateur", "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Autoriser l'utilisation de la clé hôte DSA (obsolète) pour la configuration du service SSH", - "hook_json_return_error": "Échec de la lecture au retour du script {path:s}. Erreur : {msg:s}. Contenu brut : {raw_content}", - "migration_description_0007_ssh_conf_managed_by_yunohost_step1": "La configuration SSH sera gérée par YunoHost (étape 1, automatique)", - "migration_description_0008_ssh_conf_managed_by_yunohost_step2": "La configuration SSH sera gérée par YunoHost (étape 2, manuelle)", - "migration_0007_cancelled": "YunoHost n'a pas réussi à améliorer la façon dont est gérée votre configuration SSH.", - "migration_0007_cannot_restart": "SSH ne peut pas être redémarré après avoir essayé d'annuler la migration numéro 6.", - "migration_0008_general_disclaimer": "Pour améliorer la sécurité de votre serveur, il est recommandé de laisser YunoHost gérer la configuration SSH. Votre configuration SSH actuelle diffère de la configuration recommandée. Si vous laissez YunoHost la reconfigurer, la façon dont vous vous connectez à votre serveur via SSH changera comme suit :", - "migration_0008_port": " - vous devrez vous connecter en utilisant le port 22 au lieu de votre actuel port SSH personnalisé. N'hésitez pas à le reconfigurer ;", - "migration_0008_root": " - vous ne pourrez pas vous connecter en tant que root via SSH. Au lieu de cela, vous devrez utiliser l'utilisateur admin ;", - "migration_0008_dsa": " - la clé DSA sera désactivée. Par conséquent, il se peut que vous ayez besoin d'invalider un avertissement effrayant de votre client SSH afin de revérifier l'empreinte de votre serveur ;", - "migration_0008_warning": "Si vous comprenez ces avertissements et que vous acceptez de laisser YunoHost remplacer votre configuration actuelle, exécutez la migration. Sinon, vous pouvez également passer la migration, bien que cela ne soit pas recommandé.", - "migration_0008_no_warning": "Aucun risque majeur n'a été identifié concernant l'écrasement de votre configuration SSH - mais nous ne pouvons pas en être absolument sûrs ;) ! Si vous acceptez de laisser YunoHost remplacer votre configuration actuelle, exécutez la migration. Sinon, vous pouvez également passer la migration, bien que cela ne soit pas recommandé.", - "migrations_success": "Migration {number} {name} réussie !", - "pattern_password_app": "Désolé, les mots de passe ne doivent pas contenir les caractères suivants : {forbidden_chars}", + "hook_json_return_error": "Échec de la lecture au retour du script {path}. Erreur : {msg}. Contenu brut : {raw_content}", + "pattern_password_app": "Désolé, les mots de passe ne peuvent pas contenir les caractères suivants : {forbidden_chars}", "root_password_replaced_by_admin_password": "Votre mot de passe root a été remplacé par votre mot de passe administrateur.", - "service_conf_now_managed_by_yunohost": "Le fichier de configuration '{conf}' est maintenant géré par YunoHost.", - "service_reload_failed": "Impossible de recharger le service '{service:s}'.\n\nJournaux historisés récents de ce service : {logs:s}", - "service_reloaded": "Le service '{service:s}' a été rechargé", - "service_restart_failed": "Impossible de redémarrer le service '{service:s}'\n\nJournaux historisés récents de ce service : {logs:s}", - "service_restarted": "Le service '{service:s}' a été redémarré", - "service_reload_or_restart_failed": "Impossible de recharger ou de redémarrer le service '{service:s}'\n\nJournaux historisés récents de ce service : {logs:s}", - "service_reloaded_or_restarted": "Le service '{service:s}' a été rechargé ou redémarré", - "this_action_broke_dpkg": "Cette action a laissé des paquets non configurés par dpkg/apt (les gestionnaires de paquets système). Vous pouvez essayer de résoudre ce problème en vous connectant via SSH et en exécutant `sudo dpkg --configure -a`.", - "app_action_cannot_be_ran_because_required_services_down": "Cette application requiert certains services qui sont actuellement arrêtés. Avant de continuer, vous devriez essayer de redémarrer les services suivants (et éventuellement rechercher pourquoi ils sont arrêtés) : {services}", - "admin_password_too_long": "Choisissez un mot de passe plus court que 127 caractères", + "service_reload_failed": "Impossible de recharger le service '{service}'.\n\nJournaux historisés récents de ce service : {logs}", + "service_reloaded": "Le service '{service}' a été rechargé", + "service_restart_failed": "Impossible de redémarrer le service '{service}'\n\nJournaux historisés récents de ce service : {logs}", + "service_restarted": "Le service '{service}' a été redémarré", + "service_reload_or_restart_failed": "Impossible de recharger ou de redémarrer le service '{service}'\n\nJournaux historisés récents de ce service : {logs}", + "service_reloaded_or_restarted": "Le service '{service}' a été rechargé ou redémarré", + "this_action_broke_dpkg": "Cette action a laissé des paquets non configurés par dpkg/apt (les gestionnaires de paquets du système) ... Vous pouvez essayer de résoudre ce problème en vous connectant via SSH et en exécutant `sudo apt install --fix-broken` et/ou `sudo dpkg --configure -a`.", + "app_action_cannot_be_ran_because_required_services_down": "Ces services requis doivent être en cours d'exécution pour exécuter cette action : {services}. Essayez de les redémarrer pour continuer (et éventuellement rechercher pourquoi ils sont en panne).", + "admin_password_too_long": "Veuillez choisir un mot de passe comportant moins de 127 caractères", "log_regen_conf": "Régénérer les configurations du système '{}'", - "migration_0009_not_needed": "Cette migration semble avoir déjà été jouée ? On l'ignore.", "regenconf_file_backed_up": "Le fichier de configuration '{conf}' a été sauvegardé sous '{backup}'", "regenconf_file_copy_failed": "Impossible de copier le nouveau fichier de configuration '{new}' vers '{conf}'", "regenconf_file_manually_modified": "Le fichier de configuration '{conf}' a été modifié manuellement et ne sera pas mis à jour", @@ -536,30 +330,350 @@ "regenconf_file_updated": "Le fichier de configuration '{conf}' a été mis à jour", "regenconf_now_managed_by_yunohost": "Le fichier de configuration '{conf}' est maintenant géré par YunoHost (catégorie {category}).", "regenconf_up_to_date": "La configuration est déjà à jour pour la catégorie '{category}'", - "already_up_to_date": "Il n'y a rien à faire ! Tout est déjà à jour !", - "global_settings_setting_security_nginx_compatibility": "Compatibilité versus compromis sécuritaire pour le serveur web nginx. Affecte les cryptogrammes (et d'autres aspects liés à la sécurité)", + "already_up_to_date": "Il n'y a rien à faire. Tout est déjà à jour.", + "global_settings_setting_security_nginx_compatibility": "Compatibilité versus compromis sécuritaire pour le serveur web Nginx. Affecte les cryptogrammes (et d'autres aspects liés à la sécurité)", "global_settings_setting_security_ssh_compatibility": "Compatibilité versus compromis sécuritaire pour le serveur SSH. Affecte les cryptogrammes (et d'autres aspects liés à la sécurité)", "global_settings_setting_security_postfix_compatibility": "Compatibilité versus compromis sécuritaire pour le serveur Postfix. Affecte les cryptogrammes (et d'autres aspects liés à la sécurité)", - "migration_description_0009_decouple_regenconf_from_services": "Dissocier le mécanisme « regen-conf » des services", - "migration_description_0010_migrate_to_apps_json": "Supprimer les listes d'applications obsolètes et utiliser la nouvelle liste unifiée 'apps.json' à la place", - "regenconf_file_kept_back": "Le fichier de configuration '{conf}' devait être supprimé par « regen-conf » (catégorie {category}) mais a été conservé.", - "regenconf_updated": "La configuration a été mise à jour pour la catégorie '{category}'", + "regenconf_file_kept_back": "Le fichier de configuration '{conf}' devait être supprimé par 'regen-conf' (catégorie {category}) mais a été conservé.", + "regenconf_updated": "La configuration a été mise à jour pour '{category}'", "regenconf_would_be_updated": "La configuration aurait dû être mise à jour pour la catégorie '{category}'", - "regenconf_dry_pending_applying": "Vérification de la configuration en attente qui aurait été appliquée pour la catégorie '{category}' …", + "regenconf_dry_pending_applying": "Vérification de la configuration en attente qui aurait été appliquée pour la catégorie '{category}'...", "regenconf_failed": "Impossible de régénérer la configuration pour la ou les catégorie(s) : '{categories}'", - "regenconf_pending_applying": "Applique la configuration en attente pour la catégorie '{category}' …", + "regenconf_pending_applying": "Applique la configuration en attente pour la catégorie '{category}'...", "service_regen_conf_is_deprecated": "'yunohost service regen-conf' est obsolète ! Veuillez plutôt utiliser 'yunohost tools regen-conf' à la place.", - "tools_upgrade_at_least_one": "Veuillez spécifier --apps OU --system", + "tools_upgrade_at_least_one": "Veuillez spécifier '--apps' ou '--system'", "tools_upgrade_cant_both": "Impossible de mettre à niveau le système et les applications en même temps", - "tools_upgrade_cant_hold_critical_packages": "Impossibilité de maintenir les paquets critiques...", - "tools_upgrade_regular_packages": "Mise à jour des paquets du système (non liés a YunoHost) ...", + "tools_upgrade_cant_hold_critical_packages": "Impossibilité d'ajouter le drapeau 'hold' pour les paquets critiques...", + "tools_upgrade_regular_packages": "Mise à jour des paquets du système (non liés a YunoHost)...", "tools_upgrade_regular_packages_failed": "Impossible de mettre à jour les paquets suivants : {packages_list}", - "tools_upgrade_special_packages": "Mise à jour des paquets 'spécifiques' (liés a YunoHost) ...", - "tools_upgrade_special_packages_completed": "La mise à jour des paquets de YunoHost est finie!\nPressez [Entrée] pour revenir à la ligne de commande", - "updating_app_lists": "Récupération des mises à jour des applications disponibles…", - "dpkg_lock_not_available": "Cette commande ne peut être lancée maintenant car il semblerai qu'un autre programme utilise déjà le verrou dpkg du gestionnaire de paquets du système", - "tools_upgrade_cant_unhold_critical_packages": "Impossible de dé-marquer les paquets critiques ...", - "tools_upgrade_special_packages_explanation": "Cette opération prendra fin mais la mise à jour spécifique continuera en arrière-plan. Veuillez ne pas lancer d'autre action sur votre serveur dans les 10 prochaines minutes (en fonction de la vitesse de votre matériel). Une fois que c'est fait, vous devrez peut-être vous reconnecter sur le panel d'administration web. Le journal de la mise à jour sera disponible dans Outils > Log (dans le panel d'administration web) ou dans la liste des journaux YunoHost (en ligne de commande).", + "tools_upgrade_special_packages": "Mise à jour des paquets 'spécifiques' (liés a YunoHost)...", + "tools_upgrade_special_packages_completed": "La mise à jour des paquets de YunoHost est finie !\nPressez [Entrée] pour revenir à la ligne de commande", + "dpkg_lock_not_available": "Cette commande ne peut pas être exécutée pour le moment car un autre programme semble utiliser le verrou de dpkg (le gestionnaire de package système)", + "tools_upgrade_cant_unhold_critical_packages": "Impossible d'enlever le drapeau 'hold' pour les paquets critiques...", + "tools_upgrade_special_packages_explanation": "La mise à niveau spécifique à YunoHost se poursuivra en arrière-plan. Veuillez ne pas lancer d'autres actions sur votre serveur pendant les 10 prochaines minutes (selon la vitesse du matériel). Après cela, vous devrez peut-être vous reconnecter à la webadmin. Le journal de mise à niveau sera disponible dans Outils → Journal (dans le webadmin) ou en utilisant 'yunohost log list' (à partir de la ligne de commande).", "update_apt_cache_failed": "Impossible de mettre à jour le cache APT (gestionnaire de paquets Debian). Voici un extrait du fichier sources.list qui pourrait vous aider à identifier les lignes problématiques :\n{sourceslist}", - "update_apt_cache_warning": "Des erreurs se sont produites lors de la mise à jour du cache APT (gestionnaire de paquets Debian). Voici un extrait des lignes du fichier sources.list qui pourrait vous aider à identifier les lignes problématiques :\n{sourceslist}" -} + "update_apt_cache_warning": "Des erreurs se sont produites lors de la mise à jour du cache APT (gestionnaire de paquets Debian). Voici un extrait des lignes du fichier sources.list qui pourrait vous aider à identifier les lignes problématiques :\n{sourceslist}", + "backup_permission": "Permission de sauvegarde pour {app}", + "group_created": "Le groupe '{group}' a été créé", + "group_deleted": "Suppression du groupe '{group}'", + "group_unknown": "Le groupe {group} est inconnu", + "group_updated": "Le groupe '{group}' a été mis à jour", + "group_update_failed": "La mise à jour du groupe '{group}' a échoué : {error}", + "group_creation_failed": "Échec de la création du groupe '{group}' : {error}", + "group_deletion_failed": "Échec de la suppression du groupe '{group}' : {error}", + "log_user_group_delete": "Supprimer le groupe '{}'", + "log_user_group_update": "Mettre à jour '{}' pour le groupe", + "mailbox_disabled": "La boîte aux lettres est désactivée pour l'utilisateur {user}", + "app_action_broke_system": "Cette action semble avoir cassé des services importants : {services}", + "apps_already_up_to_date": "Toutes les applications sont déjà à jour", + "migrations_must_provide_explicit_targets": "Vous devez fournir des cibles explicites lorsque vous utilisez '--skip' ou '--force-rerun'", + "migrations_no_such_migration": "Il n'y a pas de migration appelée '{id}'", + "migrations_pending_cant_rerun": "Ces migrations étant toujours en attente, vous ne pouvez pas les exécuter à nouveau : {ids}", + "migrations_exclusive_options": "'auto', '--skip' et '--force-rerun' sont des options mutuellement exclusives.", + "migrations_not_pending_cant_skip": "Ces migrations ne sont pas en attente et ne peuvent donc pas être ignorées : {ids}", + "permission_not_found": "Permission '{permission}' introuvable", + "permission_update_failed": "Impossible de mettre à jour la permission '{permission}' : {error}", + "permission_updated": "Permission '{permission}' mise à jour", + "dyndns_provider_unreachable": "Impossible d'atteindre le fournisseur DynDNS {provider} : votre YunoHost n'est pas correctement connecté à Internet ou le serveur Dynette est en panne.", + "migrations_already_ran": "Ces migrations sont déjà effectuées : {ids}", + "migrations_dependencies_not_satisfied": "Exécutez ces migrations : '{dependencies_id}', avant migration {id}.", + "migrations_failed_to_load_migration": "Impossible de charger la migration {id} : {error}", + "migrations_running_forward": "Exécution de la migration {id}...", + "migrations_success_forward": "Migration {id} terminée", + "operation_interrupted": "L'opération a-t-elle été interrompue manuellement ?", + "permission_already_exist": "L'autorisation '{permission}' existe déjà", + "permission_created": "Permission '{permission}' créée", + "permission_creation_failed": "Impossible de créer l'autorisation '{permission}' : {error}", + "permission_deleted": "Permission '{permission}' supprimée", + "permission_deletion_failed": "Impossible de supprimer la permission '{permission}' : {error}", + "group_already_exist": "Le groupe {group} existe déjà", + "group_already_exist_on_system": "Le groupe {group} existe déjà dans les groupes système", + "group_cannot_be_deleted": "Le groupe {group} ne peut pas être supprimé manuellement.", + "group_user_already_in_group": "L'utilisateur {user} est déjà dans le groupe {group}", + "group_user_not_in_group": "L'utilisateur {user} n'est pas dans le groupe {group}", + "log_permission_create": "Créer permission '{}'", + "log_permission_delete": "Supprimer permission '{}'", + "log_user_group_create": "Créer le groupe '{}'", + "log_user_permission_update": "Mise à jour des accès pour la permission '{}'", + "log_user_permission_reset": "Réinitialiser la permission '{}'", + "permission_already_allowed": "Le groupe '{group}' a déjà l'autorisation '{permission}' activée", + "permission_already_disallowed": "Le groupe '{group}' a déjà l'autorisation '{permission}' désactivé", + "permission_cannot_remove_main": "Supprimer une autorisation principale n'est pas autorisé", + "user_already_exists": "L'utilisateur '{user}' existe déjà", + "app_full_domain_unavailable": "Désolé, cette application doit être installée sur un domaine qui lui est propre, mais d'autres applications sont déjà installées sur le domaine '{domain}'. Vous pouvez utiliser un sous-domaine dédié à cette application à la place.", + "group_cannot_edit_all_users": "Le groupe 'all_users' ne peut pas être édité manuellement. C'est un groupe spécial destiné à contenir tous les utilisateurs enregistrés dans YunoHost", + "group_cannot_edit_visitors": "Le groupe 'visiteurs' ne peut pas être édité manuellement. C'est un groupe spécial représentant les visiteurs anonymes", + "group_cannot_edit_primary_group": "Le groupe '{group}' ne peut pas être édité manuellement. C'est le groupe principal destiné à ne contenir qu'un utilisateur spécifique.", + "log_permission_url": "Mise à jour de l'URL associée à l'autorisation '{}'", + "permission_already_up_to_date": "L'autorisation n'a pas été mise à jour car les demandes d'ajout/suppression correspondent déjà à l'état actuel.", + "permission_currently_allowed_for_all_users": "Cette autorisation est actuellement accordée à tous les utilisateurs en plus des autres groupes. Vous voudrez probablement soit supprimer l'autorisation 'all_users', soit supprimer les autres groupes auxquels il est actuellement autorisé.", + "app_install_failed": "Impossible d'installer {app} : {error}", + "app_install_script_failed": "Une erreur est survenue dans le script d'installation de l'application", + "permission_require_account": "Permission {permission} n'a de sens que pour les utilisateurs ayant un compte et ne peut donc pas être activé pour les visiteurs.", + "app_remove_after_failed_install": "Supprimer l'application après l'échec de l'installation...", + "diagnosis_cant_run_because_of_dep": "Impossible d'exécuter le diagnostic pour {category} alors qu'il existe des problèmes importants liés à {dep}.", + "diagnosis_found_errors": "Trouvé {errors} problème(s) significatif(s) lié(s) à {category} !", + "diagnosis_found_errors_and_warnings": "Trouvé {errors} problème(s) significatif(s) (et {warnings} (avertissement(s)) en relation avec {category} !", + "diagnosis_ip_not_connected_at_all": "Le serveur ne semble pas du tout connecté à Internet !?", + "diagnosis_ip_weird_resolvconf": "La résolution DNS semble fonctionner, mais il semble que vous utilisez un /etc/resolv.conf personnalisé.", + "diagnosis_ip_weird_resolvconf_details": "Le fichier /etc/resolv.conf doit être un lien symbolique vers /etc/resolvconf/run/resolv.conf lui-même pointant vers 127.0.0.1 (dnsmasq). Si vous souhaitez configurer manuellement les résolveurs DNS, veuillez modifier /etc/resolv.dnsmasq.conf.", + "diagnosis_dns_missing_record": "Selon la configuration DNS recommandée, vous devez ajouter un enregistrement DNS
Type : {type}
Nom : {name}
Valeur : {value}", + "diagnosis_diskusage_ok": "L'espace de stockage {mountpoint} (sur le périphérique {device}) a encore {free} ({free_percent}%) espace restant (sur {total}) !", + "diagnosis_ram_ok": "Le système dispose encore de {available} ({available_percent}%) de RAM sur {total}.", + "diagnosis_regenconf_allgood": "Tous les fichiers de configuration sont conformes à la configuration recommandée !", + "diagnosis_security_vulnerable_to_meltdown": "Vous semblez vulnérable à la vulnérabilité de sécurité critique de Meltdown", + "diagnosis_basesystem_host": "Le serveur utilise Debian {debian_version}", + "diagnosis_basesystem_kernel": "Le serveur utilise le noyau Linux {kernel_version}", + "diagnosis_basesystem_ynh_single_version": "{package} version : {version} ({repo})", + "diagnosis_basesystem_ynh_main_version": "Le serveur utilise YunoHost {main_version} ({repo})", + "diagnosis_basesystem_ynh_inconsistent_versions": "Vous exécutez des versions incohérentes des packages YunoHost ... très probablement en raison d'une mise à niveau échouée ou partielle.", + "diagnosis_failed_for_category": "Échec du diagnostic pour la catégorie '{category}': {error}", + "diagnosis_cache_still_valid": "(Le cache est encore valide pour le diagnostic {category}. Il ne sera pas re-diagnostiqué pour le moment !)", + "diagnosis_ignored_issues": "(+ {nb_ignored} problème(s) ignoré(s))", + "diagnosis_found_warnings": "Trouvé {warnings} objet(s) pouvant être amélioré(s) pour {category}.", + "diagnosis_everything_ok": "Tout semble bien pour {category} !", + "diagnosis_failed": "Échec de la récupération du résultat du diagnostic pour la catégorie '{category}' : {error}", + "diagnosis_ip_connected_ipv4": "Le serveur est connecté à Internet en IPv4 !", + "diagnosis_ip_no_ipv4": "Le serveur ne dispose pas d'une adresse IPv4.", + "diagnosis_ip_connected_ipv6": "Le serveur est connecté à Internet en IPv6 !", + "diagnosis_ip_no_ipv6": "Le serveur ne dispose pas d'une adresse IPv6.", + "diagnosis_ip_dnsresolution_working": "La résolution de nom de domaine fonctionne !", + "diagnosis_ip_broken_dnsresolution": "La résolution du nom de domaine semble interrompue pour une raison quelconque ... Un pare-feu bloque-t-il les requêtes DNS ?", + "diagnosis_ip_broken_resolvconf": "La résolution du nom de domaine semble être cassée sur votre serveur, ce qui semble lié au fait que /etc/resolv.conf ne pointe pas vers 127.0.0.1.", + "diagnosis_dns_good_conf": "Les enregistrements DNS sont correctement configurés pour le domaine {domain} (catégorie {category})", + "diagnosis_dns_bad_conf": "Certains enregistrements DNS sont manquants ou incorrects pour le domaine {domain} (catégorie {category})", + "diagnosis_dns_discrepancy": "Cet enregistrement DNS ne semble pas correspondre à la configuration recommandée :
Type : {type}
Nom : {name}
La valeur actuelle est : {current}
La valeur attendue est : {value}", + "diagnosis_services_bad_status": "Le service {service} est {status} :-(", + "diagnosis_diskusage_verylow": "L'espace de stockage {mountpoint} (sur l'appareil {device}) ne dispose que de {free} ({free_percent}%) espace restant (sur {total}). Vous devriez vraiment envisager de nettoyer de l'espace !", + "diagnosis_diskusage_low": "L'espace de stockage {mountpoint} (sur l'appareil {device}) ne dispose que de {free} ({free_percent}%) espace restant (sur {total}). Faites attention.", + "diagnosis_ram_verylow": "Le système ne dispose plus que de {available} ({available_percent}%)! (sur {total})", + "diagnosis_ram_low": "Le système n'a plus de {available} ({available_percent}%) RAM sur {total}. Faites attention.", + "diagnosis_swap_none": "Le système n'a aucun espace de swap. Vous devriez envisager d'ajouter au moins {recommended} de swap pour éviter les situations où le système manque de mémoire.", + "diagnosis_swap_notsomuch": "Le système ne dispose que de {total} de swap. Vous devez envisager d'avoir au moins {recommended} pour éviter les situations où le système manque de mémoire.", + "diagnosis_swap_ok": "Le système dispose de {total} de swap !", + "diagnosis_regenconf_manually_modified": "Le fichier de configuration {file} semble avoir été modifié manuellement.", + "diagnosis_regenconf_manually_modified_details": "C'est probablement OK si vous savez ce que vous faites ! YunoHost cessera de mettre à jour ce fichier automatiquement ... Mais attention, les mises à jour de YunoHost pourraient contenir d'importantes modifications recommandées. Si vous le souhaitez, vous pouvez inspecter les différences avec yunohost tools regen-conf {category} --dry-run --with-diff et forcer la réinitialisation à la configuration recommandée avec yunohost tools regen-conf {category} --force", + "apps_catalog_init_success": "Système de catalogue d'applications initialisé !", + "apps_catalog_failed_to_download": "Impossible de télécharger le catalogue des applications {apps_catalog} : {error}", + "diagnosis_mail_outgoing_port_25_blocked": "Le port sortant 25 semble être bloqué. Vous devriez essayer de le débloquer dans le panneau de configuration de votre fournisseur de services Internet (ou hébergeur). En attendant, le serveur ne pourra pas envoyer des emails à d'autres serveurs.", + "domain_cannot_remove_main_add_new_one": "Vous ne pouvez pas supprimer '{domain}' car il s'agit du domaine principal et de votre seul domaine. Vous devez d'abord ajouter un autre domaine à l'aide de 'yunohost domain add ', puis définir comme domaine principal à l'aide de 'yunohost domain main-domain -n ' et vous pouvez ensuite supprimer le domaine '{domain}' à l'aide de 'yunohost domain remove {domain}'.'", + "diagnosis_security_vulnerable_to_meltdown_details": "Pour résoudre ce problème, vous devez mettre à niveau votre système et redémarrer pour charger le nouveau noyau Linux (ou contacter votre fournisseur de serveur si cela ne fonctionne pas). Voir https://meltdownattack.com/ pour plus d'informations.", + "diagnosis_description_basesystem": "Système de base", + "diagnosis_description_ip": "Connectivité Internet", + "diagnosis_description_dnsrecords": "Enregistrements DNS", + "diagnosis_description_services": "État des services", + "diagnosis_description_systemresources": "Ressources système", + "diagnosis_description_ports": "Exposition des ports", + "diagnosis_description_regenconf": "Configurations système", + "diagnosis_ports_could_not_diagnose": "Impossible de diagnostiquer si les ports sont accessibles de l'extérieur.", + "diagnosis_ports_could_not_diagnose_details": "Erreur : {error}", + "apps_catalog_updating": "Mise à jour du catalogue d'applications...", + "apps_catalog_obsolete_cache": "Le cache du catalogue d'applications est vide ou obsolète.", + "apps_catalog_update_success": "Le catalogue des applications a été mis à jour !", + "diagnosis_description_mail": "Email", + "diagnosis_ports_unreachable": "Le port {port} n'est pas accessible de l'extérieur.", + "diagnosis_ports_ok": "Le port {port} est accessible de l'extérieur.", + "diagnosis_http_could_not_diagnose": "Impossible de diagnostiquer si le domaine est accessible de l'extérieur.", + "diagnosis_http_could_not_diagnose_details": "Erreur : {error}", + "diagnosis_http_ok": "Le domaine {domain} est accessible en HTTP depuis l'extérieur.", + "diagnosis_http_unreachable": "Le domaine {domain} est inaccessible en HTTP depuis l'extérieur.", + "diagnosis_unknown_categories": "Les catégories suivantes sont inconnues : {categories}", + "app_upgrade_script_failed": "Une erreur s'est produite durant l'exécution du script de mise à niveau de l'application", + "diagnosis_services_running": "Le service {service} est en cours de fonctionnement !", + "diagnosis_services_conf_broken": "La configuration est cassée pour le service {service} !", + "diagnosis_ports_needed_by": "Rendre ce port accessible est nécessaire pour les fonctionnalités de type {category} (service {service})", + "diagnosis_ports_forwarding_tip": "Pour résoudre ce problème, vous devez probablement configurer la redirection de port sur votre routeur Internet comme décrit dans https://yunohost.org/isp_box_config", + "diagnosis_http_connection_error": "Erreur de connexion : impossible de se connecter au domaine demandé, il est probablement injoignable.", + "diagnosis_no_cache": "Pas encore de cache de diagnostique pour la catégorie '{category}'", + "yunohost_postinstall_end_tip": "La post-installation terminée ! Pour finaliser votre configuration, il est recommandé de :\n- ajouter un premier utilisateur depuis la section \"Utilisateurs\" de l'interface web (ou 'yunohost user create ' en ligne de commande) ;\n- diagnostiquer les potentiels problèmes dans la section \"Diagnostic\" de l'interface web (ou 'yunohost diagnosis run' en ligne de commande) ;\n- lire les parties 'Finalisation de votre configuration' et 'Découverte de YunoHost' dans le guide de l'administrateur : https://yunohost.org/admindoc.", + "diagnosis_services_bad_status_tip": "Vous pouvez essayer de redémarrer le service, et si cela ne fonctionne pas, consultez les journaux de service dans le webadmin (à partir de la ligne de commande, vous pouvez le faire avec yunohost service restart {service} et yunohost service log {service} ).", + "diagnosis_http_bad_status_code": "Le système de diagnostique n'a pas réussi à contacter votre serveur. Il se peut qu'une autre machine réponde à la place de votre serveur. Vérifiez que le port 80 est correctement redirigé, que votre configuration Nginx est à jour et qu'un reverse-proxy n'interfère pas.", + "diagnosis_http_timeout": "Expiration du délai en essayant de contacter votre serveur de l'extérieur. Il semble être inaccessible. Vérifiez que vous transférez correctement le port 80, que Nginx est en cours d'exécution et qu'un pare-feu n'interfère pas.", + "global_settings_setting_pop3_enabled": "Activer le protocole POP3 pour le serveur de messagerie", + "log_app_action_run": "Lancer l'action de l'application '{}'", + "diagnosis_never_ran_yet": "Il apparaît que le serveur a été installé récemment et qu'il n'y a pas encore eu de diagnostic. Vous devriez en lancer un depuis la webadmin ou en utilisant 'yunohost diagnosis run' depuis la ligne de commande.", + "diagnosis_description_web": "Web", + "diagnosis_basesystem_hardware": "L'architecture du serveur est {virt} {arch}", + "group_already_exist_on_system_but_removing_it": "Le groupe {group} est déjà présent dans les groupes du système, mais YunoHost va le supprimer...", + "certmanager_warning_subdomain_dns_record": "Le sous-domaine '{subdomain}' ne résout pas vers la même adresse IP que '{domain}'. Certaines fonctionnalités seront indisponibles tant que vous n'aurez pas corrigé cela et regénéré le certificat.", + "domain_cannot_add_xmpp_upload": "Vous ne pouvez pas ajouter de domaine commençant par 'xmpp-upload.'. Ce type de nom est réservé à la fonctionnalité d'upload XMPP intégrée dans YunoHost.", + "diagnosis_mail_outgoing_port_25_ok": "Le serveur de messagerie SMTP peut envoyer des emails (le port sortant 25 n'est pas bloqué).", + "diagnosis_mail_outgoing_port_25_blocked_details": "Vous devez d'abord essayer de débloquer le port sortant 25 dans votre interface de routeur Internet ou votre interface d'hébergement. (Certains hébergeurs peuvent vous demander de leur envoyer un ticket de support pour cela).", + "diagnosis_mail_ehlo_bad_answer": "Un service non SMTP a répondu sur le port 25 en IPv{ipversion}", + "diagnosis_mail_ehlo_bad_answer_details": "Cela peut être dû à une autre machine qui répond au lieu de votre serveur.", + "diagnosis_mail_ehlo_wrong": "Un autre serveur de messagerie SMTP répond sur IPv{ipversion}. Votre serveur ne sera probablement pas en mesure de recevoir des email.", + "diagnosis_mail_ehlo_could_not_diagnose": "Impossible de diagnostiquer si le serveur de messagerie postfix est accessible de l'extérieur en IPv{ipversion}.", + "diagnosis_mail_ehlo_could_not_diagnose_details": "Erreur : {error}", + "diagnosis_mail_fcrdns_dns_missing": "Aucun DNS inverse n'est défini pour IPv{ipversion}. Certains emails seront peut-être refusés ou considérés comme des spam.", + "diagnosis_mail_fcrdns_ok": "Votre DNS inverse est correctement configuré !", + "diagnosis_mail_fcrdns_nok_details": "Vous devez d'abord essayer de configurer le DNS inverse avec {ehlo_domain} dans votre interface de routeur Internet ou votre interface d'hébergement. (Certains hébergeurs peuvent vous demander de leur envoyer un ticket de support pour cela).", + "diagnosis_mail_fcrdns_different_from_ehlo_domain": "Le DNS inverse n'est pas correctement configuré en IPv{ipversion}. Certains emails seront peut-être refusés ou considérés comme des spam.", + "diagnosis_mail_blacklist_ok": "Les adresses IP et les domaines utilisés par ce serveur ne semblent pas être sur liste noire", + "diagnosis_mail_blacklist_reason": "La raison de la liste noire est : {reason}", + "diagnosis_mail_blacklist_website": "Après avoir identifié la raison pour laquelle vous êtes répertorié et l'avoir corrigé, n'hésitez pas à demander le retrait de votre IP ou domaine sur {blacklist_website}", + "diagnosis_mail_queue_ok": "{nb_pending} emails en attente dans les files d'attente de messagerie", + "diagnosis_mail_queue_unavailable_details": "Erreur : {error}", + "diagnosis_mail_queue_too_big": "Trop d'emails en attente dans la file d'attente ({nb_pending} emails)", + "global_settings_setting_smtp_allow_ipv6": "Autoriser l'utilisation d'IPv6 pour recevoir et envoyer du courrier", + "diagnosis_display_tip": "Pour voir les problèmes détectés, vous pouvez accéder à la section Diagnostic du webadmin ou exécuter 'yunohost diagnosis show --issues --human-readable' à partir de la ligne de commande.", + "diagnosis_ip_global": "IP globale : {global}", + "diagnosis_ip_local": "IP locale : {local}", + "diagnosis_dns_point_to_doc": "Veuillez consulter la documentation sur https://yunohost.org/dns_config si vous avez besoin d'aide pour configurer les enregistrements DNS.", + "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "Certains fournisseurs ne vous laisseront pas débloquer le port sortant 25 parce qu'ils ne se soucient pas de la neutralité du Net.
- Certains d'entre eux offrent l'alternative d'utiliser un serveur de messagerie relai bien que cela implique que le relai sera en mesure d'espionner votre trafic de messagerie.
- Une alternative respectueuse de la vie privée consiste à utiliser un VPN *avec une IP publique dédiée* pour contourner ce type de limites. Voir https://yunohost.org/#/vpn_advantage
- Vous pouvez également envisager de passer à un fournisseur plus respectueux de la neutralité du net", + "diagnosis_mail_ehlo_ok": "Le serveur de messagerie SMTP est accessible de l'extérieur et peut donc recevoir des emails !", + "diagnosis_mail_ehlo_unreachable": "Le serveur de messagerie SMTP est inaccessible de l'extérieur en IPv{ipversion}. Il ne pourra pas recevoir des emails.", + "diagnosis_mail_ehlo_unreachable_details": "Impossible d'ouvrir une connexion sur le port 25 à votre serveur en IPv{ipversion}. Il semble inaccessible.
1. La cause la plus courante de ce problème est que le port 25 n'est pas correctement redirigé vers votre serveur.
2. Vous devez également vous assurer que le service postfix est en cours d'exécution.
3. Sur les configurations plus complexes: assurez-vous qu'aucun pare-feu ou proxy inversé n'interfère.", + "diagnosis_mail_ehlo_wrong_details": "Le EHLO reçu par le serveur de diagnostique distant en IPv{ipversion} est différent du domaine de votre serveur.
EHLO reçu : {wrong_ehlo}
Attendu : {right_ehlo}
La cause la plus courante à ce problème est que le port 25 n'est pas correctement redirigé vers votre serveur. Vous pouvez également vous assurer qu'aucun pare-feu ou reverse-proxy n'interfère.", + "diagnosis_mail_fcrdns_nok_alternatives_4": "Certains fournisseurs ne vous laisseront pas configurer votre DNS inversé (ou leur fonctionnalité pourrait être cassée...). Si vous rencontrez des problèmes à cause de cela, envisagez les solutions suivantes :
- Certains FAI fournissent l'alternative de à l'aide d'un relais de serveur de messagerie bien que cela implique que le relais pourra espionner votre trafic de messagerie.
- Une alternative respectueuse de la vie privée consiste à utiliser un VPN *avec une IP publique dédiée* pour contourner ce type de limites. Voir https://yunohost.org/#/vpn_advantage
- Enfin, il est également possible de changer de fournisseur", + "diagnosis_mail_fcrdns_nok_alternatives_6": "Certains fournisseurs ne vous laisseront pas configurer votre DNS inversé (ou leur fonctionnalité pourrait être cassée...). Si votre DNS inversé est correctement configuré en IPv4, vous pouvez essayer de désactiver l'utilisation d'IPv6 lors de l'envoi d'emails en exécutant yunohost settings set smtp.allow_ipv6 -v off. Remarque : cette dernière solution signifie que vous ne pourrez pas envoyer ou recevoir de emails avec les quelques serveurs qui ont uniquement de l'IPv6.", + "diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "DNS inverse actuel : {rdns_domain}
Valeur attendue : {ehlo_domain}", + "diagnosis_mail_blacklist_listed_by": "Votre IP ou domaine {item} est sur liste noire sur {blacklist_name}", + "diagnosis_mail_queue_unavailable": "Impossible de consulter le nombre d'emails en attente dans la file d'attente", + "diagnosis_ports_partially_unreachable": "Le port {port} n'est pas accessible de l'extérieur en IPv{failed}.", + "diagnosis_http_hairpinning_issue": "Votre réseau local ne semble pas supporter l'hairpinning.", + "diagnosis_http_hairpinning_issue_details": "C'est probablement à cause de la box/routeur de votre fournisseur d'accès internet. Par conséquent, les personnes extérieures à votre réseau local pourront accéder à votre serveur comme prévu, mais pas les personnes internes au réseau local (comme vous, probablement ?) si elles utilisent le nom de domaine ou l'IP globale. Vous pourrez peut-être améliorer la situation en consultant https://yunohost.org/dns_local_network", + "diagnosis_http_partially_unreachable": "Le domaine {domain} semble inaccessible en HTTP depuis l'extérieur du réseau local en IPv{failed}, bien qu'il fonctionne en IPv{passed}.", + "diagnosis_http_nginx_conf_not_up_to_date": "La configuration Nginx de ce domaine semble avoir été modifiée manuellement et empêche YunoHost de diagnostiquer si elle est accessible en HTTP.", + "diagnosis_http_nginx_conf_not_up_to_date_details": "Pour corriger la situation, inspectez la différence avec la ligne de commande en utilisant les outils yunohost tools regen-conf nginx --dry-run --with-diff et si vous êtes d'accord, appliquez les modifications avec yunohost tools regen-conf nginx --force.", + "backup_archive_cant_retrieve_info_json": "Impossible d'avoir des informations sur l'archive '{archive}'... Le fichier info.json ne peut pas être trouvé (ou n'est pas un fichier json valide).", + "backup_archive_corrupted": "Il semble que l'archive de la sauvegarde '{archive}' est corrompue : {error}", + "diagnosis_ip_no_ipv6_tip": "L'utilisation de IPv6 n'est pas obligatoire pour le fonctionnement de votre serveur, mais cela contribue à la santé d'Internet dans son ensemble. IPv6 généralement configuré automatiquement par votre système ou votre FAI s'il est disponible. Autrement, vous devrez prendre quelque minutes pour le configurer manuellement à l'aide de cette documentation : https://yunohost.org/#/ipv6. Si vous ne pouvez pas activer IPv6 ou si c'est trop technique pour vous, vous pouvez aussi ignorer cet avertissement sans que cela pose problème.", + "diagnosis_domain_expiration_not_found": "Impossible de vérifier la date d'expiration de certains domaines", + "diagnosis_domain_expiration_not_found_details": "Les informations WHOIS pour le domaine {domain} ne semblent pas contenir les informations concernant la date d'expiration ?", + "diagnosis_domain_not_found_details": "Le domaine {domain} n'existe pas dans la base de donnée WHOIS ou est expiré !", + "diagnosis_domain_expiration_success": "Vos domaines sont enregistrés et ne vont pas expirer prochainement.", + "diagnosis_domain_expiration_warning": "Certains domaines vont expirer prochainement !", + "diagnosis_domain_expiration_error": "Certains domaines vont expirer TRÈS PROCHAINEMENT !", + "diagnosis_domain_expires_in": "{domain} expire dans {days} jours.", + "certmanager_domain_not_diagnosed_yet": "Il n'y a pas encore de résultat de diagnostic pour le domaine {domain}. Merci de relancer un diagnostic pour les catégories 'Enregistrements DNS' et 'Web' dans la section Diagnostique pour vérifier si le domaine est prêt pour Let's Encrypt. (Ou si vous savez ce que vous faites, utilisez '--no-checks' pour désactiver la vérification.)", + "diagnosis_swap_tip": "Merci d'être prudent et conscient que si vous hébergez une partition SWAP sur une carte SD ou un disque SSD, cela risque de réduire drastiquement l'espérance de vie du périphérique.", + "restore_already_installed_apps": "Les applications suivantes ne peuvent pas être restaurées car elles sont déjà installées : {apps}", + "regenconf_need_to_explicitly_specify_ssh": "La configuration de ssh a été modifiée manuellement. Vous devez explicitement indiquer la mention --force à \"ssh\" pour appliquer les changements.", + "migration_0015_cleaning_up": "Nettoyage du cache et des paquets qui ne sont plus nécessaires ...", + "migration_0015_specific_upgrade": "Démarrage de la mise à jour des paquets du système qui doivent être mis à jour séparément...", + "migration_0015_modified_files": "Veuillez noter que les fichiers suivants ont été modifiés manuellement et pourraient être écrasés à la suite de la mise à niveau : {manually_modified_files}", + "migration_0015_problematic_apps_warning": "Veuillez noter que des applications qui peuvent poser problèmes ont été détectées. Il semble qu'elles n'aient pas été installées à partir du catalogue d'applications YunoHost, ou bien qu'elles ne soient pas signalées comme \"fonctionnelles\". Par conséquent, il n'est pas possible de garantir que les applications suivantes fonctionneront encore après la mise à niveau : {problematic_apps}", + "migration_0015_general_warning": "Veuillez noter que cette migration est une opération délicate. L'équipe YunoHost a fait de son mieux pour la revérifier et la tester, mais la migration pourrait quand même casser des éléments du système ou de ses applications.\n\nIl est donc recommandé :\n...- de faire une sauvegarde de toute donnée ou application critique. Plus d'informations ici https://yunohost.org/backup ;\n...- d'être patient après le lancement de la migration. Selon votre connexion internet et votre matériel, la mise à niveau peut prendre jusqu'à quelques heures.", + "migration_0015_system_not_fully_up_to_date": "Votre système n'est pas entièrement à jour. Veuillez effectuer une mise à jour normale avant de lancer la migration vers Buster.", + "migration_0015_not_enough_free_space": "L'espace libre est très faible dans /var/ ! Vous devriez avoir au moins 1 Go de libre pour effectuer cette migration.", + "migration_0015_not_stretch": "La distribution Debian actuelle n'est pas Stretch !", + "migration_0015_yunohost_upgrade": "Démarrage de la mise à jour de YunoHost ...", + "migration_0015_still_on_stretch_after_main_upgrade": "Quelque chose s'est mal passé lors de la mise à niveau, le système semble toujours être sous Debian Stretch", + "migration_0015_main_upgrade": "Démarrage de la mise à niveau générale...", + "migration_0015_patching_sources_list": "Mise à jour du fichier sources.lists...", + "migration_0015_start": "Démarrage de la migration vers Buster", + "migration_description_0015_migrate_to_buster": "Mise à niveau du système vers Debian Buster et YunoHost 4.x", + "diagnosis_dns_try_dyndns_update_force": "La configuration DNS de ce domaine devrait être automatiquement gérée par YunoHost. Si ce n'est pas le cas, vous pouvez essayer de forcer une mise à jour en utilisant yunohost dyndns update --force.", + "app_packaging_format_not_supported": "Cette application ne peut pas être installée car son format n'est pas pris en charge par votre version de YunoHost. Vous devriez probablement envisager de mettre à jour votre système.", + "migration_0015_weak_certs": "Il a été constaté que les certificats suivants utilisent encore des algorithmes de signature peu robustes et doivent être mis à jour pour être compatibles avec la prochaine version de NGINX : {certs}", + "global_settings_setting_backup_compress_tar_archives": "Lors de la création de nouvelles sauvegardes, compresser automatiquement les archives (.tar.gz) au lieu des archives non compressées (.tar). N.B. : activer cette option permet de créer des archives plus légères, mais la procédure de sauvegarde initiale sera significativement plus longues et plus gourmandes en CPU.", + "migration_description_0018_xtable_to_nftable": "Migrer les anciennes règles de trafic réseau vers le nouveau système basé sur nftables", + "service_description_php7.3-fpm": "Exécute les applications écrites en PHP avec NGINX", + "migration_0018_failed_to_reset_legacy_rules": "La réinitialisation des règles iptable par défaut a échoué : {error}", + "migration_0018_failed_to_migrate_iptables_rules": "Échec de la migration des anciennes règles iptables vers nftables : {error}", + "migration_0017_not_enough_space": "Laissez suffisamment d'espace disponible dans {path} avant de lancer la migration.", + "migration_0017_postgresql_11_not_installed": "PostgreSQL 9.6 est installé mais pas posgreSQL 11 ? Il s'est sans doute passé quelque chose d'étrange sur votre système :(...", + "migration_0017_postgresql_96_not_installed": "PostgreSQL n'a pas été installé sur votre système. Aucune opération à effectuer.", + "migration_description_0017_postgresql_9p6_to_11": "Migrer les bases de données de PostgreSQL 9.6 vers 11", + "migration_description_0016_php70_to_php73_pools": "Migrer les configurations php7.0 vers php7.3", + "diagnosis_processes_killed_by_oom_reaper": "Certains processus ont été arrêtés récemment par le système car il manquait de mémoire. Cela apparaît généralement quand le système manque de mémoire ou qu'un processus consomme trop de mémoire. Liste des processus tués :\n{kills_summary}", + "ask_user_domain": "Domaine à utiliser pour l'adresse email de l'utilisateur et le compte XMPP", + "app_manifest_install_ask_is_public": "Cette application devrait-elle être visible par les visiteurs anonymes ?", + "app_manifest_install_ask_admin": "Choisissez un administrateur pour cette application", + "app_manifest_install_ask_password": "Choisissez un mot de passe administrateur pour cette application", + "app_manifest_install_ask_path": "Choisissez le chemin d'URL (après le domaine) où cette application doit être installée", + "app_manifest_install_ask_domain": "Choisissez le domaine sur lequel vous souhaitez installer cette application", + "global_settings_setting_smtp_relay_user": "Compte utilisateur du relais SMTP", + "global_settings_setting_smtp_relay_port": "Port du relais SMTP", + "global_settings_setting_smtp_relay_host": "Un relais SMTP permet d'envoyer du courrier à la place de cette instance YunoHost. Cela est utile si vous êtes dans l'une de ces situations : le port 25 est bloqué par votre FAI ou par votre fournisseur VPS, vous avez une IP résidentielle répertoriée sur DUHL, vous ne pouvez pas configurer de reverse DNS ou le serveur n'est pas directement accessible depuis Internet et que vous voulez en utiliser un autre pour envoyer des mails.", + "diagnosis_package_installed_from_sury_details": "Certains paquets ont été installés par inadvertance à partir d'un dépôt tiers appelé Sury. L'équipe YunoHost a amélioré la stratégie de gestion de ces paquets, mais on s'attend à ce que certaines configurations qui ont installé des applications PHP7.3 tout en étant toujours sur Stretch présentent des incohérences. Pour résoudre cette situation, vous devez essayer d'exécuter la commande suivante : {cmd_to_fix}", + "app_argument_password_no_default": "Erreur lors de l'analyse de l'argument de mot de passe '{name}' : l'argument de mot de passe ne peut pas avoir de valeur par défaut pour des raisons de sécurité", + "pattern_email_forward": "L'adresse électronique doit être valide, le symbole '+' étant accepté (par exemple : johndoe+yunohost@exemple.com)", + "global_settings_setting_smtp_relay_password": "Mot de passe du relais de l'hôte SMTP", + "diagnosis_package_installed_from_sury": "Des paquets du système devraient être rétrogradé de version", + "additional_urls_already_added": "URL supplémentaire '{url}' déjà ajoutée pour la permission '{permission}'", + "unknown_main_domain_path": "Domaine ou chemin inconnu pour '{app}'. Vous devez spécifier un domaine et un chemin pour pouvoir spécifier une URL pour l'autorisation.", + "show_tile_cant_be_enabled_for_regex": "Vous ne pouvez pas activer 'show_tile' pour le moment, car l'URL de l'autorisation '{permission}' est une expression régulière", + "show_tile_cant_be_enabled_for_url_not_defined": "Vous ne pouvez pas activer 'show_tile' pour le moment, car vous devez d'abord définir une URL pour l'autorisation '{permission}'", + "regex_with_only_domain": "Vous ne pouvez pas utiliser une expression régulière pour le domaine, uniquement pour le chemin", + "regex_incompatible_with_tile": "/!\\ Packagers ! La permission '{permission}' a 'show_tile' définie sur 'true' et vous ne pouvez donc pas définir une URL regex comme URL principale", + "permission_protected": "L'autorisation {permission} est protégée. Vous ne pouvez pas ajouter ou supprimer le groupe visiteurs à/de cette autorisation.", + "migration_0019_slapd_config_will_be_overwritten": "Il semble que vous ayez modifié manuellement la configuration de slapd. Pour cette migration critique, YunoHost doit forcer la mise à jour de la configuration slapd. Les fichiers originaux seront sauvegardés dans {conf_backup_folder}.", + "migration_0019_add_new_attributes_in_ldap": "Ajouter de nouveaux attributs pour les autorisations dans la base de données LDAP", + "migrating_legacy_permission_settings": "Migration des anciens paramètres d'autorisation...", + "invalid_regex": "Regex non valide : '{regex}'", + "domain_name_unknown": "Domaine '{domain}' inconnu", + "app_label_deprecated": "Cette commande est obsolète ! Veuillez utiliser la nouvelle commande 'yunohost user permission update' pour gérer l'étiquette de l'application.", + "additional_urls_already_removed": "URL supplémentaire '{url}' déjà supprimées pour la permission '{permission}'", + "invalid_number": "Doit être un nombre", + "migration_description_0019_extend_permissions_features": "Étendre et retravailler le système de gestion des permissions applicatives", + "diagnosis_basesystem_hardware_model": "Le modèle/architecture du serveur est {model}", + "diagnosis_backports_in_sources_list": "Il semble qu'apt (le gestionnaire de paquets) soit configuré pour utiliser le dépôt des rétroportages (backports). A moins que vous ne sachiez vraiment ce que vous faites, nous vous déconseillons fortement d'installer des paquets provenant des rétroportages, car cela risque de créer des instabilités ou des conflits sur votre système.", + "postinstall_low_rootfsspace": "Le système de fichiers a une taille totale inférieure à 10 Go, ce qui est préoccupant et devrait attirer votre attention ! Vous allez certainement arriver à court d'espace disque (très) rapidement ! Il est recommandé d'avoir au moins 16 Go à la racine pour ce système de fichiers. Si vous voulez installer YunoHost malgré cet avertissement, relancez la post-installation avec --force-diskspace", + "domain_remove_confirm_apps_removal": "Le retrait de ce domaine retirera aussi ces applications :\n{apps}\n\nÊtes vous sûr de vouloir cela ? [{answers}]", + "diagnosis_rootfstotalspace_critical": "Le système de fichiers racine ne fait que {space} ! Vous allez certainement le remplir très rapidement ! Il est recommandé d'avoir au moins 16 GB pour ce système de fichiers.", + "diagnosis_rootfstotalspace_warning": "Le système de fichiers racine n'est que de {space}. Cela peut suffire, mais faites attention car vous risquez de les remplir rapidement... Il est recommandé d'avoir au moins 16 GB pour ce système de fichiers.", + "app_restore_script_failed": "Une erreur s'est produite dans le script de restauration de l'application", + "restore_backup_too_old": "Cette sauvegarde ne peut pas être restaurée car elle provient d'une version trop ancienne de YunoHost.", + "migration_update_LDAP_schema": "Mise à jour du schéma LDAP...", + "log_backup_create": "Créer une archive de sauvegarde", + "global_settings_setting_ssowat_panel_overlay_enabled": "Activer la superposition de la vignette SSOwat", + "migration_ldap_rollback_success": "Système rétabli dans son état initial.", + "permission_cant_add_to_all_users": "L'autorisation {permission} ne peut pas être ajoutée à tous les utilisateurs.", + "migration_ldap_migration_failed_trying_to_rollback": "Impossible de migrer... tentative de restauration du système.", + "migration_ldap_can_not_backup_before_migration": "La sauvegarde du système n'a pas pu être terminée avant l'échec de la migration. Erreur : {error }", + "migration_ldap_backup_before_migration": "Création d'une sauvegarde de la base de données LDAP et des paramètres des applications avant la migration proprement dite.", + "migration_description_0020_ssh_sftp_permissions": "Ajouter la prise en charge des autorisations SSH et SFTP", + "global_settings_setting_security_ssh_port": "Port SSH", + "diagnosis_sshd_config_inconsistent_details": "Veuillez exécuter yunohost settings set security.ssh.port -v VOTRE_PORT_SSH pour définir le port SSH, et vérifiez yunohost tools regen-conf ssh --dry-run --with-diff et yunohost tools regen-conf ssh --force pour réinitialiser votre configuration aux recommandations YunoHost.", + "diagnosis_sshd_config_inconsistent": "Il semble que le port SSH a été modifié manuellement dans /etc/ssh/sshd_config. Depuis YunoHost 4.2, un nouveau paramètre global 'security.ssh.port' est disponible pour éviter de modifier manuellement la configuration.", + "diagnosis_sshd_config_insecure": "La configuration SSH semble avoir été modifiée manuellement et n'est pas sécurisée car elle ne contient aucune directive 'AllowGroups' ou 'AllowUsers' pour limiter l'accès aux utilisateurs autorisés.", + "backup_create_size_estimation": "L'archive contiendra environ {size} de données.", + "global_settings_setting_security_webadmin_allowlist": "Adresses IP autorisées à accéder à la webadmin. Elles doivent être séparées par une virgule.", + "global_settings_setting_security_webadmin_allowlist_enabled": "Autoriser seulement certaines IP à accéder à la webadmin.", + "diagnosis_http_localdomain": "Le domaine {domain}, avec un TLD .local, ne devrait pas être exposé en dehors du réseau local.", + "diagnosis_dns_specialusedomain": "Le domaine {domain} est basé sur un domaine de premier niveau (TLD) à usage spécial et ne devrait donc pas avoir d'enregistrements DNS réels.", + "invalid_password": "Mot de passe incorrect", + "ldap_server_is_down_restart_it": "Le service LDAP est en panne, essayez de le redémarrer...", + "ldap_server_down": "Impossible d'atteindre le serveur LDAP", + "global_settings_setting_security_experimental_enabled": "Activer les fonctionnalités de sécurité expérimentales (ne l'activez pas si vous ne savez pas ce que vous faites !)", + "diagnosis_apps_deprecated_practices": "La version installée de cette application utilise toujours certaines pratiques de packaging obsolètes. Vous devriez vraiment envisager de mettre l'application à jour.", + "diagnosis_apps_outdated_ynh_requirement": "La version installée de cette application nécessite uniquement YunoHost >= 2.x, cela indique que l'application n'est pas à jour avec les bonnes pratiques de packaging et les helpers recommandées. Vous devriez vraiment envisager de mettre l'application à jour.", + "diagnosis_apps_bad_quality": "Cette application est actuellement signalée comme cassée dans le catalogue d'applications de YunoHost. Cela peut être un problème temporaire. En attendant que les mainteneurs tentent de résoudre le problème, la mise à jour de cette application est désactivée.", + "diagnosis_apps_broken": "Cette application est actuellement signalée comme cassée dans le catalogue d'applications de YunoHost. Cela peut être un problème temporaire. En attendant que les mainteneurs tentent de résoudre le problème, la mise à jour de cette application est désactivée.", + "diagnosis_apps_not_in_app_catalog": "Cette application est absente ou ne figure plus dans le catalogue d'applications de YunoHost. Vous devriez envisager de la désinstaller car elle ne recevra pas de mise à jour et pourrait compromettre l'intégrité et la sécurité de votre système.", + "diagnosis_apps_issue": "Un problème a été détecté pour l'application {app}", + "diagnosis_apps_allgood": "Toutes les applications installées respectent les pratiques de packaging de base", + "diagnosis_description_apps": "Applications", + "user_import_success": "Utilisateurs importés avec succès", + "user_import_nothing_to_do": "Aucun utilisateur n'a besoin d'être importé", + "user_import_failed": "L'opération d'importation des utilisateurs a totalement échoué", + "user_import_partial_failed": "L'opération d'importation des utilisateurs a partiellement échoué", + "user_import_missing_columns": "Les colonnes suivantes sont manquantes : {columns}", + "user_import_bad_file": "Votre fichier CSV n'est pas correctement formaté, il sera ignoré afin d'éviter une potentielle perte de données", + "user_import_bad_line": "Ligne incorrecte {line} : {details}", + "log_user_import": "Importer des utilisateurs", + "diagnosis_high_number_auth_failures": "Il y a eu récemment un grand nombre d'échecs d'authentification. Assurez-vous que Fail2Ban est en cours d'exécution et est correctement configuré, ou utilisez un port personnalisé pour SSH comme expliqué dans https://yunohost.org/security.", + "global_settings_setting_security_nginx_redirect_to_https": "Rediriger les requêtes HTTP vers HTTPS par défaut (NE PAS DÉSACTIVER à moins de savoir vraiment ce que vous faites !)", + "config_validate_color": "Doit être une couleur hexadécimale RVB valide", + "app_config_unable_to_apply": "Échec de l'application des valeurs du panneau de configuration.", + "app_config_unable_to_read": "Échec de la lecture des valeurs du panneau de configuration.", + "config_apply_failed": "Échec de l'application de la nouvelle configuration : {error}", + "config_cant_set_value_on_section": "Vous ne pouvez pas définir une seule valeur sur une section de configuration entière.", + "config_forbidden_keyword": "Le mot-clé '{keyword}' est réservé, vous ne pouvez pas créer ou utiliser un panneau de configuration avec une question avec cet identifiant.", + "config_no_panel": "Aucun panneau de configuration trouvé.", + "config_unknown_filter_key": "La clé de filtre '{filter_key}' est incorrecte.", + "config_validate_date": "Doit être une date valide comme dans le format AAAA-MM-JJ", + "config_validate_email": "Doit être un email valide", + "config_validate_time": "Doit être une heure valide comme HH:MM", + "config_validate_url": "Doit être une URL Web valide", + "config_version_not_supported": "Les versions du panneau de configuration '{version}' ne sont pas prises en charge.", + "danger": "Danger :", + "file_extension_not_accepted": "Le fichier '{path}' est refusé car son extension ne fait pas partie des extensions acceptées : {accept}", + "invalid_number_min": "Doit être supérieur à {min}", + "invalid_number_max": "Doit être inférieur à {max}", + "log_app_config_set": "Appliquer la configuration à l'application '{}'", + "service_not_reloading_because_conf_broken": "Le service '{name}' n'a pas été rechargé/redémarré car sa configuration est cassée : {errors}", + "app_argument_password_help_keep": "Tapez sur Entrée pour conserver la valeur actuelle", + "app_argument_password_help_optional": "Tapez un espace pour vider le mot de passe" +} \ No newline at end of file diff --git a/locales/gl.json b/locales/gl.json new file mode 100644 index 000000000..ebb65be02 --- /dev/null +++ b/locales/gl.json @@ -0,0 +1,679 @@ +{ + "password_too_simple_1": "O contrasinal ten que ter 8 caracteres como mínimo", + "aborting": "Abortando.", + "app_already_up_to_date": "{app} xa está actualizada", + "app_already_installed_cant_change_url": "Esta app xa está instalada. O URL non pode cambiarse só con esta acción. Miran en `app changeurl` se está dispoñible.", + "app_already_installed": "{app} xa está instalada", + "app_action_broke_system": "Esta acción semella que estragou estos servizos importantes: {services}", + "app_action_cannot_be_ran_because_required_services_down": "Estos servizos requeridos deberían estar en execución para realizar esta acción: {services}. Intenta reinicialos para continuar (e tamén intenta saber por que están apagados).", + "already_up_to_date": "Nada que facer. Todo está ao día.", + "admin_password_too_long": "Elixe un contrasinal menor de 127 caracteres", + "admin_password_changed": "Realizado o cambio de contrasinal de administración", + "admin_password_change_failed": "Non se puido cambiar o contrasinal", + "admin_password": "Contrasinal de administración", + "additional_urls_already_removed": "URL adicional '{url}' xa foi eliminada das URL adicionais para o permiso '{permission}'", + "additional_urls_already_added": "URL adicional '{url}' xa fora engadida ás URL adicionais para o permiso '{permission}'", + "action_invalid": "Acción non válida '{action}'", + "app_argument_required": "Requírese o argumento '{name}'", + "app_argument_password_no_default": "Erro ao procesar o argumento do contrasinal '{name}': o argumento do contrasinal non pode ter un valor por defecto por razón de seguridade", + "app_argument_invalid": "Elixe un valor válido para o argumento '{name}': {error}", + "app_argument_choice_invalid": "Usa unha destas opcións '{choices}' para o argumento '{name}' no lugar de '{value}'", + "backup_archive_writing_error": "Non se puideron engadir os ficheiros '{source}' (chamados no arquivo '{dest}' para ser copiados dentro do arquivo comprimido '{archive}'", + "backup_archive_system_part_not_available": "A parte do sistema '{part}' non está dispoñible nesta copia", + "backup_archive_corrupted": "Semella que o arquivo de copia '{archive}' está estragado : {error}", + "backup_archive_cant_retrieve_info_json": "Non se puido cargar a info desde arquivo '{archive}'... O info.json non s puido obter (ou é un json non válido).", + "backup_archive_open_failed": "Non se puido abrir o arquivo de copia de apoio", + "backup_archive_name_unknown": "Arquivo local de copia de apoio descoñecido con nome '{name}'", + "backup_archive_name_exists": "Xa existe un arquivo de copia con este nome.", + "backup_archive_broken_link": "Non se puido acceder ao arquivo da copia (ligazón rota a {path})", + "backup_archive_app_not_found": "Non se atopa {app} no arquivo da copia", + "backup_applying_method_tar": "Creando o arquivo TAR da copia...", + "backup_applying_method_custom": "Chamando polo método de copia de apoio personalizado '{method}'...", + "backup_applying_method_copy": "Copiando tódolos ficheiros necesarios...", + "backup_app_failed": "Non se fixo copia de {app}", + "backup_actually_backuping": "Creando o arquivo de copia cos ficheiros recollidos...", + "backup_abstract_method": "Este método de copia de apoio aínda non foi implementado", + "ask_password": "Contrasinal", + "ask_new_path": "Nova ruta", + "ask_new_domain": "Novo dominio", + "ask_new_admin_password": "Novo contrasinal de administración", + "ask_main_domain": "Dominio principal", + "ask_lastname": "Apelido", + "ask_firstname": "Nome", + "ask_user_domain": "Dominio a utilizar como enderezo de email e conta XMPP da usuaria", + "apps_catalog_update_success": "O catálogo de aplicacións foi actualizado!", + "apps_catalog_obsolete_cache": "A caché do catálogo de apps está baleiro ou obsoleto.", + "apps_catalog_failed_to_download": "Non se puido descargar o catálogo de apps {apps_catalog}: {error}", + "apps_catalog_updating": "Actualizando o catálogo de aplicacións...", + "apps_catalog_init_success": "Sistema do catálogo de apps iniciado!", + "apps_already_up_to_date": "Xa tes tódalas apps ao día", + "app_packaging_format_not_supported": "Esta app non se pode instalar porque o formato de empaquetado non está soportado pola túa versión de YunoHost. Deberías considerar actualizar o teu sistema.", + "app_upgraded": "{app} actualizadas", + "app_upgrade_some_app_failed": "Algunhas apps non se puideron actualizar", + "app_upgrade_script_failed": "Houbo un fallo interno no script de actualización da app", + "app_upgrade_failed": "Non se actualizou {app}: {error}", + "app_upgrade_app_name": "Actualizando {app}...", + "app_upgrade_several_apps": "Vanse actualizar as seguintes apps: {apps}", + "app_unsupported_remote_type": "Tipo remoto non soportado para a app", + "app_unknown": "App descoñecida", + "app_start_restore": "Restaurando {app}...", + "app_start_backup": "Xuntando os ficheiros para a copia de apoio de {app}...", + "app_start_remove": "Eliminando {app}...", + "app_start_install": "Instalando {app}...", + "app_sources_fetch_failed": "Non se puideron obter os ficheiros fonte, é o URL correcto?", + "app_restore_script_failed": "Houbo un erro interno do script de restablecemento da app", + "app_restore_failed": "Non se puido restablecer {app}: {error}", + "app_remove_after_failed_install": "Eliminando a app debido ao fallo na instalación...", + "app_requirements_unmeet": "Non se cumpren os requerimentos de {app}, o paquete {pkgname} ({version}) debe ser {spec}", + "app_requirements_checking": "Comprobando os paquetes requeridos por {app}...", + "app_removed": "{app} desinstalada", + "app_not_properly_removed": "{app} non se eliminou de xeito correcto", + "app_not_installed": "Non se puido atopar {app} na lista de apps instaladas: {all_apps}", + "app_not_correctly_installed": "{app} semella que non está instalada correctamente", + "app_not_upgraded": "Fallou a actualización da app '{failed_app}', como consecuencia as actualizacións das seguintes apps foron canceladas: {apps}", + "app_manifest_install_ask_is_public": "Debería esta app estar exposta ante visitantes anónimas?", + "app_manifest_install_ask_admin": "Elixe unha usuaria administradora para esta app", + "app_manifest_install_ask_password": "Elixe un contrasinal de administración para esta app", + "app_manifest_install_ask_path": "Elixe a ruta URL (após o dominio) onde será instalada esta app", + "app_manifest_install_ask_domain": "Elixe o dominio onde queres instalar esta app", + "app_manifest_invalid": "Hai algún erro no manifesto da app: {error}", + "app_location_unavailable": "Este URL ou ben non está dispoñible ou entra en conflito cunha app(s) xa instalada:\n{apps}", + "app_label_deprecated": "Este comando está anticuado! Utiliza o novo comando 'yunohost user permission update' para xestionar a etiqueta da app.", + "app_make_default_location_already_used": "Non se puido establecer a '{app}' como app por defecto no dominio, '{domain}' xa está utilizado por '{other_app}'", + "app_install_script_failed": "Houbo un fallo interno do script de instalación da app", + "app_install_failed": "Non se pode instalar {app}: {error}", + "app_install_files_invalid": "Non se poden instalar estos ficheiros", + "app_id_invalid": "ID da app non válido", + "app_full_domain_unavailable": "Lamentámolo, esta app ten que ser instalada nun dominio propio, pero xa tes outras apps instaladas no dominio '{domain}'. Podes usar un subdominio dedicado para esta app.", + "app_extraction_failed": "Non se puideron extraer os ficheiros de instalación", + "app_change_url_success": "A URL de {app} agora é {domain}{path}", + "app_change_url_no_script": "A app '{app_name}' non soporta o cambio de URL. Pode que debas actualizala.", + "app_change_url_identical_domains": "O antigo e o novo dominio/url_path son idénticos ('{domain}{path}'), nada que facer.", + "backup_deleted": "Copia de apoio eliminada", + "backup_delete_error": "Non se eliminou '{path}'", + "backup_custom_mount_error": "O método personalizado de copia non superou o paso 'mount'", + "backup_custom_backup_error": "O método personalizado da copia non superou o paso 'backup'", + "backup_csv_creation_failed": "Non se creou o ficheiro CSV necesario para restablecer a copia", + "backup_csv_addition_failed": "Non se engadiron os ficheiros a copiar ao ficheiro CSV", + "backup_creation_failed": "Non se puido crear o arquivo de copia de apoio", + "backup_create_size_estimation": "O arquivo vai conter arredor de {size} de datos.", + "backup_created": "Copia de apoio creada", + "backup_couldnt_bind": "Non se puido ligar {src} a {dest}.", + "backup_copying_to_organize_the_archive": "Copiando {size}MB para organizar o arquivo", + "backup_cleaning_failed": "Non se puido baleirar o cartafol temporal para a copia", + "backup_cant_mount_uncompress_archive": "Non se puido montar o arquivo sen comprimir porque está protexido contra escritura", + "backup_ask_for_copying_if_needed": "Queres realizar a copia de apoio utilizando temporalmente {size}MB? (Faise deste xeito porque algúns ficheiros non hai xeito de preparalos usando unha forma máis eficiente).", + "backup_running_hooks": "Executando os ganchos da copia...", + "backup_permission": "Permiso de copia para {app}", + "backup_output_symlink_dir_broken": "O directorio de arquivo '{path}' é unha ligazón simbólica rota. Pode ser que esqueceses re/montar ou conectar o medio de almacenaxe ao que apunta.", + "backup_output_directory_required": "Debes proporcionar un directorio de saída para a copia", + "backup_output_directory_not_empty": "Debes elexir un directorio de saída baleiro", + "backup_output_directory_forbidden": "Elixe un directorio de saída diferente. As copias non poden crearse en /bin, /boot, /dev, /etc, /lib, /root, /sbin, /sys, /usr, /var ou subcartafoles de /home/yunohost.backup/archives", + "backup_nothings_done": "Nada que gardar", + "backup_no_uncompress_archive_dir": "Non hai tal directorio do arquivo descomprimido", + "backup_mount_archive_for_restore": "Preparando o arquivo para restauración...", + "backup_method_tar_finished": "Creouse o arquivo de copia TAR", + "backup_method_custom_finished": "O método de copia personalizado '{method}' rematou", + "backup_method_copy_finished": "Rematou o copiado dos ficheiros", + "backup_hook_unknown": "O gancho da copia '{hook}' é descoñecido", + "certmanager_domain_cert_not_selfsigned": "O certificado para o dominio {domain} non está auto-asinado. Tes a certeza de querer substituílo? (Usa '--force' para facelo.)", + "certmanager_domain_not_diagnosed_yet": "Por agora non hai resultado de diagnóstico para o dominio {domain}. Volve facer o diagnóstico para a categoría 'Rexistros DNS' e 'Web' na sección de diagnóstico para comprobar se o dominio é compatible con Let's Encrypt. (Ou se sabes o que estás a facer, usa '--no-checks' para desactivar esas comprobacións.)", + "certmanager_certificate_fetching_or_enabling_failed": "Fallou o intento de usar o novo certificado para '{domain}'...", + "certmanager_cert_signing_failed": "Non se puido asinar o novo certificado", + "certmanager_cert_renew_success": "Certificado Let's Encrypt renovado para o dominio '{domain}'", + "certmanager_cert_install_success_selfsigned": "O certificado auto-asinado está instalado para o dominio '{domain}'", + "certmanager_cert_install_success": "O certificado Let's Encrypt está instalado para o dominio '{domain}'", + "certmanager_cannot_read_cert": "Algo fallou ao intentar abrir o certificado actual para o dominio {domain} (ficheiro: {file}), razón: {reason}", + "certmanager_attempt_to_replace_valid_cert": "Estás intentando sobrescribir un certificado correcto e en bo estado para o dominio {domain}! (Usa --force para obviar)", + "certmanager_attempt_to_renew_valid_cert": "O certificado para o dominio '{domain}' non caduca pronto! (Podes usar --force se sabes o que estás a facer)", + "certmanager_attempt_to_renew_nonLE_cert": "O certificado para o dominio '{domain}' non está proporcionado por Let's Encrypt. Non se pode renovar automáticamente!", + "certmanager_acme_not_configured_for_domain": "Non se realizou o desafío ACME para {domain} porque a súa configuración nginx non ten a parte do código correspondente... Comproba que a túa configuración nginx está ao día utilizando `yunohost tools regen-conf nginx --dry-run --with-diff`.", + "backup_with_no_restore_script_for_app": "'{app}' non ten script de restablecemento, non poderás restablecer automáticamente a copia de apoio desta app.", + "backup_with_no_backup_script_for_app": "A app '{app}' non ten script para a copia. Ignorada.", + "backup_unable_to_organize_files": "Non se puido usar o método rápido para organizar ficheiros no arquivo", + "backup_system_part_failed": "Non se puido facer copia da parte do sistema '{part}'", + "certmanager_domain_http_not_working": "O dominio {domain} semella non ser accesible a través de HTTP. Comproba a categoría 'Web' no diagnóstico para máis info. (Se sabes o que estás a facer, utiliza '--no-checks' para obviar estas comprobacións.)", + "certmanager_domain_dns_ip_differs_from_public_ip": "Os rexistros DNS para o dominio '{domain}' son diferentes aos da IP deste servidor. Comproba a categoría 'Rexistros DNS' (básico) no diagnóstico para ter máis info. Se cambiaches recentemente o rexistro A, agarda a que se propague o cambio (están dispoñibles ferramentas en liña para comprobar estos cambios). (Se sabes o que estás a facer, utiliza '--no-checks' para obviar estas comprobacións.)", + "confirm_app_install_danger": "PERIGO! Esta app aínda é experimental (pode que nin funcione)! Probablemente NON deberías instalala a non ser que sepas o que estás a facer. NON TERÁS SOPORTE nin axuda se esta app estraga o teu sistema... Se queres asumir o risco, escribe '{answers}'", + "confirm_app_install_warning": "Aviso: Esta app podería funcionar, pero non está ben integrada en YunoHost. Algunhas funcións como a identificación centralizada e as copias de apoio poderían non estar dispoñibles. Desexas instalala igualmente? [{answers}] ", + "certmanager_unable_to_parse_self_CA_name": "Non se puido obter o nome da autoridade do auto-asinado (ficheiro: {file})", + "certmanager_self_ca_conf_file_not_found": "Non se atopa o ficheiro de configuración para a autoridade de auto-asinado (ficheiro: {file})", + "certmanager_no_cert_file": "Non se puido ler o ficheiro do certificado para o dominio {domain} (ficheiro: {file})", + "certmanager_hit_rate_limit": "Recentemente crearonse demasiados certificados para este mesmo grupo de dominios {domain}. Inténtao máis tarde. Podes ler https://letsencrypt.org/docs/rate-limits/ para máis info", + "certmanager_warning_subdomain_dns_record": "O subdominio '{subdomain}' non resolve a mesmo enderezo IP que '{domain}'. Algunhas funcións non estarán dispoñibles ata que arranxes isto e rexeneres o certificado.", + "diagnosis_found_errors_and_warnings": "Atopado(s) {errors} problema(s) significativo(s) (e {warnings} avisos(s)) en relación a {category}!", + "diagnosis_found_errors": "Atopado(s) {errors} problema significativo(s) relacionado con {category}!", + "diagnosis_ignored_issues": "(+ {nb_ignored} problema ignorado(s))", + "diagnosis_cant_run_because_of_dep": "Non é posible facer o diganóstico para {category} cando aínda hai importantes problemas con {dep}.", + "diagnosis_cache_still_valid": "(A caché aínda é válida para o diagnóstico {category}. Non o repetiremos polo de agora!)", + "diagnosis_failed_for_category": "O diagnóstico fallou para a categoría '{category}': {error}", + "diagnosis_display_tip": "Para ver os problemas atopados, podes ir á sección de Diagnóstico na administración web, ou executa 'yunohost diagnosis show --issues --human-readable' desde a liña de comandos.", + "diagnosis_package_installed_from_sury_details": "Algúns paquetes foron instalados se darse conta desde un repositorio de terceiros chamado Sury. O equipo de YunoHost mellorou a estratexia para xestionar estos paquetes, pero é de agardar que algunhas instalacións que instalaron aplicacións PHP7.3 estando aínda en Stretch teñan inconsistencias co sistema. Para arranxar esta situación, deberías intentar executar o comando: {cmd_to_fix}", + "diagnosis_package_installed_from_sury": "Algúns paquetes do sistema deberían ser baixados de versión", + "diagnosis_backports_in_sources_list": "Semella que apt (o xestor de paquetes) está configurado para usar o repositorio backports. A non ser que saibas o que fas NON che recomendamos instalar paquetes desde backports, porque é probable que produzas inestabilidades e conflitos no teu sistema.", + "diagnosis_basesystem_ynh_inconsistent_versions": "Estás executando versións inconsistentes de paquetes YunoHost... probablemente debido a actualizacións parciais ou fallidas.", + "diagnosis_basesystem_ynh_main_version": "O servidor está a executar Yunohost {main_version} ({repo})", + "diagnosis_basesystem_ynh_single_version": "{package} versión: {version} ({repo})", + "diagnosis_basesystem_kernel": "O servidor está a executar o kernel Linux {kernel_version}", + "diagnosis_basesystem_host": "O servidor está a executar Debian {debian_version}", + "diagnosis_basesystem_hardware_model": "O modelo de servidor é {model}", + "diagnosis_basesystem_hardware": "A arquitectura do hardware do servidor é {virt} {arch}", + "custom_app_url_required": "Tes que proporcionar o URL para actualizar a app personalizada {app}", + "confirm_app_install_thirdparty": "PERIGO! Esta app non forma parte do catálogo de YunoHost. Ao instalar apps de terceiros poderías comprometer a integridade e seguridade do sistema. Probablemente NON deberías instalala a menos que saibas o que fas. NON SE PROPORCIONARÁ SOPORTE se esta app non funciona ou estraga o sistema... Se aínda así asumes o risco, escribe '{answers}'", + "diagnosis_dns_point_to_doc": "Revisa a documentación en https://yunohost.org/dns_config se precisas axuda para configurar os rexistros DNS.", + "diagnosis_dns_discrepancy": "O seguinte rexistro DNS non segue a configuración recomendada:
Tipo: {type}
Nome: {name}
Valor actual: {current}
Valor agardado: {value}", + "diagnosis_dns_missing_record": "Facendo caso á configuración DNS recomendada, deberías engadir un rexistro DNS coa seguinte info.
Tipo: {type}
Nome: {name}
Valor: {value}", + "diagnosis_dns_bad_conf": "Faltan algúns rexistros DNS ou están mal configurados para o dominio {domain} (categoría {category})", + "diagnosis_dns_good_conf": "Os rexistros DNS están correctamente configurados para o dominio {domain} (categoría {category})", + "diagnosis_ip_weird_resolvconf_details": "O ficheiro /etc/resolv.conf debería ser unha ligazón simbólica a /etc/resolvconf/run/resolv.conf apuntando el mesmo a 127.0.0.1 (dnsmasq). Se queres configurar manualmente a resolución DNS, por favor edita /etc/resolv.dnsmasq.conf.", + "diagnosis_ip_weird_resolvconf": "A resolución DNS semella funcionar, mais parecese que estás a utilizar un /etc/resolv.conf personalizado.", + "diagnosis_ip_broken_resolvconf": "A resolución de nomes de dominio semella non funcionar no teu servidor, que parece ter relación con que /etc/resolv.conf non sinala a 127.0.0.1.", + "diagnosis_ip_broken_dnsresolution": "A resolución de nomes de dominio semella que por algunha razón non funciona... Pode estar o cortalumes bloqueando as peticións DNS?", + "diagnosis_ip_dnsresolution_working": "A resolución de nomes de dominio está a funcionar!", + "diagnosis_ip_not_connected_at_all": "O servidor semella non ter ningún tipo de conexión a internet!?", + "diagnosis_ip_local": "IP local: {local}", + "diagnosis_ip_global": "IP global: {global}", + "diagnosis_ip_no_ipv6_tip": "Que o servidor teña conexión IPv6 non é obrigatorio para que funcione, pero é mellor para o funcionamento de Internet en conxunto. IPv6 debería estar configurado automáticamente no teu sistema ou provedor se está dispoñible. Doutro xeito, poderías ter que configurar manualmente algúns parámetros tal como se explica na documentación: https://yunohost.org/#/ipv6. Se non podes activar IPv6 ou é moi complicado para ti, podes ignorar tranquilamente esta mensaxe.", + "diagnosis_ip_no_ipv6": "O servidor non ten conexión IPv6.", + "diagnosis_ip_connected_ipv6": "O servidor está conectado a internet a través de IPv6!", + "diagnosis_ip_no_ipv4": "O servidor non ten conexión IPv4.", + "diagnosis_ip_connected_ipv4": "O servidor está conectado a internet a través de IPv4!", + "diagnosis_no_cache": "Aínda non hai datos na caché para '{category}'", + "diagnosis_failed": "Non se puido obter o resultado do diagnóstico para '{category}': {error}", + "diagnosis_everything_ok": "Semella todo correcto en {category}!", + "diagnosis_found_warnings": "Atoparonse {warnings} elemento(s) que poderían optimizarse en {category}.", + "diagnosis_services_bad_status": "O servizo {service} está {status} :(", + "diagnosis_services_conf_broken": "A configuración do {service} está estragada!", + "diagnosis_services_running": "O servizo {service} está en execución!", + "diagnosis_domain_expires_in": "{domain} caduca en {days} días.", + "diagnosis_domain_expiration_error": "Algúns dominios van caducan MOI PRONTO!", + "diagnosis_domain_expiration_warning": "Algúns dominios van caducar pronto!", + "diagnosis_domain_expiration_success": "Os teus dominios están rexistrados e non van caducar pronto.", + "diagnosis_domain_expiration_not_found_details": "A información WHOIS para o dominio {domain} non semella conter información acerca da data de caducidade?", + "diagnosis_domain_not_found_details": "O dominio {domain} non existe na base de datos de WHOIS ou está caducado!", + "diagnosis_domain_expiration_not_found": "Non se puido comprobar a data de caducidade para algúns dominios", + "diagnosis_dns_try_dyndns_update_force": "A xestión DNS deste dominio debería estar xestionada directamente por YunoHost. Se non fose o caso, podes intentar forzar unha actualización executando yunohost dyndns update --force.", + "diagnosis_swap_ok": "O sistema ten {total} de swap!", + "diagnosis_swap_notsomuch": "O sistema só ten {total} de swap. Deberías considerar ter polo menos {recommended} para evitar situacións onde o sistema esgote a memoria.", + "diagnosis_swap_none": "O sistema non ten partición swap. Deberías considerar engadir polo menos {recommended} de swap para evitar situación onde o sistema esgote a memoria.", + "diagnosis_ram_ok": "Ao sistema aínda lle queda {available} ({available_percent}%) de RAM dispoñible dun total de {total}.", + "diagnosis_ram_low": "O sistema ten {available} ({available_percent}%) da RAM dispoñible (total {total}). Ten coidado.", + "diagnosis_ram_verylow": "Ao sistema só lle queda {available} ({available_percent}%) de RAM dispoñible! (total {total})", + "diagnosis_diskusage_ok": "A almacenaxe {mountpoint} (no dispositivo {device}) aínda ten {free} ({free_percent}%) de espazo restante (de {total})!", + "diagnosis_diskusage_low": "A almacenaxe {mountpoint} (no dispositivo {device}) só lle queda {free} ({free_percent}%) de espazo libre (de {total}). Ten coidado.", + "diagnosis_diskusage_verylow": "A almacenaxe {mountpoint} (no dispositivo {device}) só lle queda {free} ({free_percent}%) de espazo libre (de {total}). Deberías considerar liberar algún espazo!", + "diagnosis_services_bad_status_tip": "Podes intentar reiniciar o servizo, e se isto non funciona, mira os rexistros do servizo na webadmin (desde a liña de comandos con yunohost service restart {service} e yunohost service log {service}).", + "diagnosis_mail_outgoing_port_25_ok": "O servidor de email SMTP pode enviar emails (porto 25 de saída non está bloqueado).", + "diagnosis_swap_tip": "Por favor ten en conta que se o servidor ten a swap instalada nunha tarxeta SD ou almacenaxe SSD podería reducir drásticamente a expectativa de vida do dispositivo.", + "diagnosis_mail_outgoing_port_25_blocked": "O servidor SMTP de email non pode enviar emails a outros servidores porque o porto saínte 25 está bloqueado en IPv{ipversion}.", + "diagnosis_mail_ehlo_unreachable": "O servidor de email SMTP non é accesible desde o exterior en IPv{ipversion}. Non poderá recibir emails.", + "diagnosis_mail_ehlo_ok": "O servidor de email SMTP é accesible desde o exterior e por tanto pode recibir emails!", + "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "Algúns provedores non che van permitir desbloquear o porto 25 saínte porque non se preocupan pola Neutralidade da Rede.
- Algúns deles dan unha alternativa usando un repetidor de servidor de email mais isto implica que o repetidor poderá espiar todo o teu tráfico de email.
- Unha alternativa é utilizar unha VPN *cun IP público dedicado* para evitar este tipo de limitación. Le https://yunohost.org/#/vpn_advantage
- Tamén podes considerar cambiar a un provedor máis amigable coa neutralidade da rede", + "diagnosis_mail_outgoing_port_25_blocked_details": "Antes deberías intentar desbloquear o porto 25 saínte no teu rúter de internet ou na web do provedor de hospedaxe. (Algúns provedores poderían pedirche que fagas unha solicitude para isto).", + "diagnosis_mail_ehlo_wrong": "Un servidor de email SMPT diferente responde en IPv{ipversion}. O teu servidor probablemente non poida recibir emails.", + "diagnosis_mail_ehlo_bad_answer_details": "Podería deberse a que outro servidor está a responder no lugar do teu.", + "diagnosis_mail_ehlo_bad_answer": "Un servizo non-SMTP respondeu no porto 25 en IPv{ipversion}", + "diagnosis_mail_ehlo_unreachable_details": "Non se puido abrir unha conexión no porto 25 do teu servidor en IPv{ipversion}. Non semella accesible.
1. A causa máis habitual é que o porto 25 non está correctamente redirixido no servidor.
2. Asegúrate tamén de que o servizo postfix está a funcionar.
3. En configuracións máis complexas: asegúrate de que o cortalumes ou reverse-proxy non están interferindo.", + "diagnosis_mail_fcrdns_nok_details": "Deberías intentar configurar o DNS inverso con {ehlo_domain} na interface do teu rúter de internet ou na interface do teu provedor de hospedaxe. (Algúns provedores de hospedaxe poderían pedirche que lle fagas unha solicitude por escrito para isto).", + "diagnosis_mail_fcrdns_dns_missing": "Non hai DNS inverso definido en IPv{ipversion}. Algúns emails poderían non ser entregrado ou ser marcados como spam.", + "diagnosis_mail_fcrdns_ok": "O DNS inverso está correctamente configurado!", + "diagnosis_mail_ehlo_could_not_diagnose_details": "Erro: {error}", + "diagnosis_mail_ehlo_could_not_diagnose": "Non se puido determinar se o servidor de email postfix é accesible desde o exterior en IPv{ipversion}.", + "diagnosis_mail_ehlo_wrong_details": "O EHLO recibido polo diagnosticador remoto en IPv{ipversion} é diferente ao dominio do teu servidor.
EHLO recibido: {wrong_ehlo}
Agardado: {right_ehlo}
A razón máis habitual para este problema é que o porto 25 non está correctamente redirixido ao teu servidor. Alternativamente, asegúrate de non ter un cortalumes ou reverse-proxy interferindo.", + "diagnosis_regenconf_manually_modified_details": "Probablemente todo sexa correcto se sabes o que estás a facer! YunoHost non vai actualizar este ficheiro automáticamente... Pero ten en conta que as actualizacións de YunoHost poderían incluír cambios importantes recomendados. Se queres podes ver as diferenzas con yunohost tools regen-conf {category} --dry-run --with-diff e forzar o restablecemento da configuración recomendada con yunohost tools regen-conf {category} --force", + "diagnosis_regenconf_manually_modified": "O ficheiro de configuración {file} semella que foi modificado manualmente.", + "diagnosis_regenconf_allgood": "Tódolos ficheiros de configuración seguen a configuración recomendada!", + "diagnosis_mail_queue_too_big": "Hai demasiados emails pendentes na cola de correo ({nb_pending} emails)", + "diagnosis_mail_queue_unavailable_details": "Erro: {error}", + "diagnosis_mail_queue_unavailable": "Non se pode consultar o número de emails pendentes na cola", + "diagnosis_mail_queue_ok": "{nb_pending} emails pendentes na cola de correo", + "diagnosis_mail_blacklist_website": "Tras ver a razón do bloqueo e arranxalo, considera solicitar que o teu dominio ou IP sexan eliminados de {blacklist_website}", + "diagnosis_mail_blacklist_reason": "A razón do bloqueo é: {reason}", + "diagnosis_mail_blacklist_listed_by": "O teu dominio ou IP {item} está na lista de bloqueo {blacklist_name}", + "diagnosis_mail_blacklist_ok": "Os IPs e dominios utilizados neste servidor non parecen estar en listas de bloqueo", + "diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "DNS inverso actual: {rdns_domain}
Valor agardado: {ehlo_domain}", + "diagnosis_mail_fcrdns_different_from_ehlo_domain": "O DNS inverso non está correctamente configurado para IPv{ipversion}. É posible que non se entreguen algúns emails ou sexan marcados como spam.", + "diagnosis_mail_fcrdns_nok_alternatives_6": "Algúns provedores non che permiten configurar DNS inverso (ou podería non funcionar...). Se o teu DNS inverso está correctamente configurado para IPv4, podes intentar desactivar o uso de IPv6 ao enviar os emails executando yunohost settings set smtp.allow_ipv6 -v off. Nota: esta última solución significa que non poderás enviar ou recibir emails desde os poucos servidores que só usan IPv6 que teñen esta limitación.", + "diagnosis_mail_fcrdns_nok_alternatives_4": "Algúns provedores non che permiten configurar o teu DNS inverso (ou podería non ser funcional...). Se tes problemas debido a isto, considera as seguintes solucións:
- Algúns ISP proporcionan alternativas como usar un repetidor de servidor de correo pero implica que o repetidor pode ver todo o teu tráfico de email.
-Unha alternativa respetuosa coa privacidade é utilizar un VPN *cun IP público dedicado* para evitar estas limitacións. Le https://yunohost.org/#/vpn_advantage
- Ou tamén podes cambiar a un provedor diferente", + "diagnosis_http_ok": "O dominio {domain} é accesible a través de HTTP desde o exterior da rede local.", + "diagnosis_http_could_not_diagnose_details": "Erro: {error}", + "diagnosis_http_could_not_diagnose": "Non se puido comprobar se os dominios son accesibles desde o exterior en IPv{ipversion}.", + "diagnosis_http_hairpinning_issue_details": "Isto acontece probablemente debido ao rúter do teu ISP. Como resultado, as persoas externas á túa rede local poderán acceder ao teu servidor tal como se espera, pero non as usuarias na rede local (como ti, probablemente?) cando usan o nome de dominio ou IP global. Podes mellorar a situación lendo https://yunohost.org/dns_local_network", + "diagnosis_http_hairpinning_issue": "A túa rede local semella que non ten hairpinning activado.", + "diagnosis_ports_forwarding_tip": "Para arranxar isto, probablemente tes que configurar o reenvío do porto no teu rúter de internet tal como se di en https://yunohost.org/isp_box_config", + "diagnosis_ports_needed_by": "A apertura deste porto é precisa para {category} (servizo {service})", + "diagnosis_ports_ok": "O porto {port} é accesible desde o exterior.", + "diagnosis_ports_partially_unreachable": "O porto {port} non é accesible desde o exterior en IPv{failed}.", + "diagnosis_ports_unreachable": "O porto {port} non é accesible desde o exterior.", + "diagnosis_ports_could_not_diagnose_details": "Erro: {error}", + "diagnosis_ports_could_not_diagnose": "Non se puido comprobar se os portos son accesibles desde o exterior en IPv{ipversion}.", + "diagnosis_description_regenconf": "Configuracións do sistema", + "diagnosis_description_mail": "Email", + "diagnosis_description_web": "Web", + "diagnosis_description_ports": "Exposición de portos", + "diagnosis_description_systemresources": "Recursos do sistema", + "diagnosis_description_services": "Comprobación do estado dos servizos", + "diagnosis_description_dnsrecords": "Rexistros DNS", + "diagnosis_description_ip": "Conectividade a internet", + "diagnosis_description_basesystem": "Sistema base", + "diagnosis_security_vulnerable_to_meltdown_details": "Para arranxar isto, deberías actualizar o sistema e reiniciar para cargar o novo kernel linux (ou contactar co provedor do servizo se isto non o soluciona). Le https://meltdownattack.com/ para máis info.", + "diagnosis_security_vulnerable_to_meltdown": "Semella que es vulnerable á vulnerabilidade crítica de seguridade Meltdown", + "diagnosis_rootfstotalspace_critical": "O sistema de ficheiros root só ten un total de {space} e podería ser preocupante! Probablemente esgotes o espazo no disco moi pronto! Recomendamos ter un sistema de ficheiros root de polo menos 16 GB.", + "diagnosis_rootfstotalspace_warning": "O sistema de ficheiros root só ten un total de {space}. Podería ser suficiente, mais pon tino porque poderías esgotar o espazo no disco rápidamente... Recoméndase ter polo meno 16 GB para o sistema de ficheiros root.", + "domain_cannot_remove_main": "Non podes eliminar '{domain}' porque é o dominio principal, primeiro tes que establecer outro dominio como principal usando 'yunohost domain main-domain -n '; aquí tes a lista dos dominios posibles: {other_domains}", + "diagnosis_sshd_config_inconsistent_details": "Executa yunohost settings set security.ssh.port -v O_TEU_PORTO_SSH para definir o porto SSH, comproba con yunohost tools regen-conf ssh --dry-run --with-diff e restablece a configuración con yunohost tools regen-conf ssh --force a configuración recomendada de YunoHost.", + "diagnosis_sshd_config_inconsistent": "Semella que o porto SSH foi modificado manualmente en /etc/ssh/sshd_config. Desde YunoHost 4.2, un novo axuste global 'security.ssh.port' está dispoñible para evitar a edición manual da configuración.", + "diagnosis_sshd_config_insecure": "Semella que a configuración SSH modificouse manualmente, e é insegura porque non contén unha directiva 'AllowGroups' ou 'AllowUsers' para limitar o acceso ás usuarias autorizadas.", + "diagnosis_processes_killed_by_oom_reaper": "Algúns procesos foron apagados recentemente polo sistema porque quedou sen memoria dispoñible. Isto acontece normalmente porque o sistema quedou sen memoria ou un proceso consumía demasiada. Resumo cos procesos apagados:\n{kills_summary}", + "diagnosis_never_ran_yet": "Semella que o servidor foi configurado recentemente e aínda non hai informes diagnósticos. Deberías iniciar un diagnóstico completo, ben desde a administración web ou usando 'yunohost diagnosis run' desde a liña de comandos.", + "diagnosis_unknown_categories": "As seguintes categorías son descoñecidas: {categories}", + "diagnosis_http_nginx_conf_not_up_to_date_details": "Para arranxar a situación, revisa as diferenzas na liña de comandos usando yunohost tools regen-conf nginx --dry-run --with-diff e se todo está ben, aplica os cambios con yunohost tools regen-conf nginx --force.", + "diagnosis_http_nginx_conf_not_up_to_date": "A configuración nginx deste dominio semella foi modificada manualmente, e está evitando que YunoHost comprobe se é accesible a través de HTTP.", + "diagnosis_http_partially_unreachable": "O dominio {domain} non semella accesible a través de HTTP desde o exterior da rede local en IPv{failed}, pero funciona en IPv{passed}.", + "diagnosis_http_unreachable": "O dominio {domain} non semella accesible a través de HTTP desde o exterior da rede local.", + "diagnosis_http_bad_status_code": "Semella que outra máquina (podería ser o rúter de internet) respondeu no lugar do teu servidor.
1. A razón máis habitual para este problema é que o porto 80 (e 443) non están correctamente redirixidos ao teu servidor.
2. En configuracións avanzadas: revisa que nin o cortalumes nin o proxy-inverso están interferindo.", + "diagnosis_http_connection_error": "Erro de conexión: non se puido conectar co dominio solicitado, moi probablemente non sexa accesible.", + "diagnosis_http_timeout": "Caducou a conexión mentras se intentaba contactar o servidor desde o exterior. Non semella accesible.
1. A razón máis habitual é que o porto 80 (e 443) non están correctamente redirixidos ao teu servidor.
2. Deberías comprobar tamén que o servizo nginx está a funcionar
3. En configuracións máis avanzadas: revisa que nin o cortalumes nin o proxy-inverso están interferindo.", + "field_invalid": "Campo non válido '{}'", + "experimental_feature": "Aviso: esta característica é experimental e non se considera estable, non deberías utilizala a menos que saibas o que estás a facer.", + "extracting": "Extraendo...", + "dyndns_unavailable": "O dominio '{domain}' non está dispoñible.", + "dyndns_domain_not_provided": "O provedor DynDNS {provider} non pode proporcionar o dominio {domain}.", + "dyndns_registration_failed": "Non se rexistrou o dominio DynDNS: {error}", + "dyndns_registered": "Dominio DynDNS rexistrado", + "dyndns_provider_unreachable": "Non se puido acadar o provedor DynDNS {provider}: pode que o teu YunoHost non teña conexión a internet ou que o servidor dynette non funcione.", + "dyndns_no_domain_registered": "Non hai dominio rexistrado con DynDNS", + "dyndns_key_not_found": "Non se atopou a chave DNS para o dominio", + "dyndns_key_generating": "Creando chave DNS... podería demorarse.", + "dyndns_ip_updated": "Actualizouse o IP en DynDNS", + "dyndns_ip_update_failed": "Non se actualizou o enderezo IP en DynDNS", + "dyndns_could_not_check_available": "Non se comprobou se {domain} está dispoñible en {provider}.", + "dyndns_could_not_check_provide": "Non se comprobou se {provider} pode proporcionar {domain}.", + "dpkg_lock_not_available": "Non se pode executar agora mesmo este comando porque semella que outro programa está a utilizar dpkg (o xestos de paquetes do sistema)", + "dpkg_is_broken": "Non podes facer isto agora mesmo porque dpkg/APT (o xestor de paquetes do sistema) semella que non está a funcionar... Podes intentar solucionalo conectándote a través de SSH e executando `sudo apt install --fix-broken`e/ou `sudo dpkg --configure -a`.", + "downloading": "Descargando...", + "done": "Feito", + "domains_available": "Dominios dispoñibles:", + "domain_name_unknown": "Dominio '{domain}' descoñecido", + "domain_uninstall_app_first": "Aínda están instaladas estas aplicacións no teu dominio:\n{apps}\n\nPrimeiro desinstalaas utilizando 'yunohost app remove id_da_app' ou móveas a outro dominio con 'yunohost app change-url id_da_app' antes de eliminar o dominio", + "domain_remove_confirm_apps_removal": "Ao eliminar o dominio tamén vas eliminar estas aplicacións:\n{apps}\n\nTes a certeza de querer facelo? [{answers}]", + "domain_hostname_failed": "Non se puido establecer o novo nome de servidor. Esto pode causar problemas máis tarde (tamén podería ser correcto).", + "domain_exists": "Xa existe o dominio", + "domain_dyndns_root_unknown": "Dominio raiz DynDNS descoñecido", + "domain_dyndns_already_subscribed": "Xa tes unha subscrición a un dominio DynDNS", + "domain_dns_conf_is_just_a_recommendation": "Este comando móstrache a configuración *recomendada*. Non realiza a configuración DNS no teu nome. É responsabilidade túa configurar as zonas DNS no servizo da empresa que xestiona o rexistro do dominio seguindo esta recomendación.", + "domain_deletion_failed": "Non se puido eliminar o dominio {domain}: {error}", + "domain_deleted": "Dominio eliminado", + "domain_creation_failed": "Non se puido crear o dominio {domain}: {error}", + "domain_created": "Dominio creado", + "domain_cert_gen_failed": "Non se puido crear o certificado", + "domain_cannot_remove_main_add_new_one": "Non podes eliminar '{domain}' porque é o dominio principal e único dominio, primeiro tes que engadir outro dominio usando 'yunohost domain add ', e despois establecelo como o dominio principal utilizando 'yunohost domain main-domain -n ' e entón poderás eliminar '{domain}' con 'yunohost domain remove {domain}'.'", + "domain_cannot_add_xmpp_upload": "Non podes engadir dominios que comecen con 'xmpp-upload.'. Este tipo de nome está reservado para a función se subida de XMPP integrada en YunoHost.", + "file_does_not_exist": "O ficheiro {path} non existe.", + "firewall_reload_failed": "Non se puido recargar o cortalumes", + "global_settings_setting_smtp_allow_ipv6": "Permitir o uso de IPv6 para recibir e enviar emais", + "global_settings_setting_ssowat_panel_overlay_enabled": "Activar as capas no panel SSOwat", + "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Permitir o uso de DSA hostkey (en desuso) para a configuración do demoño SSH", + "global_settings_unknown_setting_from_settings_file": "Chave descoñecida nos axustes: '{setting_key}', descártaa e gárdaa en /etc/yunohost/settings-unknown.json", + "global_settings_setting_security_ssh_port": "Porto SSH", + "global_settings_setting_security_postfix_compatibility": "Compromiso entre compatibilidade e seguridade para o servidor Postfix. Aféctalle ao cifrado (e outros aspectos da seguridade)", + "global_settings_setting_security_ssh_compatibility": "Compromiso entre compatibilidade e seguridade para o servidor SSH. Aféctalle ao cifrado (e outros aspectos da seguridade)", + "global_settings_setting_security_password_user_strength": "Fortaleza do contrasinal da usuaria", + "global_settings_setting_security_password_admin_strength": "Fortaleza do contrasinal de Admin", + "global_settings_setting_security_nginx_compatibility": "Compromiso entre compatiblidade e seguridade para o servidor NGINX. Afecta ao cifrado (e outros aspectos relacionados coa seguridade)", + "global_settings_setting_pop3_enabled": "Activar protocolo POP3 no servidor de email", + "global_settings_reset_success": "Fíxose copia de apoio dos axustes en {path}", + "global_settings_key_doesnt_exists": "O axuste '{settings_key}' non existe nos axustes globais, podes ver os valores dispoñibles executando 'yunohost settings list'", + "global_settings_cant_write_settings": "Non se gardou o ficheiro de configuración, razón: {reason}", + "global_settings_cant_serialize_settings": "Non se serializaron os datos da configuración, razón: {reason}", + "global_settings_cant_open_settings": "Non se puido abrir o ficheiro de axustes, razón: {reason}", + "global_settings_bad_type_for_setting": "Tipo incorrecto do axuste {setting}, recibido {received_type}, agardábase {expected_type}", + "global_settings_bad_choice_for_enum": "Elección incorrecta para o axuste {setting}, recibido '{choice}', mais as opcións dispoñibles son: {available_choices}", + "firewall_rules_cmd_failed": "Fallou algún comando das regras do cortalumes. Máis info no rexistro.", + "firewall_reloaded": "Recargouse o cortalumes", + "group_creation_failed": "Non se puido crear o grupo '{group}': {error}", + "group_created": "Creouse o grupo '{group}'", + "group_already_exist_on_system_but_removing_it": "O grupo {group} xa é un dos grupos do sistema, pero YunoHost vaino eliminar...", + "group_already_exist_on_system": "O grupo {group} xa é un dos grupos do sistema", + "group_already_exist": "Xa existe o grupo {group}", + "good_practices_about_user_password": "Vas definir o novo contrasinal de usuaria. O contrasinal debe ter 8 caracteres como mínimo—aínda que se recomenda utilizar un máis longo (ex. unha frase de paso) e/ou utilizar caracteres variados (maiúsculas, minúsculas, números e caracteres especiais).", + "good_practices_about_admin_password": "Vas definir o novo contrasinal de administración. O contrasinal debe ter 8 caracteres como mínimo—aínda que se recomenda utilizar un máis longo (ex. unha frase de paso) e/ou utilizar caracteres variados (maiúsculas, minúsculas, números e caracteres especiais).", + "global_settings_unknown_type": "Situación non agardada, o axuste {setting} semella ter o tipo {unknown_type} pero non é un valor soportado polo sistema.", + "global_settings_setting_backup_compress_tar_archives": "Ao crear novas copias de apoio, comprime os arquivos (.tar.gz) en lugar de non facelo (.tar). Nota: activando esta opción creas arquivos máis lixeiros, mais o procedemento da primeira copia será significativamente máis longo e esixente coa CPU.", + "global_settings_setting_smtp_relay_password": "Contrasinal no repetidor SMTP", + "global_settings_setting_smtp_relay_user": "Conta de usuaria no repetidor SMTP", + "global_settings_setting_smtp_relay_port": "Porto do repetidor SMTP", + "global_settings_setting_smtp_relay_host": "Servidor repetidor SMTP para enviar emails no lugar da túa instancia yunohost. É útil se estás nunha destas situacións: o teu porto 25 está bloqueado polo teu provedor ISP u VPN, se tes unha IP residencial nunha lista DUHL, se non podes configurar DNS inversa ou se este servidor non ten conexión directa a internet e queres utilizar outro para enviar os emails.", + "group_updated": "Grupo '{group}' actualizado", + "group_unknown": "Grupo descoñecido '{group}'", + "group_deletion_failed": "Non se eliminou o grupo '{group}': {error}", + "group_deleted": "Grupo '{group}' eliminado", + "group_cannot_be_deleted": "O grupo {group} non se pode eliminar manualmente.", + "group_cannot_edit_primary_group": "O grupo '{group}' non se pode editar manualmente. É o grupo primario que contén só a unha usuaria concreta.", + "group_cannot_edit_visitors": "O grupo 'visitors' non se pode editar manualmente. É un grupo especial que representa a tódas visitantes anónimas", + "group_cannot_edit_all_users": "O grupo 'all_users' non se pode editar manualmente. É un grupo especial que contén tódalas usuarias rexistradas en YunoHost", + "global_settings_setting_security_webadmin_allowlist": "Enderezos IP con permiso para acceder á webadmin. Separados por vírgulas.", + "global_settings_setting_security_webadmin_allowlist_enabled": "Permitir que só algúns IPs accedan á webadmin.", + "disk_space_not_sufficient_update": "Non hai espazo suficiente no disco para actualizar esta aplicación", + "disk_space_not_sufficient_install": "Non queda espazo suficiente no disco para instalar esta aplicación", + "log_help_to_get_log": "Para ver o rexistro completo da operación '{desc}', usa o comando 'yunohost log show {name}'", + "log_link_to_log": "Rexistro completo desta operación: '{desc}'", + "log_corrupted_md_file": "O ficheiro YAML con metadatos asociado aos rexistros está danado: '{md_file}\nErro: {error}'", + "iptables_unavailable": "Non podes andar remexendo en iptables aquí. Ou ben estás nun contedor ou o teu kernel non ten soporte para isto", + "ip6tables_unavailable": "Non podes remexer en ip6tables aquí. Ou ben estás nun contedor ou o teu kernel non ten soporte para isto", + "invalid_regex": "Regex non válido: '{regex}'", + "installation_complete": "Instalación completa", + "hook_name_unknown": "Nome descoñecido do gancho '{name}'", + "hook_list_by_invalid": "Esta propiedade non se pode usar para enumerar os ganchos", + "hook_json_return_error": "Non se puido ler a info de retorno do gancho {path}. Erro: {msg}. Contido en bruto: {raw_content}", + "hook_exec_not_terminated": "O script non rematou correctamente: {path}", + "hook_exec_failed": "Non se executou o script: {path}", + "group_user_not_in_group": "A usuaria {user} non está no grupo {group}", + "group_user_already_in_group": "A usuaria {user} xa está no grupo {group}", + "group_update_failed": "Non se actualizou o grupo '{group}': {error}", + "log_permission_delete": "Eliminar permiso '{}'", + "log_permission_create": "Crear permiso '{}'", + "log_letsencrypt_cert_install": "Instalar un certificado Let's Encrypt para o dominio '{}'", + "log_dyndns_update": "Actualizar o IP asociado ao teu subdominio YunoHost '{}'", + "log_dyndns_subscribe": "Subscribirse a un subdominio YunoHost '{}'", + "log_domain_remove": "Eliminar o dominio '{}' da configuración do sistema", + "log_domain_add": "Engadir dominio '{}' á configuración do sistema", + "log_remove_on_failed_install": "Eliminar '{}' tras unha instalación fallida", + "log_remove_on_failed_restore": "Eliminar '{}' tras un intento fallido de restablecemento desde copia", + "log_backup_restore_app": "Restablecer '{}' desde unha copia de apoio", + "log_backup_restore_system": "Restablecer o sistema desde unha copia de apoio", + "log_backup_create": "Crear copia de apoio", + "log_available_on_yunopaste": "Este rexistro está dispoñible en {url}", + "log_app_action_run": "Executar acción da app '{}'", + "log_app_makedefault": "Converter '{}' na app por defecto", + "log_app_upgrade": "Actualizar a app '{}'", + "log_app_remove": "Eliminar a app '{}'", + "log_app_install": "Instalar a app '{}'", + "log_app_change_url": "Cambiar o URL da app '{}'", + "log_operation_unit_unclosed_properly": "Non se pechou correctamente a unidade da operación", + "log_does_exists": "Non hai rexistro de operación co nome '{log}', usa 'yunohost log list' para ver tódolos rexistros de operacións dispoñibles", + "log_help_to_get_failed_log": "A operación '{desc}' non se completou. Comparte o rexistro completo da operación utilizando o comando 'yunohost log share {name}' para obter axuda", + "log_link_to_failed_log": "Non se completou a operación '{desc}'. Por favor envía o rexistro completo desta operación premendo aquí para obter axuda", + "migration_0015_start": "Comezando a migración a Buster", + "migration_update_LDAP_schema": "Actualizando esquema LDAP...", + "migration_ldap_rollback_success": "Sistema restablecido.", + "migration_ldap_migration_failed_trying_to_rollback": "Non se puido migrar... intentando volver á versión anterior do sistema.", + "migration_ldap_can_not_backup_before_migration": "O sistema de copia de apoio do sistema non se completou antes de que fallase a migración. Erro: {error}", + "migration_ldap_backup_before_migration": "Crear copia de apoio da base de datos LDAP e axustes de apps antes de realizar a migración.", + "migration_description_0020_ssh_sftp_permissions": "Engadir soporte para permisos SSH e SFTP", + "migration_description_0019_extend_permissions_features": "Extender/recrear o sistema de xestión de permisos de apps", + "migration_description_0018_xtable_to_nftable": "Migrar as regras de tráfico de rede antigas ao novo sistema nftable", + "migration_description_0017_postgresql_9p6_to_11": "Migrar bases de datos desde PostgreSQL 9.6 a 11", + "migration_description_0016_php70_to_php73_pools": "Migrar o ficheiros de configuración 'pool' de php7.0-fpm a php7.3", + "migration_description_0015_migrate_to_buster": "Actualizar o sistema a Debian Buster e YunoHost 4.x", + "migrating_legacy_permission_settings": "Migrando os axustes dos permisos anteriores...", + "main_domain_changed": "Foi cambiado o dominio principal", + "main_domain_change_failed": "Non se pode cambiar o dominio principal", + "mail_unavailable": "Este enderezo de email está reservado e debería adxudicarse automáticamente á primeira usuaria", + "mailbox_used_space_dovecot_down": "O servizo de caixa de correo Dovecot ten que estar activo se queres obter o espazo utilizado polo correo", + "mailbox_disabled": "Desactivado email para usuaria {user}", + "mail_forward_remove_failed": "Non se eliminou o reenvío de email '{mail}'", + "mail_domain_unknown": "Enderezo de email non válido para o dominio '{domain}'. Usa un dominio administrado por este servidor.", + "mail_alias_remove_failed": "Non se puido eliminar o alias de email '{mail}'", + "log_tools_reboot": "Reiniciar o servidor", + "log_tools_shutdown": "Apagar o servidor", + "log_tools_upgrade": "Actualizar paquetes do sistema", + "log_tools_postinstall": "Postinstalación do servidor YunoHost", + "log_tools_migrations_migrate_forward": "Executar migracións", + "log_domain_main_domain": "Facer que '{}' sexa o dominio principal", + "log_user_permission_reset": "Restablecer permiso '{}'", + "log_user_permission_update": "Actualizar accesos para permiso '{}'", + "log_user_update": "Actualizar info da usuaria '{}'", + "log_user_group_update": "Actualizar grupo '{}'", + "log_user_group_delete": "Eliminar grupo '{}'", + "log_user_group_create": "Crear grupo '{}'", + "log_user_delete": "Eliminar usuaria '{}'", + "log_user_create": "Engadir usuaria '{}'", + "log_regen_conf": "Rexerar configuración do sistema '{}'", + "log_letsencrypt_cert_renew": "Anovar certificado Let's Encrypt para '{}'", + "log_selfsigned_cert_install": "Instalar certificado auto-asinado para o dominio '{}'", + "log_permission_url": "Actualizar URL relativo ao permiso '{}'", + "migration_0015_general_warning": "Ten en conta que a migración é unha operación delicada. O equipo YunoHost esforzouse revisando e comprobandoa, aínda así algo podería fallar en partes do teu sistema ou as súas apps.\n\nPor tanto, é recomendable:\n- realiza unha copia de apoio de tódolos datos ou apps importantes. Máis info en https://yunohost.org/backup;\n - ten paciencia tras iniciar o proceso: dependendo da túa conexión de internet e hardware podería demorar varias horas a actualización de tódolos compoñentes.", + "migration_0015_system_not_fully_up_to_date": "O teu sistema non está ao día. Realiza unha actualización común antes de realizar a migración a Buster.", + "migration_0015_not_enough_free_space": "Queda moi pouco espazo en /var/! Deberías ter polo menos 1GB libre para realizar a migración.", + "migration_0015_not_stretch": "A distribución Debian actual non é Stretch!", + "migration_0015_yunohost_upgrade": "Iniciando a actualización do núcleo YunoHost...", + "migration_0015_still_on_stretch_after_main_upgrade": "Algo foi mal durante a actualiza ión principal, o sistema semella que aínda está en Debian Stretch", + "migration_0015_main_upgrade": "Iniciando a actualización principal...", + "migration_0015_patching_sources_list": "Correxindo os sources.lists...", + "migrations_already_ran": "Xa se realizaron estas migracións: {ids}", + "migration_0019_slapd_config_will_be_overwritten": "Semella que editaches manualmente a configuración slapd. Para esta migración crítica YunoHost precisa forzar a actualización da configuración slapd. Os ficheiros orixinais van ser copiados en {conf_backup_folder}.", + "migration_0019_add_new_attributes_in_ldap": "Engadir novos atributos para os permisos na base de datos LDAP", + "migration_0018_failed_to_reset_legacy_rules": "Fallou o restablecemento das regras antigas de iptables: {error}", + "migration_0018_failed_to_migrate_iptables_rules": "Fallou a migración das regras antigas de iptables a nftables: {error}", + "migration_0017_not_enough_space": "Crea espazo suficiente en {path} para executar a migración.", + "migration_0017_postgresql_11_not_installed": "PostgreSQL 9.6 está instado, pero non postgresql 11? Algo raro debeu acontecer no teu sistema :(...", + "migration_0017_postgresql_96_not_installed": "PostgreSQL non está instalado no teu sistema. Nada que facer.", + "migration_0015_weak_certs": "Os seguintes certificados están a utilizar algoritmos de sinatura débiles e teñen que ser actualizados para ser compatibles coa seguinte versión de nginx: {certs}", + "migration_0015_cleaning_up": "Limpando a caché e paquetes que xa non son útiles...", + "migration_0015_specific_upgrade": "Iniciando a actualización dos paquetes do sistema que precisan ser actualizados de xeito independente...", + "migration_0015_modified_files": "Ten en conta que os seguintes ficheiros semella que foron modificados manualmente e poderían ser sobrescritos na actualización: {manually_modified_files}", + "migration_0015_problematic_apps_warning": "Ten en conta que se detectaron as seguintes apps que poderían ser problemáticas. Semella que non foron instaladas usando o catálogo de YunoHost, ou non están marcadas como 'funcionais'. En consecuencia, non se pode garantir que seguirán funcionando após a actualización: {problematic_apps}", + "diagnosis_http_localdomain": "O dominio {domain}, cun TLD .local, non é de agardar que esté exposto ao exterior da rede local.", + "diagnosis_dns_specialusedomain": "O dominio {domain} baséase un dominio de nivel alto e uso especial (TLD) polo que non é de agardar que realmente teña rexistros DNS.", + "upnp_enabled": "UPnP activado", + "upnp_disabled": "UPnP desactivado", + "permission_creation_failed": "Non se creou o permiso '{permission}': {error}", + "permission_created": "Creado o permiso '{permission}'", + "permission_cannot_remove_main": "Non está permitido eliminar un permiso principal", + "permission_already_up_to_date": "Non se actualizou o permiso porque as solicitudes de adición/retirada xa coinciden co estado actual.", + "permission_already_exist": "Xa existe o permiso '{permission}'", + "permission_already_disallowed": "O grupo '{group}' xa ten o permiso '{permission}' desactivado", + "permission_already_allowed": "O grupo '{group}' xa ten o permiso '{permission}' activado", + "pattern_password_app": "Lamentámolo, os contrasinais non poden conter os seguintes caracteres: {forbidden_chars}", + "pattern_username": "Só admite caracteres alfanuméricos en minúscula e trazo baixo", + "pattern_port_or_range": "Debe ser un número válido de porto (entre 0-65535) ou rango de portos (ex. 100:200)", + "pattern_password": "Ten que ter polo menos 3 caracteres", + "pattern_mailbox_quota": "Ten que ser un tamaño co sufixo b/k/M/G/T ou 0 para non ter unha cota", + "pattern_lastname": "Ten que ser un apelido válido", + "pattern_firstname": "Ten que ser un nome válido", + "pattern_email": "Ten que ser un enderezo de email válido, sen o símbolo '+' (ex. persoa@exemplo.com)", + "pattern_email_forward": "Ten que ser un enderezo de email válido, está aceptado o símbolo '+' (ex. persoa+etiqueta@exemplo.com)", + "pattern_domain": "Ten que ser un nome de dominio válido (ex. dominiopropio.org)", + "pattern_backup_archive_name": "Ten que ser un nome de ficheiro válido con 30 caracteres como máximo, alfanuméricos ou só caracteres -_.", + "password_too_simple_4": "O contrasinal ten que ter 12 caracteres como mínimo e conter un díxito, maiúsculas, minúsculas e caracteres especiais", + "password_too_simple_3": "O contrasinal ten que ter 8 caracteres como mínimo e conter un díxito, maiúsculas, minúsculas e caracteres especiais", + "password_too_simple_2": "O contrasinal ten que ter 8 caracteres como mínimo e conter un díxito, maiúsculas e minúsculas", + "password_listed": "Este contrasinal está entre os máis utilizados no mundo. Por favor elixe outro que sexa máis orixinal.", + "packages_upgrade_failed": "Non se puideron actualizar tódolos paquetes", + "operation_interrupted": "Foi interrumpida manualmente a operación?", + "invalid_number": "Ten que ser un número", + "not_enough_disk_space": "Non hai espazo libre abondo en '{path}'", + "migrations_to_be_ran_manually": "A migración {id} ten que ser executada manualmente. Vaite a Ferramentas → Migracións na páxina webadmin, ou executa `yunohost tools migrations run`.", + "migrations_success_forward": "Migración {id} completada", + "migrations_skip_migration": "Omitindo migración {id}...", + "migrations_running_forward": "Realizando migración {id}...", + "migrations_pending_cant_rerun": "Esas migracións están pendentes, polo que non ser executadas outra vez: {ids}", + "migrations_not_pending_cant_skip": "Esas migracións non están pendentes, polo que non poden ser omitidas: {ids}", + "migrations_no_such_migration": "Non hai migración co nome '{id}'", + "migrations_no_migrations_to_run": "Sen migracións a executar", + "migrations_need_to_accept_disclaimer": "Para executar a migración {id}, tes que aceptar o seguinte aviso:\n---\n{disclaimer}\n---\nSe aceptas executar a migración, por favor volve a executar o comando coa opción '--accept-disclaimer'.", + "migrations_must_provide_explicit_targets": "Debes proporcionar obxectivos explícitos ao utilizar '--skip' ou '--force-rerun'", + "migrations_migration_has_failed": "A migración {id} non se completou, abortando. Erro: {exception}", + "migrations_loading_migration": "Cargando a migración {id}...", + "migrations_list_conflict_pending_done": "Non podes usar ao mesmo tempo '--previous' e '--done'.", + "migrations_exclusive_options": "'--auto', '--skip', e '--force-rerun' son opcións que se exclúen unhas a outras.", + "migrations_failed_to_load_migration": "Non se cargou a migración {id}: {error}", + "migrations_dependencies_not_satisfied": "Executar estas migracións: '{dependencies_id}', antes da migración {id}.", + "migrations_cant_reach_migration_file": "Non se pode acceder aos ficheiros de migración na ruta '%s'", + "regenconf_file_manually_removed": "O ficheiro de configuración '{conf}' foi eliminado manualmente e non será creado", + "regenconf_file_manually_modified": "O ficheiro de configuración '{conf}' foi modificado manualmente e non vai ser actualizado", + "regenconf_file_kept_back": "Era de agardar que o ficheiro de configuración '{conf}' fose eliminado por regen-conf (categoría {category}) mais foi mantido.", + "regenconf_file_copy_failed": "Non se puido copiar o novo ficheiro de configuración '{new}' a '{conf}'", + "regenconf_file_backed_up": "Ficheiro de configuración '{conf}' copiado a '{backup}'", + "postinstall_low_rootfsspace": "O sistema de ficheiros raiz ten un espazo total menor de 10GB, que é pouco! Probablemente vas quedar sen espazo moi pronto! É recomendable ter polo menos 16GB para o sistema raíz. Se queres instalar YunoHost obviando este aviso, volve a executar a postinstalación con --force-diskspace", + "port_already_opened": "O porto {port} xa está aberto para conexións {ip_version}", + "port_already_closed": "O porto {port} xa está pechado para conexións {ip_version}", + "permission_require_account": "O permiso {permission} só ten sentido para usuarias cunha conta, e por tanto non pode concederse a visitantes.", + "permission_protected": "O permiso {permission} está protexido. Non podes engadir ou eliminar o grupo visitantes a/de este permiso.", + "permission_updated": "Permiso '{permission}' actualizado", + "permission_update_failed": "Non se actualizou o permiso '{permission}': {error}", + "permission_not_found": "Non se atopa o permiso '{permission}'", + "permission_deletion_failed": "Non se puido eliminar o permiso '{permission}': {error}", + "permission_deleted": "O permiso '{permission}' foi eliminado", + "permission_cant_add_to_all_users": "O permiso {permission} non pode ser concecido a tódalas usuarias.", + "permission_currently_allowed_for_all_users": "Este permiso está concedido actualmente a tódalas usuarias ademáis de a outros grupos. Probablemente queiras ben eliminar o permiso 'all_users' ou ben eliminar os outros grupos que teñen permiso.", + "restore_failed": "Non se puido restablecer o sistema", + "restore_extracting": "Extraendo os ficheiros necesarios desde o arquivo...", + "restore_confirm_yunohost_installed": "Tes a certeza de querer restablecer un sistema xa instalado? [{answers}]", + "restore_complete": "Restablecemento completado", + "restore_cleaning_failed": "Non se puido despexar o directorio temporal de restablecemento", + "restore_backup_too_old": "Este arquivo de apoio non pode ser restaurado porque procede dunha versión YunoHost demasiado antiga.", + "restore_already_installed_apps": "As seguintes apps non se poden restablecer porque xa están instaladas: {apps}", + "restore_already_installed_app": "Unha app con ID '{app}' xa está instalada", + "regex_with_only_domain": "Agora xa non podes usar un regex para o dominio, só para ruta", + "regex_incompatible_with_tile": "/!\\ Empacadoras! O permiso '{permission}' agora ten show_tile establecido como 'true' polo que non podes definir o regex URL como URL principal", + "regenconf_need_to_explicitly_specify_ssh": "A configuración ssh foi modificada manualmente, pero tes que indicar explícitamente a categoría 'ssh' con --force para realmente aplicar os cambios.", + "regenconf_pending_applying": "Aplicando a configuración pendente para categoría '{category}'...", + "regenconf_failed": "Non se rexenerou a configuración para a categoría(s): {categories}", + "regenconf_dry_pending_applying": "Comprobando as configuracións pendentes que deberían aplicarse á categoría '{category}'...", + "regenconf_would_be_updated": "A configuración debería ser actualizada para a categoría '{category}'", + "regenconf_updated": "Configuración actualizada para '{category}'", + "regenconf_up_to_date": "A configuración xa está ao día para a categoría '{category}'", + "regenconf_now_managed_by_yunohost": "O ficheiro de configuración '{conf}' agora está xestionado por YunoHost (categoría {category}).", + "regenconf_file_updated": "Actualizado o ficheiro de configuración '{conf}'", + "regenconf_file_removed": "Eliminado o ficheiro de configuración '{conf}'", + "regenconf_file_remove_failed": "Non se puido eliminar o ficheiro de configuración '{conf}'", + "service_enable_failed": "Non se puido facer que o servizo '{service}' se inicie automáticamente no inicio.\n\nRexistros recentes do servizo: {logs}", + "service_disabled": "O servizo '{service}' xa non vai volver a ser iniciado ao inicio do sistema.", + "service_disable_failed": "Non se puido iniciar o servizo '{service}' ao inicio.\n\nRexistro recente do servizo: {logs}", + "service_description_yunohost-firewall": "Xestiona, abre e pecha a conexións dos portos aos servizos", + "service_description_yunohost-api": "Xestiona as interaccións entre a interface web de YunoHost e o sistema", + "service_description_ssh": "Permíteche conectar de xeito remoto co teu servidor a través dun terminal (protocolo SSH)", + "service_description_slapd": "Almacena usuarias, dominios e info relacionada", + "service_description_rspamd": "Filtra spam e outras características relacionadas co email", + "service_description_redis-server": "Unha base de datos especial utilizada para o acceso rápido a datos, cola de tarefas e comunicación entre programas", + "service_description_postfix": "Utilizado para enviar e recibir emails", + "service_description_php7.3-fpm": "Executa aplicacións escritas en PHP con NGINX", + "service_description_nginx": "Serve ou proporciona acceso a tódolos sitios web hospedados no teu servidor", + "service_description_mysql": "Almacena datos da app (base de datos SQL)", + "service_description_metronome": "Xestiona as contas de mensaxería instantánea XMPP", + "service_description_fail2ban": "Protexe contra ataques de forza bruta e outro tipo de ataques desde internet", + "service_description_dovecot": "Permite aos clientes de email acceder/obter o correo (vía IMAP e POP3)", + "service_description_dnsmasq": "Xestiona a resolución de nomes de dominio (DNS)", + "service_description_yunomdns": "Permíteche chegar ao teu servidor utilizando 'yunohost.local' na túa rede local", + "service_cmd_exec_failed": "Non se puido executar o comando '{command}'", + "service_already_stopped": "O servizo '{service}' xa está detido", + "service_already_started": "O servizo '{service}' xa se está a executar", + "service_added": "Foi engadido o servizo '{service}'", + "service_add_failed": "Non se puido engadir o servizo '{service}'", + "server_reboot_confirm": "Queres reiniciar o servidor inmediatamente? [{answers}]", + "server_reboot": "Vaise reiniciar o servidor", + "server_shutdown_confirm": "Queres apagar o servidor inmediatamente? [{answers}]", + "server_shutdown": "Vaise apagar o servidor", + "root_password_replaced_by_admin_password": "O contrasinal root foi substituído polo teu contrasinal de administración.", + "root_password_desynchronized": "Mudou o contrasinal de administración, pero YunoHost non puido transferir este cambio ao contrasinal root!", + "restore_system_part_failed": "Non se restableceu a parte do sistema '{part}'", + "restore_running_hooks": "Executando os ganchos do restablecemento...", + "restore_running_app_script": "Restablecendo a app '{app}'...", + "restore_removing_tmp_dir_failed": "Non se puido eliminar o directorio temporal antigo", + "restore_nothings_done": "Nada foi restablecido", + "restore_not_enough_disk_space": "Non hai espazo abondo (espazo: {free_space.d} B, espazo necesario: {needed_space} B, marxe de seguridade: {margin} B)", + "restore_may_be_not_enough_disk_space": "O teu sistema semella que non ten espazo abondo (libre: {free_space} B, espazo necesario: {needed_space} B, marxe de seguridade {margin} B)", + "restore_hook_unavailable": "O script de restablecemento para '{part}' non está dispoñible no teu sistema nin no arquivo", + "invalid_password": "Contrasinal non válido", + "ldap_server_is_down_restart_it": "O servidor LDAP está caído, intenta reinicialo...", + "ldap_server_down": "Non se chegou ao servidor LDAP", + "global_settings_setting_security_experimental_enabled": "Activar características de seguridade experimentais (non actives isto se non sabes o que estás a facer!)", + "yunohost_postinstall_end_tip": "Post-install completada! Para rematar a configuración considera:\n- engadir unha primeira usuaria na sección 'Usuarias' na webadmin (ou 'yunohost user create ' na liña de comandos);\n- diagnosticar potenciais problemas na sección 'Diagnóstico' na webadmin (ou 'yunohost diagnosis run' na liña de comandos);\n- ler 'Rematando a configuración' e 'Coñece YunoHost' na documentación da administración: https://yunohost.org/admindoc.", + "yunohost_not_installed": "YunoHost non está instalado correctamente. Executa 'yunohost tools postinstall'", + "yunohost_installing": "Instalando YunoHost...", + "yunohost_configured": "YunoHost está configurado", + "yunohost_already_installed": "YunoHost xa está instalado", + "user_updated": "Cambiada a info da usuaria", + "user_update_failed": "Non se actualizou usuaria {user}: {error}", + "user_unknown": "Usuaria descoñecida: {user}", + "user_home_creation_failed": "Non se puido crear cartafol home '{home}' para a usuaria", + "user_deletion_failed": "Non se puido eliminar a usuaria {user}: {error}", + "user_deleted": "Usuaria eliminada", + "user_creation_failed": "Non se puido crear a usuaria {user}: {error}", + "user_created": "Usuaria creada", + "user_already_exists": "A usuaria '{user}' xa existe", + "upnp_port_open_failed": "Non se puido abrir porto a través de UPnP", + "upnp_dev_not_found": "Non se atopa dispositivo UPnP", + "upgrading_packages": "Actualizando paquetes...", + "upgrade_complete": "Actualización completa", + "updating_apt_cache": "Obtendo actualizacións dispoñibles para os paquetes do sistema...", + "update_apt_cache_warning": "Algo fallou ao actualizar a caché de APT (xestor de paquetes Debian). Aquí tes un volcado de sources.list, que podería axudar a identificar liñas problemáticas:\n{sourceslist}", + "update_apt_cache_failed": "Non se puido actualizar a caché de APT (xestor de paquetes de Debian). Aquí tes un volcado do sources.list, que podería axudarche a identificar liñas incorrectas:\n{sourceslist}", + "unrestore_app": "{app} non vai ser restablecida", + "unlimit": "Sen cota", + "unknown_main_domain_path": "Dominio ou ruta descoñecida '{app}'. Tes que indicar un dominio e ruta para poder especificar un URL para o permiso.", + "unexpected_error": "Aconteceu un fallo non agardado: {error}", + "unbackup_app": "{app} non vai ser gardada", + "tools_upgrade_special_packages_completed": "Completada a actualización dos paquetes YunoHost.\nPreme [Enter] para recuperar a liña de comandos", + "tools_upgrade_special_packages_explanation": "A actualización especial continuará en segundo plano. Non inicies outras tarefas no servidor nos seguintes ~10 minutos (depende do hardware). Após isto, podes volver a conectar na webadmin. O rexistro da actualización estará dispoñible en Ferramentas → Rexistro (na webadmin) ou con 'yunohost log list' (na liña de comandos).", + "tools_upgrade_special_packages": "Actualizando paquetes 'special' (yunohost-related)...", + "tools_upgrade_regular_packages_failed": "Non se actualizaron os paquetes: {packages_list}", + "tools_upgrade_regular_packages": "Actualizando os paquetes 'regular' (non-yunohost-related)...", + "tools_upgrade_cant_unhold_critical_packages": "Non se desbloquearon os paquetes críticos...", + "tools_upgrade_cant_hold_critical_packages": "Non se puideron bloquear os paquetes críticos...", + "tools_upgrade_cant_both": "Non se pode actualizar o sistema e as apps ao mesmo tempo", + "tools_upgrade_at_least_one": "Por favor indica 'apps', ou 'system'", + "this_action_broke_dpkg": "Esta acción rachou dpkg/APT (xestores de paquetes do sistema)... Podes intentar resolver o problema conectando a través de SSH e executando `sudo apt install --fix-broken`e/ou `sudo dpkg --configure -a`.", + "system_username_exists": "Xa existe este nome de usuaria na lista de usuarias do sistema", + "system_upgraded": "Sistema actualizado", + "ssowat_conf_updated": "Actualizada a configuración SSOwat", + "ssowat_conf_generated": "Rexenerada a configuración para SSOwat", + "show_tile_cant_be_enabled_for_regex": "Non podes activar 'show_tile' neste intre, porque o URL para o permiso '{permission}' é un regex", + "show_tile_cant_be_enabled_for_url_not_defined": "Non podes activar 'show_tile' neste intre, primeiro tes que definir un URL para o permiso '{permission}'", + "service_unknown": "Servizo descoñecido '{service}'", + "service_stopped": "Detívose o servizo '{service}'", + "service_stop_failed": "Non se puido deter o servizo '{service}'\n\nRexistros recentes do servizo: {logs}", + "service_started": "Iniciado o servizo '{service}'", + "service_start_failed": "Non se puido iniciar o servizo '{service}'\n\nRexistros recentes do servizo: {logs}", + "service_reloaded_or_restarted": "O servizo '{service}' foi recargado ou reiniciado", + "service_reload_or_restart_failed": "Non se recargou ou reiniciou o servizo '{service}'\n\nRexistros recentes do servizo: {logs}", + "service_restarted": "Reiniciado o servizo '{service}'", + "service_restart_failed": "Non se reiniciou o servizo '{service}'\n\nRexistros recentes do servizo: {logs}", + "service_reloaded": "Recargado o servizo '{service}'", + "service_reload_failed": "Non se recargou o servizo '{service}'\n\nRexistros recentes do servizo: {logs}", + "service_removed": "Eliminado o servizo '{service}'", + "service_remove_failed": "Non se eliminou o servizo '{service}'", + "service_regen_conf_is_deprecated": "'yunohost service regen-conf' xa non se utiliza! Executa 'yunohost tools regen-conf' no seu lugar.", + "service_enabled": "O servizo '{service}' vai ser iniciado automáticamente no inicio do sistema.", + "diagnosis_apps_allgood": "Tódalas apps instaladas respectan as prácticas básicas de empaquetado", + "diagnosis_apps_bad_quality": "Esta aplicación está actualmente marcada como estragada no catálogo de aplicacións de YunoHost. Podería ser un problema temporal mentras as mantedoras intentan arranxar o problema. Ata ese momento a actualización desta app está desactivada.", + "global_settings_setting_security_nginx_redirect_to_https": "Redirixir peticións HTTP a HTTPs por defecto (NON DESACTIVAR ISTO a non ser que realmente saibas o que fas!)", + "log_user_import": "Importar usuarias", + "user_import_failed": "A operación de importación de usuarias fracasou", + "user_import_missing_columns": "Faltan as seguintes columnas: {columns}", + "user_import_nothing_to_do": "Ningunha usuaria precisa ser importada", + "user_import_partial_failed": "A operación de importación de usuarias fallou parcialmente", + "diagnosis_apps_deprecated_practices": "A versión instalada desta app aínda utiliza algunha das antigas prácticas de empaquetado xa abandonadas. Deberías considerar actualizala.", + "diagnosis_apps_outdated_ynh_requirement": "A versión instalada desta app só require yunohost >= 2.x, que normalmente indica que non está ao día coas prácticas recomendadas de empaquetado e asistentes. Deberías considerar actualizala.", + "user_import_success": "Usuarias importadas correctamente", + "diagnosis_high_number_auth_failures": "Hai un alto número sospeitoso de intentos fallidos de autenticación. Deberías comprobar que fail2ban está a executarse e que está correctamente configurado, ou utiliza un porto personalizado para SSH tal como se explica en https://yunohost.org/security.", + "user_import_bad_file": "O ficheiro CSV non ten o formato correcto e será ignorado para evitar unha potencial perda de datos", + "user_import_bad_line": "Liña incorrecta {line}: {details}", + "diagnosis_description_apps": "Aplicacións", + "diagnosis_apps_broken": "Actualmente esta aplicación está marcada como estragada no catálogo de aplicacións de YunoHost. Podería tratarse dun problema temporal mentras as mantedoras intentan arraxala. Entanto así a actualización da app está desactivada.", + "diagnosis_apps_issue": "Atopouse un problema na app {app}", + "diagnosis_apps_not_in_app_catalog": "Esta aplicación non está no catálgo de aplicacións de YunoHost. Se estivo no pasado e foi eliminada, deberías considerar desinstalala porque non recibirá actualizacións, e podería comprometer a integridade e seguridade do teu sistema.", + "app_argument_password_help_optional": "Escribe un espazo para limpar o contrasinal", + "config_validate_date": "Debe ser unha data válida co formato YYYY-MM-DD", + "config_validate_email": "Debe ser un email válido", + "config_validate_time": "Debe ser unha hora válida tal que HH:MM", + "config_validate_url": "Debe ser un URL válido", + "danger": "Perigo:", + "app_argument_password_help_keep": "Preme Enter para manter o valor actual", + "app_config_unable_to_read": "Fallou a lectura dos valores de configuración.", + "config_apply_failed": "Fallou a aplicación da nova configuración: {error}", + "config_forbidden_keyword": "O palabra chave '{keyword}' está reservada, non podes crear ou usar un panel de configuración cunha pregunta con este id.", + "config_no_panel": "Non se atopa panel configurado.", + "config_unknown_filter_key": "A chave do filtro '{filter_key}' non é correcta.", + "config_validate_color": "Debe ser un valor RGB hexadecimal válido", + "invalid_number_min": "Ten que ser maior que {min}", + "log_app_config_set": "Aplicar a configuración á app '{}'", + "app_config_unable_to_apply": "Fallou a aplicación dos valores de configuración.", + "config_cant_set_value_on_section": "Non podes establecer un valor único na sección completa de configuración.", + "config_version_not_supported": "A versión do panel de configuración '{version}' non está soportada.", + "file_extension_not_accepted": "Rexeitouse o ficheiro '{path}' porque a súa extensión non está entre as aceptadas: {accept}", + "invalid_number_max": "Ten que ser menor de {max}", + "service_not_reloading_because_conf_broken": "Non se recargou/reiniciou o servizo '{name}' porque a súa configuración está estragada: {errors}" +} \ No newline at end of file diff --git a/locales/hi.json b/locales/hi.json index 015fd4e5e..5f521b1dc 100644 --- a/locales/hi.json +++ b/locales/hi.json @@ -1,81 +1,54 @@ { - "action_invalid": "अवैध कार्रवाई '{action:s}'", + "action_invalid": "अवैध कार्रवाई '{action}'", "admin_password": "व्यवस्थापक पासवर्ड", "admin_password_change_failed": "पासवर्ड बदलने में असमर्थ", "admin_password_changed": "व्यवस्थापक पासवर्ड बदल दिया गया है", - "app_already_installed": "'{app:s}' पहले से ही इंस्टाल्ड है", - "app_argument_choice_invalid": "गलत तर्क का चयन किया गया '{name:s}' , तर्क इन विकल्पों में से होने चाहिए {choices:s}", - "app_argument_invalid": "तर्क के लिए अमान्य मान '{name:s}': {error:s}", - "app_argument_required": "तर्क '{name:s}' की आवश्यकता है", + "app_already_installed": "'{app}' पहले से ही इंस्टाल्ड है", + "app_argument_choice_invalid": "गलत तर्क का चयन किया गया '{name}' , तर्क इन विकल्पों में से होने चाहिए {choices}", + "app_argument_invalid": "तर्क के लिए अमान्य मान '{name}': {error}", + "app_argument_required": "तर्क '{name}' की आवश्यकता है", "app_extraction_failed": "इन्सटाल्ड फ़ाइलों को निकालने में असमर्थ", "app_id_invalid": "अवैध एप्लिकेशन id", - "app_incompatible": "यह एप्लिकेशन युनोहोस्ट की इस वर्जन के लिए नहीं है", "app_install_files_invalid": "फाइलों की अमान्य स्थापना", - "app_location_already_used": "इस लोकेशन पे पहले से ही कोई एप्लीकेशन इन्सटाल्ड है", - "app_location_install_failed": "इस लोकेशन पे एप्लीकेशन इंस्टाल करने में असमर्थ", "app_manifest_invalid": "एप्लीकेशन का मैनिफेस्ट अमान्य", - "app_no_upgrade": "कोई भी एप्लीकेशन को अपडेट की जरूरत नहीं", - "app_not_correctly_installed": "{app:s} ठीक ढंग से इनस्टॉल नहीं हुई", - "app_not_installed": "{app:s} इनस्टॉल नहीं हुई", - "app_not_properly_removed": "{app:s} ठीक ढंग से नहीं अनइन्सटॉल की गई", - "app_package_need_update": "इस एप्लीकेशन पैकेज को युनोहोस्ट के नए बदलावों/गाइडलिनेज़ के कारण उपडटेशन की जरूरत", - "app_removed": "{app:s} को अनइन्सटॉल कर दिया गया", + "app_not_correctly_installed": "{app} ठीक ढंग से इनस्टॉल नहीं हुई", + "app_not_installed": "{app} इनस्टॉल नहीं हुई", + "app_not_properly_removed": "{app} ठीक ढंग से नहीं अनइन्सटॉल की गई", + "app_removed": "{app} को अनइन्सटॉल कर दिया गया", "app_requirements_checking": "जरूरी पैकेजेज़ की जाँच हो रही है ....", - "app_requirements_failed": "आवश्यकताओं को पूरा करने में असमर्थ: {error}", "app_requirements_unmeet": "आवश्यकताए पूरी नहीं हो सकी, पैकेज {pkgname}({version})यह होना चाहिए {spec}", "app_sources_fetch_failed": "सोर्स फाइल्स प्राप्त करने में असमर्थ", "app_unknown": "अनजान एप्लीकेशन", "app_unsupported_remote_type": "एप्लीकेशन के लिए उन्सुपपोर्टेड रिमोट टाइप इस्तेमाल किया गया", - "app_upgrade_failed": "{app:s} अपडेट करने में असमर्थ", - "app_upgraded": "{app:s} अपडेट हो गयी हैं", - "appslist_fetched": "एप्लीकेशन की सूचि अपडेट हो गयी", - "appslist_removed": "एप्लीकेशन की सूचि निकल दी गयी है", - "appslist_retrieve_error": "दूरस्थ एप्लिकेशन सूची प्राप्त करने में असमर्थ", - "appslist_unknown": "अनजान एप्लिकेशन सूची", - "ask_current_admin_password": "वर्तमान व्यवस्थापक पासवर्ड", - "ask_email": "ईमेल का पता", + "app_upgrade_failed": "{app} अपडेट करने में असमर्थ", + "app_upgraded": "{app} अपडेट हो गयी हैं", "ask_firstname": "नाम", "ask_lastname": "अंतिम नाम", - "ask_list_to_remove": "सूचि जिसको हटाना है", "ask_main_domain": "मुख्य डोमेन", "ask_new_admin_password": "नया व्यवस्थापक पासवर्ड", "ask_password": "पासवर्ड", - "backup_action_required": "आप को सेव करने के लिए कुछ लिखना होगा", - "backup_app_failed": "एप्लीकेशन का बैकअप करने में असमर्थ '{app:s}'", - "backup_archive_app_not_found": "'{app:s}' बैकअप आरचिव में नहीं मिला", - "backup_archive_hook_not_exec": "हुक '{hook:s}' इस बैकअप में एक्सेक्युट नहीं किया गया", + "backup_app_failed": "एप्लीकेशन का बैकअप करने में असमर्थ '{app}'", + "backup_archive_app_not_found": "'{app}' बैकअप आरचिव में नहीं मिला", "backup_archive_name_exists": "इस बैकअप आरचिव का नाम पहले से ही मौजूद है", - "backup_archive_name_unknown": "'{name:s}' इस नाम की लोकल बैकअप आरचिव मौजूद नहीं", + "backup_archive_name_unknown": "'{name}' इस नाम की लोकल बैकअप आरचिव मौजूद नहीं", "backup_archive_open_failed": "बैकअप आरचिव को खोलने में असमर्थ", "backup_cleaning_failed": "टेम्पोरेरी बैकअप डायरेक्टरी को उड़ने में असमर्थ", "backup_created": "बैकअप सफलतापूर्वक किया गया", - "backup_creating_archive": "बैकअप आरचिव बनाई जा रही है ...", "backup_creation_failed": "बैकअप बनाने में विफल", - "backup_delete_error": "'{path:s}' डिलीट करने में असमर्थ", + "backup_delete_error": "'{path}' डिलीट करने में असमर्थ", "backup_deleted": "इस बैकअप को डिलीट दिया गया है", - "backup_extracting_archive": "बैकअप आरचिव को एक्सट्रेक्ट किया जा रहा है ...", - "backup_hook_unknown": "'{hook:s}' यह बैकअप हुक नहीं मिला", - "backup_invalid_archive": "अवैध बैकअप आरचिव", + "backup_hook_unknown": "'{hook}' यह बैकअप हुक नहीं मिला", "backup_nothings_done": "सेव करने के लिए कुछ नहीं", "backup_output_directory_forbidden": "निषिद्ध आउटपुट डायरेक्टरी। निम्न दिए गए डायरेक्टरी में बैकअप नहीं बन सकता /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var और /home/yunohost.backup/archives के सब-फोल्डर।", "backup_output_directory_not_empty": "आउटपुट डायरेक्टरी खाली नहीं है", "backup_output_directory_required": "बैकअप करने के लिए आउट पुट डायरेक्टरी की आवश्यकता है", - "backup_running_app_script": "'{app:s}' एप्लीकेशन की बैकअप स्क्रिप्ट चल रही है...", "backup_running_hooks": "बैकअप हुक्स चल रहे है...", - "custom_app_url_required": "आप को अपनी कस्टम एप्लिकेशन '{app:s}' को अपग्रेड करने के लिए यूआरएल(URL) देने की आवश्यकता है", - "custom_appslist_name_required": "आप को अपनी कस्टम एप्लीकेशन के लिए नाम देने की आवश्यकता है", - "diagnosis_debian_version_error": "डेबियन वर्जन प्राप्त करने में असफलता {error}", - "diagnosis_kernel_version_error": "कर्नेल वर्जन प्राप्त नहीं की जा पा रही : {error}", - "diagnosis_monitor_disk_error": "डिस्क की मॉनिटरिंग नहीं की जा पा रही: {error}", - "diagnosis_monitor_network_error": "नेटवर्क की मॉनिटरिंग नहीं की जा पा रही: {error}", - "diagnosis_monitor_system_error": "सिस्टम की मॉनिटरिंग नहीं की जा पा रही: {error}", - "diagnosis_no_apps": "कोई एप्लीकेशन इन्सटाल्ड नहीं है", - "dnsmasq_isnt_installed": "dnsmasq इन्सटाल्ड नहीं लगता,इनस्टॉल करने के लिए किप्या ये कमांड चलाये 'apt-get remove bind9 && apt-get install dnsmasq'", + "custom_app_url_required": "आप को अपनी कस्टम एप्लिकेशन '{app}' को अपग्रेड करने के लिए यूआरएल(URL) देने की आवश्यकता है", "domain_cert_gen_failed": "सर्टिफिकेट उत्पन करने में असमर्थ", "domain_created": "डोमेन बनाया गया", "domain_creation_failed": "डोमेन बनाने में असमर्थ", "domain_deleted": "डोमेन डिलीट कर दिया गया है", "domain_deletion_failed": "डोमेन डिलीट करने में असमर्थ", "domain_dyndns_already_subscribed": "DynDNS डोमेन पहले ही सब्स्क्राइड है", - "domain_dyndns_invalid": "DynDNS के साथ इनवैलिड डोमिन इस्तेमाल किया गया" -} + "password_too_simple_1": "पासवर्ड को कम से कम 8 वर्ण लंबा होना चाहिए" +} \ No newline at end of file diff --git a/locales/hu.json b/locales/hu.json index a6df4d680..9c482a370 100644 --- a/locales/hu.json +++ b/locales/hu.json @@ -1,13 +1,14 @@ { "aborting": "Megszakítás.", - "action_invalid": "Érvénytelen művelet '{action:s}'", + "action_invalid": "Érvénytelen művelet '{action}'", "admin_password": "Adminisztrátori jelszó", "admin_password_change_failed": "Nem lehet a jelszót megváltoztatni", "admin_password_changed": "Az adminisztrátori jelszó megváltozott", - "app_already_installed": "{app:s} már telepítve van", + "app_already_installed": "{app} már telepítve van", "app_already_installed_cant_change_url": "Ez az app már telepítve van. Ezzel a funkcióval az url nem változtatható. Javaslat 'app url változtatás' ha lehetséges.", - "app_already_up_to_date": "{app:s} napra kész", - "app_argument_choice_invalid": "{name:s} érvénytelen választás, csak egyike lehet {choices:s} közül", - "app_argument_invalid": "'{name:s}' hibás paraméter érték :{error:s}", - "app_argument_required": "Parameter '{name:s}' kötelező" -} + "app_already_up_to_date": "{app} napra kész", + "app_argument_choice_invalid": "{name} érvénytelen választás, csak egyike lehet {choices} közül", + "app_argument_invalid": "'{name}' hibás paraméter érték :{error}", + "app_argument_required": "Parameter '{name}' kötelező", + "password_too_simple_1": "A jelszónak legalább 8 karakter hosszúnak kell lennie" +} \ No newline at end of file diff --git a/locales/id.json b/locales/id.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/locales/id.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/locales/it.json b/locales/it.json index 2c194d5a6..1332712ef 100644 --- a/locales/it.json +++ b/locales/it.json @@ -1,390 +1,261 @@ { - "app_already_installed": "{app:s} è già installata", + "app_already_installed": "{app} è già installata", "app_extraction_failed": "Impossibile estrarre i file di installazione", - "app_not_installed": "{app:s} non è installata", + "app_not_installed": "Impossibile trovare l'applicazione {app} nell'elenco delle applicazioni installate: {all_apps}", "app_unknown": "Applicazione sconosciuta", - "ask_email": "Indirizzo email", "ask_password": "Password", - "backup_archive_name_exists": "Il nome dell'archivio del backup è già esistente", + "backup_archive_name_exists": "Il nome dell'archivio del backup è già esistente.", "backup_created": "Backup completo", - "backup_invalid_archive": "Archivio di backup non valido", - "backup_output_directory_not_empty": "La directory di output non è vuota", - "backup_running_app_script": "Esecuzione del script di backup dell'applicazione '{app:s}'...", - "domain_created": "Il dominio è stato creato", - "domain_dyndns_invalid": "Il dominio non è valido per essere usato con DynDNS", - "domain_exists": "Il dominio è già esistente", - "ldap_initialized": "LDAP è stato inizializzato", - "pattern_email": "L'indirizzo email deve essere valido (es. someone@domain.org)", + "backup_output_directory_not_empty": "Dovresti scegliere una cartella di output vuota", + "domain_created": "Dominio creato", + "domain_exists": "Il dominio esiste già", + "pattern_email": "L'indirizzo email deve essere valido, senza simboli '+' (es. tizio@dominio.com)", "pattern_mailbox_quota": "La dimensione deve avere un suffisso b/k/M/G/T o 0 per disattivare la quota", - "port_already_opened": "La porta {port:d} è già aperta per {ip_version:s} connessioni", - "port_unavailable": "La porta {port:d} non è disponibile", - "service_add_failed": "Impossibile aggiungere il servizio '{service:s}'", - "service_cmd_exec_failed": "Impossibile eseguire il comando '{command:s}'", - "service_disabled": "Il servizio '{service:s}' è stato disattivato", - "service_remove_failed": "Impossibile rimuovere il servizio '{service:s}'", - "service_removed": "Il servizio '{service:s}' è stato rimosso", - "service_stop_failed": "Impossibile fermare il servizio '{service:s}'\n\nRegistri di servizio recenti:{logs:s}", - "system_username_exists": "il nome utente esiste già negli utenti del sistema", - "unrestore_app": "L'applicazione '{app:s}' non verrà ripristinata", - "upgrading_packages": "Aggiornamento dei pacchetti…", - "user_deleted": "L'utente è stato cancellato", + "port_already_opened": "La porta {port} è già aperta per {ip_version} connessioni", + "service_add_failed": "Impossibile aggiungere il servizio '{service}'", + "service_cmd_exec_failed": "Impossibile eseguire il comando '{command}'", + "service_disabled": "Il servizio '{service}' non partirà più al boot di sistema.", + "service_remove_failed": "Impossibile rimuovere il servizio '{service}'", + "service_removed": "Servizio '{service}' rimosso", + "service_stop_failed": "Impossibile fermare il servizio '{service}'\n\nRegistri di servizio recenti:{logs}", + "system_username_exists": "Il nome utente esiste già negli utenti del sistema", + "unrestore_app": "{app} non verrà ripristinata", + "upgrading_packages": "Aggiornamento dei pacchetti...", + "user_deleted": "Utente cancellato", "admin_password": "Password dell'amministrazione", "admin_password_change_failed": "Impossibile cambiare la password", - "admin_password_changed": "La password dell'amministrazione è stata cambiata", - "app_incompatible": "L'applicazione {app} è incompatibile con la tua versione YunoHost", - "app_install_files_invalid": "Non sono validi i file di installazione", - "app_location_already_used": "L'applicazione '{app}' è già installata in questo percorso ({path})", - "app_location_install_failed": "Impossibile installare l'applicazione in questo percorso perchè andrebbe in conflitto con l'applicazione '{other_app}' già installata in '{other_path}'", - "app_manifest_invalid": "Manifesto dell'applicazione non valido: {error}", - "app_no_upgrade": "Nessun applicazione da aggiornare", - "app_not_correctly_installed": "{app:s} sembra di non essere installata correttamente", - "app_not_properly_removed": "{app:s} non è stata correttamente rimossa", - "action_invalid": "L'azione '{action:s}' non è valida", - "app_removed": "{app:s} è stata rimossa", - "app_sources_fetch_failed": "Impossibile riportare i file sorgenti", - "app_upgrade_failed": "Impossibile aggiornare {app:s}", - "app_upgraded": "{app:s} è stata aggiornata", - "appslist_fetched": "La lista delle applicazioni {appslist:s} è stata recuperata", - "appslist_removed": "La lista delle applicazioni {appslist:s} è stata rimossa", - "app_package_need_update": "Il pacchetto dell'applicazione {app} deve essere aggiornato per seguire i cambiamenti di YunoHost", - "app_requirements_checking": "Controllo i pacchetti richiesti per {app}…", - "app_requirements_failed": "Impossibile soddisfare i requisiti per {app}: {error}", + "admin_password_changed": "La password d'amministrazione è stata cambiata", + "app_install_files_invalid": "Questi file non possono essere installati", + "app_manifest_invalid": "C'è qualcosa di scorretto nel manifesto dell'applicazione: {error}", + "app_not_correctly_installed": "{app} sembra di non essere installata correttamente", + "app_not_properly_removed": "{app} non è stata correttamente rimossa", + "action_invalid": "L'azione '{action}' non è valida", + "app_removed": "{app} disinstallata", + "app_sources_fetch_failed": "Impossibile riportare i file sorgenti, l'URL è corretto?", + "app_upgrade_failed": "Impossibile aggiornare {app}: {error}", + "app_upgraded": "{app} aggiornata", + "app_requirements_checking": "Controllo i pacchetti richiesti per {app}...", "app_requirements_unmeet": "Requisiti non soddisfatti per {app}, il pacchetto {pkgname} ({version}) deve essere {spec}", - "appslist_unknown": "Lista di applicazioni {appslist:s} sconosciuta.", - "ask_current_admin_password": "Password attuale dell'amministrazione", "ask_firstname": "Nome", "ask_lastname": "Cognome", - "ask_list_to_remove": "Lista da rimuovere", "ask_main_domain": "Dominio principale", "ask_new_admin_password": "Nuova password dell'amministrazione", - "backup_action_required": "Devi specificare qualcosa da salvare", - "backup_app_failed": "Non è possibile fare il backup dell'applicazione '{app:s}'", - "backup_archive_app_not_found": "L'applicazione '{app:s}' non è stata trovata nel archivio di backup", - "app_argument_choice_invalid": "Scelta non valida per l'argomento '{name:s}', deve essere uno di {choices:s}", - "app_argument_invalid": "Valore non valido per '{name:s}': {error:s}", - "app_argument_required": "L'argomento '{name:s}' è requisito", + "backup_app_failed": "Non è possibile fare il backup {app}", + "backup_archive_app_not_found": "{app} non è stata trovata nel archivio di backup", + "app_argument_choice_invalid": "Usa una delle seguenti scelte '{choices}' per il parametro '{name}' invece di '{value}'", + "app_argument_invalid": "Scegli un valore valido per il parametro '{name}': {error}", + "app_argument_required": "L'argomento '{name}' è requisito", "app_id_invalid": "Identificativo dell'applicazione non valido", "app_unsupported_remote_type": "Il tipo remoto usato per l'applicazione non è supportato", - "appslist_retrieve_error": "Impossibile recuperare la lista di applicazioni remote {appslist:s}: {error:s}", - "appslist_retrieve_bad_format": "Il file recuperato per la lista di applicazioni {appslist:s} non è valido", - "backup_archive_broken_link": "Non è possibile accedere al archivio di backup (link rotto verso {path:s})", - "backup_archive_hook_not_exec": "Il hook '{hook:s}' non è stato eseguito in questo backup", - "backup_archive_name_unknown": "Archivio di backup locale chiamato '{name:s}' sconosciuto", - "backup_archive_open_failed": "Non è possibile aprire l'archivio di backup", + "backup_archive_broken_link": "Non è possibile accedere all'archivio di backup (link rotto verso {path})", + "backup_archive_name_unknown": "Archivio di backup locale chiamato '{name}' sconosciuto", + "backup_archive_open_failed": "Impossibile aprire l'archivio di backup", "backup_cleaning_failed": "Non è possibile pulire la directory temporanea di backup", - "backup_creating_archive": "Creazione del archivio di backup…", - "backup_creation_failed": "La creazione del backup è fallita", - "backup_delete_error": "Impossibile cancellare '{path:s}'", - "backup_deleted": "Il backup è stato cancellato", - "backup_extracting_archive": "Estrazione del archivio di backup…", - "backup_hook_unknown": "Hook di backup '{hook:s}' sconosciuto", - "backup_nothings_done": "Non c'è niente da salvare", - "backup_output_directory_forbidden": "Directory di output vietata. I backup non possono esser creati nelle sotto-cartelle /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var o /home/yunohost.backup/archives", + "backup_creation_failed": "Impossibile creare l'archivio di backup", + "backup_delete_error": "Impossibile cancellare '{path}'", + "backup_deleted": "Backup cancellato", + "backup_hook_unknown": "Hook di backup '{hook}' sconosciuto", + "backup_nothings_done": "Niente da salvare", + "backup_output_directory_forbidden": "Scegli una diversa directory di output. I backup non possono esser creati nelle sotto-cartelle /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var o /home/yunohost.backup/archives", "backup_output_directory_required": "Devi fornire una directory di output per il backup", "backup_running_hooks": "Esecuzione degli hook di backup…", - "custom_app_url_required": "Devi fornire un URL per essere in grado di aggiornare l'applicazione personalizzata {app:s}", - "custom_appslist_name_required": "Devi fornire un nome per la lista di applicazioni personalizzata", - "diagnosis_debian_version_error": "Impossibile riportare la versione di Debian: {error}", - "diagnosis_kernel_version_error": "Impossibile riportare la versione del kernel: {error}", - "diagnosis_monitor_disk_error": "Impossibile controllare i dischi: {error}", - "diagnosis_monitor_network_error": "Impossibile controllare la rete: {error}", - "diagnosis_monitor_system_error": "Impossibile controllare il sistema: {error}", - "diagnosis_no_apps": "Nessuna applicazione installata", - "dnsmasq_isnt_installed": "dnsmasq non sembra installato, impartisci il comando 'apt-get remove bind9 && apt-get install dnsmasq'", - "domain_creation_failed": "Impossibile creare un dominio", - "domain_deleted": "Il dominio è stato cancellato", - "domain_deletion_failed": "Impossibile cancellare il dominio", + "custom_app_url_required": "Devi fornire un URL per essere in grado di aggiornare l'applicazione personalizzata {app}", + "domain_creation_failed": "Impossibile creare il dominio {domain}: {error}", + "domain_deleted": "Dominio cancellato", + "domain_deletion_failed": "Impossibile cancellare il dominio {domain}: {error}", "domain_dyndns_already_subscribed": "Hai già sottoscritto un dominio DynDNS", "domain_dyndns_root_unknown": "Dominio radice DynDNS sconosciuto", - "domain_hostname_failed": "La definizione del nuovo hostname è fallita", - "domain_uninstall_app_first": "Una o più applicazioni sono installate su questo dominio. Disinstalla loro prima di procedere alla cancellazione di un dominio", - "domain_unknown": "Dominio sconosciuto", - "domain_zone_exists": "Il file di zona DNS è già esistente", - "domain_zone_not_found": "Il file di zona DNS non è stato trovato per il dominio {:s}", + "domain_hostname_failed": "Impossibile impostare il nuovo hostname. Potrebbe causare problemi in futuro (o anche no).", + "domain_uninstall_app_first": "Queste applicazioni sono già installate su questo dominio:\n{apps}\n\nDisinstallale eseguendo 'yunohost app remove app_id' o spostale in un altro dominio eseguendo 'yunohost app change-url app_id' prima di procedere alla cancellazione del dominio", "done": "Terminato", "domains_available": "Domini disponibili:", "downloading": "Scaricamento…", - "dyndns_cron_installed": "Il cronjob DynDNS è stato installato", - "dyndns_cron_remove_failed": "Impossibile rimuovere il cronjob DynDNS", - "dyndns_cron_removed": "Il cronjob DynDNS è stato rimosso", "dyndns_ip_update_failed": "Impossibile aggiornare l'indirizzo IP in DynDNS", - "dyndns_ip_updated": "Il tuo indirizzo IP è stato aggiornato in DynDNS", - "dyndns_key_generating": "Si sta generando la chiave DNS, potrebbe richiedere del tempo…", + "dyndns_ip_updated": "Il tuo indirizzo IP è stato aggiornato su DynDNS", + "dyndns_key_generating": "Generando la chiave DNS... Potrebbe richiedere del tempo.", "dyndns_key_not_found": "La chiave DNS non è stata trovata per il dominio", - "dyndns_no_domain_registered": "Nessuno dominio è stato registrato con DynDNS", - "dyndns_registered": "Il dominio DynDNS è stato registrato", - "dyndns_registration_failed": "Non è possibile registrare il dominio DynDNS: {error:s}", - "dyndns_unavailable": "Dominio {domain:s} non disponibile.", - "executing_command": "Esecuzione del comando '{command:s}'…", - "executing_script": "Esecuzione dello script '{script:s}'…", - "extracting": "Estrazione…", - "field_invalid": "Campo '{:s}' non valido", + "dyndns_no_domain_registered": "Nessuno dominio registrato con DynDNS", + "dyndns_registered": "Dominio DynDNS registrato", + "dyndns_registration_failed": "Non è possibile registrare il dominio DynDNS: {error}", + "dyndns_unavailable": "Il dominio {domain} non disponibile.", + "extracting": "Estrazione...", + "field_invalid": "Campo '{}' non valido", "firewall_reload_failed": "Impossibile ricaricare il firewall", - "firewall_reloaded": "Il firewall è stato ricaricato", + "firewall_reloaded": "Firewall ricaricato", "firewall_rules_cmd_failed": "Alcune regole del firewall sono fallite. Per ulteriori informazioni, vedi il registro.", - "format_datetime_short": "%m/%d/%Y %I:%M %p", - "hook_exec_failed": "L'esecuzione dello script è fallita: {path:s}", - "hook_exec_not_terminated": "L'esecuzione dello script non è stata terminata: {path:s}", - "hook_name_unknown": "Nome di hook '{name:s}' sconosciuto", + "hook_exec_failed": "Impossibile eseguire lo script: {path}", + "hook_exec_not_terminated": "Los script non è stato eseguito correttamente: {path}", + "hook_name_unknown": "Nome di hook '{name}' sconosciuto", "installation_complete": "Installazione completata", - "installation_failed": "Installazione fallita", "ip6tables_unavailable": "Non puoi giocare con ip6tables qui. O sei in un container o il tuo kernel non lo supporta", "iptables_unavailable": "Non puoi giocare con iptables qui. O sei in un container o il tuo kernel non lo supporta", - "ldap_init_failed_to_create_admin": "L'inizializzazione LDAP non è riuscita a creare un utente admin", - "license_undefined": "Indeterminato", - "mail_alias_remove_failed": "Impossibile rimuovere l'alias mail '{mail:s}'", - "mail_domain_unknown": "Dominio d'indirizzo mail '{domain:s}' sconosciuto", - "mail_forward_remove_failed": "Impossibile rimuovere la mail inoltrata '{mail:s}'", - "mailbox_used_space_dovecot_down": "Il servizio di posta elettronica Dovecot deve essere attivato se vuoi riportare lo spazio usato dalla posta elettronica", - "maindomain_change_failed": "Impossibile cambiare il dominio principale", - "maindomain_changed": "Il dominio principale è stato cambiato", - "monitor_disabled": "Il monitoraggio del sistema è stato disattivato", - "monitor_enabled": "Il monitoraggio del sistema è stato attivato", - "monitor_glances_con_failed": "Impossibile collegarsi al server Glances", - "monitor_not_enabled": "Il monitoraggio del server non è attivato", - "monitor_period_invalid": "Periodo di tempo non valido", - "monitor_stats_file_not_found": "I file statistici non sono stati trovati", - "monitor_stats_no_update": "Nessuna statistica di monitoraggio da aggiornare", - "monitor_stats_period_unavailable": "Nessuna statistica disponibile per il periodo", - "mountpoint_unknown": "Punto di mount sconosciuto", - "mysql_db_creation_failed": "La creazione del database MySQL è fallita", - "mysql_db_init_failed": "L'inizializzazione del database MySQL è fallita", - "mysql_db_initialized": "Il database MySQL è stato inizializzato", - "new_domain_required": "Devi fornire il nuovo dominio principale", - "no_appslist_found": "Nessuna lista di applicazioni trovata", - "no_internet_connection": "Il server non è collegato a Internet", - "no_ipv6_connectivity": "La connessione IPv6 non è disponibile", - "not_enough_disk_space": "Non c'è abbastanza spazio libero in '{path:s}'", - "package_not_installed": "Il pacchetto '{pkgname}' non è installato", - "package_unknown": "Pacchetto '{pkgname}' sconosciuto", - "packages_no_upgrade": "Nessuno pacchetto da aggiornare", - "packages_upgrade_critical_later": "I pacchetti critici {packages:s} verranno aggiornati più tardi", + "mail_alias_remove_failed": "Impossibile rimuovere l'alias mail '{mail}'", + "mail_domain_unknown": "Indirizzo mail non valido per il dominio '{domain}'. Usa un dominio gestito da questo server.", + "mail_forward_remove_failed": "Impossibile rimuovere la mail inoltrata '{mail}'", + "mailbox_used_space_dovecot_down": "La casella di posta elettronica Dovecot deve essere attivato se vuoi recuperare lo spazio usato dalla posta elettronica", + "main_domain_change_failed": "Impossibile cambiare il dominio principale", + "main_domain_changed": "Il dominio principale è stato cambiato", + "not_enough_disk_space": "Non c'è abbastanza spazio libero in '{path}'", "packages_upgrade_failed": "Impossibile aggiornare tutti i pacchetti", - "path_removal_failed": "Impossibile rimuovere il percorso {:s}", - "pattern_backup_archive_name": "Deve essere un nome di file valido con caratteri alfanumerici e -_. soli", + "pattern_backup_archive_name": "Deve essere un nome di file valido di massimo 30 caratteri di lunghezza, con caratteri alfanumerici e \"-_.\" come unica punteggiatura", "pattern_domain": "Deve essere un nome di dominio valido (es. il-mio-dominio.org)", "pattern_firstname": "Deve essere un nome valido", "pattern_lastname": "Deve essere un cognome valido", - "pattern_listname": "Caratteri alfanumerici e trattini bassi soli", "pattern_password": "Deve contenere almeno 3 caratteri", - "pattern_port": "Deve essere un numero di porta valido (es. 0-65535)", "pattern_port_or_range": "Deve essere un numero di porta valido (es. 0-65535) o una fascia di porte valida (es. 100:200)", - "pattern_positive_number": "Deve essere un numero positivo", "pattern_username": "Caratteri minuscoli alfanumerici o trattini bassi soli", - "port_already_closed": "La porta {port:d} è già chiusa per le connessioni {ip_version:s}", - "port_available": "La porta {port:d} è disponibile", - "restore_action_required": "Devi specificare qualcosa da ripristinare", - "restore_already_installed_app": "Un'applicazione è già installata con l'identificativo '{app:s}'", - "restore_app_failed": "Impossibile ripristinare l'applicazione '{app:s}'", + "port_already_closed": "La porta {port} è già chiusa per le connessioni {ip_version}", + "restore_already_installed_app": "Un'applicazione con l'ID '{app}' è già installata", + "app_restore_failed": "Impossibile ripristinare l'applicazione '{app}': {error}", "restore_cleaning_failed": "Impossibile pulire la directory temporanea di ripristino", "restore_complete": "Ripristino completo", - "restore_confirm_yunohost_installed": "Sei sicuro di volere ripristinare un sistema già installato? {answers:s}", + "restore_confirm_yunohost_installed": "Sei sicuro di volere ripristinare un sistema già installato? {answers}", "restore_failed": "Impossibile ripristinare il sistema", - "user_update_failed": "Impossibile aggiornare l'utente", - "network_check_smtp_ko": "La posta in uscita (SMTP porta 25) sembra bloccata dalla tua rete", - "network_check_smtp_ok": "La posta in uscita (SMTP porta 25) non è bloccata", - "no_restore_script": "Nessuno script di ripristino trovato per l'applicazone '{app:s}'", - "package_unexpected_error": "Un'errore inaspettata si è verificata durante il trattamento del pacchetto '{pkgname}'", - "restore_hook_unavailable": "Lo script di ripristino per '{part:s}' non è disponibile per il tuo sistema e non è nemmeno nell'archivio", - "restore_nothings_done": "Non è stato ripristinato nulla", - "restore_running_app_script": "Esecuzione dello script di ripristino dell'applicazione '{app:s}'…", - "restore_running_hooks": "Esecuzione degli hook di ripristino…", - "service_added": "Il servizio '{service:s}' è stato aggiunto", - "service_already_started": "Il servizio '{service:s}' è già stato avviato", - "service_already_stopped": "Il servizio '{service:s}' è già stato fermato", - "service_conf_file_backed_up": "Il file di configurazione '{conf}' è stato salvato in '{backup}'", - "service_conf_file_copy_failed": "Impossibile copiare il nuovo file di configurazione '{new}' in '{conf}'", - "service_conf_file_manually_modified": "Il file di configurazione '{conf}' è stato modificato manualmente e non verrà aggiornato", - "service_conf_file_manually_removed": "Il file di configurazione '{conf}' è stato rimosso manualmente e non verrà creato", - "service_conf_file_not_managed": "Il file di configurazione '{conf}' non è ancora amministrato e non verrà aggiornato", - "service_conf_file_remove_failed": "Impossibile rimuovere il file di configurazione '{conf}'", - "service_conf_file_removed": "Il file di configurazione '{conf}' è stato rimosso", - "service_conf_file_updated": "Il file di configurazione '{conf}' è stato aggiornato", - "service_conf_up_to_date": "La configurazione è già aggiornata per il servizio '{service}'", - "service_conf_updated": "La configurazione è stata aggiornata per il servizio '{service}'", - "service_conf_would_be_updated": "La configurazione sarebbe stata aggiornata per il servizio '{service}'", - "service_disable_failed": "Impossibile disabilitare il servizio '{service:s}'\n\nRegistri di servizio recenti:{logs:s}", - "service_enable_failed": "Impossibile abilitare il servizio '{service:s}'\n\nRegistri di servizio recenti:{logs:s}", - "service_enabled": "Il servizio '{service:s}' è stato attivato", - "service_no_log": "Nessuno registro da visualizzare per il servizio '{service:s}'", - "service_regenconf_dry_pending_applying": "Verifica della configurazione in sospeso che sarebbe stata applicata per il servizio '{service}'…", - "service_regenconf_failed": "Impossibile rigenerare la configurazione per il/i servizio/i: {services}", - "service_regenconf_pending_applying": "Applicazione della configurazione in sospeso per il servizio '{service}'…", - "service_start_failed": "Impossibile eseguire il servizio '{service:s}'\n\nRegistri di servizio recenti:{logs:s}", - "service_started": "Il servizio '{service:s}' è stato avviato", - "service_status_failed": "Impossibile determinare lo stato del servizio '{service:s}'", - "service_stopped": "Il servizio '{service:s}' è stato fermato", - "service_unknown": "Servizio '{service:s}' sconosciuto", - "ssowat_conf_generated": "La configurazione SSOwat è stata generata", - "ssowat_conf_updated": "La configurazione SSOwat è stata aggiornata", - "ssowat_persistent_conf_read_error": "Un'errore si è verificata durante la lettura della configurazione persistente SSOwat: {error:s}. Modifica il file persistente /etc/ssowat/conf.json per correggere la sintassi JSON", - "ssowat_persistent_conf_write_error": "Un'errore si è verificata durante la registrazione della configurazione persistente SSOwat: {error:s}. Modifica il file persistente /etc/ssowat/conf.json per correggere la sintassi JSON", - "system_upgraded": "Il sistema è stato aggiornato", - "unbackup_app": "L'applicazione '{app:s}' non verrà salvata", - "unexpected_error": "Un'errore inaspettata si è verificata", - "unit_unknown": "Unità '{unit:s}' sconosciuta", + "user_update_failed": "Impossibile aggiornare l'utente {user}: {error}", + "restore_hook_unavailable": "Lo script di ripristino per '{part}' non è disponibile per il tuo sistema e non è nemmeno nell'archivio", + "restore_nothings_done": "Nulla è stato ripristinato", + "restore_running_app_script": "Ripristino dell'app '{app}'...", + "restore_running_hooks": "Esecuzione degli hook di ripristino...", + "service_added": "Il servizio '{service}' è stato aggiunto", + "service_already_started": "Il servizio '{service}' è già avviato", + "service_already_stopped": "Il servizio '{service}' è già stato fermato", + "service_disable_failed": "Impossibile disabilitare l'avvio al boot del servizio '{service}'\n\nRegistri di servizio recenti:{logs}", + "service_enable_failed": "Impossibile eseguire il servizio '{service}' al boot di sistema.\n\nRegistri di servizio recenti:{logs}", + "service_enabled": "Il servizio '{service}' si avvierà automaticamente al boot di sistema.", + "service_start_failed": "Impossibile eseguire il servizio '{service}'\n\nRegistri di servizio recenti:{logs}", + "service_started": "Servizio '{service}' avviato", + "service_stopped": "Servizio '{service}' fermato", + "service_unknown": "Servizio '{service}' sconosciuto", + "ssowat_conf_generated": "La configurazione SSOwat rigenerata", + "ssowat_conf_updated": "Configurazione SSOwat aggiornata", + "system_upgraded": "Sistema aggiornato", + "unbackup_app": "{app} non verrà salvata", + "unexpected_error": "È successo qualcosa di inatteso: {error}", "unlimit": "Nessuna quota", - "update_cache_failed": "Impossibile aggiornare la cache APT", - "updating_apt_cache": "Recupero degli aggiornamenti disponibili per i pacchetti di sistema…", + "updating_apt_cache": "Recupero degli aggiornamenti disponibili per i pacchetti di sistema...", "upgrade_complete": "Aggiornamento completo", "upnp_dev_not_found": "Nessuno supporto UPnP trovato", - "upnp_disabled": "UPnP è stato disattivato", - "upnp_enabled": "UPnP è stato attivato", - "upnp_port_open_failed": "Impossibile aprire le porte UPnP", - "user_created": "L'utente è stato creato", - "user_creation_failed": "Impossibile creare l'utente", - "user_deletion_failed": "Impossibile cancellare l'utente", - "user_home_creation_failed": "Impossibile creare la home directory del utente", - "user_info_failed": "Impossibile riportare le informazioni del utente", - "user_unknown": "Utente sconosciuto: {user:s}", - "user_updated": "L'utente è stato aggiornato", + "upnp_disabled": "UPnP è disattivato", + "upnp_enabled": "UPnP è attivato", + "upnp_port_open_failed": "Impossibile aprire le porte attraverso UPnP", + "user_created": "Utente creato", + "user_creation_failed": "Impossibile creare l'utente {user}: {error}", + "user_deletion_failed": "Impossibile cancellare l'utente {user}: {error}", + "user_home_creation_failed": "Impossibile creare la home directory '{home}' del utente", + "user_unknown": "Utente sconosciuto: {user}", + "user_updated": "Info dell'utente cambiate", "yunohost_already_installed": "YunoHost è già installato", - "yunohost_ca_creation_failed": "Impossibile creare una certificate authority", - "yunohost_configured": "YunoHost è stato configurato", - "yunohost_installing": "Installazione di YunoHost…", - "yunohost_not_installed": "YunoHost non è o non corretamente installato. Esegui 'yunohost tools postinstall'", + "yunohost_configured": "YunoHost ora è configurato", + "yunohost_installing": "Installazione di YunoHost...", + "yunohost_not_installed": "YunoHost non è correttamente installato. Esegui 'yunohost tools postinstall'", "domain_cert_gen_failed": "Impossibile generare il certificato", - "certmanager_attempt_to_replace_valid_cert": "Stai provando a sovrascrivere un certificato buono e valido per il dominio {domain:s}! (Usa --force per ignorare)", - "certmanager_domain_unknown": "Dominio {domain:s} sconosciuto", - "certmanager_domain_cert_not_selfsigned": "Il ceritifcato per il dominio {domain:s} non è auto-firmato. Sei sicuro di volere sostituirlo? (Usa --force)", - "certmanager_certificate_fetching_or_enabling_failed": "L'attivazione del nuovo certificato per {domain:s} sembra fallita per qualche motivo…", - "certmanager_attempt_to_renew_nonLE_cert": "Il certificato per il dominio {domain:s} non è emesso da Let's Encrypt. Impossibile rinnovarlo automaticamente!", - "certmanager_attempt_to_renew_valid_cert": "Il certificato per il dominio {domain:s} non è a scadere! Usa --force per ignorare", - "certmanager_domain_http_not_working": "Sembra che non sia possibile accedere al dominio {domain:s} attraverso HTTP. Verifica la configurazione del DNS e di nginx", - "app_already_installed_cant_change_url": "Questa applicazione è già installata. L'URL non può essere cambiato solo da questa funzione. Guarda se `app changeurl` è disponibile.", - "app_already_up_to_date": "{app:s} è già aggiornata", - "app_change_no_change_url_script": "L'applicazione {app_name:s} non supporta ancora il cambio del proprio URL, potrebbe essere necessario aggiornarla.", - "app_change_url_failed_nginx_reload": "Riavvio di nginx fallito. Questo è il risultato di 'nginx -t':\n{nginx_errors:s}", - "app_change_url_identical_domains": "Il vecchio ed il nuovo dominio/percorso_url sono identici ('{domain:s}{path:s}'), nessuna operazione necessaria.", - "app_change_url_no_script": "L'applicazione '{app_name:s}' non supporta ancora la modifica dell'URL. Forse dovresti aggiornare l'applicazione.", - "app_change_url_success": "URL dell'applicazione {app:s} cambiato con successo in {domain:s}{path:s}", - "app_make_default_location_already_used": "Impostazione dell'applicazione '{app}' come predefinita del dominio {domain} non riuscita perchè è già stata impostata per l'altra applicazione '{other_app}'", - "app_location_unavailable": "Questo URL non è disponibile o va in conflitto con la/le applicazione/i già installata/e:\n{apps:s}", - "app_upgrade_app_name": "Aggiornando l'applicazione {app}…", - "app_upgrade_some_app_failed": "Impossibile aggiornare alcune applicazioni", - "appslist_corrupted_json": "Caricamento della lista delle applicazioni non riuscita. Sembra che {filename:s} sia corrotto.", - "appslist_could_not_migrate": "Migrazione della lista delle applicazioni {appslist:s} non riuscita! Impossibile analizzare l'URL... La vecchia operazione pianificata è stata tenuta in {bkp_file:s}.", - "appslist_migrating": "Migrando la lista di applicazioni {appslist:s}…", - "appslist_name_already_tracked": "C'è già una lista di applicazioni registrata con il nome {name:s}.", - "appslist_url_already_tracked": "C'è già una lista di applicazioni registrata con URL {url:s}.", - "ask_path": "Percorso", - "backup_abstract_method": "Questo metodo di backup non è ancora stato implementato", - "backup_applying_method_borg": "Inviando tutti i file da salvare nel backup nel deposito borg-backup…", - "backup_applying_method_copy": "Copiando tutti i files nel backup…", - "backup_applying_method_custom": "Chiamando il metodo di backup personalizzato '{method:s}'…", - "backup_applying_method_tar": "Creando l'archivio tar del backup…", - "backup_archive_mount_failed": "Montaggio dell'archivio del backup non riuscito", - "backup_archive_system_part_not_available": "La parte di sistema '{part:s}' non è disponibile in questo backup", - "backup_archive_writing_error": "Impossibile aggiungere i file al backup nell'archivio compresso", - "backup_ask_for_copying_if_needed": "Alcuni files non possono essere preparati al backup utilizzando il metodo che consente di evitare il consumo temporaneo di spazio nel sistema. Per eseguire il backup, {size:s}MB dovranno essere utilizzati temporaneamente. Sei d'accordo?", - "backup_borg_not_implemented": "Il metodo di backup Borg non è ancora stato implementato", + "certmanager_attempt_to_replace_valid_cert": "Stai provando a sovrascrivere un certificato buono e valido per il dominio {domain}! (Usa --force per ignorare)", + "certmanager_domain_cert_not_selfsigned": "Il certificato per il dominio {domain} non è auto-firmato. Sei sicuro di volere sostituirlo? (Usa '--force')", + "certmanager_certificate_fetching_or_enabling_failed": "Il tentativo di usare il nuovo certificato per {domain} non funziona...", + "certmanager_attempt_to_renew_nonLE_cert": "Il certificato per il dominio {domain} non è emesso da Let's Encrypt. Impossibile rinnovarlo automaticamente!", + "certmanager_attempt_to_renew_valid_cert": "Il certificato per il dominio {domain} non è in scadenza! (Puoi usare --force per forzare se sai quel che stai facendo)", + "certmanager_domain_http_not_working": "Il dominio {domain} non sembra accessibile attraverso HTTP. Verifica nella sezione 'Web' nella diagnosi per maggiori informazioni. (Se sai cosa stai facendo, usa '--no-checks' per disattivare i controlli.)", + "app_already_installed_cant_change_url": "Questa applicazione è già installata. L'URL non può essere cambiato solo da questa funzione. Controlla se `app changeurl` è disponibile.", + "app_already_up_to_date": "{app} è già aggiornata", + "app_change_url_identical_domains": "Il vecchio ed il nuovo dominio/percorso_url sono identici ('{domain}{path}'), nessuna operazione necessaria.", + "app_change_url_no_script": "L'applicazione '{app_name}' non supporta ancora la modifica dell'URL. Forse dovresti aggiornarla.", + "app_change_url_success": "L'URL dell'applicazione {app} è stato cambiato in {domain}{path}", + "app_make_default_location_already_used": "Impostazione dell'applicazione '{app}' come predefinita del dominio non riuscita perché il dominio '{domain}' è in uso per dall'applicazione '{other_app}'", + "app_location_unavailable": "Questo URL non è più disponibile o va in conflitto con la/le applicazione/i già installata/e:\n{apps}", + "app_upgrade_app_name": "Aggiornamento di {app}...", + "app_upgrade_some_app_failed": "Alcune applicazioni non possono essere aggiornate", + "backup_abstract_method": "Questo metodo di backup deve essere ancora implementato", + "backup_applying_method_copy": "Copiando tutti i files nel backup...", + "backup_applying_method_custom": "Chiamando il metodo di backup personalizzato '{method}'...", + "backup_applying_method_tar": "Creando l'archivio TAR del backup...", + "backup_archive_system_part_not_available": "La parte di sistema '{part}' non è disponibile in questo backup", + "backup_archive_writing_error": "Impossibile aggiungere i file '{source}' (indicati nell'archivio '{dest}') al backup nell'archivio compresso '{archive}'", + "backup_ask_for_copying_if_needed": "Vuoi effettuare il backup usando {size}MB temporaneamente? (È necessario usare questo sistema poiché alcuni file non possono essere preparati in un modo più efficiente)", "backup_cant_mount_uncompress_archive": "Impossibile montare in modalità sola lettura la cartella di archivio non compressa", - "backup_copying_to_organize_the_archive": "Copiando {size:s}MB per organizzare l'archivio", - "backup_couldnt_bind": "Impossibile legare {src:s} a {dest:s}.", + "backup_copying_to_organize_the_archive": "Copiando {size}MB per organizzare l'archivio", + "backup_couldnt_bind": "Impossibile legare {src} a {dest}.", "backup_csv_addition_failed": "Impossibile aggiungere file del backup nel file CSV", - "backup_csv_creation_failed": "Impossibile creare il file CVS richiesto per le future operazioni di ripristino", + "backup_csv_creation_failed": "Impossibile creare il file CVS richiesto per le operazioni di ripristino", "backup_custom_backup_error": "Il metodo di backup personalizzato è fallito allo step 'backup'", "backup_custom_mount_error": "Il metodo di backup personalizzato è fallito allo step 'mount'", - "backup_custom_need_mount_error": "Il metodo di backup personalizzato è fallito allo step 'need_mount'", - "backup_method_borg_finished": "Backup in borg terminato", "backup_method_copy_finished": "Copia di backup terminata", - "backup_method_custom_finished": "Metodo di backup personalizzato '{method:s}' terminato", - "backup_method_tar_finished": "Archivio tar di backup creato", + "backup_method_custom_finished": "Metodo di backup personalizzato '{method}' terminato", + "backup_method_tar_finished": "Archivio TAR di backup creato", "backup_no_uncompress_archive_dir": "La cartella di archivio non compressa non esiste", - "backup_php5_to_php7_migration_may_fail": "Conversione del tuo archivio per supportare php7 non riuscita, le tue app php potrebbero fallire in fase di ripristino (motivo: {error:s})", - "backup_system_part_failed": "Impossibile creare il backup della parte di sistema '{part:s}'", + "backup_system_part_failed": "Impossibile creare il backup della parte di sistema '{part}'", "backup_unable_to_organize_files": "Impossibile organizzare i file nell'archivio con il metodo veloce", - "backup_with_no_backup_script_for_app": "L'app {app:s} non ha script di backup. Ignorata.", - "backup_with_no_restore_script_for_app": "L'app {app:s} non ha script di ripristino, non sarai in grado di ripristinarla automaticamente dal backup di questa app.", - "certmanager_acme_not_configured_for_domain": "Il certificato per il dominio {domain:s} non sembra essere correttamente installato. Per favore esegui cert-install per questo dominio prima.", - "certmanager_cannot_read_cert": "Qualcosa è andato storto nel tentativo di aprire il certificato attuale per il dominio {domain:s} (file: {file:s}), motivo: {reason:s}", - "certmanager_cert_install_success": "Certificato Let's Encrypt per il dominio {domain:s} installato con successo!", + "backup_with_no_backup_script_for_app": "L'app {app} non ha script di backup. Ignorata.", + "backup_with_no_restore_script_for_app": "L'app {app} non ha script di ripristino, non sarai in grado di ripristinarla automaticamente dal backup di questa app.", + "certmanager_acme_not_configured_for_domain": "La challenge ACME non può validare il {domain} perché la relativa configurazione di nginx è mancante... Assicurati che la tua configurazione di nginx sia aggiornata con il comando `yunohost tools regen-conf nginx --dry-run --with-diff`.", + "certmanager_cannot_read_cert": "Qualcosa è andato storto nel tentativo di aprire il certificato attuale per il dominio {domain} (file: {file}), motivo: {reason}", + "certmanager_cert_install_success": "Certificato Let's Encrypt per il dominio {domain} installato", "aborting": "Annullamento.", "admin_password_too_long": "Per favore scegli una password più corta di 127 caratteri", - "app_not_upgraded": "Le seguenti app non sono state aggiornate: {apps}", - "app_start_install": "Installando l'applicazione {app}…", - "app_start_remove": "Rimuovendo l'applicazione {app}…", - "app_start_backup": "Raccogliendo file da salvare nel backup per {app}…", - "app_start_restore": "Ripristinando l'applicazione {app}…", - "app_upgrade_several_apps": "Le seguenti app saranno aggiornate : {apps}", + "app_not_upgraded": "Impossibile aggiornare le applicazioni '{failed_app}' e di conseguenza l'aggiornamento delle seguenti applicazione è stato cancellato: {apps}", + "app_start_install": "Installando '{app}'...", + "app_start_remove": "Rimozione di {app}...", + "app_start_backup": "Raccogliendo file da salvare nel backup per '{app}'...", + "app_start_restore": "Ripristino di '{app}'...", + "app_upgrade_several_apps": "Le seguenti applicazioni saranno aggiornate : {apps}", "ask_new_domain": "Nuovo dominio", "ask_new_path": "Nuovo percorso", - "backup_actually_backuping": "Creando un archivio di backup con i file raccolti…", - "backup_mount_archive_for_restore": "Preparando l'archivio per il ripristino…", - "certmanager_cert_install_success_selfsigned": "Certificato autofirmato installato con successo per il dominio {domain:s}!", - "certmanager_cert_renew_success": "Certificato di Let's Encrypt rinnovato con successo per il dominio {domain:s}!", - "certmanager_cert_signing_failed": "Firma del nuovo certificato fallita", + "backup_actually_backuping": "Creazione di un archivio di backup con i file raccolti...", + "backup_mount_archive_for_restore": "Preparazione dell'archivio per il ripristino…", + "certmanager_cert_install_success_selfsigned": "Certificato autofirmato installato con successo per il dominio {domain}", + "certmanager_cert_renew_success": "Certificato di Let's Encrypt rinnovato con successo per il dominio {domain}", + "certmanager_cert_signing_failed": "Impossibile firmare il nuovo certificato", "good_practices_about_user_password": "Ora stai per impostare una nuova password utente. La password dovrebbe essere di almeno 8 caratteri - anche se è buona pratica utilizzare password più lunghe (es. una sequenza di parole) e/o utilizzare vari tipi di caratteri (maiuscole, minuscole, numeri e simboli).", "password_listed": "Questa password è una tra le più utilizzate al mondo. Per favore scegline una più unica.", - "password_too_simple_1": "La password deve essere lunga almeno 8 caratteri", + "password_too_simple_1": "La password deve contenere almeno 8 caratteri", "password_too_simple_2": "La password deve essere lunga almeno 8 caratteri e contenere numeri, maiuscole e minuscole", "password_too_simple_3": "La password deve essere lunga almeno 8 caratteri e contenere numeri, maiuscole e minuscole e simboli", "password_too_simple_4": "La password deve essere lunga almeno 12 caratteri e contenere numeri, maiuscole e minuscole", - "users_available": "Utenti disponibili:", - "yunohost_ca_creation_success": "L'autorità di certificazione locale è stata creata.", - "app_action_cannot_be_ran_because_required_services_down": "Questa app richiede alcuni servizi che attualmente non sono attivi. Prima di continuare, dovresti provare a riavviare i seguenti servizi (e possibilmente capire perchè questi non siano attivi) : {services}", - "backup_output_symlink_dir_broken": "Hai un collegamento errato alla tua cartella di archiviazione '{path:s}'. Potresti avere delle impostazioni particolari per salvare i tuoi dati su un altro spazio, in questo caso probabilmente ti sei scordato di rimontare o collegare il tuo hard disk o la chiavetta usb.", - "certmanager_conflicting_nginx_file": "Impossibile preparare il dominio per il controllo ACME: il file di configurazione nginx {filepath:s} è in conflitto e dovrebbe essere prima rimosso", - "certmanager_couldnt_fetch_intermediate_cert": "Tempo scaduto durante il tentativo di recupero di un certificato intermedio da Let's Encrypt. Installazione/rinnovo non riuscito - per favore riprova più tardi.", - "certmanager_domain_dns_ip_differs_from_public_ip": "Il valore DNS 'A' per il dominio {domain:s} è diverso dall'IP di questo server. Se hai modificato recentemente il tuo valore A, attendi che si propaghi (esistono online alcuni siti per il controllo della propagazione DNS). (Se sai cosa stai facendo, usa --no-checks per disabilitare quei controlli.)", - "certmanager_domain_not_resolved_locally": "Il dominio {domain:s} non può essere risolto in locale dal server Yunohost. Questo può accadere se hai modificato recentemente il tuo valore DNS. Se così fosse, per favore aspetta qualche ora per far si che si propaghi. Se il problema persiste, prova ad aggiungere {domain:s} in /etc/hosts. (Se sai cosa stai facendo, usa --no-checks per disabilitare quei controlli.)", - "certmanager_error_no_A_record": "Nessun valore DNS 'A' trovato per {domain:s}. Devi far puntare il tuo nome di dominio verso la tua macchina per essere in grado di installare un certificato Let's Encrypt! (Se sai cosa stai facendo, usa --no-checks per disabilitare quei controlli.)", - "certmanager_hit_rate_limit": "Troppi certificati già rilasciati per l'esatta serie di dominii {domain:s} recentemente. Per favore riprova più tardi. Guarda https://letsencrypt.org/docs/rate-limits/ per maggiori dettagli", - "certmanager_http_check_timeout": "Tempo scaduto durante il tentativo di contatto del tuo server a se stesso attraverso HTTP utilizzando l'indirizzo IP pubblico (dominio {domain:s} con ip {ip:s}). Potresti avere un problema di hairpinning o il firewall/router davanti al tuo server non è correttamente configurato.", - "certmanager_no_cert_file": "Impossibile leggere il file di certificato per il dominio {domain:s} (file: {file:s})", - "certmanager_self_ca_conf_file_not_found": "File di configurazione non trovato per l'autorità di autofirma (file: {file:s})", - "certmanager_unable_to_parse_self_CA_name": "Impossibile analizzare il nome dell'autorità di autofirma (file: {file:s})", - "confirm_app_install_warning": "Attenzione: questa applicazione potrebbe funzionare ma non è ben integrata in YunoHost. Alcune funzionalità come l'accesso unico e il backup/ripristino potrebbero non essere disponibili. Installare comunque? [{answers:s}] ", - "confirm_app_install_danger": "ATTENZIONE! Questa applicazione è ancora sperimentale (se non esplicitamente non funzionante) e probabilmente potrebbe danneggiare il tuo sistema! Probabilmente NON dovresti installarla a meno che tu non sappia cosa stai facendo. Sicuro di volerti prendere questo rischio? [{answers:s}] ", - "confirm_app_install_thirdparty": "ATTENZIONE! Installando applicazioni di terze parti potresti compromettere l'integrita e la sicurezza del tuo sistema. Probabilmente NON dovresti installarle a meno che tu non sappia cosa stai facendo. Sicuro di volerti prendere questo rischio? [{answers:s}] ", - "dpkg_is_broken": "Non puoi eseguire questo ora perchè dpkg/apt (i gestori di pacchetti del sistema) sembrano essere in stato danneggiato... Puoi provare a risolvere il problema connettendoti via SSH ed eseguire `sudo dpkg --configure -a`.", - "domain_cannot_remove_main": "Non è possibile rimuovere il dominio principale ora. Prima imposta un nuovo dominio principale", - "domain_dns_conf_is_just_a_recommendation": "Questo comando ti mostra qual è la configurazione *raccomandata*. Non ti imposta la configurazione DNS al tuo posto. È tua responsabilità configurare la tua zona DNS nel tuo registrar in accordo con queste raccomandazioni.", - "domain_dyndns_dynette_is_unreachable": "Impossibile raggiungere la dynette YunoHost, o il tuo YunHost non è correttamente connesso a internet o il server dynette non è attivo. Errore: {error}", - "dyndns_could_not_check_provide": "Impossibile controllare se {provider:s} possano fornire {domain:s}.", - "dyndns_could_not_check_available": "Impossibile controllare se {domain:s} è disponibile su {provider:s}.", - "dyndns_domain_not_provided": "Il fornitore Dyndns {provider:s} non può fornire il dominio {domain:s}.", - "experimental_feature": "Attenzione: questa funzionalità è sperimentale e non è considerata stabile, non dovresti utilizzarla a meno che tu non sappia cosa stai facendo.", - "file_does_not_exist": "Il file {path:s} non esiste.", - "global_settings_bad_choice_for_enum": "Scelta sbagliata per l'impostazione {setting:s}, ricevuta '{choice:s}' ma le scelte disponibili sono : {available_choices:s}", - "global_settings_bad_type_for_setting": "Tipo errato per l'impostazione {setting:s}, ricevuto {received_type:s}, atteso {expected_type:s}", - "global_settings_cant_open_settings": "Apertura del file delle impostazioni non riuscita, motivo: {reason:s}", - "global_settings_cant_serialize_settings": "Serializzazione dei dati delle impostazioni non riuscita, motivo: {reason:s}", - "global_settings_cant_write_settings": "Scrittura del file delle impostazioni non riuscita, motivo: {reason:s}", - "global_settings_key_doesnt_exists": "La chiave '{settings_key:s}' non esiste nelle impostazioni globali, puoi vedere tutte le chiavi disponibili eseguendo 'yunohost settings list'", - "global_settings_reset_success": "Successo. Le tue impostazioni precedenti sono state salvate in {path:s}", - "global_settings_setting_example_bool": "Esempio di opzione booleana", - "global_settings_setting_example_enum": "Esempio di opzione enum", - "already_up_to_date": "Niente da fare! Tutto è già aggiornato!", - "global_settings_setting_example_int": "Esempio di opzione int", - "global_settings_setting_example_string": "Esempio di opzione string", - "global_settings_setting_security_nginx_compatibility": "Bilanciamento tra compatibilità e sicurezza per il server web nginx. Riguarda gli algoritmi di cifratura (e altri aspetti legati alla sicurezza)", + "app_action_cannot_be_ran_because_required_services_down": "I seguenti servizi dovrebbero essere in funzione per completare questa azione: {services}. Prova a riavviarli per proseguire (e possibilmente cercare di capire come ma non funzionano più).", + "backup_output_symlink_dir_broken": "La tua cartella d'archivio '{path}' è un link simbolico interrotto. Probabilmente hai dimenticato di montare o montare nuovamente il supporto al quale punta il link.", + "certmanager_domain_dns_ip_differs_from_public_ip": "I record DNS per il dominio '{domain}' è diverso dall'IP di questo server. Controlla la sezione (basic) 'Record DNS' nella diagnosi per maggiori informazioni. Se hai modificato recentemente il tuo valore A, attendi che si propaghi (esistono online alcuni siti per il controllo della propagazione DNS). (Se sai cosa stai facendo, usa '--no-checks' per disattivare i controlli.)", + "certmanager_hit_rate_limit": "Troppi certificati già rilasciati per questa esatta serie di domini {domain} recentemente. Per favore riprova più tardi. Guarda https://letsencrypt.org/docs/rate-limits/ per maggiori dettagli", + "certmanager_no_cert_file": "Impossibile leggere il file di certificato per il dominio {domain} (file: {file})", + "certmanager_self_ca_conf_file_not_found": "File di configurazione non trovato per l'autorità di auto-firma (file: {file})", + "certmanager_unable_to_parse_self_CA_name": "Impossibile analizzare il nome dell'autorità di auto-firma (file: {file})", + "confirm_app_install_warning": "Attenzione: Questa applicazione potrebbe funzionare, ma non è ben integrata in YunoHost. Alcune funzionalità come il single sign-on e il backup/ripristino potrebbero non essere disponibili. Installare comunque? [{answers}] ", + "confirm_app_install_danger": "ATTENZIONE! Questa applicazione è ancora sperimentale (se non esplicitamente dichiarata non funzionante)! Probabilmente NON dovresti installarla a meno che tu non sappia cosa stai facendo. NESSUN SUPPORTO verrà dato se quest'app non funziona o se rompe il tuo sistema... Se comunque accetti di prenderti questo rischio,digita '{answers}'", + "confirm_app_install_thirdparty": "PERICOLO! Quest'applicazione non fa parte del catalogo YunoHost. Installando app di terze parti potresti compromettere l'integrita e la sicurezza del tuo sistema. Probabilmente NON dovresti installarla a meno che tu non sappia cosa stai facendo. NESSUN SUPPORTO verrà dato se quest'app non funziona o se rompe il tuo sistema... Se comunque accetti di prenderti questo rischio, digita '{answers}'", + "dpkg_is_broken": "Non puoi eseguire questo ora perchè dpkg/APT (i gestori di pacchetti del sistema) sembrano essere in stato danneggiato... Puoi provare a risolvere il problema connettendoti via SSH ed eseguire `sudo apt install --fix-broken` e/o `sudo dpkg --configure -a`.", + "domain_cannot_remove_main": "Non puoi rimuovere '{domain}' essendo il dominio principale, prima devi impostare un nuovo dominio principale con il comando 'yunohost domain main-domain -n '; ecco la lista dei domini candidati: {other_domains}", + "domain_dns_conf_is_just_a_recommendation": "Questo comando ti mostra la configurazione *raccomandata*. Non ti imposta la configurazione DNS al tuo posto. È tua responsabilità configurare la tua zona DNS nel tuo registrar in accordo con queste raccomandazioni.", + "dyndns_could_not_check_provide": "Impossibile controllare se {provider} possano fornire {domain}.", + "dyndns_could_not_check_available": "Impossibile controllare se {domain} è disponibile su {provider}.", + "dyndns_domain_not_provided": "Il fornitore DynDNS {provider} non può fornire il dominio {domain}.", + "experimental_feature": "Attenzione: Questa funzionalità è sperimentale e non è considerata stabile, non dovresti utilizzarla a meno che tu non sappia cosa stai facendo.", + "file_does_not_exist": "Il file {path} non esiste.", + "global_settings_bad_choice_for_enum": "Scelta sbagliata per l'impostazione {setting}, ricevuta '{choice}', ma le scelte disponibili sono: {available_choices}", + "global_settings_bad_type_for_setting": "Tipo errato per l'impostazione {setting}, ricevuto {received_type}, atteso {expected_type}", + "global_settings_cant_open_settings": "Apertura del file delle impostazioni non riuscita, motivo: {reason}", + "global_settings_cant_serialize_settings": "Serializzazione dei dati delle impostazioni non riuscita, motivo: {reason}", + "global_settings_cant_write_settings": "Scrittura del file delle impostazioni non riuscita, motivo: {reason}", + "global_settings_key_doesnt_exists": "La chiave '{settings_key}' non esiste nelle impostazioni globali, puoi vedere tutte le chiavi disponibili eseguendo 'yunohost settings list'", + "global_settings_reset_success": "Le impostazioni precedenti sono state salvate in {path}", + "already_up_to_date": "Niente da fare. Tutto è già aggiornato.", + "global_settings_setting_security_nginx_compatibility": "Bilanciamento tra compatibilità e sicurezza per il server web NGIX. Riguarda gli algoritmi di cifratura (e altri aspetti legati alla sicurezza)", "global_settings_setting_security_password_admin_strength": "Complessità della password di amministratore", "global_settings_setting_security_password_user_strength": "Complessità della password utente", "global_settings_setting_security_ssh_compatibility": "Bilanciamento tra compatibilità e sicurezza per il server SSH. Riguarda gli algoritmi di cifratura (e altri aspetti legati alla sicurezza)", - "global_settings_unknown_setting_from_settings_file": "Chiave sconosciuta nelle impostazioni: '{setting_key:s}', scartata e salvata in /etc/yunohost/settings-unknown.json", - "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Consenti l'uso del (deprecato) hostkey DSA per la configurazione del demone SSH", - "global_settings_unknown_type": "Situazione inaspettata, l'impostazione {setting:s} sembra essere di tipo {unknown_type:s} ma non è un tipo supportato dal sistema.", - "good_practices_about_admin_password": "Stai per definire una nuova password di amministratore. La password deve essere almeno di 8 caratteri - anche se è buona pratica utilizzare password più lunghe (es. una frase, una serie di parole) e/o utilizzare vari tipi di caratteri (maiuscole, minuscole, numeri e simboli).", - "invalid_url_format": "Formato URL non valido", - "log_corrupted_md_file": "Il file dei metadati yaml associato con i registri è corrotto: '{md_file}'", - "log_category_404": "La categoria di registrazione '{category}' non esiste", + "global_settings_unknown_setting_from_settings_file": "Chiave sconosciuta nelle impostazioni: '{setting_key}', scartata e salvata in /etc/yunohost/settings-unknown.json", + "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Consenti l'uso del hostkey DSA (deprecato) per la configurazione del demone SSH", + "global_settings_unknown_type": "Situazione inaspettata, l'impostazione {setting} sembra essere di tipo {unknown_type} ma non è un tipo supportato dal sistema.", + "good_practices_about_admin_password": "Stai per impostare una nuova password di amministratore. La password deve essere almeno di 8 caratteri - anche se è buona pratica utilizzare password più lunghe (es. una frase, una serie di parole) e/o utilizzare vari tipi di caratteri (maiuscole, minuscole, numeri e simboli).", + "log_corrupted_md_file": "Il file dei metadati YAML associato con i registri è danneggiato: '{md_file}'\nErrore: {error}", "log_link_to_log": "Registro completo di questa operazione: '{desc}'", - "log_help_to_get_log": "Per vedere il registro dell'operazione '{desc}', usa il comando 'yunohost log display {name}'", + "log_help_to_get_log": "Per vedere il registro dell'operazione '{desc}', usa il comando 'yunohost log show {name}'", "global_settings_setting_security_postfix_compatibility": "Bilanciamento tra compatibilità e sicurezza per il server Postfix. Riguarda gli algoritmi di cifratura (e altri aspetti legati alla sicurezza)", - "log_link_to_failed_log": "L'operazione '{desc}' è fallita! Per ottenere aiuto, per favore fornisci il registro completo dell'operazione cliccando qui", - "log_help_to_get_failed_log": "L'operazione '{desc}' è fallita! Per ottenere aiuto, per favore condividi il registro completo dell'operazione utilizzando il comando 'yunohost log display {name} --share'", + "log_link_to_failed_log": "Impossibile completare l'operazione '{desc}'! Per ricevere aiuto, per favore fornisci il registro completo dell'operazione cliccando qui", + "log_help_to_get_failed_log": "L'operazione '{desc}' non può essere completata. Per ottenere aiuto, per favore condividi il registro completo dell'operazione utilizzando il comando 'yunohost log share {name}'", "log_does_exists": "Non esiste nessun registro delle operazioni chiamato '{log}', usa 'yunohost log list' per vedere tutti i registri delle operazioni disponibili", - "log_app_addaccess": "Aggiungi accesso a '{}'", - "log_app_removeaccess": "Rimuovi accesso a '{}'", - "log_app_clearaccess": "Rimuovi tutti gli accessi a '{}'", - "log_app_fetchlist": "Aggiungi un elenco di applicazioni", - "log_app_removelist": "Rimuovi un elenco di applicazioni", - "log_app_change_url": "Cambia l'url dell'applicazione '{}'", - "log_app_install": "Installa l'applicazione '{}'", - "log_app_remove": "Rimuovi l'applicazione '{}'", - "log_app_upgrade": "Aggiorna l'applicazione '{}'", - "log_app_makedefault": "Rendi predefinita l'applicazione '{}'", + "log_app_change_url": "Cambia l'URL dell'app '{}'", + "log_app_install": "Installa l'app '{}'", + "log_app_remove": "Rimuovi l'app '{}'", + "log_app_upgrade": "Aggiorna l'app '{}'", + "log_app_makedefault": "Rendi '{}' l'app predefinita", "log_available_on_yunopaste": "Questo registro è ora disponibile via {url}", "log_backup_restore_system": "Ripristina sistema da un archivio di backup", "log_backup_restore_app": "Ripristina '{}' da un archivio di backup", @@ -393,47 +264,370 @@ "log_domain_add": "Aggiungi il dominio '{}' nella configurazione di sistema", "log_domain_remove": "Rimuovi il dominio '{}' dalla configurazione di sistema", "log_dyndns_subscribe": "Sottoscrivi un sottodominio YunoHost '{}'", - "log_dyndns_update": "Aggiorna l'ip associato con il tuo sottodominio YunoHost '{}'", + "log_dyndns_update": "Aggiorna l'IP associato con il tuo sottodominio YunoHost '{}'", "log_letsencrypt_cert_install": "Installa un certificato Let's encrypt sul dominio '{}'", "log_selfsigned_cert_install": "Installa un certificato autofirmato sul dominio '{}'", - "log_letsencrypt_cert_renew": "Rinnova il certificato Let's encrypt sul dominio '{}'", - "log_service_enable": "Abilita il servizio '{}'", + "log_letsencrypt_cert_renew": "Rinnova il certificato Let's Encrypt sul dominio '{}'", "log_regen_conf": "Rigenera configurazioni di sistema '{}'", "log_user_create": "Aggiungi l'utente '{}'", "log_user_delete": "Elimina l'utente '{}'", - "log_user_update": "Aggiornate le informazioni dell'utente '{}'", - "log_tools_maindomain": "Rendi '{}' dominio principale", - "log_tools_migrations_migrate_forward": "Migra avanti", - "log_tools_migrations_migrate_backward": "Migra indietro", + "log_user_update": "Aggiorna le informazioni dell'utente '{}'", + "log_domain_main_domain": "Rendi '{}' il dominio principale", + "log_tools_migrations_migrate_forward": "Esegui le migrazioni", "log_tools_postinstall": "Postinstallazione del tuo server YunoHost", "log_tools_upgrade": "Aggiornamento dei pacchetti di sistema", "log_tools_shutdown": "Spegni il tuo server", "log_tools_reboot": "Riavvia il tuo server", "mail_unavailable": "Questo indirizzo email è riservato e dovrebbe essere automaticamente assegnato al primo utente", - "migrate_tsig_end": "Migrazione a hmac-sha512 terminata", - "migrate_tsig_failed": "Migrazione del dominio dyndns {domain} verso hmac-sha512 fallita, torno indetro. Errore: {error_code} - {error}", - "migrate_tsig_start": "Trovato un algoritmo di chiave non abbastanza sicuro per la firma TSIG del dominio '{domain}', inizio della migrazione verso la più sicura hmac-sha512", - "migrate_tsig_wait": "Aspetta 3 minuti che il server dyndns prenda la nuova chiave in gestione…", - "migrate_tsig_wait_2": "2 minuti…", - "migrate_tsig_wait_3": "1 minuto…", - "migrate_tsig_wait_4": "30 secondi…", - "migrate_tsig_not_needed": "Non sembra tu stia utilizzando un dominio dyndns, quindi non è necessaria nessuna migrazione!", - "migration_description_0001_change_cert_group_to_sslcert": "Cambia permessi del gruppo di certificati da 'metronome' a 'ssl-cert'", - "migration_description_0002_migrate_to_tsig_sha256": "Migliora la sicurezza del TSIG dyndns utilizzando SHA512 invece di MD5", - "migration_description_0003_migrate_to_stretch": "Aggiorna il sistema a Debian Stretch e YunoHost 3.0", - "migration_description_0004_php5_to_php7_pools": "Riconfigura le PHP pools ad utilizzare PHP 7 invece di 5", - "migration_description_0005_postgresql_9p4_to_9p6": "Migra i database da postgresql 9.4 a 9.6", - "migration_description_0006_sync_admin_and_root_passwords": "Sincronizza password di amministratore e root", - "migration_description_0010_migrate_to_apps_json": "Rimuovi gli elenchi di app deprecati ed usa invece il nuovo elenco unificato 'apps.json'", - "migration_0003_backward_impossible": "La migrazione a Stretch non può essere annullata.", - "migration_0003_start": "Migrazione a Stretch iniziata. I registri saranno disponibili in {logfile}.", - "migration_0003_patching_sources_list": "Sistemando il file sources.lists…", - "migration_0003_main_upgrade": "Iniziando l'aggiornamento principale…", - "migration_0003_fail2ban_upgrade": "Iniziando l'aggiornamento di fail2ban…", - "migration_0003_restoring_origin_nginx_conf": "Il tuo file /etc/nginx/nginx.conf è stato modificato in qualche modo. La migrazione lo riporterà al suo stato originale… Il file precedente sarà disponibile come {backup_dest}.", - "migration_0003_yunohost_upgrade": "Iniziando l'aggiornamento dei pacchetti yunohost… La migrazione terminerà, ma l'aggiornamento attuale avverrà subito dopo. Dopo che l'operazione sarà completata, probabilmente dovrai riaccedere all'interfaccia di amministrazione.", - "migration_0003_not_jessie": "La distribuzione attuale non è Jessie!", - "migration_0003_system_not_fully_up_to_date": "Il tuo sistema non è completamente aggiornato. Per favore prima esegui un aggiornamento normale prima di migrare a stretch.", - "this_action_broke_dpkg": "Questa azione ha danneggiato dpkg/apt (i gestori di pacchetti del sistema)… Puoi provare a risolvere questo problema connettendoti via SSH ed eseguendo `sudo dpkg --configure -a`.", - "updating_app_lists": "Recupero degli aggiornamenti disponibili per le applicazioni…" -} + "this_action_broke_dpkg": "Questa azione ha danneggiato dpkg/APT (i gestori di pacchetti del sistema)... Puoi provare a risolvere questo problema connettendoti via SSH ed eseguendo `sudo apt install --fix-broken` e/o `sudo dpkg --configure -a`.", + "app_action_broke_system": "Questa azione sembra avere rotto questi servizi importanti: {services}", + "app_remove_after_failed_install": "Rimozione dell'applicazione a causa del fallimento dell'installazione...", + "app_install_script_failed": "Si è verificato un errore nello script di installazione dell'applicazione", + "app_install_failed": "Impossibile installare {app}:{error}", + "app_full_domain_unavailable": "Spiacente, questa app deve essere installata su un proprio dominio, ma altre applicazioni sono già installate sul dominio '{domain}'. Potresti usare invece un sotto-dominio dedicato per questa app.", + "app_upgrade_script_failed": "È stato trovato un errore nello script di aggiornamento dell'applicazione", + "apps_already_up_to_date": "Tutte le applicazioni sono aggiornate", + "apps_catalog_init_success": "Catalogo delle applicazioni inizializzato!", + "apps_catalog_updating": "Aggiornamento del catalogo delle applicazioni…", + "apps_catalog_failed_to_download": "Impossibile scaricare il catalogo delle applicazioni {apps_catalog} : {error}", + "apps_catalog_obsolete_cache": "La cache del catalogo della applicazioni è vuoto o obsoleto.", + "apps_catalog_update_success": "Il catalogo delle applicazioni è stato aggiornato!", + "backup_archive_corrupted": "Sembra che l'archivio di backup '{archive}' sia corrotto: {error}", + "backup_archive_cant_retrieve_info_json": "Impossibile caricare informazione per l'archivio '{archive}'... Impossibile scaricare info.json (oppure non è un json valido).", + "app_packaging_format_not_supported": "Quest'applicazione non può essere installata perché il formato non è supportato dalla vostra versione di YunoHost. Dovreste considerare di aggiornare il vostro sistema.", + "certmanager_domain_not_diagnosed_yet": "Non c'è ancora alcun risultato di diagnosi per il dominio {domain}. Riavvia una diagnosi per la categoria 'DNS records' e 'Web' nella sezione di diagnosi per verificare se il dominio è pronto per Let's Encrypt. (Se sai cosa stai facendo, usa '--no-checks' per disattivare i controlli.)", + "backup_permission": "Backup dei permessi per {app}", + "ask_user_domain": "Dominio da usare per l'indirizzo email e l'account XMPP dell'utente", + "app_manifest_install_ask_is_public": "Quest'applicazione dovrà essere visibile ai visitatori anonimi?", + "app_manifest_install_ask_admin": "Scegli un utente amministratore per quest'applicazione", + "app_manifest_install_ask_password": "Scegli una password di amministrazione per quest'applicazione", + "app_manifest_install_ask_path": "Scegli il percorso dove installare quest'applicazione", + "app_manifest_install_ask_domain": "Scegli il dominio dove installare quest'app", + "app_argument_password_no_default": "Errore durante il parsing dell'argomento '{name}': l'argomento password non può avere un valore di default per ragioni di sicurezza", + "additional_urls_already_added": "L'URL aggiuntivo '{url}' è già utilizzato come URL aggiuntivo per il permesso '{permission}'", + "diagnosis_basesystem_ynh_inconsistent_versions": "Stai eseguendo versioni incompatibili dei pacchetti YunoHost... probabilmente a causa di aggiornamenti falliti o parziali.", + "diagnosis_basesystem_ynh_main_version": "Il server sta eseguendo YunoHost {main_version} ({repo})", + "diagnosis_basesystem_ynh_single_version": "Versione {package}: {version} ({repo})", + "diagnosis_basesystem_kernel": "Il server sta eseguendo Linux kernel {kernel_version}", + "diagnosis_basesystem_host": "Il server sta eseguendo Debian {debian_version}", + "diagnosis_basesystem_hardware": "L'architettura hardware del server è {virt} {arch}", + "certmanager_warning_subdomain_dns_record": "Il sottodominio '{subdomain}' non si risolve nello stesso indirizzo IP di '{domain}'. Alcune funzioni non saranno disponibili finchè questa cosa non verrà sistemata e rigenerato il certificato.", + "app_label_deprecated": "Questo comando è deprecato! Utilizza il nuovo comando 'yunohost user permission update' per gestire la label dell'app.", + "additional_urls_already_removed": "L'URL aggiuntivo '{url}' è già stato rimosso come URL aggiuntivo per il permesso '{permission}'", + "diagnosis_services_bad_status_tip": "Puoi provare a riavviare il servizio, e se non funziona, controlla ai log del servizio in amministrazione (dalla linea di comando, puoi farlo con yunohost service restart {service} e yunohost service log {service}).", + "diagnosis_services_bad_status": "Il servizio {service} è {status} :(", + "diagnosis_services_conf_broken": "Il servizio {service} è mal-configurato!", + "diagnosis_services_running": "Il servizio {service} sta funzionando!", + "diagnosis_domain_expires_in": "{domain} scadrà tra {days} giorni.", + "diagnosis_domain_expiration_error": "Alcuni domini scadranno MOLTO PRESTO!", + "diagnosis_domain_expiration_warning": "Alcuni domini scadranno a breve!", + "diagnosis_domain_expiration_success": "I tuoi domini sono registrati e non scadranno a breve.", + "diagnosis_domain_expiration_not_found_details": "Le informazioni WHOIS per il dominio {domain} non sembrano contenere la data di scadenza, giusto?", + "diagnosis_domain_not_found_details": "Il dominio {domain} non esiste nel database WHOIS o è scaduto!", + "diagnosis_domain_expiration_not_found": "Non riesco a controllare la data di scadenza di alcuni domini", + "diagnosis_dns_try_dyndns_update_force": "La configurazione DNS di questo dominio dovrebbe essere gestita automaticamente da YunoHost. Se non avviene, puoi provare a forzare un aggiornamento usando il comando yunohost dyndns update --force.", + "diagnosis_dns_point_to_doc": "Controlla la documentazione a https://yunohost.org/dns_config se hai bisogno di aiuto nel configurare i record DNS.", + "diagnosis_dns_discrepancy": "Il record DNS non sembra seguire la configurazione DNS raccomandata:
Type: {type}
Name: {name}
Current value: {current}
Expected value: {value}", + "diagnosis_dns_missing_record": "Stando alla configurazione DNS raccomandata, dovresti aggiungere un record DNS con le seguenti informazioni.
Type: {type}
Name: {name}
Value: {value}", + "diagnosis_dns_bad_conf": "Alcuni record DNS sono mancanti o incorretti per il dominio {domain} (categoria {category})", + "diagnosis_dns_good_conf": "I recordDNS sono configurati correttamente per il dominio {domain} (categoria {category})", + "diagnosis_ip_weird_resolvconf_details": "Il file /etc/resolv.conf dovrebbe essere un symlink a /etc/resolvconf/run/resolv.conf che punta a 127.0.0.1 (dnsmasq). Se vuoi configurare manualmente i DNS, modifica /etc/resolv.dnsmasq.conf.", + "diagnosis_ip_weird_resolvconf": "La risoluzione dei nomi di rete sembra funzionare, ma mi pare che tu stia usando un /etc/resolv.conf personalizzato.", + "diagnosis_ip_broken_resolvconf": "La risoluzione dei nomi di rete sembra non funzionare sul tuo server, e sembra collegato a /etc/resolv.conf che non punta a 127.0.0.1.", + "diagnosis_ip_broken_dnsresolution": "La risoluzione dei nomi di rete sembra non funzionare per qualche ragione... È presente un firewall che blocca le richieste DNS?", + "diagnosis_ip_dnsresolution_working": "Risoluzione dei nomi di rete funzionante!", + "diagnosis_ip_not_connected_at_all": "Sei sicuro che il server sia collegato ad Internet!?", + "diagnosis_ip_local": "IP locale: {local}", + "diagnosis_ip_global": "IP globale: {global}", + "diagnosis_ip_no_ipv6_tip": "Avere IPv6 funzionante non è obbligatorio per far funzionare il server, ma è un bene per Internet stesso. IPv6 dovrebbe essere configurato automaticamente dal sistema o dal tuo provider se è disponibile. Altrimenti, potresti aver bisogno di configurare alcune cose manualmente come è spiegato nella documentazione: https://yunohost.org/#/ipv6. Se non puoi abilitare IPv6 o se ti sembra troppo complicato per te, puoi tranquillamente ignorare questo avvertimento.", + "diagnosis_ip_no_ipv6": "Il server non ha IPv6 funzionante.", + "diagnosis_ip_connected_ipv6": "Il server è connesso ad Internet tramite IPv6!", + "diagnosis_ip_no_ipv4": "Il server non ha IPv4 funzionante.", + "diagnosis_ip_connected_ipv4": "Il server è connesso ad Internet tramite IPv4!", + "diagnosis_no_cache": "Nessuna diagnosi nella cache per la categoria '{category}'", + "diagnosis_found_warnings": "Trovato {warnings} oggetti che potrebbero essere migliorati per {category}.", + "diagnosis_failed": "Recupero dei risultati della diagnosi per la categoria '{category}' fallito: {error}", + "diagnosis_everything_ok": "Tutto ok per {category}!", + "diagnosis_found_errors_and_warnings": "Trovato {errors} problemi (e {warnings} alerts) significativi collegati a {category}!", + "diagnosis_found_errors": "Trovato {errors} problemi significativi collegati a {category}!", + "diagnosis_ignored_issues": "(+ {nb_ignored} problemi ignorati)", + "diagnosis_cant_run_because_of_dep": "Impossibile lanciare la diagnosi per {category} mentre ci sono problemi importanti collegati a {dep}.", + "diagnosis_cache_still_valid": "(La cache della diagnosi di {category} è ancora valida. Non la ricontrollo di nuovo per ora!)", + "diagnosis_failed_for_category": "Diagnosi fallita per la categoria '{category}:{error}", + "diagnosis_display_tip": "Per vedere i problemi rilevati, puoi andare alla sezione Diagnosi del amministratore, o eseguire 'yunohost diagnosis show --issues --human-readable' dalla riga di comando.", + "diagnosis_package_installed_from_sury_details": "Alcuni pacchetti sono stati inavvertitamente installati da un repository di terze parti chiamato Sury. Il team di YunoHost ha migliorato la gestione di tali pacchetti, ma ci si aspetta che alcuni setup di app PHP7.3 abbiano delle incompatibilità anche se sono ancora in Stretch. Per sistemare questa situazione, dovresti provare a lanciare il seguente comando: {cmd_to_fix}", + "diagnosis_package_installed_from_sury": "Alcuni pacchetti di sistema dovrebbero fare il downgrade", + "diagnosis_mail_ehlo_bad_answer": "Un servizio diverso da SMTP ha risposto sulla porta 25 su IPv{ipversion}", + "diagnosis_mail_ehlo_unreachable_details": "Impossibile aprire una connessione sulla porta 25 sul tuo server su IPv{ipversion}. Sembra irraggiungibile.
1. La causa più probabile di questo problema è la porta 25 non correttamente inoltrata al tuo server.
2. Dovresti esser sicuro che il servizio postfix sia attivo.
3. Su setup complessi: assicuratu che nessun firewall o reverse-proxy stia interferendo.", + "diagnosis_mail_ehlo_unreachable": "Il server SMTP non è raggiungibile dall'esterno su IPv{ipversion}. Non potrà ricevere email.", + "diagnosis_mail_ehlo_ok": "Il server SMTP è raggiungibile dall'esterno e quindi può ricevere email!", + "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "Alcuni provider non ti permettono di aprire la porta 25 in uscita perché non gli importa della Net Neutrality.
- Alcuni mettono a disposizione un alternativa attraverso un mail server relay anche se implica che il relay ha la capacità di leggere il vostro traffico email.
- Un alternativa privacy-friendly è quella di usare una VPN *con un indirizzo IP pubblico dedicato* per bypassare questo tipo di limite. Vedi https://yunohost.org/#/vpn_advantage
- Puoi anche prendere in considerazione di cambiare per un provider pro Net Neutrality", + "diagnosis_mail_outgoing_port_25_blocked_details": "Come prima cosa dovresti sbloccare la porta 25 in uscita dall'interfaccia del tuo router internet o del tuo hosting provider. (Alcuni hosting provider potrebbero richiedere l'invio di un ticket di supporto per la richiesta).", + "diagnosis_mail_outgoing_port_25_blocked": "Il server SMTP non può inviare email ad altri server perché la porta 25 è bloccata in uscita su IPv{ipversion}.", + "diagnosis_mail_outgoing_port_25_ok": "Il server SMTP è abile all'invio delle email (porta 25 in uscita non bloccata).", + "diagnosis_swap_tip": "Attenzione. Sii consapevole che se il server ha lo swap su di una memoria SD o un disco SSD, potrebbe drasticamente ridurre la durata di vita del dispositivo.", + "diagnosis_swap_ok": "Il sistema ha {total} di memoria swap!", + "diagnosis_swap_notsomuch": "Il sistema ha solo {total} di swap. Dovresti considerare almeno di aggiungere {recommended} di memoria swap per evitare situazioni dove il sistema esaurisce la memoria.", + "diagnosis_swap_none": "Il sistema non ha lo swap. Dovresti considerare almeno di aggiungere {recommended} di memoria swap per evitare situazioni dove il sistema esaurisce la memoria.", + "diagnosis_ram_ok": "Il sistema ha ancora {available} ({available_percent}%) di RAM disponibile su {total}.", + "diagnosis_ram_low": "Il sistema ha solo {available} ({available_percent}%) di RAM disponibile (su {total}). Fa attenzione.", + "diagnosis_ram_verylow": "Il sistema ha solo {available} ({available_percent}%) di RAM disponibile (su {total})", + "diagnosis_diskusage_ok": "Lo storage {mountpoint} (nel device {device} ha solo {free} ({free_percent}%) di spazio libero rimanente (su {total})!", + "diagnosis_diskusage_low": "Lo storage {mountpoint} (nel device {device} ha solo {free} ({free_percent}%) di spazio libero rimanente (su {total}). Fa attenzione.", + "diagnosis_diskusage_verylow": "Lo storage {mountpoint} (nel device {device} ha solo {free} ({free_percent}%) di spazio libero rimanente (su {total}). Dovresti seriamente considerare di fare un po' di pulizia!", + "diagnosis_mail_fcrdns_nok_details": "Dovresti prima configurare il DNS inverso con {ehlo_domain} nell'interfaccia del tuo router internet o del tuo hosting provider. (Alcuni hosting provider potrebbero richiedere l'invio di un ticket di supporto per la richiesta).", + "diagnosis_mail_fcrdns_dns_missing": "Nessun DNS inverso è configurato per IPv{ipversion}. Alcune email potrebbero non essere inviate o segnalate come spam.", + "diagnosis_mail_fcrdns_ok": "Il tuo DNS inverso è configurato correttamente!", + "diagnosis_mail_ehlo_could_not_diagnose_details": "Errore: {error}", + "diagnosis_mail_ehlo_could_not_diagnose": "Non è possibile verificare se il server mail postfix è raggiungibile dall'esterno su IPv{ipversion}.", + "diagnosis_mail_ehlo_wrong": "Un server mail SMTP diverso sta rispondendo su IPv{ipversion}. Probabilmente il tuo server non può ricevere email.", + "diagnosis_mail_ehlo_bad_answer_details": "Potrebbe essere un'altra macchina a rispondere al posto del tuo server.", + "diagnosis_mail_fcrdns_nok_alternatives_4": "Alcuni provider non ti permettono di configurare un DNS inverso (o la loro configurazione non funziona...). Se stai avendo problemi a causa di ciò, considera le seguenti soluzioni:
- Alcuni ISP mettono a disposizione un alternativa attraverso un mail server relay anche se implica che il relay ha la capacità di leggere il vostro traffico email.
- Un alternativa privacy-friendly è quella di usare una VPN *con un indirizzo IP pubblico dedicato* per bypassare questo tipo di limite. Vedi https://yunohost.org/#/vpn_advantage
- Puoi anche prendere in considerazione di cambiare internet provider", + "diagnosis_mail_ehlo_wrong_details": "L'EHLO ricevuto dalla diagnostica remota su IPv{ipversion} è differente dal dominio del tuo server.
EHLO ricevuto: {wrong_ehlo}
EHLO atteso: {right_ehlo}
La causa più comune di questo problema è la porta 25 non correttamente inoltrata al tuo server. Oppure assicurati che nessun firewall o reverse-proxy stia interferendo.", + "diagnosis_mail_blacklist_ok": "Gli IP e i domini utilizzati da questo server non sembrano essere nelle blacklist", + "diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "DNS invero corrente: {rdns_domain}
Valore atteso: {ehlo_domain}", + "diagnosis_mail_fcrdns_different_from_ehlo_domain": "Il DNS inverso non è correttamente configurato su IPv{ipversion}. Alcune email potrebbero non essere spedite o segnalate come SPAM.", + "diagnosis_mail_fcrdns_nok_alternatives_6": "Alcuni provider non permettono di configurare un DNS inverso (o non è configurato bene...). Se il tuo DNS inverso è correttamente configurato per IPv4, puoi provare a disabilitare l'utilizzo di IPv6 durante l'invio mail eseguendo yunohost settings set smtp.allow_ipv6 -v off. NB: se esegui il comando non sarà più possibile inviare o ricevere email da i pochi IPv6-only server mail esistenti.", + "yunohost_postinstall_end_tip": "La post-installazione è completata! Per rifinire il tuo setup, considera di:\n\t- aggiungere il primo utente nella sezione 'Utenti' del webadmin (o eseguendo da terminale 'yunohost user create ');\n\t- eseguire una diagnosi alla ricerca di problemi nella sezione 'Diagnosi' del webadmin (o eseguendo da terminale 'yunohost diagnosis run');\n\t- leggere 'Finalizing your setup' e 'Getting to know YunoHost' nella documentazione admin: https://yunohost.org/admindoc.", + "user_already_exists": "L'utente '{user}' esiste già", + "update_apt_cache_warning": "Qualcosa è andato storto mentre eseguivo l'aggiornamento della cache APT (package manager di Debian). Ecco il dump di sources.list, che potrebbe aiutare ad identificare le linee problematiche:\n{sourceslist}", + "update_apt_cache_failed": "Impossibile aggiornare la cache di APT (package manager di Debian). Ecco il dump di sources.list, che potrebbe aiutare ad identificare le linee problematiche:\n{sourceslist}", + "unknown_main_domain_path": "Percorso o dominio sconosciuto per '{app}'. Devi specificare un dominio e un percorso per poter specificare un URL per il permesso.", + "tools_upgrade_special_packages_completed": "Aggiornamento pacchetti YunoHost completato.\nPremi [Invio] per tornare al terminale", + "tools_upgrade_special_packages_explanation": "L'aggiornamento speciale continuerà in background. Per favore non iniziare nessun'altra azione sul tuo server per i prossimi ~10 minuti (dipende dalla velocità hardware). Dopo questo, dovrai ri-loggarti nel webadmin. Il registro di aggiornamento sarà disponibile in Strumenti → Log/Registri (nel webadmin) o dalla linea di comando eseguendo 'yunohost log list'.", + "tools_upgrade_special_packages": "Adesso aggiorno i pacchetti 'speciali' (correlati a yunohost)...", + "tools_upgrade_regular_packages_failed": "Impossibile aggiornare i pacchetti: {packages_list}", + "tools_upgrade_regular_packages": "Adesso aggiorno i pacchetti 'normali' (non correlati a yunohost)...", + "tools_upgrade_cant_unhold_critical_packages": "Impossibile annullare il blocco dei pacchetti critici/importanti...", + "tools_upgrade_cant_hold_critical_packages": "Impossibile bloccare i pacchetti critici/importanti...", + "tools_upgrade_cant_both": "Impossibile aggiornare sia il sistema e le app nello stesso momento", + "tools_upgrade_at_least_one": "Specifica 'apps', o 'system'", + "show_tile_cant_be_enabled_for_regex": "Non puoi abilitare 'show_tile' in questo momento, perché l'URL del permesso '{permission}' è una regex", + "show_tile_cant_be_enabled_for_url_not_defined": "Non puoi abilitare 'show_tile' in questo momento, devi prima definire un URL per il permesso '{permission}'", + "service_reloaded_or_restarted": "Il servizio '{service}' è stato ricaricato o riavviato", + "service_reload_or_restart_failed": "Impossibile ricaricare o riavviare il servizio '{service}'\n\nUltimi registri del servizio: {logs}", + "service_restarted": "Servizio '{service}' riavviato", + "service_restart_failed": "Impossibile riavviare il servizio '{service}'\n\nUltimi registri del servizio: {logs}", + "service_reloaded": "Servizio '{service}' ricaricato", + "service_reload_failed": "Impossibile ricaricare il servizio '{service}'\n\nUltimi registri del servizio: {logs}", + "service_regen_conf_is_deprecated": "'yunohost service regen-conf' è obsoleto! Per favore usa 'yunohost tools regen-conf' al suo posto.", + "service_description_yunohost-firewall": "Gestisce l'apertura e la chiusura delle porte ai servizi", + "service_description_yunohost-api": "Gestisce l'interazione tra l'interfaccia web YunoHost ed il sistema", + "service_description_ssh": "Ti consente di accedere da remoto al tuo server attraverso il terminale (protocollo SSH)", + "service_description_slapd": "Memorizza utenti, domini e info correlate", + "service_description_rspamd": "Filtra SPAM, e altre funzionalità legate alle mail", + "service_description_redis-server": "Un database specializzato usato per un veloce accesso ai dati, task queue, e comunicazioni tra programmi", + "service_description_postfix": "Usato per inviare e ricevere email", + "service_description_php7.3-fpm": "Esegue app scritte in PHP con NGINX", + "service_description_nginx": "Serve o permette l'accesso a tutti i siti pubblicati sul tuo server", + "service_description_mysql": "Memorizza i dati delle app (database SQL)", + "service_description_metronome": "Gestisce gli account di messaggistica instantanea XMPP", + "service_description_fail2ban": "Ti protegge dal brute-force e altri tipi di attacchi da Internet", + "service_description_dovecot": "Consente ai client mail di accedere/recuperare le email (via IMAP e POP3)", + "service_description_dnsmasq": "Gestisce la risoluzione dei domini (DNS)", + "server_reboot_confirm": "Il server si riavvierà immediatamente, sei sicuro? [{answers}]", + "server_reboot": "Il server si riavvierà", + "server_shutdown_confirm": "Il server si spegnerà immediatamente, sei sicuro? [{answers}]", + "server_shutdown": "Il server si spegnerà", + "root_password_replaced_by_admin_password": "La tua password di root è stata sostituita dalla tua password d'amministratore.", + "root_password_desynchronized": "La password d'amministratore è stata cambiata, ma YunoHost non ha potuto propagarla alla password di root!", + "restore_system_part_failed": "Impossibile ripristinare la sezione di sistema '{part}'", + "restore_removing_tmp_dir_failed": "Impossibile rimuovere una vecchia directory temporanea", + "restore_not_enough_disk_space": "Spazio libero insufficiente (spazio: {free_space}B, necessario: {needed_space}B, margine di sicurezza: {margin}B)", + "restore_may_be_not_enough_disk_space": "Il tuo sistema non sembra avere abbastanza spazio (libero: {free_space}B, necessario: {needed_space}B, margine di sicurezza: {margin}B)", + "restore_extracting": "Sto estraendo i file necessari dall'archivio...", + "restore_already_installed_apps": "Le seguenti app non possono essere ripristinate perché sono già installate: {apps}", + "regex_with_only_domain": "Non puoi usare una regex per il dominio, solo per i percorsi", + "regex_incompatible_with_tile": "/!\\ Packagers! Il permesso '{permission}' ha show_tile impostato su 'true' e perciò non è possibile definire un URL regex per l'URL principale", + "regenconf_need_to_explicitly_specify_ssh": "La configurazione ssh è stata modificata manualmente, ma devi specificare la categoria 'ssh' con --force per applicare le modifiche.", + "regenconf_pending_applying": "Applico le configurazioni in attesa per la categoria '{category}'...", + "regenconf_failed": "Impossibile rigenerare la configurazione per le categorie: {categories}", + "regenconf_dry_pending_applying": "Controllo configurazioni in attesa che potrebbero essere applicate alla categoria '{category}'...", + "regenconf_would_be_updated": "La configurazione sarebbe stata aggiornata per la categoria '{category}'", + "regenconf_updated": "Configurazione aggiornata per '{category}'", + "regenconf_up_to_date": "Il file di configurazione è già aggiornato per la categoria '{category}'", + "regenconf_now_managed_by_yunohost": "Il file di configurazione '{conf}' da adesso è gestito da YunoHost (categoria {category}).", + "regenconf_file_updated": "File di configurazione '{conf}' aggiornato", + "regenconf_file_removed": "File di configurazione '{conf}' rimosso", + "regenconf_file_remove_failed": "Impossibile rimuovere il file di configurazione '{conf}'", + "regenconf_file_manually_removed": "Il file di configurazione '{conf}' è stato rimosso manualmente, e non sarà generato", + "regenconf_file_manually_modified": "Il file di configurazione '{conf}' è stato modificato manualmente e non sarà aggiornato", + "regenconf_file_kept_back": "Il file di configurazione '{conf}' dovrebbe esser stato cancellato da regen-conf (categoria {category}), ma non è così.", + "regenconf_file_copy_failed": "Impossibile copiare il nuovo file di configurazione da '{new}' a '{conf}'", + "regenconf_file_backed_up": "File di configurazione '{conf}' salvato in '{backup}'", + "permission_require_account": "Il permesso {permission} ha senso solo per gli utenti con un account, quindi non può essere attivato per i visitatori.", + "permission_protected": "Il permesso {permission} è protetto. Non puoi aggiungere o rimuovere il gruppo visitatori dal permesso.", + "permission_updated": "Permesso '{permission}' aggiornato", + "permission_update_failed": "Impossibile aggiornare il permesso '{permission}': {error}", + "permission_not_found": "Permesso '{permission}' non trovato", + "permission_deletion_failed": "Impossibile cancellare il permesso '{permission}': {error}", + "permission_deleted": "Permesso '{permission}' cancellato", + "permission_currently_allowed_for_all_users": "Il permesso è attualmente garantito a tutti gli utenti oltre gli altri gruppi. Probabilmente vuoi o rimuovere il permesso 'all_user' o rimuovere gli altri gruppi per cui è garantito attualmente.", + "permission_creation_failed": "Impossibile creare i permesso '{permission}': {error}", + "permission_created": "Permesso '{permission}' creato", + "permission_cannot_remove_main": "Non è possibile rimuovere un permesso principale", + "permission_already_up_to_date": "Il permesso non è stato aggiornato perché la richiesta di aggiunta/rimozione è già coerente con lo stato attuale.", + "permission_already_exist": "Permesso '{permission}' esiste già", + "permission_already_disallowed": "Il gruppo '{group}' ha già il permesso '{permission}' disabilitato", + "permission_already_allowed": "Il gruppo '{group}' ha già il permesso '{permission}' abilitato", + "pattern_password_app": "Mi spiace, le password non possono contenere i seguenti caratteri: {forbidden_chars}", + "pattern_email_forward": "Dev'essere un indirizzo mail valido, simbolo '+' accettato (es: tizio+tag@example.com)", + "operation_interrupted": "L'operazione è stata interrotta manualmente?", + "invalid_number": "Dev'essere un numero", + "migrations_to_be_ran_manually": "Migrazione {id} dev'essere eseguita manualmente. Vai in Strumenti → Migrazioni nella pagina webadmin, o esegui `yunohost tools migrations run`.", + "migrations_success_forward": "Migrazione {id} completata", + "migrations_skip_migration": "Salto migrazione {id}...", + "migrations_running_forward": "Eseguo migrazione {id}...", + "migrations_pending_cant_rerun": "Queste migrazioni sono ancora in attesa, quindi non possono essere eseguite nuovamente: {ids}", + "migrations_not_pending_cant_skip": "Queste migrazioni non sono in attesa, quindi non possono essere saltate: {ids}", + "migrations_no_such_migration": "Non esiste una migrazione chiamata '{id}'", + "migrations_no_migrations_to_run": "Nessuna migrazione da eseguire", + "migrations_need_to_accept_disclaimer": "Per eseguire la migrazione {id}, devi accettare il disclaimer seguente:\n---\n{disclaimer}\n---\nSe accetti di eseguire la migrazione, per favore reinserisci il comando con l'opzione '--accept-disclaimer'.", + "migrations_must_provide_explicit_targets": "Devi specificare i target quando utilizzi '--skip' o '--force-rerun'", + "migrations_migration_has_failed": "Migrazione {id} non completata, annullamento. Errore: {exception}", + "migrations_loading_migration": "Caricamento migrazione {id}...", + "migrations_list_conflict_pending_done": "Non puoi usare sia '--previous' e '--done' allo stesso tempo.", + "migrations_exclusive_options": "'--auto', '--skip', e '--force-rerun' sono opzioni che si escludono a vicenda.", + "migrations_failed_to_load_migration": "Impossibile caricare la migrazione {id}: {error}", + "migrations_dependencies_not_satisfied": "Esegui queste migrazioni: '{dependencies_id}', prima di {id}.", + "migrations_cant_reach_migration_file": "Impossibile accedere ai file di migrazione nel path '%s'", + "migrations_already_ran": "Migrazioni già effettuate: {ids}", + "migration_0019_slapd_config_will_be_overwritten": "Sembra che tu abbia modificato manualmente la configurazione slapd. Per questa importante migrazione, YunoHost deve forzare l'aggiornamento della configurazione slapd. I file originali verranno back-uppati in {conf_backup_folder}.", + "migration_0019_add_new_attributes_in_ldap": "Aggiungi nuovi attributi ai permessi nel database LDAP", + "migration_0018_failed_to_reset_legacy_rules": "Impossibile resettare le regole iptables legacy: {error}", + "migration_0018_failed_to_migrate_iptables_rules": "Migrazione fallita delle iptables legacy a nftables: {error}", + "migration_0017_not_enough_space": "Libera abbastanza spazio in {path} per eseguire la migrazione.", + "migration_0017_postgresql_11_not_installed": "PostgreSQL 9.6 è installato, ma non PostgreSQL 11 ?! Qualcosa di strano potrebbe esser successo al tuo sistema :'( ...", + "migration_0017_postgresql_96_not_installed": "PostgreSQL non è stato installato sul tuo sistema. Nulla da fare.", + "migration_0015_weak_certs": "I seguenti certificati utilizzano ancora un algoritmo di firma debole e dovrebbero essere aggiornati per essere compatibili con la prossima versione di nginx: {certs}", + "migration_0015_cleaning_up": "Sto pulendo la cache e i pacchetti non più utili...", + "migration_0015_specific_upgrade": "Inizio l'aggiornamento dei pacchetti di sistema che necessitano di essere aggiornati da soli...", + "migration_0015_modified_files": "Attenzioni, i seguenti file sembrano esser stati modificati manualmente, e potrebbero essere sovrascritti dopo l'aggiornamento: {manually_modified_files}", + "migration_0015_problematic_apps_warning": "Alcune applicazioni potenzialmente problematiche sono state rilevate nel sistema. Sembra che non siano state installate attraverso il catalogo app YunoHost, o non erano flaggate come 'working'/'funzionanti'. Di conseguenza, non è possibile garantire che funzioneranno ancora dopo l'aggiornamento: {problematic_apps}", + "migration_0015_general_warning": "Attenzione, sappi che questa migrazione è un'operazione delicata. Il team YunoHost ha fatto del suo meglio nel controllarla e testarla, ma le probabilità che il sistema e/o qualche app si danneggi non sono nulle.\n\nPerciò, ti raccomandiamo di:\n\t- Effettuare un backup di tutti i dati e app importanti. Maggiori informazioni su https://yunohost.org/backup;\n\t- Sii paziente dopo aver lanciato l'operazione: in base alla tua connessione internet e al tuo hardware, potrebbero volerci alcune ore per aggiornare tutto.", + "migration_0015_system_not_fully_up_to_date": "Il tuo sistema non è completamente aggiornato. Esegui un aggiornamento classico prima di lanciare la migrazione a Buster.", + "migration_0015_not_enough_free_space": "Poco spazio libero disponibile in /var/! Dovresti avere almeno 1GB libero per effettuare questa migrazione.", + "migration_0015_not_stretch": "La distribuzione Debian corrente non è Stretch!", + "migration_0015_yunohost_upgrade": "Inizio l'aggiornamento del core di YunoHost...", + "migration_0015_still_on_stretch_after_main_upgrade": "Qualcosa è andato storto durante l'aggiornamento principale, il sistema sembra essere ancora su Debian Stretch", + "migration_0015_main_upgrade": "Inizio l'aggiornamento principale...", + "migration_0015_patching_sources_list": "Applico le patch a sources.lists...", + "migration_0015_start": "Inizio migrazione a Buster", + "migration_description_0019_extend_permissions_features": "Estendi il sistema di gestione dei permessi app", + "migration_description_0018_xtable_to_nftable": "Migra le vecchie regole di traffico network sul nuovo sistema nftable", + "migration_description_0017_postgresql_9p6_to_11": "Migra i database da PostgreSQL 9.6 a 11", + "migration_description_0016_php70_to_php73_pools": "MIgra i file di configurazione 'pool' di php7.0-fpm su php7.3", + "migration_description_0015_migrate_to_buster": "Aggiorna il sistema a Debian Buster e YunoHost 4.X", + "migrating_legacy_permission_settings": "Impostando le impostazioni legacy dei permessi..", + "mailbox_disabled": "E-mail disabilitate per l'utente {user}", + "log_user_permission_reset": "Resetta il permesso '{}'", + "log_user_permission_update": "Aggiorna gli accessi del permesso '{}'", + "log_user_group_update": "Aggiorna il gruppo '{}'", + "log_user_group_delete": "Cancella il gruppo '{}'", + "log_user_group_create": "Crea il gruppo '{}'", + "log_permission_url": "Aggiorna l'URL collegato al permesso '{}'", + "log_permission_delete": "Cancella permesso '{}'", + "log_permission_create": "Crea permesso '{}'", + "log_app_action_run": "Esegui l'azione dell'app '{}'", + "log_operation_unit_unclosed_properly": "Operazion unit non è stata chiusa correttamente", + "invalid_regex": "Regex invalida:'{regex}'", + "hook_list_by_invalid": "Questa proprietà non può essere usata per listare gli hooks", + "hook_json_return_error": "Impossibile leggere la risposta del hook {path}. Errore: {msg}. Contenuto raw: {raw_content}", + "group_user_not_in_group": "L'utente {user} non è nel gruppo {group}", + "group_user_already_in_group": "L'utente {user} è già nel gruppo {group}", + "group_update_failed": "Impossibile aggiornare il gruppo '{group}': {error}", + "group_updated": "Gruppo '{group}' aggiornato", + "group_unknown": "Gruppo '{group}' sconosciuto", + "group_deletion_failed": "Impossibile cancellare il gruppo '{group}': {error}", + "group_deleted": "Gruppo '{group}' cancellato", + "group_cannot_be_deleted": "Il gruppo {group} non può essere eliminato manualmente.", + "group_cannot_edit_primary_group": "Il gruppo '{group}' non può essere modificato manualmente. È il gruppo principale con lo scopo di contenere solamente uno specifico utente.", + "group_cannot_edit_visitors": "Il gruppo 'visitatori' non può essere modificato manualmente. È un gruppo speciale che rappresenta i visitatori anonimi", + "group_cannot_edit_all_users": "Il gruppo 'all_users' non può essere modificato manualmente. È un gruppo speciale che contiene tutti gli utenti registrati in YunoHost", + "group_creation_failed": "Impossibile creare il gruppo '{group}': {error}", + "group_created": "Gruppo '{group}' creato", + "group_already_exist_on_system_but_removing_it": "Il gruppo {group} esiste già tra i gruppi di sistema, ma YunoHost lo cancellerà...", + "group_already_exist_on_system": "Il gruppo {group} esiste già tra i gruppi di sistema", + "group_already_exist": "Il gruppo {group} esiste già", + "global_settings_setting_backup_compress_tar_archives": "Quando creo nuovi backup, usa un archivio (.tar.gz) al posto di un archivio non compresso (.tar). NB: abilitare quest'opzione significa create backup più leggeri, ma la procedura durerà di più e il carico CPU sarà maggiore.", + "global_settings_setting_smtp_relay_password": "Password del relay SMTP", + "global_settings_setting_smtp_relay_user": "User account del relay SMTP", + "global_settings_setting_smtp_relay_port": "Porta del relay SMTP", + "global_settings_setting_smtp_relay_host": "Utilizza SMTP relay per inviare mail al posto di questa instanza yunohost. Utile se sei in una di queste situazioni: la tua porta 25 è bloccata dal tuo provider ISP o VPS; hai un IP residenziale listato su DUHL; non sei puoi configurare il DNS inverso; oppure questo server non è direttamente esposto a Internet e vuoi usarne un'altro per spedire email.", + "global_settings_setting_smtp_allow_ipv6": "Permetti l'utilizzo di IPv6 per ricevere e inviare mail", + "global_settings_setting_pop3_enabled": "Abilita il protocollo POP3 per il server mail", + "dyndns_provider_unreachable": "Incapace di raggiungere il provider DynDNS {provider}: o il tuo YunoHost non è connesso ad internet o il server dynette è down.", + "dpkg_lock_not_available": "Impossibile eseguire il comando in questo momento perché un altro programma sta bloccando dpkg (il package manager di sistema)", + "domain_name_unknown": "Dominio '{domain}' sconosciuto", + "domain_cannot_remove_main_add_new_one": "Non puoi rimuovere '{domain}' visto che è il dominio principale nonché il tuo unico dominio, devi prima aggiungere un altro dominio eseguendo 'yunohost domain add ', impostarlo come dominio principale con 'yunohost domain main-domain n ', e solo allora potrai rimuovere il dominio '{domain}' eseguendo 'yunohost domain remove {domain}'.'", + "domain_cannot_add_xmpp_upload": "Non puoi aggiungere domini che iniziano per 'xmpp-upload.'. Questo tipo di nome è riservato per la funzionalità di upload XMPP integrata in YunoHost.", + "diagnosis_processes_killed_by_oom_reaper": "Alcuni processi sono stati terminati dal sistema che era a corto di memoria. Questo è un sintomo di insufficienza di memoria nel sistema o di un processo che richiede troppa memoria. Lista dei processi terminati:\n{kills_summary}", + "diagnosis_never_ran_yet": "Sembra che questo server sia stato impostato recentemente e non è presente nessun report di diagnostica. Dovresti partire eseguendo una diagnostica completa, da webadmin o da terminale con il comando 'yunohost diagnosis run'.", + "diagnosis_unknown_categories": "Le seguenti categorie sono sconosciute: {categories}", + "diagnosis_http_nginx_conf_not_up_to_date_details": "Per sistemare, ispeziona le differenze nel terminale eseguendo yunohost tools regen-conf nginx --dry-run --with-diff e se ti va bene, applica le modifiche con yunohost tools regen-conf ngix --force.", + "diagnosis_http_nginx_conf_not_up_to_date": "La configurazione nginx di questo dominio sembra esser stato modificato manualmente, e impedisce a YunoHost di controlalre se è raggiungibile su HTTP.", + "diagnosis_http_partially_unreachable": "Il dominio {domain} sembra irraggiungibile attraverso HTTP dall'esterno della tua LAN su IPv{failed}, anche se funziona su IPv{passed}.", + "diagnosis_http_unreachable": "Il dominio {domain} sembra irraggiungibile attraverso HTTP dall'esterno della tua LAN.", + "diagnosis_http_bad_status_code": "Sembra che un altro dispositivo (forse il tuo router internet) abbia risposto al posto del tuo server
1. La causa più comune è la porta 80 (e 443) non correttamente inoltrata al tuo server.
2. Su setup più complessi: assicurati che nessun firewall o reverse-proxy stia interferendo.", + "diagnosis_http_connection_error": "Errore connessione: impossibile connettersi al dominio richiesto, probabilmente è irraggiungibile.", + "diagnosis_http_timeout": "Andato in time-out cercando di contattare il server dall'esterno. Sembra essere irraggiungibile.
1. La causa più comune è la porta 80 (e 443) non correttamente inoltrata al tuo server.
2. Dovresti accertarti che il servizio nginx sia attivo.
3. Su setup più complessi: assicurati che nessun firewall o reverse-proxy stia interferendo.", + "diagnosis_http_ok": "Il dominio {domain} è raggiungibile attraverso HTTP al di fuori della tua LAN.", + "diagnosis_http_could_not_diagnose_details": "Errore: {error}", + "diagnosis_http_could_not_diagnose": "Non posso controllare se i domini sono raggiungibili dall'esterno su IPv{ipversion}.", + "diagnosis_http_hairpinning_issue_details": "Questo probabilmente è causato dal tuo ISP router. Come conseguenza, persone al di fuori della tua LAN saranno in grado di accedere al tuo server come atteso, ma non le persone all'interno della LAN (tipo te, immagino) utilizzando il dominio internet o l'IP globale. Dovresti essere in grado di migliorare la situazione visitando https://yunohost.org/dns_local_network", + "diagnosis_http_hairpinning_issue": "La tua rete locale sembra non avere \"hairpinning\" abilitato.", + "diagnosis_ports_forwarding_tip": "Per sistemare questo problema, probabilmente dovresti configurare l'inoltro della porta sul tuo router internet come descritto qui https://yunohost.org/isp_box_config", + "diagnosis_ports_needed_by": "Esporre questa porta è necessario per le feature di {category} (servizio {service})", + "diagnosis_ports_ok": "La porta {port} è raggiungibile dall'esterno.", + "diagnosis_ports_partially_unreachable": "La porta {port} non è raggiungibile dall'esterno su IPv{failed}.", + "diagnosis_ports_unreachable": "La porta {port} non è raggiungibile dall'esterno.", + "diagnosis_ports_could_not_diagnose_details": "Errore: {error}", + "diagnosis_ports_could_not_diagnose": "Impossibile diagnosticare se le porte sono raggiungibili dall'esterno su IPv{ipversion}.", + "diagnosis_description_regenconf": "Configurazioni sistema", + "diagnosis_description_mail": "Email", + "diagnosis_description_web": "Web", + "diagnosis_description_ports": "Esposizione porte", + "diagnosis_description_systemresources": "Risorse di sistema", + "diagnosis_description_services": "Check stato servizi", + "diagnosis_description_dnsrecords": "Record DNS", + "diagnosis_description_ip": "Connettività internet", + "diagnosis_description_basesystem": "Sistema base", + "diagnosis_security_vulnerable_to_meltdown_details": "Per sistemare, dovresti aggiornare il tuo sistema e fare il reboot per caricare il nuovo kernel linux (o contatta il tuo server provider se non funziona). Visita https://meltdownattack.com/ per maggiori info.", + "diagnosis_security_vulnerable_to_meltdown": "Sembra che tu sia vulnerabile alla vulnerabilità di sicurezza critica \"Meltdown\"", + "diagnosis_regenconf_manually_modified_details": "Questo è probabilmente OK se sai cosa stai facendo! YunoHost smetterà di aggiornare automaticamente questo file... Ma sappi che gli aggiornamenti di YunoHost potrebbero contenere importanti cambiamenti. Se vuoi, puoi controllare le differente con yunohost tools regen-conf {category} --dry-run --with-diff e forzare il reset della configurazione raccomandata con yunohost tools regen-conf {category} --force", + "diagnosis_regenconf_manually_modified": "Il file di configurazione {file} sembra esser stato modificato manualmente.", + "diagnosis_regenconf_allgood": "Tutti i file di configurazione sono allineati con le configurazioni raccomandate!", + "diagnosis_mail_queue_too_big": "Troppe email in attesa nella coda ({nb_pending} emails)", + "diagnosis_mail_queue_unavailable_details": "Errore: {error}", + "diagnosis_mail_queue_unavailable": "Impossibile consultare il numero di email in attesa", + "diagnosis_mail_queue_ok": "{nb_pending} emails in attesa nelle code", + "diagnosis_mail_blacklist_website": "Dopo aver identificato il motivo e averlo risolto, sentiti libero di chiedere di rimuovere il tuo IP o dominio da {blacklist_website}", + "diagnosis_mail_blacklist_reason": "Il motivo della blacklist è: {reason}", + "diagnosis_mail_blacklist_listed_by": "Il tuo IP o dominio {item} è nella blacklist {blacklist_name}", + "diagnosis_backports_in_sources_list": "Sembra che apt (il package manager) sia configurato per utilizzare le backport del repository. A meno che tu non sappia quello che stai facendo, scoraggiamo fortemente di installare pacchetti tramite esse, perché ci sono alte probabilità di creare conflitti con il tuo sistema.", + "diagnosis_basesystem_hardware_model": "Modello server: {model}", + "postinstall_low_rootfsspace": "La radice del filesystem ha uno spazio totale inferiore ai 10 GB, ed è piuttosto preoccupante! Consumerai tutta la memoria molto velocemente! Raccomandiamo di avere almeno 16 GB per la radice del filesystem. Se vuoi installare YunoHost ignorando questo avviso, esegui nuovamente il postinstall con l'argomento --force-diskspace", + "domain_remove_confirm_apps_removal": "Rimuovere questo dominio rimuoverà anche le seguenti applicazioni:\n{apps}\n\nSei sicuro di voler continuare? [{answers}]", + "diagnosis_rootfstotalspace_critical": "La radice del filesystem ha un totale di solo {space}, ed è piuttosto preoccupante! Probabilmente consumerai tutta la memoria molto velocemente! Raccomandiamo di avere almeno 16 GB per la radice del filesystem.", + "diagnosis_rootfstotalspace_warning": "La radice del filesystem ha un totale di solo {space}. Potrebbe non essere un problema, ma stai attento perché potresti consumare tutta la memoria velocemente... Raccomandiamo di avere almeno 16 GB per la radice del filesystem.", + "restore_backup_too_old": "Questo archivio backup non può essere ripristinato perché è stato generato da una versione troppo vecchia di YunoHost.", + "permission_cant_add_to_all_users": "Il permesso {permission} non può essere aggiunto a tutto gli utenti.", + "migration_update_LDAP_schema": "Aggiorno lo schema LDAP...", + "migration_ldap_rollback_success": "Sistema ripristinato allo stato precedente.", + "migration_ldap_migration_failed_trying_to_rollback": "Impossibile migrare... provo a ripristinare il sistema.", + "migration_ldap_can_not_backup_before_migration": "Il backup del sistema non è stato completato prima che la migrazione fallisse. Errore: {error}", + "migration_ldap_backup_before_migration": "Sto generando il backup del database LDAP e delle impostazioni delle app prima di effettuare la migrazione.", + "migration_description_0020_ssh_sftp_permissions": "Aggiungi il supporto ai permessi SSH e SFTP", + "log_backup_create": "Crea un archivio backup", + "global_settings_setting_ssowat_panel_overlay_enabled": "Abilita il pannello sovrapposto SSOwat", + "global_settings_setting_security_ssh_port": "Porta SSH", + "diagnosis_sshd_config_inconsistent_details": "Esegui yunohost settings set security.ssh.port -v PORTA_SSH per definire la porta SSH, e controlla con yunohost tools regen-conf ssh --dry-run --with-diff, poi yunohost tools regen-conf ssh --force per resettare la tua configurazione con le raccomandazioni YunoHost.", + "diagnosis_sshd_config_inconsistent": "Sembra che la porta SSH sia stata modificata manualmente in /etc/ssh/sshd_config: A partire da YunoHost 4.2, una nuova configurazione globale 'security.ssh.port' è disponibile per evitare di modificare manualmente la configurazione.", + "diagnosis_sshd_config_insecure": "Sembra che la configurazione SSH sia stata modificata manualmente, ed non è sicuro dato che non contiene le direttive 'AllowGroups' o 'Allowusers' che limitano l'accesso agli utenti autorizzati.", + "backup_create_size_estimation": "L'archivio conterrà circa {size} di dati.", + "app_restore_script_failed": "C'è stato un errore all'interno dello script di recupero", + "global_settings_setting_security_webadmin_allowlist": "Indirizzi IP con il permesso di accedere al webadmin, separati da virgola.", + "global_settings_setting_security_webadmin_allowlist_enabled": "Permetti solo ad alcuni IP di accedere al webadmin.", + "disk_space_not_sufficient_update": "Non c'è abbastanza spazio libero per aggiornare questa applicazione", + "disk_space_not_sufficient_install": "Non c'è abbastanza spazio libero per installare questa applicazione" +} \ No newline at end of file diff --git a/locales/mk.json b/locales/mk.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/locales/mk.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/locales/nb_NO.json b/locales/nb_NO.json index 0967ef424..dc217d74e 100644 --- a/locales/nb_NO.json +++ b/locales/nb_NO.json @@ -1 +1,120 @@ -{} +{ + "aborting": "Avbryter…", + "admin_password": "Administrasjonspassord", + "admin_password_change_failed": "Kan ikke endre passord", + "admin_password_changed": "Administrasjonspassord endret", + "admin_password_too_long": "Velg et passord kortere enn 127 tegn", + "app_already_installed": "{app} er allerede installert", + "app_already_up_to_date": "{app} er allerede oppdatert", + "app_argument_invalid": "Velg en gydlig verdi for argumentet '{name}': {error}", + "app_argument_required": "Argumentet '{name}' er påkrevd", + "app_id_invalid": "Ugyldig program-ID", + "dyndns_key_not_found": "Fant ikke DNS-nøkkel for domenet", + "app_not_correctly_installed": "{app} ser ikke ut til å ha blitt installert på riktig måte", + "dyndns_provider_unreachable": "Kunne ikke nå DynDNS-tilbyder {provider}: Enten har du ikke satt opp din YunoHost rett, dynette-tjeneren er nede, eller du mangler nett.", + "app_not_properly_removed": "{app} har ikke blitt fjernet på riktig måte", + "app_removed": "{app} fjernet", + "app_requirements_checking": "Sjekker påkrevde pakker for {app}…", + "app_start_install": "Installerer programmet '{app}'…", + "action_invalid": "Ugyldig handling '{action}'", + "app_start_restore": "Gjenoppretter programmet '{app}'…", + "backup_created": "Sikkerhetskopi opprettet", + "backup_archive_name_exists": "En sikkerhetskopi med dette navnet finnes allerede.", + "backup_archive_name_unknown": "Ukjent lokalt sikkerhetskopiarkiv ved navn '{name}'", + "already_up_to_date": "Ingenting å gjøre. Alt er oppdatert.", + "backup_method_copy_finished": "Sikkerhetskopi fullført", + "backup_method_tar_finished": "TAR-sikkerhetskopiarkiv opprettet", + "app_action_cannot_be_ran_because_required_services_down": "Dette programmet krever noen tjenester som ikke kjører. Før du fortsetter, du bør prøve å starte følgende tjenester på ny (og antagelig undersøke hvorfor de er nede): {services}", + "app_already_installed_cant_change_url": "Dette programmet er allerede installert. Nettadressen kan ikke endres kun med denne funksjonen. Ta en titt på `app changeurl` hvis den er tilgjengelig.", + "domain_exists": "Domenet finnes allerede", + "domains_available": "Tilgjengelige domener:", + "done": "Ferdig", + "downloading": "Laster ned…", + "dyndns_could_not_check_provide": "Kunne ikke sjekke om {provider} kan tilby {domain}.", + "dyndns_could_not_check_available": "Kunne ikke sjekke om {domain} er tilgjengelig på {provider}.", + "mail_domain_unknown": "Ukjent e-postadresse for domenet '{domain}'", + "log_remove_on_failed_restore": "Fjern '{}' etter mislykket gjenoppretting fra sikkerhetskopiarkiv", + "log_letsencrypt_cert_install": "Installer et Let's Encrypt-sertifikat på '{}'-domenet", + "log_letsencrypt_cert_renew": "Forny '{}'-Let's Encrypt-sertifikat", + "log_user_update": "Oppdater brukerinfo for '{}'", + "mail_alias_remove_failed": "Kunne ikke fjerne e-postaliaset '{mail}'", + "app_action_broke_system": "Denne handlingen ser ut til å ha knekt disse viktige tjenestene: {services}", + "app_argument_choice_invalid": "Bruk én av disse valgene '{choices}' for argumentet '{name}'", + "app_extraction_failed": "Kunne ikke pakke ut installasjonsfilene", + "app_install_files_invalid": "Disse filene kan ikke installeres", + "backup_abstract_method": "Denne sikkerhetskopimetoden er ikke implementert enda", + "backup_actually_backuping": "Oppretter sikkerhetskopiarkiv fra innsamlede filer…", + "backup_app_failed": "Kunne ikke sikkerhetskopiere programmet '{app}'", + "backup_applying_method_tar": "Lager TAR-sikkerhetskopiarkiv…", + "backup_archive_app_not_found": "Fant ikke programmet '{app}' i sikkerhetskopiarkivet", + "backup_archive_open_failed": "Kunne ikke åpne sikkerhetskopiarkivet", + "app_start_remove": "Fjerner programmet '{app}'…", + "app_start_backup": "Samler inn filer for sikkerhetskopiering for {app}…", + "backup_applying_method_copy": "Kopier alle filer til sikkerhetskopi…", + "backup_creation_failed": "Kunne ikke opprette sikkerhetskopiarkiv", + "backup_couldnt_bind": "Kunne ikke binde {src} til {dest}.", + "backup_csv_addition_failed": "Kunne ikke legge til filer for sikkerhetskopi inn i CSV-filen", + "backup_deleted": "Sikkerhetskopi slettet", + "backup_no_uncompress_archive_dir": "Det finnes ingen slik utpakket arkivmappe", + "backup_delete_error": "Kunne ikke slette '{path}'", + "certmanager_cert_signing_failed": "Kunne ikke signere det nye sertifikatet", + "extracting": "Pakker ut…", + "log_domain_add": "Legg til '{}'-domenet i systemoppsett", + "log_domain_remove": "Fjern '{}'-domenet fra systemoppsett", + "log_dyndns_subscribe": "Abonner på YunoHost-underdomenet '{}'", + "log_dyndns_update": "Oppdater IP-adressen tilknyttet ditt YunoHost-underdomene '{}'", + "backup_nothings_done": "Ingenting å lagre", + "field_invalid": "Ugyldig felt '{}'", + "firewall_reloaded": "Brannmur gjeninnlastet", + "log_app_change_url": "Endre nettadresse for '{}'-programmet", + "log_app_install": "Installer '{}'-programmet", + "log_app_remove": "Fjern '{}'-programmet", + "log_app_upgrade": "Oppgrader '{}'-programmet", + "log_app_makedefault": "Gjør '{}' til forvalgt program", + "log_available_on_yunopaste": "Denne loggen er nå tilgjengelig via {url}", + "log_tools_shutdown": "Slå av tjeneren din", + "log_tools_reboot": "Utfør omstart av tjeneren din", + "apps_already_up_to_date": "Alle programmer allerede oppdatert", + "backup_mount_archive_for_restore": "Forbereder arkiv for gjenopprettelse…", + "backup_copying_to_organize_the_archive": "Kopierer {size} MB for å organisere arkivet", + "domain_cannot_remove_main": "Kan ikke fjerne hoveddomene. Sett et først", + "domain_cert_gen_failed": "Kunne ikke opprette sertifikat", + "domain_created": "Domene opprettet", + "domain_creation_failed": "Kunne ikke opprette domene", + "domain_dyndns_root_unknown": "Ukjent DynDNS-rotdomene", + "dyndns_ip_update_failed": "Kunne ikke oppdatere IP-adresse til DynDNS", + "dyndns_ip_updated": "Oppdaterte din IP på DynDNS", + "dyndns_key_generating": "Oppretter DNS-nøkkel… Dette kan ta en stund.", + "dyndns_no_domain_registered": "Inget domene registrert med DynDNS", + "dyndns_registered": "DynDNS-domene registrert", + "global_settings_setting_security_password_admin_strength": "Admin-passordets styrke", + "dyndns_registration_failed": "Kunne ikke registrere DynDNS-domene: {error}", + "global_settings_setting_security_password_user_strength": "Brukerpassordets styrke", + "log_backup_restore_app": "Gjenopprett '{}' fra sikkerhetskopiarkiv", + "log_remove_on_failed_install": "Fjern '{}' etter mislykket installasjon", + "log_selfsigned_cert_install": "Installer selvsignert sertifikat på '{}'-domenet", + "log_user_delete": "Slett '{}' bruker", + "log_user_group_delete": "Slett '{}' gruppe", + "log_user_group_update": "Oppdater '{}' gruppe", + "app_unknown": "Ukjent program", + "app_upgrade_app_name": "Oppgraderer {app}…", + "app_upgrade_failed": "Kunne ikke oppgradere {app}", + "app_upgrade_some_app_failed": "Noen programmer kunne ikke oppgraderes", + "app_upgraded": "{app} oppgradert", + "ask_firstname": "Fornavn", + "ask_lastname": "Etternavn", + "ask_main_domain": "Hoveddomene", + "ask_new_admin_password": "Nytt administrasjonspassord", + "app_upgrade_several_apps": "Følgende programmer vil oppgraderes: {apps}", + "ask_new_domain": "Nytt domene", + "ask_new_path": "Ny sti", + "ask_password": "Passord", + "domain_deleted": "Domene slettet", + "domain_deletion_failed": "Kunne ikke slette domene", + "domain_dyndns_already_subscribed": "Du har allerede abonnement på et DynDNS-domene", + "log_link_to_log": "Full logg for denne operasjonen: '{desc}'", + "log_help_to_get_log": "For å vise loggen for operasjonen '{desc}', bruk kommandoen 'yunohost log show {name}'", + "log_user_create": "Legg til '{}' bruker", + "app_change_url_success": "{app} nettadressen er nå {domain}{path}", + "app_install_failed": "Kunne ikke installere {app}: {error}" +} \ No newline at end of file diff --git a/locales/ne.json b/locales/ne.json new file mode 100644 index 000000000..9bc5c0bfa --- /dev/null +++ b/locales/ne.json @@ -0,0 +1,3 @@ +{ + "password_too_simple_1": "पासवर्ड कम्तिमा characters अक्षर लामो हुनु आवश्यक छ" +} \ No newline at end of file diff --git a/locales/nl.json b/locales/nl.json index 166df89ff..5e612fc77 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -1,99 +1,63 @@ { - "action_invalid": "Ongeldige actie '{action:s}'", + "action_invalid": "Ongeldige actie '{action}'", "admin_password": "Administrator wachtwoord", - "admin_password_changed": "Het administratie wachtwoord is gewijzigd", - "app_already_installed": "{app:s} is al geïnstalleerd", - "app_argument_invalid": "'{name:s}' bevat ongeldige waarde: {error:s}", - "app_argument_required": "Het '{name:s}' moet ingevuld worden", + "admin_password_changed": "Het administratie wachtwoord werd gewijzigd", + "app_already_installed": "{app} is al geïnstalleerd", + "app_argument_invalid": "Kies een geldige waarde voor '{name}': {error}", + "app_argument_required": "Het '{name}' moet ingevuld worden", "app_extraction_failed": "Kan installatiebestanden niet uitpakken", "app_id_invalid": "Ongeldige app-id", - "app_install_files_invalid": "Ongeldige installatiebestanden", - "app_location_already_used": "Er is al een app geïnstalleerd op deze locatie", - "app_location_install_failed": "Kan app niet installeren op deze locatie", + "app_install_files_invalid": "Deze bestanden kunnen niet worden geïnstalleerd", "app_manifest_invalid": "Ongeldig app-manifest", - "app_no_upgrade": "Geen apps op te upgraden", - "app_not_installed": "{app:s} is niet geïnstalleerd", - "app_recent_version_required": "{:s} vereist een nieuwere versie van moulinette", - "app_removed": "{app:s} succesvol verwijderd", - "app_sources_fetch_failed": "Kan bronbestanden niet ophalen", + "app_not_installed": "{app} is niet geïnstalleerd", + "app_removed": "{app} succesvol verwijderd", + "app_sources_fetch_failed": "Kan bronbestanden niet ophalen, klopt de URL?", "app_unknown": "Onbekende app", - "app_upgrade_failed": "Kan app {app:s} niet updaten", - "app_upgraded": "{app:s} succesvol geüpgraded", - "appslist_fetched": "App-lijst {appslist:s} succesvol opgehaald", - "appslist_removed": "App-lijst {appslist:s} succesvol verwijderd", - "appslist_unknown": "App-lijst {appslist:s} is onbekend.", - "ask_current_admin_password": "Huidig administratorwachtwoord", - "ask_email": "Email-adres", + "app_upgrade_failed": "Kan app {app} niet updaten", + "app_upgraded": "{app} succesvol geüpgraded", "ask_firstname": "Voornaam", "ask_lastname": "Achternaam", "ask_new_admin_password": "Nieuw administratorwachtwoord", "ask_password": "Wachtwoord", "backup_archive_name_exists": "Een backuparchief met dezelfde naam bestaat al", "backup_cleaning_failed": "Kan tijdelijke backup map niet leeg maken", - "backup_creating_archive": "Backup wordt gestart...", - "backup_invalid_archive": "Ongeldig backup archief", "backup_output_directory_not_empty": "Doelmap is niet leeg", - "backup_running_app_script": "Backup script voor app '{app:s}' is gestart...", - "custom_app_url_required": "U moet een URL opgeven om uw aangepaste app {app:s} bij te werken", - "custom_appslist_name_required": "U moet een naam opgeven voor uw aangepaste app-lijst", - "dnsmasq_isnt_installed": "dnsmasq lijkt niet geïnstalleerd te zijn, voer alstublieft het volgende commando uit: 'apt-get remove bind9 && apt-get install dnsmasq'", + "custom_app_url_required": "U moet een URL opgeven om uw aangepaste app {app} bij te werken", "domain_cert_gen_failed": "Kan certificaat niet genereren", "domain_created": "Domein succesvol aangemaakt", "domain_creation_failed": "Kan domein niet aanmaken", "domain_deleted": "Domein succesvol verwijderd", "domain_deletion_failed": "Kan domein niet verwijderen", "domain_dyndns_already_subscribed": "U heeft reeds een domein bij DynDNS geregistreerd", - "domain_dyndns_invalid": "Het domein is ongeldig voor DynDNS", "domain_dyndns_root_unknown": "Onbekend DynDNS root domein", "domain_exists": "Domein bestaat al", "domain_uninstall_app_first": "Een of meerdere apps zijn geïnstalleerd op dit domein, verwijder deze voordat u het domein verwijdert", - "domain_unknown": "Onbekend domein", - "domain_zone_exists": "DNS zone bestand bestaat al", - "domain_zone_not_found": "DNS zone bestand niet gevonden voor domein: {:s}", "done": "Voltooid", "downloading": "Downloaden...", - "dyndns_cron_remove_failed": "De cron-job voor DynDNS kon niet worden verwijderd", "dyndns_ip_update_failed": "Kan het IP adres niet updaten bij DynDNS", "dyndns_ip_updated": "IP adres is aangepast bij DynDNS", "dyndns_key_generating": "DNS sleutel word aangemaakt, wacht een moment...", "dyndns_unavailable": "DynDNS subdomein is niet beschikbaar", - "executing_script": "Script uitvoeren...", "extracting": "Uitpakken...", "installation_complete": "Installatie voltooid", - "installation_failed": "Installatie gefaald", - "ldap_initialized": "LDAP is klaar voor gebruik", - "license_undefined": "Niet gedefinieerd", - "mail_alias_remove_failed": "Kan mail-alias '{mail:s}' niet verwijderen", - "monitor_stats_no_update": "Er zijn geen recente monitoringstatistieken bij te werken", - "mysql_db_creation_failed": "Aanmaken MySQL database gefaald", - "mysql_db_init_failed": "Initialiseren MySQL database gefaald", - "mysql_db_initialized": "MySQL database is succesvol geïnitialiseerd", - "network_check_smtp_ko": "Uitgaande mail (SMPT port 25) wordt blijkbaar geblokkeerd door uw het netwerk", - "no_appslist_found": "Geen app-lijst gevonden", - "no_internet_connection": "Server is niet verbonden met het internet", - "no_ipv6_connectivity": "IPv6-stack is onbeschikbaar", - "path_removal_failed": "Kan pad niet verwijderen {:s}", + "mail_alias_remove_failed": "Kan mail-alias '{mail}' niet verwijderen", "pattern_email": "Moet een geldig emailadres bevatten (bv. abc@example.org)", - "pattern_listname": "Slechts cijfers, letters en '_' zijn toegelaten", "pattern_mailbox_quota": "Mailbox quota moet een waarde bevatten met b/k/M/G/T erachter of 0 om geen quota in te stellen", "pattern_password": "Wachtwoord moet tenminste 3 karakters lang zijn", - "port_already_closed": "Poort {port:d} is al gesloten voor {ip_version:s} verbindingen", - "port_already_opened": "Poort {port:d} is al open voor {ip_version:s} verbindingen", - "port_available": "Poort {port:d} is beschikbaar", - "port_unavailable": "Poort {port:d} is niet beschikbaar", - "restore_app_failed": "De app '{app:s}' kon niet worden terug gezet", - "restore_hook_unavailable": "De herstel-hook '{hook:s}' is niet beschikbaar op dit systeem", - "service_add_failed": "Kan service '{service:s}' niet toevoegen", - "service_already_started": "Service '{service:s}' draait al", - "service_cmd_exec_failed": "Kan '{command:s}' niet uitvoeren", - "service_disabled": "Service '{service:s}' is uitgeschakeld", - "service_remove_failed": "Kan service '{service:s}' niet verwijderen", + "port_already_closed": "Poort {port} is al gesloten voor {ip_version} verbindingen", + "port_already_opened": "Poort {port} is al open voor {ip_version} verbindingen", + "app_restore_failed": "De app '{app}' kon niet worden terug gezet: {error}", + "restore_hook_unavailable": "De herstel-hook '{part}' is niet beschikbaar op dit systeem", + "service_add_failed": "Kan service '{service}' niet toevoegen", + "service_already_started": "Service '{service}' draait al", + "service_cmd_exec_failed": "Kan '{command}' niet uitvoeren", + "service_disabled": "Service '{service}' is uitgeschakeld", + "service_remove_failed": "Kan service '{service}' niet verwijderen", "service_removed": "Service werd verwijderd", - "service_stop_failed": "Kan service '{service:s}' niet stoppen", - "service_unknown": "De service '{service:s}' bestaat niet", - "show_diff": "Let op de volgende verschillen zijn:\n{diff:s}", + "service_stop_failed": "Kan service '{service}' niet stoppen", + "service_unknown": "De service '{service}' bestaat niet", "unexpected_error": "Er is een onbekende fout opgetreden", - "unrestore_app": "App '{app:s}' wordt niet teruggezet", + "unrestore_app": "App '{app}' wordt niet teruggezet", "updating_apt_cache": "Lijst van beschikbare pakketten wordt bijgewerkt...", "upgrade_complete": "Upgrade voltooid", "upgrading_packages": "Pakketten worden geüpdate...", @@ -103,40 +67,50 @@ "upnp_port_open_failed": "Kan UPnP poorten niet openen", "user_deleted": "Gebruiker werd verwijderd", "user_home_creation_failed": "Kan de map voor deze gebruiker niet aanmaken", - "user_unknown": "Gebruikersnaam {user:s} is onbekend", + "user_unknown": "Gebruikersnaam {user} is onbekend", "user_update_failed": "Kan gebruiker niet bijwerken", "yunohost_configured": "YunoHost configuratie is OK", "admin_password_change_failed": "Wachtwoord kan niet veranderd worden", - "app_argument_choice_invalid": "Ongeldige keuze voor argument '{name:s}'. Het moet een van de volgende keuzes zijn {choices:s}", - "app_incompatible": "Deze applicatie is incompatibel met uw YunoHost versie", - "app_not_correctly_installed": "{app:s} schijnt niet juist geïnstalleerd te zijn", - "app_not_properly_removed": "{app:s} werd niet volledig verwijderd", - "app_package_need_update": "Het is noodzakelijk om het app pakket te updaten, in navolging van veranderingen aan YunoHost", - "app_requirements_checking": "Controleer noodzakelijke pakketten...", - "app_requirements_failed": "Er wordt niet aan de aanvorderingen voldaan: {error}", + "app_argument_choice_invalid": "Ongeldige keuze voor argument '{name}'. Het moet een van de volgende keuzes zijn {choices}", + "app_not_correctly_installed": "{app} schijnt niet juist geïnstalleerd te zijn", + "app_not_properly_removed": "{app} werd niet volledig verwijderd", + "app_requirements_checking": "Noodzakelijke pakketten voor {app} aan het controleren...", "app_requirements_unmeet": "Er wordt niet aan de aanvorderingen voldaan, het pakket {pkgname} ({version}) moet {spec} zijn", "app_unsupported_remote_type": "Niet ondersteund besturings type voor de app", - "appslist_retrieve_error": "Niet mogelijk om de externe applicatie lijst op te halen {appslist:s}: {error:s}", - "appslist_retrieve_bad_format": "Opgehaald bestand voor applicatie lijst {appslist:s} is geen geldige applicatie lijst", - "appslist_name_already_tracked": "Er is reeds een geregistreerde applicatie lijst met de naam {name:s}.", - "appslist_url_already_tracked": "Er is reeds een geregistreerde applicatie lijst met de url {url:s}.", - "appslist_migrating": "Migreer applicatielijst {appslist:s} ...", - "appslist_could_not_migrate": "Kon applicatielijst {appslist:s} niet migreren! Niet in staat om de url te verwerken... De oude cron job is opgeslagen onder {bkp_file:s}.", - "appslist_corrupted_json": "Kon de applicatielijst niet laden. Het schijnt, dat {filename:s} beschadigd is.", - "ask_list_to_remove": "Te verwijderen lijst", "ask_main_domain": "Hoofd-domein", - "backup_action_required": "U moet iets om op te slaan uitkiezen", - "backup_app_failed": "Kon geen backup voor app '{app:s}' aanmaken", - "backup_archive_app_not_found": "App '{app:s}' kon niet in het backup archief gevonden worden", - "backup_archive_broken_link": "Het backup archief kon niet geopend worden (Ongeldig verwijs naar {path:s})", - "backup_archive_hook_not_exec": "Hook '{hook:s}' kon voor deze backup niet uitgevoerd worden", - "backup_archive_name_unknown": "Onbekend lokaal backup archief namens '{name:s}' gevonden", + "backup_app_failed": "Kon geen backup voor app '{app}' aanmaken", + "backup_archive_app_not_found": "App '{app}' kon niet in het backup archief gevonden worden", + "backup_archive_broken_link": "Het backup archief kon niet geopend worden (Ongeldig verwijs naar {path})", + "backup_archive_name_unknown": "Onbekend lokaal backup archief namens '{name}' gevonden", "backup_archive_open_failed": "Kan het backup archief niet openen", "backup_created": "Backup aangemaakt", "backup_creation_failed": "Aanmaken van backup mislukt", - "backup_delete_error": "Kon pad '{path:s}' niet verwijderen", + "backup_delete_error": "Kon pad '{path}' niet verwijderen", "backup_deleted": "Backup werd verwijderd", - "backup_extracting_archive": "Backup archief uitpakken...", - "backup_hook_unknown": "backup hook '{hook:s}' onbekend", - "backup_nothings_done": "Niets om op te slaan" -} + "backup_hook_unknown": "backup hook '{hook}' onbekend", + "backup_nothings_done": "Niets om op te slaan", + "password_too_simple_1": "Het wachtwoord moet minimaal 8 tekens lang zijn", + "already_up_to_date": "Er is niets te doen, alles is al up-to-date.", + "admin_password_too_long": "Gelieve een wachtwoord te kiezen met minder dan 127 karakters", + "app_action_cannot_be_ran_because_required_services_down": "De volgende diensten moeten actief zijn om deze actie uit te voeren: {services}. Probeer om deze te herstarten om verder te gaan (en om eventueel te onderzoeken waarom ze niet werken).", + "aborting": "Annulatie.", + "app_upgrade_app_name": "Bezig {app} te upgraden...", + "app_make_default_location_already_used": "Kan '{app}' niet de standaardapp maken op het domein, '{domain}' wordt al gebruikt door '{other_app}'", + "app_install_failed": "Kan {app} niet installeren: {error}", + "app_remove_after_failed_install": "Bezig de app te verwijderen na gefaalde installatie...", + "app_manifest_install_ask_domain": "Kies het domein waar deze app op geïnstalleerd moet worden", + "app_manifest_install_ask_path": "Kies het pad waar deze app geïnstalleerd moet worden", + "app_manifest_install_ask_admin": "Kies een administrator voor deze app", + "app_change_url_success": "{app} URL is nu {domain}{path}", + "app_full_domain_unavailable": "Sorry, deze app moet op haar eigen domein geïnstalleerd worden, maar andere apps zijn al geïnstalleerd op het domein '{domain}'. U kunt wel een subdomein aan deze app toewijden.", + "app_install_script_failed": "Er is een fout opgetreden in het installatiescript van de app", + "app_location_unavailable": "Deze URL is niet beschikbaar of is in conflict met de al geïnstalleerde app(s):\n{apps}", + "app_manifest_install_ask_password": "Kies een administratiewachtwoord voor deze app", + "app_manifest_install_ask_is_public": "Moet deze app zichtbaar zijn voor anomieme bezoekers?", + "app_not_upgraded": "De app '{failed_app}' kon niet upgraden en daardoor zijn de upgrades van de volgende apps geannuleerd: {apps}", + "app_start_install": "{app} installeren...", + "app_start_remove": "{app} verwijderen...", + "app_start_backup": "Bestanden aan het verzamelen voor de backup van {app}...", + "app_start_restore": "{app} herstellen...", + "app_upgrade_several_apps": "De volgende apps zullen worden geüpgraded: {apps}" +} \ No newline at end of file diff --git a/locales/oc.json b/locales/oc.json index fc6f6946c..a2a5bfe31 100644 --- a/locales/oc.json +++ b/locales/oc.json @@ -2,110 +2,77 @@ "admin_password": "Senhal d’administracion", "admin_password_change_failed": "Impossible de cambiar lo senhal", "admin_password_changed": "Lo senhal d’administracion es ben estat cambiat", - "app_already_installed": "{app:s} es ja installat", - "app_already_up_to_date": "{app:s} es ja a jorn", + "app_already_installed": "{app} es ja installat", + "app_already_up_to_date": "{app} es ja a jorn", "installation_complete": "Installacion acabada", - "app_id_invalid": "Id d’aplicacion incorrècte", - "app_install_files_invalid": "Fichièrs d’installacion incorrèctes", - "app_no_upgrade": "Pas cap d’aplicacion de metre a jorn", - "app_not_correctly_installed": "{app:s} sembla pas ben installat", - "app_not_installed": "{app:s} es pas installat", - "app_not_properly_removed": "{app:s} es pas estat corrèctament suprimit", - "app_removed": "{app:s} es estat suprimit", + "app_id_invalid": "ID d’aplicacion incorrècte", + "app_install_files_invalid": "Installacion impossibla d’aquestes fichièrs", + "app_not_correctly_installed": "{app} sembla pas ben installat", + "app_not_installed": "Impossible de trobar l’aplicacion {app} dins la lista de las aplicacions installadas : {all_apps}", + "app_not_properly_removed": "{app} es pas estat corrèctament suprimit", + "app_removed": "{app} es estada suprimida", "app_unknown": "Aplicacion desconeguda", - "app_upgrade_app_name": "Mesa a jorn de l’aplicacion {app}…", - "app_upgrade_failed": "Impossible de metre a jorn {app:s}", - "app_upgrade_some_app_failed": "D’aplicacions se pòdon pas metre a jorn", - "app_upgraded": "{app:s} es estat mes a jorn", - "appslist_fetched": "Recuperacion de la lista d’aplicacions {appslist:s} corrèctament realizada", - "appslist_migrating": "Migracion de la lista d’aplicacion{appslist:s}…", - "appslist_name_already_tracked": "I a ja una lista d’aplicacion enregistrada amb lo nom {name:s}.", - "appslist_removed": "Supression de la lista d’aplicacions {appslist:s} corrèctament realizada", - "appslist_retrieve_bad_format": "Lo fichièr recuperat per la lista d’aplicacions {appslist:s} es pas valid", - "appslist_unknown": "La lista d’aplicacions {appslist:s} es desconeguda.", - "appslist_url_already_tracked": "I a ja una lista d’aplicacions enregistrada amb l’URL {url:s}.", - "ask_current_admin_password": "Senhal administrator actual", - "ask_email": "Adreça de corrièl", + "app_upgrade_app_name": "Actualizacion de l’aplicacion {app}...", + "app_upgrade_failed": "Impossible d’actualizar {app} : {error}", + "app_upgrade_some_app_failed": "D’aplicacions se pòdon pas actualizar", + "app_upgraded": "{app} es estada actualizada", "ask_firstname": "Prenom", "ask_lastname": "Nom", - "ask_list_to_remove": "Lista de suprimir", "ask_main_domain": "Domeni màger", "ask_new_admin_password": "Nòu senhal administrator", "ask_password": "Senhal", - "ask_path": "Camin", - "backup_action_required": "Devètz precisar çò que cal salvagardar", - "backup_app_failed": "Impossible de salvagardar l’aplicacion « {app:s} »", - "backup_applying_method_copy": "Còpia de totes los fichièrs dins la salvagarda…", - "backup_applying_method_tar": "Creacion de l’archiu tar de la salvagarda…", - "backup_archive_name_exists": "Un archiu de salvagarda amb aquesta nom existís ja", - "backup_archive_name_unknown": "L’archiu local de salvagarda apelat « {name:s} » es desconegut", - "action_invalid": "Accion « {action:s} » incorrècta", - "app_argument_choice_invalid": "Causida invalida pel paramètre « {name:s} », cal que siá un de {choices:s}", - "app_argument_invalid": "Valor invalida pel paramètre « {name:s} » : {error:s}", - "app_argument_required": "Lo paramètre « {name:s} » es requesit", - "app_change_url_failed_nginx_reload": "La reaviada de nginx a fracassat. Vaquí la sortida de « nginx -t » :\n{nginx_errors:s}", - "app_change_url_identical_domains": "L’ancian e lo novèl coble domeni/camin son identics per {domain:s}{path:s}, pas res a far.", - "app_change_url_success": "L’URL de l’aplicacion {app:s} a cambiat per {domain:s}{path:s}", - "app_checkurl_is_deprecated": "Packagers /!\\ ’app checkurl’ es obsolèt ! Utilizatz ’app register-url’ a la plaça !", + "backup_app_failed": "Impossible de salvagardar l’aplicacion « {app} »", + "backup_applying_method_copy": "Còpia de totes los fichièrs dins la salvagarda...", + "backup_applying_method_tar": "Creacion de l’archiu TAR de la salvagarda...", + "backup_archive_name_exists": "Un archiu de salvagarda amb aquesta nom existís ja.", + "backup_archive_name_unknown": "L’archiu local de salvagarda apelat « {name} » es desconegut", + "action_invalid": "Accion « {action} » incorrècta", + "app_argument_choice_invalid": "Utilizatz una de las opcions « {choices} » per l’argument « {name} »", + "app_argument_invalid": "Causissètz una valor invalida pel paramètre « {name} » : {error}", + "app_argument_required": "Lo paramètre « {name} » es requesit", + "app_change_url_identical_domains": "L’ancian e lo novèl coble domeni/camin son identics per {domain}{path}, pas res a far.", + "app_change_url_success": "L’URL de l’aplicacion {app} es ara {domain}{path}", "app_extraction_failed": "Extraccion dels fichièrs d’installacion impossibla", - "app_incompatible": "L’aplicacion {app} es pas compatibla amb vòstra version de YunoHost", - "app_location_already_used": "L’aplicacion « {app} » es ja installada a aqueste emplaçament ({path})", - "app_manifest_invalid": "Manifest d’aplicacion incorrècte : {error}", - "app_package_need_update": "Lo paquet de l’aplicacion {app} deu èsser mes a jorn per seguir los cambiaments de YunoHost", - "app_requirements_checking": "Verificacion dels paquets requesits per {app}…", + "app_manifest_invalid": "I a quicòm que truca amb lo manifest de l’aplicacion : {error}", + "app_requirements_checking": "Verificacion dels paquets requesits per {app}...", "app_sources_fetch_failed": "Recuperacion dels fichièrs fonts impossibla, l’URL es corrècta ?", "app_unsupported_remote_type": "Lo tipe alonhat utilizat per l’aplicacion es pas suportat", - "appslist_retrieve_error": "Impossible de recuperar la lista d’aplicacions alonhadas {appslist:s} : {error:s}", - "backup_archive_app_not_found": "L’aplicacion « {app:s} » es pas estada trobada dins l’archiu de la salvagarda", - "backup_archive_broken_link": "Impossible d‘accedir a l’archiu de salvagarda (ligam invalid cap a {path:s})", - "backup_archive_mount_failed": "Lo montatge de l’archiu de salvagarda a fracassat", + "backup_archive_app_not_found": "L’aplicacion « {app} » es pas estada trobada dins l’archiu de la salvagarda", + "backup_archive_broken_link": "Impossible d’accedir a l’archiu de salvagarda (ligam invalid cap a {path})", "backup_archive_open_failed": "Impossible de dobrir l’archiu de salvagarda", - "backup_archive_system_part_not_available": "La part « {part:s} » del sistèma es pas disponibla dins aquesta salvagarda", + "backup_archive_system_part_not_available": "La part « {part} » del sistèma es pas disponibla dins aquesta salvagarda", "backup_cleaning_failed": "Impossible de netejar lo repertòri temporari de salvagarda", - "backup_copying_to_organize_the_archive": "Còpia de {size:s} Mio per organizar l’archiu", + "backup_copying_to_organize_the_archive": "Còpia de {size} Mio per organizar l’archiu", "backup_created": "Salvagarda acabada", - "backup_creating_archive": "Creacion de l’archiu de salvagarda…", - "backup_creation_failed": "Impossible de crear la salvagarda", + "backup_creation_failed": "Creacion impossibla de l’archiu de salvagarda", "app_already_installed_cant_change_url": "Aquesta aplicacion es ja installada. Aquesta foncion pòt pas simplament cambiar l’URL. Agachatz « app changeurl » s’es disponible.", - "app_change_no_change_url_script": "L’aplicacion {app_name:s} pren pas en compte lo cambiament d’URL, poiretz aver de la metre a jorn.", - "app_change_url_no_script": "L’aplicacion {app_name:s} pren pas en compte lo cambiament d’URL, benlèu que vos cal la metre a jorn.", + "app_change_url_no_script": "L’aplicacion {app_name} pren pas en compte lo cambiament d’URL, benlèu que vos cal l’actualizar.", "app_make_default_location_already_used": "Impossible de configurar l’aplicacion « {app} » per defaut pel domeni {domain} perque es ja utilizat per l’aplicacion {other_app}", - "app_location_install_failed": "Impossible d’installar l’aplicacion a aqueste emplaçament per causa de conflicte amb l’aplicacion {other_app} qu’es ja installada sus {other_path}", - "app_location_unavailable": "Aquesta URL es pas disponibla o en conflicte amb una aplicacion existenta :\n{apps:s}", - "appslist_corrupted_json": "Cargament impossible de la lista d’aplicacion. Sembla que {filename:s} siá gastat.", - "backup_delete_error": "Impossible de suprimir « {path:s} »", + "app_location_unavailable": "Aquesta URL es pas disponibla o en conflicte amb una aplicacion existenta :\n{apps}", + "backup_delete_error": "Supression impossibla de « {path} »", "backup_deleted": "La salvagarda es estada suprimida", - "backup_hook_unknown": "Script de salvagarda « {hook:s} » desconegut", - "backup_invalid_archive": "Archiu de salvagarda incorrècte", - "backup_method_borg_finished": "La salvagarda dins Borg es acabada", + "backup_hook_unknown": "Script de salvagarda « {hook} » desconegut", "backup_method_copy_finished": "La còpia de salvagarda es acabada", - "backup_method_tar_finished": "L’archiu tar de la salvagarda es estat creat", - "backup_output_directory_not_empty": "Lo dorsièr de sortida es pas void", + "backup_method_tar_finished": "L’archiu TAR de la salvagarda es estat creat", + "backup_output_directory_not_empty": "Devètz causir un dorsièr de sortida void", "backup_output_directory_required": "Vos cal especificar un dorsièr de sortida per la salvagarda", - "backup_running_app_script": "Lançament de l’escript de salvagarda de l’aplicacion « {app:s} »...", - "backup_running_hooks": "Execucion dels scripts de salvagarda…", - "backup_system_part_failed": "Impossible de salvagardar la part « {part:s} » del sistèma", - "app_requirements_failed": "Impossible de complir las condicions requesidas per {app} : {error}", + "backup_running_hooks": "Execucion dels scripts de salvagarda...", + "backup_system_part_failed": "Impossible de salvagardar la part « {part} » del sistèma", "app_requirements_unmeet": "Las condicions requesidas per {app} son pas complidas, lo paquet {pkgname} ({version}) deu èsser {spec}", - "appslist_could_not_migrate": "Migracion de la lista impossibla {appslist:s} ! Impossible d’analizar l’URL… L’anciana tasca cron es estada servada dins {bkp_file:s}.", "backup_abstract_method": "Aqueste metòde de salvagarda es pas encara implementat", - "backup_applying_method_custom": "Crida lo metòde de salvagarda personalizat « {method:s} »…", - "backup_borg_not_implemented": "Lo metòde de salvagarda Bord es pas encara implementat", - "backup_couldnt_bind": "Impossible de ligar {src:s} amb {dest:s}.", + "backup_applying_method_custom": "Crida del metòde de salvagarda personalizat « {method} »...", + "backup_couldnt_bind": "Impossible de ligar {src} amb {dest}.", "backup_csv_addition_failed": "Impossible d’ajustar de fichièrs a la salvagarda dins lo fichièr CSV", "backup_custom_backup_error": "Fracàs del metòde de salvagarda personalizat a l’etapa « backup »", "backup_custom_mount_error": "Fracàs del metòde de salvagarda personalizat a l’etapa « mount »", - "backup_custom_need_mount_error": "Fracàs del metòde de salvagarda personalizat a l’etapa « need_mount »", - "backup_method_custom_finished": "Lo metòde de salvagarda personalizat « {method:s} » es acabat", + "backup_method_custom_finished": "Lo metòde de salvagarda personalizat « {method} » es acabat", "backup_nothings_done": "I a pas res de salvagardar", "backup_unable_to_organize_files": "Impossible d’organizar los fichièrs dins l’archiu amb lo metòde rapid", - "service_status_failed": "Impossible de determinar l’estat del servici « {service:s} »", - "service_stopped": "Lo servici « {service:s} » es estat arrestat", - "service_unknown": "Servici « {service:s} » desconegut", - "unbackup_app": "L’aplicacion « {app:s} » serà pas salvagardada", - "unit_unknown": "Unitat « {unit:s} » desconeguda", + "service_stopped": "Lo servici « {service} » es estat arrestat", + "service_unknown": "Servici « {service} » desconegut", + "unbackup_app": "L’aplicacion « {app} » serà pas salvagardada", "unlimit": "Cap de quòta", - "unrestore_app": "L’aplicacion « {app:s} » serà pas restaurada", + "unrestore_app": "L’aplicacion « {app} » serà pas restaurada", "upnp_dev_not_found": "Cap de periferic compatible UPnP pas trobat", "upnp_disabled": "UPnP es desactivat", "upnp_enabled": "UPnP es activat", @@ -113,177 +80,101 @@ "yunohost_already_installed": "YunoHost es ja installat", "yunohost_configured": "YunoHost es estat configurat", "yunohost_installing": "Installacion de YunoHost…", - "backup_applying_method_borg": "Mandadís de totes los fichièrs a la salvagarda dins lo repertòri borg-backup…", "backup_csv_creation_failed": "Creacion impossibla del fichièr CSV necessari a las operacions futuras de restauracion", - "backup_extracting_archive": "Extraccion de l’archiu de salvagarda…", - "backup_output_symlink_dir_broken": "Avètz un ligam simbolic copat allòc de vòstre repertòri d’archiu « {path:s} ». Poiriatz aver una configuracion personalizada per salvagardar vòstras donadas sus un autre sistèma de fichièrs, en aquel cas, saique oblidèretz de montar o de connectar lo disc o la clau USB.", - "backup_with_no_backup_script_for_app": "L’aplicacion {app:s} a pas cap de script de salvagarda. I fasèm pas cas.", - "backup_with_no_restore_script_for_app": "L’aplicacion {app:s} a pas cap de script de restauracion, poiretz pas restaurar automaticament la salvagarda d’aquesta aplicacion.", - "certmanager_acme_not_configured_for_domain": "Lo certificat del domeni {domain:s} sembla pas corrèctament installat. Mercés de lançar d’en primièr cert-install per aqueste domeni.", - "certmanager_attempt_to_renew_nonLE_cert": "Lo certificat pel domeni {domain:s} es pas provesit per Let’s Encrypt. Impossible de lo renovar automaticament !", - "certmanager_attempt_to_renew_valid_cert": "Lo certificat pel domeni {domain:s} es a man d’expirar ! (Podètz utilizar --force se sabètz çò que fasètz)", - "certmanager_cannot_read_cert": "Quicòm a trucat en ensajar de dobrir lo certificat actual pel domeni {domain:s} (fichièr : {file:s}), rason : {reason:s}", - "certmanager_cert_install_success": "Installacion capitada del certificat Let’s Encrypt pel domeni {domain:s} !", - "certmanager_cert_install_success_selfsigned": "Installacion capitada del certificat auto-signat pel domeni {domain:s} !", - "certmanager_cert_signing_failed": "Fracàs de la signatura del nòu certificat", - "certmanager_domain_cert_not_selfsigned": "Lo certificat del domeni {domain:s} es pas auto-signat. Volètz vertadièrament lo remplaçar ? (Utiliatz --force)", - "certmanager_domain_dns_ip_differs_from_public_ip": "L’enregistrament DNS « A » del domeni {domain:s} es diferent de l’adreça IP d’aqueste servidor. Se fa pauc qu’avètz modificat l’enregistrament « A », mercés d’esperar l’espandiment (qualques verificadors d’espandiment son disponibles en linha). (Se sabètz çò que fasèm, utilizatz --no-checks per desactivar aqueles contraròtles)", - "certmanager_domain_http_not_working": "Sembla que lo domeni {domain:s} es pas accessible via HTTP. Mercés de verificar que las configuracions DNS e nginx son corrèctas", - "certmanager_domain_unknown": "Domeni desconegut {domain:s}", - "certmanager_no_cert_file": "Lectura impossibla del fichièr del certificat pel domeni {domain:s} (fichièr : {file:s})", - "certmanager_self_ca_conf_file_not_found": "Lo fichièr de configuracion per l’autoritat del certificat auto-signat es introbabla (fichièr : {file:s})", - "certmanager_unable_to_parse_self_CA_name": "Analisi impossible lo nom de l’autoritat del certificat auto-signat (fichièr : {file:s})", - "custom_app_url_required": "Cal que donetz una URL per actualizar vòstra aplicacion personalizada {app:s}", - "custom_appslist_name_required": "Cal que nomenetz vòstra lista d’aplicacions personalizadas", - "diagnosis_debian_version_error": "Impossible de determinar la version de Debian : {error}", - "diagnosis_kernel_version_error": "Impossible de recuperar la version del nuclèu : {error}", - "diagnosis_no_apps": "Pas cap d’aplicacion installada", - "dnsmasq_isnt_installed": "dnsmasq sembla pas èsser installat, mercés de lançar « apt-get remove bind9 && apt-get install dnsmasq »", + "backup_output_symlink_dir_broken": "Vòstre repertòri d’archiu « {path} » es un ligam simbolic copat. Saique oblidèretz de re/montar o de connectar supòrt.", + "backup_with_no_backup_script_for_app": "L’aplicacion {app} a pas cap de script de salvagarda. I fasèm pas cas.", + "backup_with_no_restore_script_for_app": "{app} a pas cap de script de restauracion, poiretz pas restaurar automaticament la salvagarda d’aquesta aplicacion.", + "certmanager_acme_not_configured_for_domain": "Lo certificat pel domeni {domain} sembla pas corrèctament installat. Mercés de lançar d’en primièr « cert-install » per aqueste domeni.", + "certmanager_attempt_to_renew_nonLE_cert": "Lo certificat pel domeni {domain} es pas provesit per Let’s Encrypt. Impossible de lo renovar automaticament !", + "certmanager_attempt_to_renew_valid_cert": "Lo certificat pel domeni {domain} es a man d’expirar ! (Podètz utilizar --force se sabètz çò que fasètz)", + "certmanager_cannot_read_cert": "Quicòm a trucat en ensajar de dobrir lo certificat actual pel domeni {domain} (fichièr : {file}), rason : {reason}", + "certmanager_cert_install_success": "Lo certificat Let’s Encrypt es ara installat pel domeni « {domain} »", + "certmanager_cert_install_success_selfsigned": "Lo certificat auto-signat es ara installat pel domeni « {domain} »", + "certmanager_cert_signing_failed": "Signatura impossibla del nòu certificat", + "certmanager_domain_cert_not_selfsigned": "Lo certificat pel domeni {domain} es pas auto-signat. Volètz vertadièrament lo remplaçar ? (Utilizatz « --force » per o far)", + "certmanager_domain_dns_ip_differs_from_public_ip": "L’enregistrament DNS « A » pel domeni {domain} es diferent de l’adreça IP d’aqueste servidor. Se fa pauc qu’avètz modificat l’enregistrament « A », mercés d’esperar l’espandiment (qualques verificadors d’espandiment son disponibles en linha). (Se sabètz çò que fasèm, utilizatz --no-checks per desactivar aqueles contraròtles)", + "certmanager_domain_http_not_working": "Sembla que lo domeni {domain} es pas accessible via HTTP. Mercés de verificar que las configuracions DNS e NGINK son corrèctas", + "certmanager_no_cert_file": "Lectura impossibla del fichièr del certificat pel domeni {domain} (fichièr : {file})", + "certmanager_self_ca_conf_file_not_found": "Impossible de trobar lo fichièr de configuracion per l’autoritat del certificat auto-signat (fichièr : {file})", + "certmanager_unable_to_parse_self_CA_name": "Analisi impossibla del nom de l’autoritat del certificat auto-signat (fichièr : {file})", + "custom_app_url_required": "Cal que donetz una URL per actualizar vòstra aplicacion personalizada {app}", "domain_cannot_remove_main": "Impossible de levar lo domeni màger. Definissètz un novèl domeni màger d’en primièr", "domain_cert_gen_failed": "Generacion del certificat impossibla", - "domain_created": "Lo domeni es creat", - "domain_creation_failed": "Creacion del certificat impossibla", - "domain_deleted": "Lo domeni es suprimit", - "domain_deletion_failed": "Supression impossibla del domeni", - "domain_dyndns_invalid": "Domeni incorrècte per una utilizacion amb DynDNS", + "domain_created": "Domeni creat", + "domain_creation_failed": "Creacion del domeni {domain}: impossibla", + "domain_deleted": "Domeni suprimit", + "domain_deletion_failed": "Supression impossibla del domeni {domain}: {error}", "domain_dyndns_root_unknown": "Domeni DynDNS màger desconegut", "domain_exists": "Lo domeni existís ja", - "domain_hostname_failed": "Fracàs de la creacion d’un nòu nom d’òst", - "domain_unknown": "Domeni desconegut", - "domain_zone_exists": "Lo fichièr zòna DNS existís ja", - "domain_zone_not_found": "Fichèr de zòna DNS introbable pel domeni {:s}", + "domain_hostname_failed": "Fracàs de la creacion d’un nòu nom d’òst. Aquò poirà provocar de problèmas mai tard (mas es pas segur… benlèu que coparà pas res).", "domains_available": "Domenis disponibles :", "done": "Acabat", "downloading": "Telecargament…", - "dyndns_could_not_check_provide": "Impossible de verificar se {provider:s} pòt provesir {domain:s}.", - "dyndns_cron_installed": "La tasca cron pel domeni DynDNS es installada", - "dyndns_cron_remove_failed": "Impossible de levar la tasca cron pel domeni DynDNS", - "dyndns_cron_removed": "La tasca cron pel domeni DynDNS es levada", + "dyndns_could_not_check_provide": "Impossible de verificar se {provider} pòt provesir {domain}.", "dyndns_ip_update_failed": "Impossible d’actualizar l’adreça IP sul domeni DynDNS", - "dyndns_ip_updated": "Vòstra adreça IP es estada actualizada pel domeni DynDNS", - "dyndns_key_generating": "La clau DNS es a se generar, pòt trigar una estona…", + "dyndns_ip_updated": "Vòstra adreça IP actualizada pel domeni DynDNS", + "dyndns_key_generating": "La clau DNS es a se generar… pòt trigar una estona.", "dyndns_key_not_found": "Clau DNS introbabla pel domeni", "dyndns_no_domain_registered": "Cap de domeni pas enregistrat amb DynDNS", - "dyndns_registered": "Lo domeni DynDNS es enregistrat", - "dyndns_registration_failed": "Enregistrament del domeni DynDNS impossibla : {error:s}", - "dyndns_domain_not_provided": "Lo provesidor DynDNS {provider:s} pòt pas fornir lo domeni {domain:s}.", - "dyndns_unavailable": "Lo domeni {domain:s} es pas disponible.", + "dyndns_registered": "Domeni DynDNS enregistrat", + "dyndns_registration_failed": "Enregistrament del domeni DynDNS impossible : {error}", + "dyndns_domain_not_provided": "Lo provesidor DynDNS {provider} pòt pas fornir lo domeni {domain}.", + "dyndns_unavailable": "Lo domeni {domain} es pas disponible.", "extracting": "Extraccion…", - "field_invalid": "Camp incorrècte : « {:s} »", - "format_datetime_short": "%d/%m/%Y %H:%M", - "global_settings_cant_open_settings": "Fracàs de la dobertura del fichièr de configuracion, rason : {reason:s}", - "global_settings_key_doesnt_exists": "La clau « {settings_key:s} » existís pas dins las configuracions globalas, podètz veire totas las claus disponiblas en picant « yunohost settings list »", - "global_settings_reset_success": "Capitada ! Vòstra configuracion precedenta es estada salvagarda dins {path:s}", - "global_settings_setting_example_bool": "Exemple d’opcion booleana", - "global_settings_unknown_setting_from_settings_file": "Clau desconeguda dins los paramètres : {setting_key:s}, apartada e salvagardada dins /etc/yunohost/settings-unknown.json", - "installation_failed": "Fracàs de l’installacion", - "invalid_url_format": "Format d’URL pas valid", - "ldap_initialized": "L’annuari LDAP es inicializat", - "license_undefined": "indefinida", - "maindomain_change_failed": "Modificacion impossibla del domeni màger", - "maindomain_changed": "Lo domeni màger es estat modificat", - "migrate_tsig_end": "La migracion cap a hmac-sha512 es acabada", - "migrate_tsig_wait_2": "2 minutas…", - "migrate_tsig_wait_3": "1 minuta…", - "migrate_tsig_wait_4": "30 segondas…", - "migration_description_0002_migrate_to_tsig_sha256": "Melhora la seguretat de DynDNS TSIG en utilizar SHA512 allòc de MD5", - "migration_description_0003_migrate_to_stretch": "Mesa a nivèl del sistèma cap a Debian Stretch e YunoHost 3.0", - "migration_0003_backward_impossible": "La migracion Stretch es pas reversibla.", - "migration_0003_start": "Aviada de la migracion cap a Stretech. Los jornals seràn disponibles dins {logfile}.", - "migration_0003_patching_sources_list": "Petaçatge de sources.lists…", - "migration_0003_main_upgrade": "Aviada de la mesa a nivèl màger…", - "migration_0003_fail2ban_upgrade": "Aviada de la mesa a nivèl de fail2ban…", - "migration_0003_not_jessie": "La distribucion Debian actuala es pas Jessie !", + "field_invalid": "Camp incorrècte : « {} »", + "global_settings_cant_open_settings": "Fracàs de la dobertura del fichièr de configuracion, rason : {reason}", + "global_settings_key_doesnt_exists": "La clau « {settings_key} » existís pas dins las configuracions globalas, podètz veire totas las claus disponiblas en executant « yunohost settings list »", + "global_settings_reset_success": "Configuracion precedenta ara salvagarda dins {path}", + "global_settings_unknown_setting_from_settings_file": "Clau desconeguda dins los paramètres : {setting_key}, apartada e salvagardada dins /etc/yunohost/settings-unknown.json", + "main_domain_change_failed": "Modificacion impossibla del domeni màger", + "main_domain_changed": "Lo domeni màger es estat modificat", "migrations_cant_reach_migration_file": "Impossible d’accedir als fichièrs de migracion amb lo camin %s", - "migrations_current_target": "La cibla de migracion est {}", - "migrations_error_failed_to_load_migration": "ERROR : fracàs del cargament de la migracion {number} {name}", "migrations_list_conflict_pending_done": "Podètz pas utilizar --previous e --done a l’encòp.", - "migrations_loading_migration": "Cargament de la migracion{number} {name}…", + "migrations_loading_migration": "Cargament de la migracion {id}…", "migrations_no_migrations_to_run": "Cap de migracion de lançar", - "migrations_show_currently_running_migration": "Realizacion de la migracion {number} {name}…", - "migrations_show_last_migration": "La darrièra migracion realizada es {}", - "monitor_glances_con_failed": "Connexion impossibla al servidor Glances", - "monitor_not_enabled": "Lo seguiment de l’estat del servidor es pas activat", - "monitor_stats_no_update": "Cap de donadas d’estat del servidor d’actualizar", - "mountpoint_unknown": "Ponch de montatge desconegut", - "mysql_db_creation_failed": "Creacion de la basa de donadas MySQL impossibla", - "no_appslist_found": "Cap de lista d’aplicacions pas trobada", - "no_internet_connection": "Lo servidor es pas connectat a Internet", - "package_not_installed": "Lo paquet « {pkgname} » es pas installat", - "package_unknown": "Paquet « {pkgname} » desconegut", - "packages_no_upgrade": "I a pas cap de paquet d’actualizar", "packages_upgrade_failed": "Actualizacion de totes los paquets impossibla", - "path_removal_failed": "Impossible de suprimir lo camin {:s}", "pattern_domain": "Deu èsser un nom de domeni valid (ex : mon-domeni.org)", "pattern_email": "Deu èsser una adreça electronica valida (ex : escais@domeni.org)", "pattern_firstname": "Deu èsser un pichon nom valid", "pattern_lastname": "Deu èsser un nom valid", "pattern_password": "Deu conténer almens 3 caractèrs", - "pattern_port": "Deu èsser un numèro de pòrt valid (ex : 0-65535)", "pattern_port_or_range": "Deu èsser un numèro de pòrt valid (ex : 0-65535) o un interval de pòrt (ex : 100:200)", - "pattern_positive_number": "Deu èsser un nombre positiu", - "port_already_closed": "Lo pòrt {port:d} es ja tampat per las connexions {ip_version:s}", - "port_already_opened": "Lo pòrt {port:d} es ja dubèrt per las connexions {ip_version:s}", - "port_available": "Lo pòrt {port:d} es disponible", - "port_unavailable": "Lo pòrt {port:d} es pas disponible", - "restore_already_installed_app": "Una aplicacion es ja installada amb l’id « {app:s} »", - "restore_app_failed": "Impossible de restaurar l’aplicacion « {app:s} »", - "backup_ask_for_copying_if_needed": "D’unes fichièrs an pas pogut èsser preparatz per la salvagarda en utilizar lo metòde qu’evita de gastar d’espaci sul sistèma de manièra temporària. Per lançar la salvagarda, cal utilizar temporàriament {size:s} Mo. Acceptatz ?", + "port_already_closed": "Lo pòrt {port} es ja tampat per las connexions {ip_version}", + "port_already_opened": "Lo pòrt {port} es ja dubèrt per las connexions {ip_version}", + "restore_already_installed_app": "Una aplicacion es ja installada amb l’id « {app} »", + "app_restore_failed": "Impossible de restaurar l’aplicacion « {app} »: {error}", + "backup_ask_for_copying_if_needed": "Volètz far una salvagarda en utilizant {size} Mo temporàriament ? (Aqueste biais de far es emplegat perque unes fichièrs an pas pogut èsser preparats amb un metòde mai eficaç.)", "yunohost_not_installed": "YunoHost es pas installat o corrèctament installat. Mercés d’executar « yunohost tools postinstall »", - "backup_output_directory_forbidden": "Repertòri de destinacion defendut. Las salvagardas pòdon pas se realizar dins los repertòris bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var ou /home/yunohost.backup/archives", - "certmanager_attempt_to_replace_valid_cert": "Sètz a remplaçar un certificat corrècte e valid pel domeni {domain:s} ! (Utilizatz --force per cortcircuitar)", - "certmanager_cert_renew_success": "Renovèlament capitat d’un certificat Let’s Encrypt pel domeni {domain:s} !", - "certmanager_certificate_fetching_or_enabling_failed": "Sembla d’aver fracassat l’activacion d’un nòu certificat per {domain:s}…", - "certmanager_conflicting_nginx_file": "Impossible de preparar lo domeni pel desfís ACME : lo fichièr de configuracion nginx {filepath:s} es en conflicte e deu èsser levat d’en primièr", - "certmanager_couldnt_fetch_intermediate_cert": "Expiracion del relambi pendent l’ensag de recuperacion del certificat intermediari dins de Let’s Encrypt. L’installacion / lo renovèlament es estat interromput - tornatz ensajar mai tard.", - "certmanager_domain_not_resolved_locally": "Lo domeni {domain:s} pòt pas èsser determinat dins de vòstre servidor YunoHost. Pòt arribar s’avètz recentament modificat vòstre enregistrament DNS. Dins aqueste cas, mercés d’esperar unas oras per l’espandiment. Se lo problèma dura, consideratz ajustar {domain:s} a /etc/hosts. (Se sabètz çò que fasètz, utilizatz --no-checks per desactivar las verificacions.)", - "certmanager_error_no_A_record": "Cap d’enregistrament DNS « A » pas trobat per {domain:s}. Vos cal indicar que lo nom de domeni mene a vòstra maquina per poder installar un certificat Let’S Encrypt ! (Se sabètz çò que fasètz, utilizatz --no-checks per desactivar las verificacions.)", - "certmanager_hit_rate_limit": "Tròp de certificats son ja estats demandats recentament per aqueste ensem de domeni {domain:s}. Mercés de tornar ensajar mai tard. Legissètz https://letsencrypt.org/docs/rate-limits/ per mai detalhs", - "certmanager_http_check_timeout": "Expiracion del relambi d’ensag del servidor de se contactar via HTTP amb son adreça IP publica {domain:s} amb l’adreça {ip:s}. Coneissètz benlèu de problèmas d’hairpinning o lo parafuòc/router amont de vòstre servidor es mal configurat.", + "backup_output_directory_forbidden": "Causissètz un repertòri de destinacion deferent. Las salvagardas pòdon pas se realizar dins los repertòris bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var ou /home/yunohost.backup/archives", + "certmanager_attempt_to_replace_valid_cert": "Sètz a remplaçar un certificat corrècte e valid pel domeni {domain} ! (Utilizatz --force per cortcircuitar)", + "certmanager_cert_renew_success": "Renovèlament capitat d’un certificat Let’s Encrypt pel domeni « {domain} »", + "certmanager_certificate_fetching_or_enabling_failed": "Sembla qu’utilizar lo nòu certificat per {domain} fonciona pas...", + "certmanager_hit_rate_limit": "Tròp de certificats son ja estats demandats recentament per aqueste ensem de domeni {domain}. Mercés de tornar ensajar mai tard. Legissètz https://letsencrypt.org/docs/rate-limits/ per mai detalhs", "domain_dns_conf_is_just_a_recommendation": "Aqueste pagina mòstra la configuracion *recomandada*. Non configura *pas* lo DNS per vos. Sètz responsable de la configuracion de vòstra zòna DNS en çò de vòstre registrar DNS amb aquesta recomandacion.", "domain_dyndns_already_subscribed": "Avètz ja soscrich a un domeni DynDNS", - "domain_dyndns_dynette_is_unreachable": "Impossible de contactar la dynette YunoHost, siá YunoHost pas es pas corrèctament connectat a Internet, siá lo servidor de la dynett es arrestat. Error : {error}", "domain_uninstall_app_first": "Una o mantuna aplicacions son installadas sus aqueste domeni. Mercés de las desinstallar d’en primièr abans de suprimir aqueste domeni", "firewall_reload_failed": "Impossible de recargar lo parafuòc", - "firewall_reloaded": "Lo parafuòc es estat recargat", + "firewall_reloaded": "Parafuòc recargat", "firewall_rules_cmd_failed": "Unas règlas del parafuòc an fracassat. Per mai informacions, consultatz lo jornal.", - "global_settings_bad_choice_for_enum": "La valor del paramètre {setting:s} es incorrècta. Recebut : {received_type:s}, mas las opcions esperadas son : {expected_type:s}", - "global_settings_bad_type_for_setting": "Lo tipe del paramètre {setting:s} es incorrècte. Recebut : {received_type:s}, esperat {expected_type:s}", - "global_settings_cant_write_settings": "Fracàs de l’escritura del fichièr de configuracion, rason : {reason:s}", - "global_settings_setting_example_enum": "Exemple d’opcion de tipe enumeracion", - "global_settings_setting_example_int": "Exemple d’opcion de tipe entièr", - "global_settings_setting_example_string": "Exemple d’opcion de tipe cadena", - "global_settings_unknown_type": "Situacion inesperada, la configuracion {setting:s} sembla d’aver lo tipe {unknown_type:s} mas es pas un tipe pres en carga pel sistèma.", - "hook_exec_failed": "Fracàs de l’execucion del script « {path:s} »", - "hook_exec_not_terminated": "L’execucion del escript « {path:s} » es pas acabada", + "global_settings_bad_choice_for_enum": "La valor del paramètre {setting} es incorrècta. Recebut : {choice}, mas las opcions esperadas son : {available_choices}", + "global_settings_bad_type_for_setting": "Lo tipe del paramètre {setting} es incorrècte, recebut : {received_type}, esperat {expected_type}", + "global_settings_cant_write_settings": "Fracàs de l’escritura del fichièr de configuracion, rason : {reason}", + "global_settings_unknown_type": "Situacion inesperada, la configuracion {setting} sembla d’aver lo tipe {unknown_type} mas es pas un tipe pres en carga pel sistèma.", + "hook_exec_failed": "Fracàs de l’execucion del script : « {path} »", + "hook_exec_not_terminated": "Lo escript « {path} » a pas acabat corrèctament", "hook_list_by_invalid": "La proprietat de tria de las accions es invalida", - "hook_name_unknown": "Nom de script « {name:s} » desconegut", - "ldap_init_failed_to_create_admin": "L’inicializacion de LDAP a pas pogut crear l’utilizaire admin", - "mail_domain_unknown": "Lo domeni de corrièl « {domain:s} » es desconegut", + "hook_name_unknown": "Nom de script « {name} » desconegut", + "mail_domain_unknown": "Lo domeni de corrièl « {domain} » es desconegut", "mailbox_used_space_dovecot_down": "Lo servici corrièl Dovecot deu èsser aviat, se volètz conéisser l’espaci ocupat per la messatjariá", - "migrate_tsig_failed": "La migracion del domeni dyndns {domain} cap a hmac-sha512 a pas capitat, anullacion de las modificacions. Error : {error_code} - {error}", - "migrate_tsig_wait": "Esperem 3 minutas que lo servidor dyndns prenga en compte la novèla clau…", - "migrate_tsig_not_needed": "Sembla qu’utilizatz pas un domeni dyndns, donc cap de migracion es pas necessària !", - "migration_0003_yunohost_upgrade": "Aviada de la mesa a nivèl del paquet YunoHost… La migracion acabarà, mas la mesa a jorn reala se realizarà tot bèl aprèp. Un còp acabada, poiretz vos reconnectar a l’administracion web.", - "migration_0003_system_not_fully_up_to_date": "Lo sistèma es pas complètament a jorn. Mercés de lançar una mesa a jorn classica abans de començar la migracion per Stretch.", - "migration_0003_modified_files": "Mercés de notar que los fichièrs seguents son estats detectats coma modificats manualament e poiràn èsser escafats a la fin de la mesa a nivèl : {manually_modified_files}", - "monitor_period_invalid": "Lo periòde de temps es incorrècte", - "monitor_stats_file_not_found": "Lo fichièr d’estatisticas es introbable", - "monitor_stats_period_unavailable": "Cap d’estatisticas son pas disponiblas pel periòde", - "mysql_db_init_failed": "Impossible d’inicializar la basa de donadas MySQL", - "service_disable_failed": "Impossible de desactivar lo servici « {service:s} »↵\n↵\nJornals recents : {logs:s}", - "service_disabled": "Lo servici « {service:s} » es desactivat", - "service_enable_failed": "Impossible d’activar lo servici « {service:s} »↵\n↵\nJornals recents : {logs:s}", - "service_enabled": "Lo servici « {service:s} » es activat", - "service_no_log": "Cap de jornal de far veire pel servici « {service:s} »", - "service_regenconf_dry_pending_applying": "Verificacion de las configuracions en espèra que poirián èsser aplicadas pel servici « {service} »…", - "service_regenconf_failed": "Regeneracion impossibla de la configuracion pels servicis : {services}", - "service_regenconf_pending_applying": "Aplicacion de las configuracions en espèra pel servici « {service} »…", - "service_remove_failed": "Impossible de levar lo servici « {service:s} »", - "service_removed": "Lo servici « {service:s} » es estat levat", - "service_start_failed": "Impossible d’aviar lo servici « {service:s} »↵\n↵\nJornals recents : {logs:s}", - "service_started": "Lo servici « {service:s} » es aviat", - "service_stop_failed": "Impossible d’arrestar lo servici « {service:s} »↵\n\nJornals recents : {logs:s}", + "service_disable_failed": "Impossible de desactivar lo servici « {service} »↵\n↵\nJornals recents : {logs}", + "service_disabled": "Lo servici « {service} » es desactivat", + "service_enable_failed": "Impossible d’activar lo servici « {service} »↵\n↵\nJornals recents : {logs}", + "service_enabled": "Lo servici « {service} » es activat", + "service_remove_failed": "Impossible de levar lo servici « {service} »", + "service_removed": "Lo servici « {service} » es estat levat", + "service_start_failed": "Impossible d’aviar lo servici « {service} »↵\n↵\nJornals recents : {logs}", + "service_started": "Lo servici « {service} » es aviat", + "service_stop_failed": "Impossible d’arrestar lo servici « {service} »↵\n\nJornals recents : {logs}", "ssowat_conf_generated": "La configuracion SSowat es generada", "ssowat_conf_updated": "La configuracion SSOwat es estada actualizada", "system_upgraded": "Lo sistèma es estat actualizat", @@ -296,132 +187,73 @@ "user_deleted": "L’utilizaire es suprimit", "user_deletion_failed": "Supression impossibla de l’utilizaire", "user_home_creation_failed": "Creacion impossibla del repertòri personal a l’utilizaire", - "user_info_failed": "Recuperacion impossibla de las informacions tocant l’utilizaire", - "user_unknown": "Utilizaire « {user:s} » desconegut", + "user_unknown": "Utilizaire « {user} » desconegut", "user_update_failed": "Modificacion impossibla de l’utilizaire", "user_updated": "L’utilizaire es estat modificat", - "yunohost_ca_creation_failed": "Creacion impossibla de l’autoritat de certificacion", - "yunohost_ca_creation_success": "L’autoritat de certificacion locala es creada.", - "service_conf_file_kept_back": "Lo fichièr de configuracion « {conf} » deuriá èsser suprimit pel servici {service} mas es estat servat.", - "service_conf_file_manually_modified": "Lo fichièr de configuracion « {conf} » es estat modificat manualament e serà pas actualizat", - "service_conf_file_manually_removed": "Lo fichièr de configuracion « {conf} » es suprimit manualament e serà pas creat", - "service_conf_file_remove_failed": "Supression impossibla del fichièr de configuracion « {conf} »", - "service_conf_file_removed": "Lo fichièr de configuracion « {conf} » es suprimit", - "service_conf_file_updated": "Lo fichièr de configuracion « {conf} » es actualizat", - "service_conf_new_managed_file": "Lo servici {service} gerís ara lo fichièr de configuracion « {conf} ».", - "service_conf_up_to_date": "La configuracion del servici « {service} » es ja actualizada", - "service_conf_would_be_updated": "La configuracion del servici « {service} » seriá estada actualizada", - "service_description_avahi-daemon": "permet d’aténher vòstre servidor via yunohost.local sus vòstre ret local", "service_description_dnsmasq": "gerís la resolucion dels noms de domeni (DNS)", "updating_apt_cache": "Actualizacion de la lista dels paquets disponibles…", - "service_conf_file_backed_up": "Lo fichièr de configuracion « {conf} » es salvagardat dins « {backup} »", - "service_conf_file_copy_failed": "Còpia impossibla del nòu fichièr de configuracion « {new} » cap a « {conf} »", - "server_reboot_confirm": "Lo servidor es per reaviar sul pic, o volètz vertadièrament ? {answers:s}", - "service_add_failed": "Apondon impossible del servici « {service:s} »", - "service_added": "Lo servici « {service:s} » es ajustat", - "service_already_started": "Lo servici « {service:s} » es ja aviat", - "service_already_stopped": "Lo servici « {service:s} » es ja arrestat", + "server_reboot_confirm": "Lo servidor es per reaviar sul pic, o volètz vertadièrament ? {answers}", + "service_add_failed": "Apondon impossible del servici « {service} »", + "service_added": "Lo servici « {service} » es ajustat", + "service_already_started": "Lo servici « {service} » es ja aviat", + "service_already_stopped": "Lo servici « {service} » es ja arrestat", "restore_cleaning_failed": "Impossible de netejar lo repertòri temporari de restauracion", "restore_complete": "Restauracion acabada", - "restore_confirm_yunohost_installed": "Volètz vertadièrament restaurar un sistèma ja installat ? {answers:s}", + "restore_confirm_yunohost_installed": "Volètz vertadièrament restaurar un sistèma ja installat ? {answers}", "restore_extracting": "Extraccions dels fichièrs necessaris dins de l’archiu…", "restore_failed": "Impossible de restaurar lo sistèma", - "restore_hook_unavailable": "Lo script de restauracion « {part:s} » es pas disponible sus vòstre sistèma e es pas tanpauc dins l’archiu", - "restore_may_be_not_enough_disk_space": "Lo sistèma sembla d’aver pas pro d’espaci disponible (liure : {free_space:d} octets, necessari : {needed_space:d} octets, marge de seguretat : {margin:d} octets)", - "restore_mounting_archive": "Montatge de l’archiu dins « {path:s} »", - "restore_not_enough_disk_space": "Espaci disponible insufisent (liure : {free_space:d} octets, necessari : {needed_space:d} octets, marge de seguretat : {margin:d} octets)", + "restore_hook_unavailable": "Lo script de restauracion « {part} » es pas disponible sus vòstre sistèma e es pas tanpauc dins l’archiu", + "restore_may_be_not_enough_disk_space": "Lo sistèma sembla d’aver pas pro d’espaci disponible (liure : {free_space} octets, necessari : {needed_space} octets, marge de seguretat : {margin} octets)", + "restore_not_enough_disk_space": "Espaci disponible insufisent (liure : {free_space} octets, necessari : {needed_space} octets, marge de seguretat : {margin} octets)", "restore_nothings_done": "Res es pas estat restaurat", "restore_removing_tmp_dir_failed": "Impossible de levar u ancian repertòri temporari", - "restore_running_app_script": "Lançament del script de restauracion per l’aplicacion « {app:s} »…", + "restore_running_app_script": "Lançament del script de restauracion per l’aplicacion « {app} »…", "restore_running_hooks": "Execucion dels scripts de restauracion…", - "restore_system_part_failed": "Restauracion impossibla de la part « {part:s} » del sistèma", + "restore_system_part_failed": "Restauracion impossibla de la part « {part} » del sistèma", "server_shutdown": "Lo servidor serà atudat", - "server_shutdown_confirm": "Lo servidor es per s’atudar sul pic, o volètz vertadièrament ? {answers:s}", + "server_shutdown_confirm": "Lo servidor es per s’atudar sul pic, o volètz vertadièrament ? {answers}", "server_reboot": "Lo servidor es per reaviar", - "network_check_mx_ko": "L’enregistrament DNS MX es pas especificat", - "new_domain_required": "Vos cal especificar lo domeni màger", - "no_ipv6_connectivity": "La connectivitat IPv6 es pas disponibla", - "not_enough_disk_space": "Espaci disc insufisent sus « {path:s} »", - "package_unexpected_error": "Una error inesperada es apareguda amb lo paquet « {pkgname} »", - "packages_upgrade_critical_later": "Los paquets critics {packages:s} seràn actualizats mai tard", - "restore_action_required": "Devètz precisar çò que cal restaurar", - "service_cmd_exec_failed": "Impossible d’executar la comanda « {command:s} »", - "service_conf_updated": "La configuracion es estada actualizada pel servici « {service} »", + "not_enough_disk_space": "Espaci disc insufisent sus « {path} »", + "service_cmd_exec_failed": "Impossible d’executar la comanda « {command} »", "service_description_mysql": "garda las donadas de las aplicacions (base de donadas SQL)", - "service_description_php5-fpm": "executa d’aplicacions escrichas en PHP amb nginx", "service_description_postfix": "emplegat per enviar e recebre de corrièls", - "service_description_rmilter": "verifica mantun paramètres dels corrièls", "service_description_slapd": "garda los utilizaires, domenis e lors informacions ligadas", "service_description_ssh": "vos permet de vos connectar a distància a vòstre servidor via un teminal (protocòl SSH)", "service_description_yunohost-api": "permet las interaccions entre l’interfàcia web de YunoHost e le sistèma", "service_description_yunohost-firewall": "gerís los pòrts de connexion dobèrts e tampats als servicis", - "ssowat_persistent_conf_read_error": "Error en legir la configuracion duradissa de SSOwat : {error:s}. Modificatz lo fichièr /etc/ssowat/conf.json.persistent per reparar la sintaxi JSON", - "ssowat_persistent_conf_write_error": "Error en salvagardar la configuracion duradissa de SSOwat : {error:s}. Modificatz lo fichièr /etc/ssowat/conf.json.persistent per reparar la sintaxi JSON", - "certmanager_old_letsencrypt_app_detected": "\nYunohost a detectat que l’aplicacion ’letsencrypt’ es installada, aquò es en conflicte amb las novèlas foncionalitats integradas de gestion dels certificats de Yunohost. Se volètz utilizar aquelas foncionalitats integradas, mercés de lançar las comandas seguentas per migrar vòstra installacion :\n\n yunohost app remove letsencrypt\n yunohost domain cert-install\n\nN.B. : aquò provarà de tornar installar los certificats de totes los domenis amb un certificat Let’s Encrypt o las auto-signats", - "diagnosis_monitor_disk_error": "Impossible de supervisar los disques : {error}", - "diagnosis_monitor_network_error": "Impossible de supervisar la ret : {error}", - "diagnosis_monitor_system_error": "Impossible de supervisar lo sistèma : {error}", - "executing_command": "Execucion de la comanda « {command:s} »…", - "executing_script": "Execucion del script « {script:s} »…", - "global_settings_cant_serialize_settings": "Fracàs de la serializacion de las donadas de parametratge, rason : {reason:s}", + "global_settings_cant_serialize_settings": "Fracàs de la serializacion de las donadas de parametratge, rason : {reason}", "ip6tables_unavailable": "Podètz pas jogar amb ip6tables aquí. Siá sèts dins un contenedor, siá vòstre nuclèu es pas compatible amb aquela opcion", "iptables_unavailable": "Podètz pas jogar amb iptables aquí. Siá sèts dins un contenedor, siá vòstre nuclèu es pas compatible amb aquela opcion", - "update_cache_failed": "Impossible d’actualizar lo cache de l’APT", - "mail_alias_remove_failed": "Supression impossibla de l’alias de corrièl « {mail:s} »", - "mail_forward_remove_failed": "Supression impossibla del corrièl de transferiment « {mail:s} »", - "migrate_tsig_start": "L’algorisme de generacion de claus es pas pro securizat per la signatura TSIG del domeni « {domain} », lançament de la migracion cap a hmac-sha512 que’s mai securizat", - "migration_description_0001_change_cert_group_to_sslcert": "Càmbia las permissions de grop dels certificats de « metronome » per « ssl-cert »", - "migration_0003_restoring_origin_nginx_conf": "Vòstre fichièr /etc/nginx/nginx.conf es estat modificat manualament. La migracion reïnicializarà d’en primièr son estat origina… Lo fichièr precedent serà disponible coma {backup_dest}.", - "migration_0003_still_on_jessie_after_main_upgrade": "Quicòm a trucat pendent la mesa a nivèl màger : lo sistèma es encara jos Jessie ?!? Per trobar lo problèma, agachatz {log}…", - "migration_0003_general_warning": "Notatz qu’aquesta migracion es una operacion delicata. Encara que la còla YunoHost aguèsse fach çò melhor per la tornar legir e provar, la migracion poiriá copar de parts del sistèma o de las aplicacions.\n\nEn consequéncia, vos recomandam :\n· · · · - de lançar una salvagarda de vòstras donadas o aplicacions criticas. Mai d’informacions a https://yunohost.org/backup ;\n· · · · - d’èsser pacient aprèp aver lançat la migracion : segon vòstra connexion Internet e material, pòt trigar qualques oras per que tot siá mes al nivèl.\n\nEn mai, lo pòrt per SMTP, utilizat pels clients de corrièls extèrns (coma Thunderbird o K9-Mail per exemple) foguèt cambiat de 465 (SSL/TLS) per 587 (STARTTLS). L’ancian pòrt 465 serà automaticament tampat e lo nòu pòrt 587 serà dobèrt dins lo parafuòc. Vosautres e vòstres utilizaires *auretz* d’adaptar la configuracion de vòstre client de corrièl segon aqueles cambiaments !", - "migration_0003_problematic_apps_warning": "Notatz que las aplicacions seguentas, saique problematicas, son estadas desactivadas. Semblan d’aver estadas installadas d’una lista d’aplicacions o que son pas marcadas coma «working ». En consequéncia, podèm pas assegurar que tendràn de foncionar aprèp la mesa a nivèl : {problematic_apps}", - "migrations_bad_value_for_target": "Nombre invalid pel paramètre « target », los numèros de migracion son 0 o {}", - "migrations_migration_has_failed": "La migracion {number} {name} a pas capitat amb l’excepcion {exception}, anullacion", - "migrations_skip_migration": "Passatge de la migracion {number} {name}…", - "migrations_to_be_ran_manually": "La migracion {number} {name} deu èsser lançada manualament. Mercés d’anar a Aisinas > Migracion dins l’interfàcia admin, o lançar « yunohost tools migrations migrate ».", - "migrations_need_to_accept_disclaimer": "Per lançar la migracion {number} {name} , avètz d’acceptar aquesta clausa de non-responsabilitat :\n---\n{disclaimer}\n---\nS’acceptatz de lançar la migracion, mercés de tornar executar la comanda amb l’opcion accept-disclaimer.", - "monitor_disabled": "La supervision del servidor es desactivada", - "monitor_enabled": "La supervision del servidor es activada", - "mysql_db_initialized": "La basa de donadas MySQL es estada inicializada", - "no_restore_script": "Lo script de salvagarda es pas estat trobat per l’aplicacion « {app:s} »", + "mail_alias_remove_failed": "Supression impossibla de l’alias de corrièl « {mail} »", + "mail_forward_remove_failed": "Supression impossibla del corrièl de transferiment « {mail} »", + "migrations_migration_has_failed": "La migracion {id} a pas capitat, abandon. Error : {exception}", + "migrations_skip_migration": "Passatge de la migracion {id}…", + "migrations_to_be_ran_manually": "La migracion {id} deu èsser lançada manualament. Mercés d’anar a Aisinas > Migracion dins l’interfàcia admin, o lançar « yunohost tools migrations run ».", + "migrations_need_to_accept_disclaimer": "Per lançar la migracion {id} , avètz d’acceptar aquesta clausa de non-responsabilitat :\n---\n{disclaimer}\n---\nS’acceptatz de lançar la migracion, mercés de tornar executar la comanda amb l’opcion accept-disclaimer.", "pattern_backup_archive_name": "Deu èsser un nom de fichièr valid compausat de 30 caractèrs alfanumerics al maximum e « -_. »", - "pattern_listname": "Deu èsser compausat solament de caractèrs alfanumerics e de tirets basses", "service_description_dovecot": "permet als clients de messatjariá d’accedir/recuperar los corrièls (via IMAP e POP3)", "service_description_fail2ban": "protegís contra los atacs brute-force e d’autres atacs venents d’Internet", - "service_description_glances": "susvelha las informacions sistèma de vòstre servidor", "service_description_metronome": "gerís los comptes de messatjariás instantanèas XMPP", "service_description_nginx": "fornís o permet l’accès a totes los sites web albergats sus vòstre servidor", - "service_description_nslcd": "gerís la connexion en linha de comanda dels utilizaires YunoHost", "service_description_redis-server": "una basa de donadas especializada per un accès rapid a las donadas, las filas d’espèra e la comunicacion entre programas", "service_description_rspamd": "filtra lo corrièl pas desirat e mai foncionalitats ligadas al corrièl", - "migrations_backward": "Migracion en darrièr.", - "migrations_forward": "Migracion en avant", - "network_check_smtp_ko": "Lo trafic de corrièl sortent (pòrt 25 SMTP) sembla blocat per vòstra ret", - "network_check_smtp_ok": "Lo trafic de corrièl sortent (pòrt 25 SMTP) es pas blocat", "pattern_mailbox_quota": "Deu èsser una talha amb lo sufixe b/k/M/G/T o 0 per desactivar la quòta", - "backup_archive_writing_error": "Impossible d’ajustar los fichièrs a la salvagarda dins l’archiu comprimit", + "backup_archive_writing_error": "Impossible d’ajustar los fichièrs « {source} » a la salvagarda (nomenats dins l’archiu « {dest} »)dins l’archiu comprimit « {archive} »", "backup_cant_mount_uncompress_archive": "Impossible de montar en lectura sola lo repertòri de l’archiu descomprimit", "backup_no_uncompress_archive_dir": "Lo repertòri de l’archiu descomprimit existís pas", "pattern_username": "Deu èsser compausat solament de caractèrs alfanumerics en letras minusculas e de tirets basses", "experimental_feature": "Atencion : aquesta foncionalitat es experimentala e deu pas èsser considerada coma establa, deuriatz pas l’utilizar levat que sapiatz çò que fasètz.", - "log_corrupted_md_file": "Lo fichièr yaml de metadonada amb los jornals d’audit es corromput : « {md_file} »", - "log_category_404": "La categoria de jornals d’audit « {category} » existís pas", + "log_corrupted_md_file": "Lo fichièr YAML de metadonadas ligat als jornals d’audit es damatjat : « {md_file} »\nError : {error}", "log_link_to_log": "Jornal complèt d’aquesta operacion : {desc}", - "log_help_to_get_log": "Per veire lo jornal d’aquesta operacion « {desc} », utilizatz la comanda « yunohost log display {name} »", - "backup_php5_to_php7_migration_may_fail": "Impossible de convertir vòstre archiu per prendre en carga PHP 7, la restauracion de vòstras aplicacions PHP pòt reüssir pas (rason : {error:s})", + "log_help_to_get_log": "Per veire lo jornal d’aquesta operacion « {desc} », utilizatz la comanda « yunohost log show {name} »", "log_link_to_failed_log": "L’operacion « {desc} » a pas capitat ! Per obténer d’ajuda, mercés de fornir lo jornal complèt de l’operacion", - "log_help_to_get_failed_log": "L’operacion « {desc} » a pas reüssit ! Per obténer d’ajuda, mercés de partejar lo jornal d’audit complèt d’aquesta operacion en utilizant la comanda « yunohost log display {name} --share »", + "log_help_to_get_failed_log": "L’operacion « {desc} » a pas reüssit ! Per obténer d’ajuda, mercés de partejar lo jornal d’audit complèt d’aquesta operacion en utilizant la comanda « yunohost log share {name} »", "log_does_exists": "I a pas cap de jornal d’audit per l’operacion amb lo nom « {log} », utilizatz « yunohost log list » per veire totes los jornals d’operacion disponibles", "log_operation_unit_unclosed_properly": "L’operacion a pas acabat corrèctament", - "log_app_addaccess": "Ajustar l’accès a « {} »", - "log_app_removeaccess": "Tirar l’accès a « {} »", - "log_app_clearaccess": "Tirar totes los accèsses a « {} »", - "log_app_fetchlist": "Ajustar una lista d’aplicacions", - "log_app_removelist": "Levar una lista d’aplicacions", "log_app_change_url": "Cambiar l’URL de l’aplicacion « {} »", "log_app_install": "Installar l’aplicacion « {} »", "log_app_remove": "Levar l’aplicacion « {} »", - "log_app_upgrade": "Metre a jorn l’aplicacion « {} »", + "log_app_upgrade": "Actualizar l’aplicacion « {} »", "log_app_makedefault": "Far venir « {} » l’aplicacion per defaut", "log_available_on_yunopaste": "Lo jornal es ara disponible via {url}", "log_backup_restore_system": "Restaurar lo sistèma a partir d’una salvagarda", @@ -431,36 +263,22 @@ "log_domain_add": "Ajustar lo domeni « {} » dins la configuracion sistèma", "log_domain_remove": "Tirar lo domeni « {} » d’a la configuracion sistèma", "log_dyndns_subscribe": "S’abonar al subdomeni YunoHost « {} »", - "log_dyndns_update": "Metre a jorn l’adreça IP ligada a vòstre jos-domeni YunoHost « {} »", - "log_letsencrypt_cert_install": "Installar lo certificat Let's encrypt sul domeni « {} »", + "log_dyndns_update": "Actualizar l’adreça IP ligada a vòstre jos-domeni YunoHost « {} »", + "log_letsencrypt_cert_install": "Installar un certificat Let's Encrypt sul domeni « {} »", "log_selfsigned_cert_install": "Installar lo certificat auto-signat sul domeni « {} »", - "log_letsencrypt_cert_renew": "Renovar lo certificat Let's encrypt de « {} »", - "log_service_enable": "Activar lo servici « {} »", - "log_service_regen_conf": "Regenerar la configuracion sistèma de « {} »", + "log_letsencrypt_cert_renew": "Renovar lo certificat Let's Encrypt de « {} »", "log_user_create": "Ajustar l’utilizaire « {} »", "log_user_delete": "Levar l’utilizaire « {} »", - "log_user_update": "Metre a jorn las informacions a l’utilizaire « {} »", - "log_tools_maindomain": "Far venir « {} » lo domeni màger", - "log_tools_migrations_migrate_forward": "Migrar", - "log_tools_migrations_migrate_backward": "Tornar en arrièr", + "log_user_update": "Actualizar las informacions de l’utilizaire « {} »", + "log_domain_main_domain": "Far venir « {} » lo domeni màger", + "log_tools_migrations_migrate_forward": "Executar las migracions", "log_tools_postinstall": "Realizar la post installacion del servidor YunoHost", - "log_tools_upgrade": "Mesa a jorn dels paquets sistèma", + "log_tools_upgrade": "Actualizacion dels paquets sistèma", "log_tools_shutdown": "Atudar lo servidor", "log_tools_reboot": "Reaviar lo servidor", "mail_unavailable": "Aquesta adreça electronica es reservada e deu èsser automaticament atribuida al tot bèl just primièr utilizaire", - "migration_description_0004_php5_to_php7_pools": "Tornar configurar lo pools PHP per utilizar PHP 7 allòc del 5", - "migration_description_0005_postgresql_9p4_to_9p6": "Migracion de las basas de donadas de postgresql 9.4 cap a 9.6", - "migration_0005_postgresql_94_not_installed": "Postgresql es pas installat sul sistèma. Pas res de far !", - "migration_0005_postgresql_96_not_installed": "Avèm trobat que Postgresql 9.4 es installat, mas cap de version de Postgresql 9.6 pas trobada !? Quicòm d’estranh a degut arribar a vòstre sistèma :( …", - "migration_0005_not_enough_space": "I a pas pro d’espaci disponible sus {path} per lançar la migracion d’aquela passa :(.", - "recommend_to_add_first_user": "La post installacion es acabada, mas YunoHost fa besonh d’almens un utilizaire per foncionar coma cal. Vos cal n’ajustar un en utilizant la comanda « yunohost user create $username » o ben l’interfàcia d’administracion.", - "service_description_php7.0-fpm": "executa d’aplicacions escrichas en PHP amb nginx", - "users_available": "Lista dels utilizaires disponibles :", - "good_practices_about_admin_password": "Sètz per definir un nòu senhal per l’administracion. Lo senhal deu almens conténer 8 caractèrs - encara que siá de bon far d’utilizar un senhal mai long qu’aquò (ex. una passafrasa) e/o d’utilizar mantun tipes de caractèrs (majuscula, minuscula, nombre e caractèrs especials).", - "good_practices_about_user_password": "Sètz a mand de definir un nòu senhal d’utilizaire. Lo nòu senhal deu conténer almens 8 caractèrs, es de bon far d’utilizar un senhal mai long (es a dire una frasa de senhal) e/o utilizar mantuns tipes de caractèrs (majusculas, minusculas, nombres e caractèrs especials).", - "migration_description_0006_sync_admin_and_root_passwords": "Sincronizar los senhals admin e root", - "migration_0006_disclaimer": "Ara YunoHost s’espèra que los senhals admin e root sián sincronizats. En lançant aquesta migracion, vòstre senhal root serà remplaçat pel senhal admin.", - "migration_0006_done": "Lo senhal root es estat remplaçat pel senhal admin.", + "good_practices_about_admin_password": "Sètz per definir un nòu senhal per l’administracion. Lo senhal deu almens conténer 8 caractèrs - encara que siá de bon far d’utilizar un senhal mai long qu’aquò (ex. una passafrasa) e/o d’utilizar mantun tipe de caractèrs (majuscula, minuscula, nombre e caractèrs especials).", + "good_practices_about_user_password": "Sètz a mand de definir un nòu senhal d’utilizaire. Lo nòu senhal deu conténer almens 8 caractèrs, es de bon far d’utilizar un senhal mai long (es a dire una frasa de senhal) e/o utilizar mantun tipe de caractèrs (majusculas, minusculas, nombres e caractèrs especials).", "password_listed": "Aqueste senhal es un dels mai utilizats al monde. Se vos plai utilizatz-ne un mai unic.", "password_too_simple_1": "Lo senhal deu conténer almens 8 caractèrs", "password_too_simple_2": "Lo senhal deu conténer almens 8 caractèrs e numbres, majusculas e minusculas", @@ -468,47 +286,37 @@ "password_too_simple_4": "Lo senhal deu conténer almens 12 caractèrs, de nombre, majusculas, minisculas e caractèrs specials", "root_password_desynchronized": "Lo senhal de l’administrator es estat cambiat, mas YunoHost a pas pogut l’espandir al senhal root !", "aborting": "Interrupcion.", - "app_not_upgraded": "Las aplicacions seguentas son pas estadas actualizadas : {apps}", - "app_start_install": "Installacion de l’aplicacion {app}…", - "app_start_remove": "Supression de l’aplicacion {app}…", - "app_start_backup": "Recuperacion dels fichièrs de salvagardar per {app}…", - "app_start_restore": "Restauracion de l’aplicacion {app}…", - "app_upgrade_several_apps": "Las aplicacions seguentas seràn mesas a jorn : {apps}", + "app_not_upgraded": "L’aplicacion « {failed_app} » a pas reüssit a s’actualizar e coma consequéncia las mesas a jorn de las aplicacions seguentas son estadas anulladas : {apps}", + "app_start_install": "Installacion de l’aplicacion {app}...", + "app_start_remove": "Supression de l’aplicacion {app}...", + "app_start_backup": "Recuperacion dels fichièrs de salvagardar per {app}...", + "app_start_restore": "Restauracion de l’aplicacion {app}...", + "app_upgrade_several_apps": "Las aplicacions seguentas seràn actualizadas : {apps}", "ask_new_domain": "Nòu domeni", "ask_new_path": "Nòu camin", - "backup_actually_backuping": "Creacion d’un archiu de seguretat a partir dels fichièrs recuperats…", - "backup_mount_archive_for_restore": "Preparacion de l’archiu per restauracion…", - "dyndns_could_not_check_available": "Verificacion impossibla de la disponibilitat de {domain:s} sus {provider:s}.", - "file_does_not_exist": "Lo camin {path:s} existís pas.", + "backup_actually_backuping": "Creacion d’un archiu de seguretat a partir dels fichièrs recuperats...", + "backup_mount_archive_for_restore": "Preparacion de l’archiu per restauracion...", + "dyndns_could_not_check_available": "Verificacion impossibla de la disponibilitat de {domain} sus {provider}.", + "file_does_not_exist": "Lo camin {path} existís pas.", "global_settings_setting_security_password_admin_strength": "Fòrça del senhal administrator", "global_settings_setting_security_password_user_strength": "Fòrça del senhal utilizaire", - "migration_description_0007_ssh_conf_managed_by_yunohost_step1": "La configuracion SSH serà gerada per YunoHost (etapa 1, automatica)", - "migration_description_0008_ssh_conf_managed_by_yunohost_step2": "Daissar YunoHost gerir la configuracion SSH (etapa 2, manuala)", - "migration_0007_cancelled": "YunoHost a pas reüssit a melhorar lo biais de gerir la configuracion SSH.", "root_password_replaced_by_admin_password": "Lo senhal root es estat remplaçat pel senhal administrator.", - "service_restarted": "Lo servici '{service:s}' es estat reaviat", + "service_restarted": "Lo servici '{service}' es estat reaviat", "admin_password_too_long": "Causissètz un senhal d’almens 127 caractèrs", - "migration_0007_cannot_restart": "SSH pòt pas èsser reavit aprèp aver ensajat d’anullar la migracion numèro 6.", - "migrations_success": "Migracion {number} {name} reüssida !", - "service_conf_now_managed_by_yunohost": "Lo fichièr de configuracion « {conf} » es ara gerit per YunoHost.", - "service_reloaded": "Lo servici « {servici:s} » es estat tornat cargar", + "service_reloaded": "Lo servici « {service} » es estat tornat cargar", "already_up_to_date": "I a pas res a far ! Tot es ja a jorn !", - "app_action_cannot_be_ran_because_required_services_down": "Aquesta aplicacion necessita unes servicis que son actualament encalats. Abans de contunhar deuriatz ensajar de reaviar los servicis seguents (e tanben cercar perque son tombats en pana) : {services}", - "confirm_app_install_warning": "Atencion : aquesta aplicacion fonciona mas non es pas ben integrada amb YunoHost. Unas foncionalitats coma l’autentificacion unica e la còpia de seguretat/restauracion pòdon èsser indisponiblas. volètz l’installar de totas manièras ? [{answers:s}] ", - "confirm_app_install_danger": "ATENCION ! Aquesta aplicacion es encara experimentala (autrament dich, fonciona pas) e es possible que còpe lo sistèma ! Deuriatz PAS l’installar se non sabètz çò que fasètz. Volètz vertadièrament córrer aqueste risc ? [{answers:s}] ", - "confirm_app_install_thirdparty": "ATENCION ! L’installacion d’aplicacions tèrças pòt comprometre l’integralitat e la seguretat del sistèma. Deuriatz PAS l’installar se non sabètz pas çò que fasètz. Volètz vertadièrament córrer aqueste risc ? [{answers:s}] ", + "app_action_cannot_be_ran_because_required_services_down": "Aquestas aplicacions necessitan d’èsser lançadas per poder executar aquesta accion : {services}. Abans de contunhar deuriatz ensajar de reaviar los servicis seguents (e tanben cercar perque son tombats en pana) : {services}", + "confirm_app_install_warning": "Atencion : aquesta aplicacion fonciona mas non es pas ben integrada amb YunoHost. Unas foncionalitats coma l’autentificacion unica e la còpia de seguretat/restauracion pòdon èsser indisponiblas. volètz l’installar de totas manièras ? [{answers}] ", + "confirm_app_install_danger": "PERILH ! Aquesta aplicacion es encara experimentala (autrament dich, fonciona pas) e es possible que còpe lo sistèma ! Deuriatz PAS l’installar se non sabètz çò que fasètz. Volètz vertadièrament córrer aqueste risc ? [{answers}]", + "confirm_app_install_thirdparty": "ATENCION ! L’installacion d’aplicacions tèrças pòt comprometre l’integralitat e la seguretat del sistèma. Deuriatz PAS l’installar se non sabètz pas çò que fasètz. Volètz vertadièrament córrer aqueste risc ? [{answers}] ", "dpkg_lock_not_available": "Aquesta comanda pòt pas s’executar pel moment perque un autre programa sembla utilizar lo varrolh de dpkg (lo gestionari de paquets del sistèma)", "log_regen_conf": "Regenerar las configuracions del sistèma « {} »", - "service_reloaded_or_restarted": "Lo servici « {service:s} » es estat recargat o reaviat", + "service_reloaded_or_restarted": "Lo servici « {service} » es estat recargat o reaviat", "tools_upgrade_regular_packages_failed": "Actualizacion impossibla dels paquets seguents : {packages_list}", "tools_upgrade_special_packages_completed": "L’actualizacion dels paquets de YunoHost es acabada !\nQuichatz [Entrada] per tornar a la linha de comanda", - "updating_app_lists": "Recuperacion de las mesas a jorn disponiblas per las aplicacions…", - "dpkg_is_broken": "Podètz pas far aquò pel moment perque dpkg/apt (los gestionaris de paquets del sistèma) sembla èsser mal configurat... Podètz ensajar de solucionar aquò en vos connectar via SSH e en executar « sudo dpkg --configure -a ».", + "dpkg_is_broken": "Podètz pas far aquò pel moment perque dpkg/APT (los gestionaris de paquets del sistèma) sembla èsser mal configurat… Podètz ensajar de solucionar aquò en vos connectar via SSH e en executar « sudo dpkg --configure -a ».", "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Autorizar l’utilizacion de la clau òst DSA (obsolèta) per la configuracion del servici SSH", - "migration_0008_general_disclaimer": "Per melhorar la seguretat del servidor, es recomandat de daissar YunoHost gerir la configuracion SSH. Vòstra configuracion actuala es diferenta de la configuracion recomandada. Se daissatz YunoHost la reconfigurar, lo biais de vos connectar al servidor via SSH cambiarà coma aquò :", - "hook_json_return_error": "Fracàs de la lectura del retorn de l’script {path:s}. Error : {msg:s}. Contengut brut : {raw_content}", - "migration_0008_port": " - vos cal vos connectar en utilizar lo pòrt 22 allòc de vòstre pòrt SSH actual personalizat. Esitetz pas a lo reconfigurar ;", - "migration_0009_not_needed": "Sembla qu’i aguèt ja una migracion. Passem.", + "hook_json_return_error": "Fracàs de la lectura del retorn de l’script {path}. Error : {msg}. Contengut brut : {raw_content}", "pattern_password_app": "O planhèm, los senhals devon pas conténer los caractèrs seguents : {forbidden_chars}", "regenconf_file_backed_up": "Lo fichièr de configuracion « {conf} » es estat salvagardat dins « {backup} »", "regenconf_file_copy_failed": "Còpia impossibla del nòu fichièr de configuracion « {new} » cap a « {conf} »", @@ -526,19 +334,13 @@ "regenconf_pending_applying": "Aplicacion de la configuracion en espèra per la categoria « {category} »…", "tools_upgrade_cant_both": "Actualizacion impossibla del sistèma e de las aplicacions a l’encòp", "tools_upgrade_cant_hold_critical_packages": "Manteniment impossible dels paquets critiques…", - "global_settings_setting_security_nginx_compatibility": "Solucion de compromés entre compatibilitat e seguretat pel servidor web nginx. Afècta los criptografs (e d’autres aspèctes ligats amb la seguretat)", + "global_settings_setting_security_nginx_compatibility": "Solucion de compromés entre compatibilitat e seguretat pel servidor web NGINX Afècta los criptografs (e d’autres aspèctes ligats amb la seguretat)", "global_settings_setting_security_ssh_compatibility": "Solucion de compromés entre compatibilitat e seguretat pel servidor SSH. Afècta los criptografs (e d’autres aspèctes ligats amb la seguretat)", "global_settings_setting_security_postfix_compatibility": "Solucion de compromés entre compatibilitat e seguretat pel servidor Postfix. Afècta los criptografs (e d’autres aspèctes ligats amb la seguretat)", - "migration_description_0010_migrate_to_apps_json": "Levar las appslists despreciadas e utilizar la nòva lista unificada « apps.json » allòc", - "migration_0008_root": " - vos poiretz pas vos connectar coma root via SSH. Allòc auretz d’utilizar l’utilizaire admin;", - "migration_0008_warning": "Se comprenètz aquestes avertiments e qu’acceptatz de daissar YunoHost remplaçar la configuracion actuala, començatz la migracion. Autrament podètz tanben passar la migracion, encara que non siá pas recomandat.", "service_regen_conf_is_deprecated": "« yunohost service regen-conf » es despreciat ! Utilizatz « yunohost tools regen-conf » allòc.", - "service_reload_failed": "Impossible de recargar lo servici « {service:s} »\n\nJornal d’audit recent : {logs:s}", - "service_restart_failed": "Impossible de reaviar lo servici « {service:s} »\n\nJornal d’audit recent : {logs:s}", - "service_reload_or_restart_failed": "Impossible de recargar o reaviar lo servici « {service:s} »\n\nJornal d’audit recent : {logs:s}", - "migration_description_0009_decouple_regenconf_from_services": "Desassociar lo mecanisme de regen-conf dels servicis", - "migration_0008_dsa": " - la clau DSA serà desactivada. En consequéncia, poiriatz aver d’invalidar un messatge espaurugant del client SSH, e tornar verificar l’emprunta del servidor;", - "migration_0008_no_warning": "Cap de risc important es estat detectat per remplaçar e la configuracion SSH, mas podèm pas n’èsser totalament segur ;) Se acceptatz que YunoHost remplace la configuracion actuala, començatz la migracion. Autrament, podètz passar la migracion, tot ben que non siá pas recomandat.", + "service_reload_failed": "Impossible de recargar lo servici « {service} »\n\nJornal d’audit recent : {logs}", + "service_restart_failed": "Impossible de reaviar lo servici « {service} »\n\nJornal d’audit recent : {logs}", + "service_reload_or_restart_failed": "Impossible de recargar o reaviar lo servici « {service} »\n\nJornal d’audit recent : {logs}", "regenconf_file_kept_back": "S’espèra que lo fichièr de configuracion « {conf} » siá suprimit per regen-conf (categoria {category} mas es estat mantengut.", "this_action_broke_dpkg": "Aquesta accion a copat dpkg/apt (los gestionaris de paquets del sistèma)… Podètz ensajar de resòlver aqueste problèma en vos connectant amb SSH e executant « sudo dpkg --configure -a ».", "tools_upgrade_at_least_one": "Especificatz --apps O --system", @@ -547,5 +349,168 @@ "tools_upgrade_special_packages": "Actualizacion dels paquets « especials » (ligats a YunoHost)…", "tools_upgrade_special_packages_explanation": "Aquesta accion s’acabarà mas l’actualizacion especiala actuala contunharà en rèire-plan. Comencetz pas cap d’autra accion sul servidor dins las ~ 10 minutas que venon (depend de la velocitat de la maquina). Un còp acabat, benlèu que vos calrà vos tornar connectar a l’interfàcia d’administracion. Los jornals d’audit de l’actualizacion seràn disponibles a Aisinas > Jornals d’audit (dins l’interfàcia d’administracion) o amb « yunohost log list » (en linha de comanda).", "update_apt_cache_failed": "I a agut d’errors en actualizar la memòria cache d’APT (lo gestionari de paquets de Debian). Aquí avètz las linhas de sources.list que pòdon vos ajudar a identificar las linhas problematicas : \n{sourceslist}", - "update_apt_cache_warning": "I a agut d’errors en actualizar la memòria cache d’APT (lo gestionari de paquets de Debian). Aquí avètz las linhas de sources.list que pòdon vos ajudar a identificar las linhas problematicas : \n{sourceslist}" -} + "update_apt_cache_warning": "I a agut d’errors en actualizar la memòria cache d’APT (lo gestionari de paquets de Debian). Aquí avètz las linhas de sources.list que pòdon vos ajudar a identificar las linhas problematicas : \n{sourceslist}", + "backup_permission": "Autorizacion de salvagarda per l’aplicacion {app}", + "group_created": "Grop « {group} » creat", + "group_creation_failed": "Fracàs de la creacion del grop « {group} » : {error}", + "group_deleted": "Lo grop « {group} » es estat suprimit", + "group_deletion_failed": "Fracàs de la supression del grop « {group} » : {error}", + "group_unknown": "Lo grop « {group} » es desconegut", + "log_user_group_delete": "Suprimir lo grop « {} »", + "group_updated": "Lo grop « {group} » es estat actualizat", + "group_update_failed": "Actualizacion impossibla del grop « {group} » : {error}", + "log_user_group_update": "Actualizar lo grop « {} »", + "permission_already_exist": "La permission « {permission} » existís ja", + "permission_created": "Permission « {permission} » creada", + "permission_creation_failed": "Creacion impossibla de la permission", + "permission_deleted": "Permission « {permission} » suprimida", + "permission_deletion_failed": "Fracàs de la supression de la permission « {permission} »", + "permission_not_found": "Permission « {permission} » pas trobada", + "permission_update_failed": "Fracàs de l’actualizacion de la permission", + "permission_updated": "La permission « {permission} » es estada actualizada", + "mailbox_disabled": "La bóstia de las letras es desactivada per l’utilizaire {user}", + "migrations_success_forward": "Migracion {id} corrèctament realizada !", + "migrations_running_forward": "Execucion de la migracion {id}…", + "migrations_must_provide_explicit_targets": "Devètz fornir una cibla explicita quand utilizatz using --skip o --force-rerun", + "migrations_exclusive_options": "--auto, --skip, e --force-rerun son las opcions exclusivas.", + "migrations_failed_to_load_migration": "Cargament impossible de la migracion {id} : {error}", + "migrations_already_ran": "Aquelas migracions s’executèron ja : {ids}", + "diagnosis_basesystem_ynh_main_version": "Lo servidor fonciona amb YunoHost {main_version} ({repo})", + "migrations_dependencies_not_satisfied": "Executatz aquestas migracions : « {dependencies_id} », abans la migracion {id}.", + "migrations_no_such_migration": "I a pas cap de migracion apelada « {id} »", + "migrations_not_pending_cant_skip": "Aquestas migracions son pas en espèra, las podètz pas doncas ignorar : {ids}", + "app_action_broke_system": "Aquesta accion sembla aver copat de servicis importants : {services}", + "diagnosis_ip_no_ipv6": "Lo servidor a pas d’adreça IPv6 activa.", + "diagnosis_ip_not_connected_at_all": "Lo servidor sembla pas connectat a Internet ?!", + "diagnosis_description_regenconf": "Configuracion sistèma", + "diagnosis_http_ok": "Lo domeni {domain} accessible de l’exterior.", + "app_full_domain_unavailable": "Aquesta aplicacion a d’èsser installada sul seu pròpri domeni, mas i a d’autras aplicacions installadas sus aqueste domeni « {domain} ». Podètz utilizar allòc un josdomeni dedicat a aquesta aplicacion.", + "diagnosis_dns_bad_conf": "Configuracion DNS incorrècta o inexistenta pel domeni {domain} (categoria {category})", + "diagnosis_ram_verylow": "Lo sistèma a solament {available} ({available_percent}%) de memòria RAM disponibla ! (d’un total de {total})", + "diagnosis_ram_ok": "Lo sistèma a encara {available} ({available_percent}%) de memòria RAM disponibla d’un total de {total}).", + "permission_already_allowed": "Lo grop « {group} » a ja la permission « {permission} » activada", + "permission_already_disallowed": "Lo grop « {group} » a ja la permission « {permission} » desactivada", + "permission_cannot_remove_main": "La supression d’una permission màger es pas autorizada", + "log_permission_url": "Actualizacion de l’URL ligada a la permission « {} »", + "app_install_failed": "Installacion impossibla de {app} : {error}", + "app_install_script_failed": "Una error s’es producha en installar lo script de l’aplicacion", + "apps_already_up_to_date": "Totas las aplicacions son ja al jorn", + "app_remove_after_failed_install": "Supression de l’aplicacion aprèp fracàs de l’installacion...", + "group_already_exist": "Lo grop {group} existís ja", + "group_already_exist_on_system": "Lo grop {group} existís ja dins lo sistèma de grops", + "group_user_not_in_group": "L’utilizaire {user} es pas dins lo grop {group}", + "log_user_permission_reset": "Restablir la permission « {} »", + "user_already_exists": "L’utilizaire {user} existís ja", + "diagnosis_basesystem_host": "Lo servidor fonciona amb Debian {debian_version}", + "diagnosis_basesystem_kernel": "Lo servidor fonciona amb lo nuclèu Linuxl {kernel_version}", + "diagnosis_basesystem_ynh_single_version": "{package} version : {version} ({repo})", + "diagnosis_basesystem_ynh_inconsistent_versions": "Utilizatz de versions inconsistentas dels paquets de YunoHost… probablament a causa d'una actualizacion fracassada o parciala.", + "diagnosis_ignored_issues": "(+ {nb_ignored} problèma(es) ignorat(s))", + "diagnosis_everything_ok": "Tot sembla corrècte per {category} !", + "diagnosis_ip_connected_ipv4": "Lo servidor es connectat a Internet via IPv4 !", + "diagnosis_ip_no_ipv4": "Lo servidor a pas d’adreça IPv4 activa.", + "diagnosis_ip_connected_ipv6": "Lo servidor es connectat a Internet via IPv6 !", + "diagnosis_ip_dnsresolution_working": "La resolucion del nom de domeni fonciona !", + "diagnosis_dns_good_conf": "Bona configuracion DNS pel domeni {domain} (categoria {category})", + "diagnosis_failed_for_category": "Lo diagnostic a reüssit per la categoria « {category} » : {error}", + "diagnosis_cache_still_valid": "(Memòria cache totjorn valida pel diagnostic {category}. Se tornarà pas diagnosticar pel moment !)", + "diagnosis_found_errors": "{errors} errors importantas trobadas ligadas a {category} !", + "diagnosis_services_bad_status": "Lo servici {service} es {status} :(", + "diagnosis_swap_ok": "Lo sistèma a {total} d’escambi !", + "diagnosis_regenconf_allgood": "Totes los fichièrs de configuracion son confòrmes a la configuracion recomandada !", + "diagnosis_regenconf_manually_modified": "Lo fichièr de configuracion {file} foguèt modificat manualament.", + "diagnosis_regenconf_manually_modified_details": "Es probablament bon tan que sabètz çò que fasètz ;) !", + "diagnosis_security_vulnerable_to_meltdown": "Semblatz èsser vulnerable a la vulnerabilitat de seguretat critica de Meltdown", + "diagnosis_description_basesystem": "Sistèma de basa", + "diagnosis_description_ip": "Connectivitat Internet", + "diagnosis_description_dnsrecords": "Enregistraments DNS", + "diagnosis_description_services": "Verificacion d’estat de servicis", + "diagnosis_description_systemresources": "Resorgas sistèma", + "diagnosis_description_ports": "Exposicion dels pòrts", + "diagnosis_ports_unreachable": "Lo pòrt {port} es pas accessible de l’exterior.", + "diagnosis_ports_ok": "Lo pòrt {port} es accessible de l’exterior.", + "diagnosis_http_unreachable": "Lo domeni {domain} es pas accessible via HTTP de l’exterior.", + "diagnosis_unknown_categories": "La categorias seguentas son desconegudas : {categories}", + "diagnosis_ram_low": "Lo sistèma a {available} ({available_percent}%) de memòria RAM disponibla d’un total de {total}). Atencion.", + "log_permission_create": "Crear la permission « {} »", + "log_permission_delete": "Suprimir la permission « {} »", + "log_user_group_create": "Crear lo grop « {} »", + "log_user_permission_update": "Actualizacion dels accèsses per la permission « {} »", + "operation_interrupted": "L’operacion es estada interrompuda manualament ?", + "group_cannot_be_deleted": "Lo grop « {group} » pòt pas èsser suprimit manualament.", + "diagnosis_found_warnings": "Trobat {warnings} element(s) que se poirián melhorar per {category}.", + "diagnosis_dns_missing_record": "Segon la configuracion DNS recomandada, vos calriá ajustar un enregistrament DNS\ntipe: {type}\nnom: {name}\nvalor: {value}", + "diagnosis_dns_discrepancy": "La configuracion DNS seguenta sembla pas la configuracion recomandada :
Tipe : {type}
Nom : {name}
Valors actualas : {current]
Valor esperada : {value}", + "diagnosis_ports_could_not_diagnose": "Impossible de diagnosticar se los pòrts son accessibles de l’exterior.", + "diagnosis_ports_could_not_diagnose_details": "Error : {error}", + "diagnosis_http_could_not_diagnose": "Impossible de diagnosticar se lo domeni es accessible de l’exterior.", + "diagnosis_http_could_not_diagnose_details": "Error : {error}", + "apps_catalog_updating": "Actualizacion del catalòg d’aplicacion...", + "apps_catalog_failed_to_download": "Telecargament impossible del catalòg d’aplicacions {apps_catalog} : {error}", + "apps_catalog_obsolete_cache": "La memòria cache del catalòg d’aplicacion es voida o obsolèta.", + "apps_catalog_update_success": "Lo catalòg d’aplicacions es a jorn !", + "diagnosis_description_mail": "Corrièl", + "app_upgrade_script_failed": "Una error s’es producha pendent l’execucion de l’script de mesa a nivèl de l’aplicacion", + "diagnosis_cant_run_because_of_dep": "Execucion impossibla del diagnostic per {category} mentre que i a de problèmas importants ligats amb {dep}.", + "diagnosis_found_errors_and_warnings": "Avèm trobat {errors} problèma(s) important(s) (e {warnings} avis(es)) ligats a {category} !", + "diagnosis_failed": "Recuperacion impossibla dels resultats del diagnostic per la categoria « {category} » : {error}", + "diagnosis_ip_broken_dnsresolution": "La resolucion del nom de domeni es copada per una rason… Lo parafuòc bloca las requèstas DNS ?", + "diagnosis_no_cache": "I a pas encara de diagnostic de cache per la categoria « {category} »", + "apps_catalog_init_success": "Sistèma de catalòg d’aplicacion iniciat !", + "diagnosis_services_running": "Lo servici {service} es lançat !", + "diagnosis_services_conf_broken": "La configuracion es copada pel servici {service} !", + "diagnosis_ports_needed_by": "Es necessari qu’aqueste pòrt siá accessible pel servici {service}", + "diagnosis_diskusage_low": "Lo lòc d’emmagazinatge {mountpoint} (sul periferic {device}) a solament {free} ({free_percent}%). Siatz prudent.", + "dyndns_provider_unreachable": "Impossible d’atenher lo provesidor Dyndns : siá vòstre YunoHost es pas corrèctament connectat a Internet siá lo servidor dynette es copat.", + "diagnosis_services_bad_status_tip": "Podètz ensajar de reaviar lo servici, e se non fonciona pas, podètz agachar los jornals de servici a la pagina web d’administracion(en linha de comanda podètz utilizar yunohost service restart {service} e yunohost service log {service}).", + "diagnosis_http_connection_error": "Error de connexion : connexion impossibla al domeni demandat, benlèu qu’es pas accessible.", + "group_user_already_in_group": "L’utilizaire {user} es ja dins lo grop « {group} »", + "diagnosis_ip_broken_resolvconf": "La resolucion del nom de domeni sembla copada sul servidor, poiriá èsser ligada al fait que /etc/resolv.conf manda pas a 127.0.0.1.", + "diagnosis_ip_weird_resolvconf": "La resolucion del nom de domeni sembla foncionar, mas sembla qu’utiilizatz un fichièr /etc/resolv.conf personalizat.", + "diagnosis_diskusage_verylow": "Lo lòc d’emmagazinatge {mountpoint} (sul periferic {device}) a solament {free} ({free_percent}%). Deuriatz considerar de liberar un pauc d’espaci.", + "global_settings_setting_pop3_enabled": "Activar lo protocòl POP3 pel servidor de corrièr", + "diagnosis_diskusage_ok": "Lo lòc d’emmagazinatge {mountpoint} (sul periferic {device}) a encara {free} ({free_percent}%) de liure !", + "diagnosis_swap_none": "Lo sistèma a pas cap de memòria d’escambi. Auriatz de considerar d’ajustar almens {recommended} d’escambi per evitar las situacions ont lo sistèma manca de memòria.", + "diagnosis_swap_notsomuch": "Lo sistèma a solament {total} de memòria d’escambi. Auriatz de considerar d’ajustar almens {recommended} d’escambi per evitar las situacions ont lo sistèma manca de memòria.", + "diagnosis_description_web": "Web", + "diagnosis_ip_global": "IP Global  : {global}", + "diagnosis_ip_local": "IP locala : {local}", + "diagnosis_mail_ehlo_could_not_diagnose_details": "Error : {error}", + "diagnosis_mail_queue_unavailable_details": "Error : {error}", + "diagnosis_basesystem_hardware": "L’arquitectura del servidor es {virt} {arch}", + "backup_archive_corrupted": "Sembla que l’archiu de la salvagarda « {archive} » es corromput : {error}", + "diagnosis_domain_expires_in": "{domain} expiraà d’aquí {days} jorns.", + "migration_0015_cleaning_up": "Netejatge de la memòria cache e dels paquets pas mai necessaris…", + "restore_already_installed_apps": "Restauracion impossibla de las aplicacions seguentas que son ja installadas : {apps}", + "diagnosis_package_installed_from_sury": "D’unes paquets sistèma devon èsser meses a nivèl", + "ask_user_domain": "Domeni d’utilizar per l’adreça de corrièl de l’utilizaire e lo compte XMPP", + "app_manifest_install_ask_is_public": "Aquesta aplicacion serà visible pels visitaires anonims ?", + "app_manifest_install_ask_admin": "Causissètz un administrator per aquesta aplicacion", + "app_manifest_install_ask_password": "Causissètz lo senhal administrator per aquesta aplicacion", + "app_manifest_install_ask_path": "Causissètz lo camin ont volètz installar aquesta aplicacion", + "app_manifest_install_ask_domain": "Causissètz lo domeni ont volètz installar aquesta aplicacion", + "app_argument_password_no_default": "Error pendent l’analisi de l’argument del senhal « {name} » : l’argument de senhal pòt pas aver de valor per defaut per de rason de seguretat", + "app_label_deprecated": "Aquesta comanda es estada renduda obsolèta. Mercés d'utilizar lo nòva \"yunohost user permission update\" per gerir letiquetada de l'aplication", + "additional_urls_already_removed": "URL addicionala {url} es ja estada elimida per la permission «#permission:s»", + "additional_urls_already_added": "URL addicionadal «{url}'» es ja estada aponduda per la permission «{permission}»", + "migration_0015_yunohost_upgrade": "Aviada de la mesa a jorn de YunoHost...", + "migration_0015_main_upgrade": "Aviada de la mesa a nivèl generala...", + "migration_0015_patching_sources_list": "Mesa a jorn del fichièr sources.lists...", + "migration_0015_start": "Aviar la migracion cap a Buster", + "migration_description_0017_postgresql_9p6_to_11": "Migrar las basas de donadas de PostgreSQL 9.6 cap a 11", + "migration_description_0016_php70_to_php73_pools": "Migrar los fichièrs de configuracion php7.0 cap a php7.3", + "migration_description_0015_migrate_to_buster": "Mesa a nivèl dels sistèmas Debian Buster e YunoHost 4.x", + "migrating_legacy_permission_settings": "Migracion dels paramètres de permission ancians...", + "log_app_action_run": "Executar l’accion de l’aplicacion « {} »", + "diagnosis_basesystem_hardware_model": "Lo modèl del servidor es {model}", + "backup_archive_cant_retrieve_info_json": "Obtencion impossibla de las informacions de l’archiu « {archive} »... Se pòt pas recuperar lo fichièr info.json (o es pas un fichièr json valid).", + "app_packaging_format_not_supported": "Se pòt pas installar aquesta aplicacion pr’amor que son format es pas pres en carga per vòstra version de YunoHost. Deuriatz considerar actualizar lo sistèma.", + "diagnosis_mail_fcrdns_ok": "Vòstre DNS inverse es corrèctament configurat !", + "diagnosis_mail_outgoing_port_25_ok": "Lo servidor de messatge SMTP pòt enviar de corrièls (lo pòrt 25 es pas blocat).", + "diagnosis_domain_expiration_warning": "D’unes domenis expiraràn lèu !", + "diagnosis_domain_expiration_success": "Vòstres domenis son enregistrats e expiraràn pas lèu.", + "diagnosis_domain_not_found_details": "Lo domeni {domain} existís pas a la basa de donadas WHOIS o a expirat !", + "diagnosis_domain_expiration_not_found": "Impossible de verificar la data d’expiracion d’unes domenis", + "backup_create_size_estimation": "L’archiu contendrà apr’aquí {size} de donadas.", + "app_restore_script_failed": "Una error s’es producha a l’interior del script de restauracion de l’aplicacion" +} \ No newline at end of file diff --git a/locales/pl.json b/locales/pl.json index 0967ef424..caf108367 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -1 +1,12 @@ -{} +{ + "password_too_simple_1": "Hasło musi mieć co najmniej 8 znaków", + "app_already_up_to_date": "{app} jest obecnie aktualna", + "app_already_installed": "{app} jest już zainstalowane", + "already_up_to_date": "Nic do zrobienia. Wszystko jest obecnie aktualne.", + "admin_password_too_long": "Proszę wybrać hasło krótsze niż 127 znaków", + "admin_password_changed": "Hasło administratora zostało zmienione", + "admin_password_change_failed": "Nie można zmienić hasła", + "admin_password": "Hasło administratora", + "action_invalid": "Nieprawidłowa operacja '{action}'", + "aborting": "Przerywanie." +} \ No newline at end of file diff --git a/locales/pt.json b/locales/pt.json index 80a0d5ddd..534e0cb27 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -1,134 +1,83 @@ { - "action_invalid": "Acção Inválida '{action:s}'", + "action_invalid": "Acção Inválida '{action}'", "admin_password": "Senha de administração", "admin_password_change_failed": "Não foi possível alterar a senha", - "admin_password_changed": "A palavra-passe de administração foi alterada com sucesso", - "app_already_installed": "{app:s} já está instalada", - "app_extraction_failed": "Não foi possível extrair os ficheiros para instalação", - "app_id_invalid": "A ID da aplicação é inválida", - "app_install_files_invalid": "Ficheiros para instalação corrompidos", - "app_location_already_used": "A aplicação {app} Já está instalada nesta localização ({path})", - "app_location_install_failed": "Não é possível instalar a aplicação neste diretório porque está em conflito com a aplicação '{other_app}', que já está instalada no diretório '{other_path}'", + "admin_password_changed": "A senha da administração foi alterada", + "app_already_installed": "{app} já está instalada", + "app_extraction_failed": "Não foi possível extrair os arquivos para instalação", + "app_id_invalid": "App ID invaĺido", + "app_install_files_invalid": "Esses arquivos não podem ser instalados", "app_manifest_invalid": "Manifesto da aplicação inválido: {error}", - "app_no_upgrade": "Não existem aplicações para atualizar", - "app_not_installed": "{app:s} não está instalada", - "app_recent_version_required": "{:s} requer uma versão mais recente da moulinette", - "app_removed": "{app:s} removida com êxito", - "app_sources_fetch_failed": "Incapaz obter os ficheiros fonte", + "app_not_installed": "Não foi possível encontrar {app} na lista de aplicações instaladas: {all_apps}", + "app_removed": "{app} desinstalada", + "app_sources_fetch_failed": "Não foi possível carregar os arquivos de código fonte, a URL está correta?", "app_unknown": "Aplicação desconhecida", - "app_upgrade_failed": "Não foi possível atualizar {app:s}", - "app_upgraded": "{app:s} atualizada com sucesso", - "appslist_fetched": "A lista de aplicações, {appslist:s}, foi trazida com sucesso", - "appslist_removed": "A Lista de aplicações {appslist:s} foi removida", - "appslist_retrieve_error": "Não foi possível obter a lista de aplicações remotas {appslist:s}: {error:s}", - "appslist_unknown": "Desconhece-se a lista de aplicaçoes {appslist:s}.", - "ask_current_admin_password": "Senha atual da administração", - "ask_email": "Endereço de Email", + "app_upgrade_failed": "Não foi possível atualizar {app}: {error}", + "app_upgraded": "{app} atualizado", "ask_firstname": "Primeiro nome", "ask_lastname": "Último nome", - "ask_list_to_remove": "Lista para remover", "ask_main_domain": "Domínio principal", "ask_new_admin_password": "Nova senha de administração", "ask_password": "Senha", "backup_created": "Backup completo", - "backup_creating_archive": "A criar ficheiro de backup...", - "backup_invalid_archive": "Arquivo de backup inválido", - "backup_output_directory_not_empty": "A pasta de destino não se encontra vazia", - "custom_app_url_required": "Deve fornecer um link para atualizar a sua aplicação personalizada {app:s}", - "custom_appslist_name_required": "Deve fornecer um nome para a sua lista de aplicações personalizada", + "backup_output_directory_not_empty": "Você deve escolher um diretório de saída que esteja vazio", + "custom_app_url_required": "Deve fornecer um link para atualizar a sua aplicação personalizada {app}", "domain_cert_gen_failed": "Não foi possível gerar o certificado", "domain_created": "Domínio criado com êxito", - "domain_creation_failed": "Não foi possível criar o domínio", + "domain_creation_failed": "Não foi possível criar o domínio {domain}: {error}", "domain_deleted": "Domínio removido com êxito", - "domain_deletion_failed": "Não foi possível eliminar o domínio", + "domain_deletion_failed": "Não foi possível eliminar o domínio {domain}: {error}", "domain_dyndns_already_subscribed": "Já subscreveu um domínio DynDNS", - "domain_dyndns_invalid": "Domínio inválido para ser utilizado com DynDNS", "domain_dyndns_root_unknown": "Domínio root (administrador) DynDNS desconhecido", "domain_exists": "O domínio já existe", "domain_uninstall_app_first": "Existem uma ou mais aplicações instaladas neste domínio. Por favor desinstale-as antes de proceder com a remoção do domínio.", - "domain_unknown": "Domínio desconhecido", - "domain_zone_exists": "Ficheiro para zona DMZ já existe", - "domain_zone_not_found": "Ficheiro para zona DMZ não encontrado no domínio {:s}", "done": "Concluído.", "downloading": "Transferência em curso...", - "dyndns_cron_installed": "Gestor de tarefas cron DynDNS instalado com êxito", - "dyndns_cron_remove_failed": "Não foi possível remover o gestor de tarefas cron DynDNS", - "dyndns_cron_removed": "Gestor de tarefas cron DynDNS removido com êxito", - "dyndns_ip_update_failed": "Não foi possível atualizar o endereço IP a partir de DynDNS", - "dyndns_ip_updated": "Endereço IP atualizado com êxito a partir de DynDNS", + "dyndns_ip_update_failed": "Não foi possível atualizar o endereço IP para DynDNS", + "dyndns_ip_updated": "Endereço IP atualizado com êxito para DynDNS", "dyndns_key_generating": "A chave DNS está a ser gerada, isto pode demorar um pouco...", "dyndns_registered": "Dom+inio DynDNS registado com êxito", - "dyndns_registration_failed": "Não foi possível registar o domínio DynDNS: {error:s}", - "dyndns_unavailable": "Subdomínio DynDNS indisponível", - "executing_script": "A executar o script...", + "dyndns_registration_failed": "Não foi possível registar o domínio DynDNS: {error}", + "dyndns_unavailable": "O domínio '{domain}' não está disponível.", "extracting": "Extração em curso...", - "field_invalid": "Campo inválido '{:s}'", + "field_invalid": "Campo inválido '{}'", "firewall_reloaded": "Firewall recarregada com êxito", - "hook_argument_missing": "Argumento em falta '{:s}'", - "hook_choice_invalid": "Escolha inválida '{:s}'", "installation_complete": "Instalação concluída", - "installation_failed": "A instalação falhou", "iptables_unavailable": "Não pode alterar aqui a iptables. Ou o seu kernel não o suporta ou está num espaço reservado.", - "ldap_initialized": "LDAP inicializada com êxito", - "license_undefined": "indefinido", - "mail_alias_remove_failed": "Não foi possível remover a etiqueta de correio '{mail:s}'", - "mail_domain_unknown": "Domínio de endereço de correio desconhecido '{domain:s}'", - "mail_forward_remove_failed": "Não foi possível remover o reencaminhamento de correio '{mail:s}'", - "maindomain_change_failed": "Incapaz alterar o domínio raiz", - "maindomain_changed": "Domínio raiz alterado com êxito", - "monitor_disabled": "Monitorização do servidor parada com êxito", - "monitor_enabled": "Monitorização do servidor ativada com êxito", - "monitor_glances_con_failed": "Não foi possível ligar ao servidor Glances", - "monitor_not_enabled": "A monitorização do servidor não está ativa", - "monitor_period_invalid": "Período de tempo inválido", - "monitor_stats_file_not_found": "Ficheiro de estatísticas não encontrado", - "monitor_stats_no_update": "Não existem estatísticas de monitorização para atualizar", - "monitor_stats_period_unavailable": "Não existem estatísticas disponíveis para este período", - "mountpoint_unknown": "Ponto de montagem desconhecido", - "mysql_db_creation_failed": "Criação da base de dados MySQL falhou", - "mysql_db_init_failed": "Inicialização da base de dados MySQL falhou", - "mysql_db_initialized": "Base de dados MySQL iniciada com êxito", - "new_domain_required": "Deve escrever um novo domínio principal", - "no_appslist_found": "Não foi encontrada a lista de aplicações", - "no_internet_connection": "O servidor não está ligado à Internet", - "packages_no_upgrade": "Não existem pacotes para atualizar", - "packages_upgrade_critical_later": "Os pacotes críticos ({packages:s}) serão atualizados depois", + "mail_alias_remove_failed": "Não foi possível remover a etiqueta de correio '{mail}'", + "mail_domain_unknown": "Domínio de endereço de correio '{domain}' inválido. Por favor, usa um domínio administrado per esse servidor.", + "mail_forward_remove_failed": "Não foi possível remover o reencaminhamento de correio '{mail}'", + "main_domain_change_failed": "Incapaz alterar o domínio raiz", + "main_domain_changed": "Domínio raiz alterado com êxito", "packages_upgrade_failed": "Não foi possível atualizar todos os pacotes", - "path_removal_failed": "Incapaz remover o caminho {:s}", "pattern_domain": "Deve ser um nome de domínio válido (p.e. meu-dominio.org)", "pattern_email": "Deve ser um endereço de correio válido (p.e. alguem@dominio.org)", "pattern_firstname": "Deve ser um primeiro nome válido", "pattern_lastname": "Deve ser um último nome válido", - "pattern_listname": "Apenas são permitidos caracteres alfanuméricos e travessões", "pattern_password": "Deve ter no mínimo 3 caracteres", - "pattern_port": "Deve ser um número de porta válido (entre 0-65535)", "pattern_username": "Devem apenas ser carácteres minúsculos alfanuméricos e subtraços", - "restore_confirm_yunohost_installed": "Quer mesmo restaurar um sistema já instalado? [{answers:s}]", - "service_add_failed": "Incapaz adicionar serviço '{service:s}'", + "restore_confirm_yunohost_installed": "Quer mesmo restaurar um sistema já instalado? [{answers}]", + "service_add_failed": "Incapaz adicionar serviço '{service}'", "service_added": "Serviço adicionado com êxito", - "service_already_started": "O serviço '{service:s}' já está em execussão", - "service_already_stopped": "O serviço '{service:s}' já está parado", - "service_cmd_exec_failed": "Incapaz executar o comando '{command:s}'", - "service_disable_failed": "Incapaz desativar o serviço '{service:s}'", - "service_disabled": "O serviço '{service:s}' foi desativado com êxito", - "service_enable_failed": "Incapaz de ativar o serviço '{service:s}'", - "service_enabled": "Serviço '{service:s}' ativado com êxito", - "service_no_log": "Não existem registos para mostrar do serviço '{service:s}'", - "service_remove_failed": "Incapaz de remover o serviço '{service:s}'", + "service_already_started": "O serviço '{service}' já está em execussão", + "service_already_stopped": "O serviço '{service}' já está parado", + "service_cmd_exec_failed": "Incapaz executar o comando '{command}'", + "service_disable_failed": "Incapaz desativar o serviço '{service}'", + "service_disabled": "O serviço '{service}' foi desativado com êxito", + "service_enable_failed": "Incapaz de ativar o serviço '{service}'", + "service_enabled": "Serviço '{service}' ativado com êxito", + "service_remove_failed": "Incapaz de remover o serviço '{service}'", "service_removed": "Serviço eliminado com êxito", - "service_start_failed": "Não foi possível iniciar o serviço '{service:s}'", - "service_started": "O serviço '{service:s}' foi iniciado com êxito", - "service_status_failed": "Incapaz determinar o estado do serviço '{service:s}'", - "service_stop_failed": "Incapaz parar o serviço '{service:s}'", - "service_stopped": "O serviço '{service:s}' foi parado com êxito", - "service_unknown": "Serviço desconhecido '{service:s}'", + "service_start_failed": "Não foi possível iniciar o serviço '{service}'", + "service_started": "O serviço '{service}' foi iniciado com êxito", + "service_stop_failed": "Incapaz parar o serviço '{service}'", + "service_stopped": "O serviço '{service}' foi parado com êxito", + "service_unknown": "Serviço desconhecido '{service}'", "ssowat_conf_generated": "Configuração SSOwat gerada com êxito", "ssowat_conf_updated": "Configuração persistente SSOwat atualizada com êxito", "system_upgraded": "Sistema atualizado com êxito", "system_username_exists": "O utilizador já existe no registo do sistema", "unexpected_error": "Ocorreu um erro inesperado", - "unit_unknown": "Unidade desconhecida '{unit:s}'", - "update_cache_failed": "Não foi possível atualizar os cabeçalhos APT", "updating_apt_cache": "A atualizar a lista de pacotes disponíveis...", "upgrade_complete": "Atualização completa", "upgrading_packages": "Atualização de pacotes em curso...", @@ -136,62 +85,111 @@ "user_creation_failed": "Não foi possível criar o utilizador", "user_deleted": "Utilizador eliminado com êxito", "user_deletion_failed": "Incapaz eliminar o utilizador", - "user_info_failed": "Incapaz obter informações sobre o utilizador", "user_unknown": "Utilizador desconhecido", "user_update_failed": "Não foi possível atualizar o utilizador", "user_updated": "Utilizador atualizado com êxito", "yunohost_already_installed": "AYunoHost já está instalado", - "yunohost_ca_creation_failed": "Incapaz criar o certificado de autoridade", "yunohost_configured": "YunoHost configurada com êxito", "yunohost_installing": "A instalar a YunoHost...", "yunohost_not_installed": "YunoHost ainda não está corretamente configurado. Por favor execute as 'ferramentas pós-instalação yunohost'.", - "app_incompatible": "A aplicação {app} é incompatível com a sua versão de Yunohost", - "app_not_correctly_installed": "{app:s} parece não estar corretamente instalada", - "app_not_properly_removed": "{app:s} não foi corretamente removido", + "app_not_correctly_installed": "{app} parece não estar corretamente instalada", + "app_not_properly_removed": "{app} não foi corretamente removido", "app_requirements_checking": "Verificando os pacotes necessários para {app}...", "app_unsupported_remote_type": "A aplicação não possui suporte ao tipo remoto utilizado", - "backup_archive_app_not_found": "A aplicação '{app:s}' não foi encontrada no arquivo de backup", - "backup_archive_broken_link": "Impossível acessar o arquivo de backup (link quebrado ao {path:s})", - "backup_archive_hook_not_exec": "O gancho '{hook:s}' não foi executado neste backup", - "backup_archive_name_exists": "O nome do arquivo de backup já existe", - "backup_archive_open_failed": "Não é possível abrir o arquivo de backup", - "backup_cleaning_failed": "Não é possível limpar a pasta temporária de backups", - "backup_creation_failed": "A criação do backup falhou", - "backup_delete_error": "Impossível apagar '{path:s}'", - "backup_deleted": "O backup foi suprimido", - "backup_extracting_archive": "Extraindo arquivo de backup...", - "backup_hook_unknown": "Gancho de backup '{hook:s}' desconhecido", - "backup_nothings_done": "Não há nada para guardar", - "backup_output_directory_forbidden": "Diretório de saída proibido. Os backups não podem ser criados em /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var ou /home/yunohost.backup/archives subpastas", - "app_already_installed_cant_change_url": "Este aplicativo já está instalado. A URL não pode ser alterada apenas por esta função. Olhe para o `app changeurl` se estiver disponível.", - "app_already_up_to_date": "{app:s} já está atualizado", - "app_argument_choice_invalid": "Escolha inválida para o argumento '{name:s}', deve ser um dos {choices:s}", - "app_argument_invalid": "Valor inválido de argumento '{name:s}': {error:s}", - "app_argument_required": "O argumento '{name:s}' é obrigatório", - "app_change_url_failed_nginx_reload": "Falha ao reiniciar o nginx. Aqui está o retorno de 'nginx -t':\n{nginx_errors:s}", - "app_change_no_change_url_script": "A aplicação {app_name:s} ainda não permite mudança da URL, talvez seja necessário atualiza-la.", - "app_location_unavailable": "Esta url não está disponível ou está em conflito com outra aplicação já instalada", - "app_package_need_update": "O pacote da aplicação {app} precisa ser atualizado para aderir as mudanças do YunoHost", - "app_requirements_failed": "Não foi possível atender aos requisitos da aplicação {app}: {error}", - "app_upgrade_app_name": "Atualizando aplicação {app}…", + "backup_archive_app_not_found": "Não foi possível encontrar {app} no arquivo de backup", + "backup_archive_broken_link": "Não foi possível acessar o arquivo de backup (link quebrado ao {path})", + "backup_archive_name_exists": "Já existe um arquivo de backup com esse nome.", + "backup_archive_open_failed": "Não foi possível abrir o arquivo de backup", + "backup_cleaning_failed": "Não foi possível limpar o diretório temporário de backup", + "backup_creation_failed": "Não foi possível criar o arquivo de backup", + "backup_delete_error": "Não foi possível remover '{path}'", + "backup_deleted": "Backup removido", + "backup_hook_unknown": "O gancho de backup '{hook}' é desconhecido", + "backup_nothings_done": "Nada há se salvar", + "backup_output_directory_forbidden": "Escolha um diretório de saída diferente. Backups não podem ser criados nos subdiretórios /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var ou /home/yunohost.backup/archives", + "app_already_installed_cant_change_url": "Este aplicativo já está instalado. A URL não pode ser alterada apenas por esta função. Confira em `app changeurl` se está disponível.", + "app_already_up_to_date": "{app} já está atualizado", + "app_argument_choice_invalid": "Use uma das opções '{choices}' para o argumento '{name}' em vez de '{value}'", + "app_argument_invalid": "Escolha um valor válido para o argumento '{name}': {error}", + "app_argument_required": "O argumento '{name}' é obrigatório", + "app_location_unavailable": "Esta url ou não está disponível ou está em conflito com outra(s) aplicação(ões) já instalada(s):\n{apps}", + "app_upgrade_app_name": "Atualizando {app}…", "app_upgrade_some_app_failed": "Não foi possível atualizar algumas aplicações", - "appslist_corrupted_json": "Falha ao carregar a lista de aplicações. O arquivo {filename:s} aparenta estar corrompido.", - "appslist_migrating": "Migando lista de aplicações {appslist:s}…", - "appslist_name_already_tracked": "Já existe uma lista de aplicações registrada com o nome {name:s}.", - "appslist_retrieve_bad_format": "O arquivo recuperado para a lista de aplicações {appslist:s} é invalido", - "appslist_url_already_tracked": "Já existe uma lista de aplicações registrada com a url {url:s}.", - "ask_path": "Caminho", - "backup_abstract_method": "Este metodo de backup ainda não foi implementado", - "backup_action_required": "Deve-se especificar algo a salvar", - "backup_app_failed": "Não foi possível fazer o backup dos aplicativos '{app:s}'", - "backup_applying_method_custom": "Chamando o metodo personalizado de backup '{method:s}'…", - "backup_applying_method_tar": "Criando o arquivo tar de backup…", - "backup_archive_mount_failed": "Falha ao montar o arquivo de backup", - "backup_archive_name_unknown": "Desconhece-se o arquivo local de backup de nome '{name:s}'", - "backup_archive_system_part_not_available": "A seção do sistema '{part:s}' está indisponivel neste backup", - "backup_ask_for_copying_if_needed": "Alguns arquivos não consiguiram ser preparados para backup utilizando o metodo que não gasta espaço de disco temporariamente. Para realizar o backup {size:s}MB precisam ser usados temporariamente. Você concorda?", - "backup_borg_not_implemented": "O método de backup Borg ainda não foi implementado.", - "backup_cant_mount_uncompress_archive": "Não foi possível montar em modo leitura o diretorio de arquivos não comprimido", - "backup_copying_to_organize_the_archive": "Copiando {size:s}MB para organizar o arquivo", - "app_change_url_identical_domains": "O antigo e o novo domínio / url_path são idênticos ('{domain:s}{path:s}'), nada para fazer." -} + "backup_abstract_method": "Este método de backup ainda não foi implementado", + "backup_app_failed": "Não foi possível fazer o backup de '{app}'", + "backup_applying_method_custom": "Chamando o método personalizado de backup '{method}'…", + "backup_applying_method_tar": "Criando o arquivo TAR de backup…", + "backup_archive_name_unknown": "Desconhece-se o arquivo local de backup de nome '{name}'", + "backup_archive_system_part_not_available": "A seção do sistema '{part}' está indisponível neste backup", + "backup_ask_for_copying_if_needed": "Você quer efetuar o backup usando {size}MB temporariamente? (E necessário fazer dessa forma porque alguns arquivos não puderam ser preparados usando um método mais eficiente)", + "backup_cant_mount_uncompress_archive": "Não foi possível montar o arquivo descomprimido como protegido contra escrita", + "backup_copying_to_organize_the_archive": "Copiando {size}MB para organizar o arquivo", + "app_change_url_identical_domains": "O antigo e o novo domínio / url_path são idênticos ('{domain}{path}'), nada para fazer.", + "password_too_simple_1": "A senha precisa ter pelo menos 8 caracteres", + "admin_password_too_long": "Escolha uma senha que contenha menos de 127 caracteres", + "aborting": "Abortando.", + "app_change_url_no_script": "A aplicação '{app_name}' ainda não permite modificar a URL. Talvez devesse atualizá-la.", + "app_argument_password_no_default": "Erro ao interpretar argumento da senha '{name}': O argumento da senha não pode ter um valor padrão por segurança", + "app_action_cannot_be_ran_because_required_services_down": "Estes serviços devem estar funcionado para executar esta ação: {services}. Tente reiniciá-los para continuar (e possivelmente investigar o porquê de não estarem funcionado).", + "app_action_broke_system": "Esta ação parece ter quebrado estes serviços importantes: {services}", + "already_up_to_date": "Nada a ser feito. Tudo já está atualizado.", + "additional_urls_already_removed": "A URL adicional '{url}'já está removida para a permissão '{permission}'", + "additional_urls_already_added": "A URL adicional '{url}' já está adicionada para a permissão '{permission}'", + "app_install_script_failed": "Ocorreu um erro dentro do script de instalação do aplicativo", + "app_install_failed": "Não foi possível instalar {app}: {error}", + "app_full_domain_unavailable": "Desculpe, esse app deve ser instalado num domínio próprio mas já há outros apps instalados no domínio '{domain}'. Você pode usar um subdomínio dedicado a esse aplicativo.", + "app_change_url_success": "A URL agora é {domain}{path}", + "apps_catalog_obsolete_cache": "O cache do catálogo de aplicações está vazio ou obsoleto.", + "apps_catalog_failed_to_download": "Não foi possível fazer o download do catálogo de aplicações {apps_catalog}: {error}", + "apps_catalog_updating": "Atualizando o catálogo de aplicações...", + "apps_catalog_init_success": "Catálogo de aplicações do sistema inicializado!", + "apps_already_up_to_date": "Todas as aplicações já estão atualizadas", + "app_packaging_format_not_supported": "Essa aplicação não pode ser instalada porque o formato dela não é suportado pela sua versão do YunoHost. Considere atualizar seu sistema.", + "app_upgrade_script_failed": "Ocorreu um erro dentro do script de atualização da aplicação", + "app_upgrade_several_apps": "As seguintes aplicações serão atualizadas: {apps}", + "app_start_restore": "Restaurando {app}...", + "app_start_backup": "Obtendo os arquivos para fazer o backup de {app}...", + "app_start_remove": "Removendo {app}...", + "app_start_install": "Instalando {app}...", + "app_restore_script_failed": "Ocorreu um erro dentro do script de restauração da aplicação", + "app_restore_failed": "Não foi possível restaurar {app}: {error}", + "app_remove_after_failed_install": "Removendo a aplicação após a falha da instalação...", + "app_requirements_unmeet": "Os requisitos para a aplicação {app} não foram satisfeitos, o pacote {pkgname} ({version}) devem ser {spec}", + "app_not_upgraded": "Não foi possível atualizar a aplicação '{failed_app}' e, como consequência, a atualização das seguintes aplicações foi cancelada: {apps}", + "app_manifest_install_ask_is_public": "Essa aplicação deve ser visível para visitantes anônimos?", + "app_manifest_install_ask_admin": "Escolha um usuário de administrador para essa aplicação", + "app_manifest_install_ask_password": "Escolha uma senha de administrador para essa aplicação", + "app_manifest_install_ask_path": "Escolha o caminho da url (depois do domínio) em que essa aplicação deve ser instalada", + "app_manifest_install_ask_domain": "Escolha o domínio em que esta aplicação deve ser instalada", + "app_label_deprecated": "Este comando está deprecado! Por favor use o novo comando 'yunohost user permission update' para gerenciar a etiqueta da aplicação.", + "app_make_default_location_already_used": "Não foi passível fazer a aplicação '{app}' ser a padrão no domínio, '{domain}' já está sendo usado por '{other_app}'", + "backup_archive_writing_error": "Não foi possível adicionar os arquivos '{source}' (nomeados dentro do arquivo '{dest}') ao backup no arquivo comprimido '{archive}'", + "backup_archive_corrupted": "Parece que o arquivo de backup '{archive}' está corrompido: {error}", + "backup_archive_cant_retrieve_info_json": "Não foi possível carregar informações para o arquivo '{archive}'... Não foi possível carregar info.json (ou não é um JSON válido).", + "backup_applying_method_copy": "Copiando todos os arquivos para o backup...", + "backup_actually_backuping": "Criando cópia de backup dos arquivos obtidos...", + "ask_user_domain": "Domínio para usar para o endereço de email e conta XMPP do usuário", + "ask_new_path": "Novo caminho", + "ask_new_domain": "Novo domínio", + "apps_catalog_update_success": "O catálogo de aplicações foi atualizado!", + "backup_no_uncompress_archive_dir": "Não existe tal diretório de arquivo descomprimido", + "backup_mount_archive_for_restore": "Preparando o arquivo para restauração...", + "backup_method_tar_finished": "Arquivo de backup TAR criado", + "backup_method_custom_finished": "Método de backup personalizado '{method}' finalizado", + "backup_method_copy_finished": "Cópia de backup finalizada", + "backup_custom_mount_error": "O método personalizado de backup não pôde passar do passo de 'mount'", + "backup_custom_backup_error": "O método personalizado de backup não pôde passar do passo de 'backup'", + "backup_csv_creation_failed": "Não foi possível criar o arquivo CSV necessário para a restauração", + "backup_csv_addition_failed": "Não foi possível adicionar os arquivos que estarão no backup ao arquivo CSV", + "backup_create_size_estimation": "O arquivo irá conter cerca de {size} de dados.", + "backup_couldnt_bind": "Não foi possível vincular {src} ao {dest}", + "certmanager_attempt_to_replace_valid_cert": "Você está tentando sobrescrever um certificado bom e válido para o domínio {domain}! (Use --force para prosseguir mesmo assim)", + "backup_with_no_restore_script_for_app": "A aplicação {app} não tem um script de restauração, você não será capaz de automaticamente restaurar o backup dessa aplicação.", + "backup_with_no_backup_script_for_app": "A aplicação '{app}' não tem um script de backup. Ignorando.", + "backup_unable_to_organize_files": "Não foi possível usar o método rápido de organizar os arquivos no arquivo de backup", + "backup_system_part_failed": "Não foi possível fazer o backup da parte do sistema '{part}'", + "backup_running_hooks": "Executando os hooks de backup...", + "backup_permission": "Permissão de backup para {app}", + "backup_output_symlink_dir_broken": "O diretório de seu arquivo '{path}' é um link simbólico quebrado. Talvez você tenha esquecido de re/montar ou conectar o dispositivo de armazenamento para onde o link aponta.", + "backup_output_directory_required": "Você deve especificar um diretório de saída para o backup" +} \ No newline at end of file diff --git a/locales/ru.json b/locales/ru.json index 306a8763a..5a74524bf 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1,46 +1,33 @@ { - "action_invalid": "Неверное действие '{action:s}'", + "action_invalid": "Неверное действие '{action}'", "admin_password": "Пароль администратора", "admin_password_change_failed": "Невозможно изменить пароль", "admin_password_changed": "Пароль администратора был изменен", - "app_already_installed": "{app:s} уже установлено", + "app_already_installed": "{app} уже установлено", "app_already_installed_cant_change_url": "Это приложение уже установлено. URL не может быть изменен только с помощью этой функции. Изучите `app changeurl`, если это доступно.", - "app_argument_choice_invalid": "Неверный выбор для аргумента '{name:s}', Это должно быть '{choices:s}'", - "app_argument_invalid": "Недопустимое значение аргумента '{name:s}': {error:s}'", - "app_already_up_to_date": "{app:s} уже обновлено", - "app_argument_required": "Аргумент '{name:s}' необходим", - "app_change_no_change_url_script": "Приложение {app_name:s} не поддерживает изменение URL, вы должны обновить его.", - "app_change_url_identical_domains": "Старый и новый domain/url_path идентичны ('{domain:s}{path:s}'), ничего делать не надо.", - "app_change_url_no_script": "Приложение '{app_name:s}' не поддерживает изменение url. Наверное, вам нужно обновить приложение.", - "app_change_url_success": "Успешно изменён {app:s} url на {domain:s}{path:s}", + "app_argument_choice_invalid": "Неверный выбор для аргумента '{name}', Это должно быть '{choices}'", + "app_argument_invalid": "Недопустимое значение аргумента '{name}': {error}'", + "app_already_up_to_date": "{app} уже обновлено", + "app_argument_required": "Аргумент '{name}' необходим", + "app_change_url_identical_domains": "Старый и новый domain/url_path идентичны ('{domain}{path}'), ничего делать не надо.", + "app_change_url_no_script": "Приложение '{app_name}' не поддерживает изменение url. Наверное, вам нужно обновить приложение.", + "app_change_url_success": "Успешно изменён {app} url на {domain}{path}", "app_extraction_failed": "Невозможно извлечь файлы для инсталляции", "app_id_invalid": "Неправильный id приложения", - "app_incompatible": "Приложение {app} несовместимо с вашей версией YonoHost", "app_install_files_invalid": "Неправильные файлы инсталляции", - "app_location_already_used": "Приложение '{app}' уже установлено по этому адресу ({path})", - "app_location_install_failed": "Невозможно установить приложение в это место, потому что оно конфликтует с приложением, '{other_app}' установленном на '{other_path}'", - "app_location_unavailable": "Этот url отсутствует или конфликтует с уже установленным приложением или приложениями: {apps:s}", + "app_location_unavailable": "Этот url отсутствует или конфликтует с уже установленным приложением или приложениями: {apps}", "app_manifest_invalid": "Недопустимый манифест приложения: {error}", - "app_no_upgrade": "Нет приложений, требующих обновления", - "app_not_correctly_installed": "{app:s} , кажется, установлены неправильно", - "app_not_installed": "{app:s} не установлены", - "app_not_properly_removed": "{app:s} удалены неправильно", - "app_package_need_update": "Пакет приложения {app} должен быть обновлён в соответствии с изменениями YonoHost", - "app_removed": "{app:s} удалено", + "app_not_correctly_installed": "{app} , кажется, установлены неправильно", + "app_not_installed": "{app} не установлены", + "app_not_properly_removed": "{app} удалены неправильно", + "app_removed": "{app} удалено", "app_requirements_checking": "Проверяю необходимые пакеты для {app}...", "app_sources_fetch_failed": "Невозможно получить исходные файлы", "app_unknown": "Неизвестное приложение", "app_upgrade_app_name": "Обновление приложения {app}...", - "app_upgrade_failed": "Невозможно обновить {app:s}", + "app_upgrade_failed": "Невозможно обновить {app}", "app_upgrade_some_app_failed": "Невозможно обновить некоторые приложения", - "app_upgraded": "{app:s} обновлено", - "appslist_corrupted_json": "Не могу загрузить список приложений. Кажется, {filename:s} поврежден.", - "appslist_fetched": "Был выбран список приложений {appslist:s}", - "appslist_name_already_tracked": "Уже есть зарегистрированный список приложений по имени {name:s}.", - "appslist_removed": "Список приложений {appslist:s} удалён", - "appslist_retrieve_bad_format": "Неверный файл списка приложений{appslist:s}", - "appslist_retrieve_error": "Невозможно получить список удаленных приложений {appslist:s}: {error:s}", - "appslist_unknown": "Список приложений {appslist:s} неизвестен.", - "appslist_url_already_tracked": "Это уже зарегистрированный список приложений с url{url:s}.", - "installation_complete": "Установка завершена" -} + "app_upgraded": "{app} обновлено", + "installation_complete": "Установка завершена", + "password_too_simple_1": "Пароль должен быть не менее 8 символов" +} \ No newline at end of file diff --git a/locales/sv.json b/locales/sv.json index 0967ef424..39707d07c 100644 --- a/locales/sv.json +++ b/locales/sv.json @@ -1 +1,11 @@ -{} +{ + "password_too_simple_1": "Lösenordet måste bestå av minst åtta tecken", + "app_action_broke_system": "Åtgärden verkar ha fått följande viktiga tjänster att haverera: {services}", + "already_up_to_date": "Ingenting att göra. Allt är redan uppdaterat.", + "admin_password": "Administratörslösenord", + "admin_password_too_long": "Välj gärna ett lösenord som inte innehåller fler än 127 tecken", + "admin_password_change_failed": "Kan inte byta lösenord", + "action_invalid": "Ej tillåten åtgärd '{action}'", + "admin_password_changed": "Administratörskontots lösenord ändrades", + "aborting": "Avbryter." +} \ No newline at end of file diff --git a/locales/tr.json b/locales/tr.json index 0967ef424..6c881eec7 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -1 +1,3 @@ -{} +{ + "password_too_simple_1": "Şifre en az 8 karakter uzunluğunda olmalı" +} \ No newline at end of file diff --git a/locales/uk.json b/locales/uk.json new file mode 100644 index 000000000..35923908f --- /dev/null +++ b/locales/uk.json @@ -0,0 +1,679 @@ +{ + "app_manifest_install_ask_domain": "Оберіть домен, в якому треба встановити цей застосунок", + "app_manifest_invalid": "Щось не так з маніфестом застосунку: {error}", + "app_location_unavailable": "Ця URL-адреса або недоступна, або конфліктує з уже встановленим застосунком (застосунками):\n{apps}", + "app_label_deprecated": "Ця команда застаріла! Будь ласка, використовуйте нову команду 'yunohost user permission update' для управління заголовком застосунку.", + "app_make_default_location_already_used": "Неможливо зробити '{app}' типовим застосунком на домені, '{domain}' вже використовується '{other_app}'", + "app_install_script_failed": "Сталася помилка в скрипті встановлення застосунку", + "app_install_failed": "Неможливо встановити {app}: {error}", + "app_install_files_invalid": "Ці файли не можуть бути встановлені", + "app_id_invalid": "Неприпустимий ID застосунку", + "app_full_domain_unavailable": "Вибачте, цей застосунок повинен бути встановлений на власному домені, але інші застосунки вже встановлені на домені '{domain}'. Замість цього ви можете використовувати піддомен, призначений для цього застосунку.", + "app_extraction_failed": "Не вдалося витягти файли встановлення", + "app_change_url_success": "URL-адреса {app} тепер {domain}{path}", + "app_change_url_no_script": "Застосунок '{app_name}' поки не підтримує зміну URL-адрес. Можливо, вам слід оновити його.", + "app_change_url_identical_domains": "Старий і новий domain/url_path збігаються ('{domain}{path}'), нічого робити не треба.", + "app_argument_required": "Аргумент '{name}' необхідний", + "app_argument_password_no_default": "Помилка під час розбору аргументу пароля '{name}': аргумент пароля не може мати типове значення з причин безпеки", + "app_argument_invalid": "Виберіть правильне значення для аргументу '{name}': {error}", + "app_argument_choice_invalid": "Використовуйте один з цих варіантів '{choices}' для аргументу '{name}' замість '{value}'", + "app_already_up_to_date": "{app} має найостаннішу версію", + "app_already_installed_cant_change_url": "Цей застосунок уже встановлено. URL-адреса не може бути змінена тільки цією функцією. Перевірте в `app changeurl`, якщо вона доступна.", + "app_already_installed": "{app} уже встановлено", + "app_action_broke_system": "Ця дія, схоже, порушила роботу наступних важливих служб: {services}", + "app_action_cannot_be_ran_because_required_services_down": "Для виконання цієї дії повинні бути запущені наступні необхідні служби: {services}. Спробуйте перезапустити їх, щоб продовжити (і, можливо, з'ясувати, чому вони не працюють).", + "already_up_to_date": "Нічого не потрібно робити. Все вже актуально.", + "admin_password_too_long": "Будь ласка, виберіть пароль коротше 127 символів", + "admin_password_changed": "Пароль адміністрації було змінено", + "admin_password_change_failed": "Неможливо змінити пароль", + "admin_password": "Пароль адміністрації", + "additional_urls_already_removed": "Додаткова URL-адреса '{url}' вже видалена в додатковій URL-адресі для дозволу '{permission}'", + "additional_urls_already_added": "Додаткова URL-адреса '{url}' вже додана в додаткову URL-адресу для дозволу '{permission}'", + "action_invalid": "Неприпустима дія '{action}'", + "aborting": "Переривання.", + "diagnosis_description_web": "Мережа", + "service_reloaded_or_restarted": "Службу '{service}' була перезавантажено або перезапущено", + "service_reload_or_restart_failed": "Не вдалося перезавантажити або перезапустити службу '{service}' \n\nНедавні журнали служби: {logs}", + "service_restarted": "Службу '{service}' перезапущено", + "service_restart_failed": "Не вдалося запустити службу '{service}' \n\nНедавні журнали служб: {logs}", + "service_reloaded": "Служба '{service}' перезавантажена", + "service_reload_failed": "Не вдалося перезавантажити службу '{service}'\n\nОстанні журнали служби: {logs}", + "service_removed": "Служба '{service}' вилучена", + "service_remove_failed": "Не вдалося видалити службу '{service}'", + "service_regen_conf_is_deprecated": "'yunohost service regen-conf' застарів! Будь ласка, використовуйте 'yunohost tools regen-conf' замість цього.", + "service_enabled": "Служба '{service}' тепер буде автоматично запускатися під час завантаження системи.", + "service_enable_failed": "Неможливо змусити службу '{service}' автоматично запускатися під час завантаження.\n\nНедавні журнали служби: {logs}", + "service_disabled": "Служба '{service}' більше не буде запускатися під час завантаження системи.", + "service_disable_failed": "Неможливо змусити службу '{service}' не запускатися під час завантаження.\n\nОстанні журнали служби: {logs}", + "service_description_yunohost-firewall": "Управляє відкритими і закритими портами з'єднання зі службами", + "service_description_yunohost-api": "Управляє взаємодією між вебінтерфейсом YunoHost і системою", + "service_description_ssh": "Дозволяє віддалено під'єднуватися до сервера через термінал (протокол SSH)", + "service_description_slapd": "Зберігає користувачів, домени і пов'язані з ними дані", + "service_description_rspamd": "Фільтри спаму і інші функції, пов'язані з е-поштою", + "service_description_redis-server": "Спеціалізована база даних, яка використовується для швидкого доступу до даних, черги завдань і зв'язку між програмами", + "service_description_postfix": "Використовується для надсилання та отримання е-пошти", + "service_description_php7.3-fpm": "Запускає застосунки, написані мовою програмування PHP за допомогою NGINX", + "service_description_nginx": "Обслуговує або надає доступ до всіх вебсайтів, розміщених на вашому сервері", + "service_description_mysql": "Зберігає дані застосунків (база даних SQL)", + "service_description_metronome": "Управління обліковими записами миттєвих повідомлень XMPP", + "service_description_fail2ban": "Захист від перебирання (брутфорсу) та інших видів атак з Інтернету", + "service_description_dovecot": "Дозволяє поштовим клієнтам отримувати доступ до електронної пошти (через IMAP і POP3)", + "service_description_dnsmasq": "Обробляє роздільність доменних імен (DNS)", + "service_description_yunomdns": "Дозволяє вам отримати доступ до вашого сервера, використовуючи 'yunohost.local' у вашій локальній мережі", + "service_cmd_exec_failed": "Не вдалося виконати команду '{command}'", + "service_already_stopped": "Службу '{service}' вже зупинено", + "service_already_started": "Службу '{service}' вже запущено", + "service_added": "Службу '{service}' було додано", + "service_add_failed": "Не вдалося додати службу '{service}'", + "server_reboot_confirm": "Сервер буде негайно перезавантажено, ви впевнені? [{answers}]", + "server_reboot": "Сервер буде перезавантажено", + "server_shutdown_confirm": "Сервер буде негайно вимкнено, ви впевнені? [{answers}]", + "server_shutdown": "Сервер буде вимкнено", + "root_password_replaced_by_admin_password": "Ваш кореневий (root) пароль було замінено на пароль адміністратора.", + "root_password_desynchronized": "Пароль адміністратора було змінено, але YunoHost не зміг поширити це на кореневий (root) пароль!", + "restore_system_part_failed": "Не вдалося відновити системний розділ '{part}'", + "restore_running_hooks": "Запуск хуків відновлення…", + "restore_running_app_script": "Відновлення застосунку '{app}'…", + "restore_removing_tmp_dir_failed": "Неможливо видалити старий тимчасовий каталог", + "restore_nothings_done": "Нічого не було відновлено", + "restore_not_enough_disk_space": "Недостатньо місця (простір: {free_space} Б, необхідний простір: {needed_space} Б, межа безпеки: {margin: d} Б)", + "restore_may_be_not_enough_disk_space": "Схоже, у вашій системі недостатньо місця (вільно: {free_space} Б, необхідний простір: {needed_space} Б, межа безпеки: {margin: d} Б)", + "restore_hook_unavailable": "Скрипт відновлення для '{part}' недоступний у вашій системі і в архіві його теж немає", + "restore_failed": "Не вдалося відновити систему", + "restore_extracting": "Витягнення необхідних файлів з архіву…", + "restore_confirm_yunohost_installed": "Ви дійсно хочете відновити вже встановлену систему? [{answers}]", + "restore_complete": "Відновлення завершено", + "restore_cleaning_failed": "Не вдалося очистити тимчасовий каталог відновлення", + "restore_backup_too_old": "Цей архів резервних копій не може бути відновлений, бо він отриманий з дуже старої версії YunoHost.", + "restore_already_installed_apps": "Наступні програми не можуть бути відновлені, тому що вони вже встановлені: {apps}", + "restore_already_installed_app": "Застосунок з ID «{app}» вже встановлено", + "regex_with_only_domain": "Ви не можете використовувати regex для домену, тільки для шляху", + "regex_incompatible_with_tile": "/! \\ Packagers! Дозвіл '{permission}' має значення show_tile 'true', тому ви не можете визначити regex URL в якості основної URL", + "regenconf_need_to_explicitly_specify_ssh": "Конфігурація ssh була змінена вручну, але вам потрібно явно вказати категорію 'ssh' з --force, щоб застосувати зміни.", + "regenconf_pending_applying": "Застосування очікує конфігурації для категорії '{category}'...", + "regenconf_failed": "Не вдалося відновити конфігурацію для категорії (категорій): {categories}", + "regenconf_dry_pending_applying": "Перевірка очікує конфігурації, яка була б застосована для категорії '{category}'…", + "regenconf_would_be_updated": "Конфігурація була б оновлена для категорії '{category}'", + "regenconf_updated": "Конфігурація оновлена для категорії '{category}'", + "regenconf_up_to_date": "Конфігурація вже оновлена для категорії '{category}'", + "regenconf_now_managed_by_yunohost": "Конфігураційний файл '{conf}' тепер управляється YunoHost (категорія {category}).", + "regenconf_file_updated": "Конфігураційний файл '{conf}' оновлено", + "regenconf_file_removed": "Конфігураційний файл '{conf}' видалено", + "regenconf_file_remove_failed": "Неможливо видалити файл конфігурації '{conf}'", + "regenconf_file_manually_removed": "Конфігураційний файл '{conf}' було видалено вручну і не буде створено", + "regenconf_file_manually_modified": "Конфігураційний файл '{conf}' було змінено вручну і не буде оновлено", + "regenconf_file_kept_back": "Очікувалося видалення конфігураційного файлу '{conf}' за допомогою regen-conf (категорія {category}), але його було збережено.", + "regenconf_file_copy_failed": "Не вдалося скопіювати новий файл конфігурації '{new}' в '{conf}'", + "regenconf_file_backed_up": "Конфігураційний файл '{conf}' збережено в '{backup}'", + "postinstall_low_rootfsspace": "Загальне місце кореневої файлової системи становить менше 10 ГБ, що викликає занепокоєння! Швидше за все, дисковий простір закінчиться дуже скоро! Рекомендовано мати не менше 16 ГБ для кореневої файлової системи. Якщо ви хочете встановити YunoHost попри це попередження, повторно запустіть післявстановлення з параметром --force-diskspace", + "port_already_opened": "Порт {port} вже відкрито для з'єднань {ip_version}", + "port_already_closed": "Порт {port} вже закрито для з'єднань {ip_version}", + "permission_require_account": "Дозвіл {permission} має зміст тільки для користувачів, що мають обліковий запис, і тому не може бути увімкненим для відвідувачів.", + "permission_protected": "Дозвіл {permission} захищено. Ви не можете додавати або вилучати групу відвідувачів до/з цього дозволу.", + "permission_updated": "Дозвіл '{permission}' оновлено", + "permission_update_failed": "Не вдалося оновити дозвіл '{permission}': {error}", + "permission_not_found": "Дозвіл '{permission}' не знайдено", + "permission_deletion_failed": "Не вдалося видалити дозвіл '{permission}': {error}", + "permission_deleted": "Дозвіл '{permission}' видалено", + "permission_cant_add_to_all_users": "Дозвіл {permission} не може бути додано всім користувачам.", + "permission_currently_allowed_for_all_users": "Наразі цей дозвіл надається всім користувачам на додачу до інших груп. Імовірно, вам потрібно або видалити дозвіл 'all_users', або видалити інші групи, яким його зараз надано.", + "permission_creation_failed": "Не вдалося створити дозвіл '{permission}': {error}", + "permission_created": "Дозвіл '{permission}' створено", + "permission_cannot_remove_main": "Вилучення основного дозволу заборонене", + "permission_already_up_to_date": "Дозвіл не було оновлено, тому що запити на додавання/вилучення вже відповідають поточному стану.", + "permission_already_exist": "Дозвіл '{permission}' вже існує", + "permission_already_disallowed": "Група '{group}' вже має вимкнений дозвіл '{permission}'", + "permission_already_allowed": "Група '{group}' вже має увімкнений дозвіл '{permission}'", + "pattern_password_app": "На жаль, паролі не можуть містити такі символи: {forbidden_chars}", + "pattern_username": "Має складатися тільки з букв і цифр в нижньому регістрі і символів підкреслення", + "pattern_port_or_range": "Має бути припустимий номер порту (наприклад, 0-65535) або діапазон портів (наприклад, 100:200)", + "pattern_password": "Має бути довжиною не менше 3 символів", + "pattern_mailbox_quota": "Має бути розмір з суфіксом b/k/M/G/T або 0, щоб не мати квоти", + "pattern_lastname": "Має бути припустиме прізвище", + "pattern_firstname": "Має бути припустиме ім'я", + "pattern_email": "Має бути припустима адреса е-пошти, без символу '+' (наприклад, someone@example.com)", + "pattern_email_forward": "Має бути припустима адреса е-пошти, символ '+' приймається (наприклад, someone+tag@example.com)", + "pattern_domain": "Має бути припустиме доменне ім'я (наприклад, my-domain.org)", + "pattern_backup_archive_name": "Має бути правильна назва файлу, що містить не більше 30 символів, тільки букви і цифри і символи -_", + "password_too_simple_4": "Пароль має складатися не менше ніж з 12 символів і містити цифри, великі та малі символи і спеціальні символи", + "password_too_simple_3": "Пароль має складатися не менше ніж з 8 символів і містити цифри, великі та малі символи і спеціальні символи", + "password_too_simple_2": "Пароль має складатися не менше ніж з 8 символів і містити цифри, великі та малі символи", + "password_too_simple_1": "Пароль має складатися не менше ніж з 8 символів", + "password_listed": "Цей пароль входить в число найбільш часто використовуваних паролів у світі. Будь ласка, виберіть щось неповторюваніше.", + "packages_upgrade_failed": "Не вдалося оновити всі пакети", + "operation_interrupted": "Операція була вручну перервана?", + "invalid_number": "Має бути числом", + "not_enough_disk_space": "Недостатньо вільного місця на '{path}'", + "migrations_to_be_ran_manually": "Міграція {id} повинна бути запущена вручну. Будь ласка, перейдіть в розділ Засоби → Міграції на сторінці вебадміністрації або виконайте команду `yunohost tools migrations run`.", + "migrations_success_forward": "Міграцію {id} завершено", + "migrations_skip_migration": "Пропускання міграції {id}...", + "migrations_running_forward": "Виконання міграції {id}...", + "migrations_pending_cant_rerun": "Наступні міграції ще не завершені, тому не можуть бути запущені знову: {ids}", + "migrations_not_pending_cant_skip": "Наступні міграції не очікують виконання, тому не можуть бути пропущені: {ids}", + "migrations_no_such_migration": "Не існує міграції під назвою '{id}'", + "migrations_no_migrations_to_run": "Немає міграцій для запуску", + "migrations_need_to_accept_disclaimer": "Щоб запустити міграцію {id}, ви повинні прийняти наступну відмову від відповідальності:\n---\n{disclaimer}\n---\nЯкщо ви згодні запустити міграцію, будь ласка, повторіть команду з опцією '--accept-disclaimer'.", + "migrations_must_provide_explicit_targets": "Ви повинні вказати явні цілі при використанні '--skip' або '--force-rerun'", + "migrations_migration_has_failed": "Міграція {id} не завершена, перериваємо. Помилка: {exception}", + "migrations_loading_migration": "Завантаження міграції {id}...", + "migrations_list_conflict_pending_done": "Ви не можете одночасно використовувати '--previous' і '--done'.", + "migrations_exclusive_options": "'--auto', '--skip', і '--force-rerun' є взаємовиключними опціями.", + "migrations_failed_to_load_migration": "Не вдалося завантажити міграцію {id}: {error}", + "migrations_dependencies_not_satisfied": "Запустіть ці міграції: '{dependencies_id}', перед міграцією {id}.", + "migrations_cant_reach_migration_file": "Не вдалося отримати доступ до файлів міграцій за шляхом '%s'", + "migrations_already_ran": "Наступні міграції вже виконано: {ids}", + "migration_0019_slapd_config_will_be_overwritten": "Схоже, що ви вручну відредагували конфігурацію slapd. Для цього критичного переходу YunoHost повинен примусово оновити конфігурацію slapd. Оригінальні файли будуть збережені в {conf_backup_folder}.", + "migration_0019_add_new_attributes_in_ldap": "Додавання нових атрибутів для дозволів у базі даних LDAP", + "migration_0018_failed_to_reset_legacy_rules": "Не вдалося скинути спадкові правила iptables: {error}", + "migration_0018_failed_to_migrate_iptables_rules": "Не вдалося перенести спадкові правила iptables в nftables: {error}", + "migration_0017_not_enough_space": "Звільніть достатньо місця в {path} для запуску міграції.", + "migration_0017_postgresql_11_not_installed": "PostgreSQL 9.6 встановлено, але не PostgreSQL 11‽ Можливо, у вашій системі відбулося щось дивне :(...", + "migration_0017_postgresql_96_not_installed": "PostgreSQL не встановлено у вашій системі. Нічого не потрібно робити.", + "migration_0015_weak_certs": "Було виявлено, що такі сертифікати все ще використовують слабкі алгоритми підпису і повинні бути оновлені для сумісності з наступною версією nginx: {certs}", + "migration_0015_cleaning_up": "Очищення кеш-пам'яті і пакетів, які більше не потрібні...", + "migration_0015_specific_upgrade": "Початок оновлення системних пакетів, які повинні бути оновлені незалежно...", + "migration_0015_modified_files": "Зверніть увагу, що такі файли були змінені вручну і можуть бути перезаписані після оновлення: {manually_modified_files}", + "migration_0015_problematic_apps_warning": "Зверніть увагу, що були виявлені наступні, можливо, проблемні встановлені застосунки. Схоже, що вони не були встановлені з каталогу застосунків YunoHost або не зазначені як «робочі». Отже, не можна гарантувати, що вони будуть працювати після оновлення: {problematic_apps}", + "migration_0015_general_warning": "Будь ласка, зверніть увагу, що ця міграція є делікатною операцією. Команда YunoHost зробила все можливе, щоб перевірити і протестувати її, але міграція все ще може порушити частина системи або її застосунків.\n\nТому рекомендовано:\n - Виконати резервне копіювання всіх важливих даних або застосунків. Подробиці на сайті https://yunohost.org/backup; \n - Наберіться терпіння після запуску міграції: В залежності від вашого з'єднання з Інтернетом і апаратного забезпечення, оновлення може зайняти до декількох годин.", + "migration_0015_system_not_fully_up_to_date": "Ваша система не повністю оновлена. Будь ласка, виконайте регулярне оновлення перед запуском міграції на Buster.", + "migration_0015_not_enough_free_space": "Вільного місця в /var/ досить мало! У вас повинно бути не менше 1 ГБ вільного місця, щоб запустити цю міграцію.", + "migration_0015_not_stretch": "Поточний дистрибутив Debian не є Stretch!", + "migration_0015_yunohost_upgrade": "Початок оновлення ядра YunoHost...", + "migration_0015_still_on_stretch_after_main_upgrade": "Щось пішло не так під час основного оновлення, система, схоже, все ще знаходиться на Debian Stretch", + "migration_0015_main_upgrade": "Початок основного оновлення...", + "migration_0015_patching_sources_list": "Виправлення sources.lists...", + "migration_0015_start": "Початок міграції на Buster", + "migration_update_LDAP_schema": "Оновлення схеми LDAP...", + "migration_ldap_rollback_success": "Система відкотилася.", + "migration_ldap_migration_failed_trying_to_rollback": "Не вдалося виконати міграцію... Пробуємо відкотити систему.", + "migration_ldap_can_not_backup_before_migration": "Не вдалося завершити резервне копіювання системи перед невдалою міграцією. Помилка: {error}", + "migration_ldap_backup_before_migration": "Створення резервної копії бази даних LDAP і налаштування застосунків перед фактичною міграцією.", + "migration_description_0020_ssh_sftp_permissions": "Додавання підтримки дозволів SSH і SFTP", + "migration_description_0019_extend_permissions_features": "Розширення/переробка системи управління дозволами застосунків", + "migration_description_0018_xtable_to_nftable": "Перенесення старих правил мережевого трафіку в нову систему nftable", + "migration_description_0017_postgresql_9p6_to_11": "Перенесення баз даних з PostgreSQL 9.6 на 11", + "migration_description_0016_php70_to_php73_pools": "Перенесення php7.0-fpm 'pool' conf файлів на php7.3", + "migration_description_0015_migrate_to_buster": "Оновлення системи до Debian Buster і YunoHost 4.x", + "migrating_legacy_permission_settings": "Перенесення спадкових налаштувань дозволів...", + "main_domain_changed": "Основний домен було змінено", + "main_domain_change_failed": "Неможливо змінити основний домен", + "mail_unavailable": "Ця е-пошта зарезервована і буде автоматично виділена найпершому користувачеві", + "mailbox_used_space_dovecot_down": "Поштова служба Dovecot повинна бути запущена, якщо ви хочете отримати використане місце в поштовій скриньці", + "mailbox_disabled": "Е-пошта вимкнена для користувача {user}", + "mail_forward_remove_failed": "Не вдалося видалити переадресацію електронної пошти '{mail}'", + "mail_domain_unknown": "Неправильна адреса е-пошти для домену '{domain}'. Будь ласка, використовуйте домен, що адмініструється цим сервером.", + "mail_alias_remove_failed": "Не вдалося видалити аліас електронної пошти '{mail}'", + "log_tools_reboot": "Перезавантаження сервера", + "log_tools_shutdown": "Вимикання сервера", + "log_tools_upgrade": "Оновлення системних пакетів", + "log_tools_postinstall": "Післявстановлення сервера YunoHost", + "log_tools_migrations_migrate_forward": "Запущено міграції", + "log_domain_main_domain": "Зроблено '{}' основним доменом", + "log_user_permission_reset": "Скинуто дозвіл «{}»", + "log_user_permission_update": "Оновлено доступи для дозволу '{}'", + "log_user_update": "Оновлено відомості для користувача '{}'", + "log_user_group_update": "Оновлено групу '{}'", + "log_user_group_delete": "Видалено групу «{}»", + "log_user_group_create": "Створено групу '{}'", + "log_user_delete": "Видалення користувача '{}'", + "log_user_create": "Додавання користувача '{}'", + "log_regen_conf": "Перестворення системних конфігурацій '{}'", + "log_letsencrypt_cert_renew": "Оновлення сертифікату Let's Encrypt на домені '{}'", + "log_selfsigned_cert_install": "Установлення самопідписаного сертифікату на домені '{}'", + "log_permission_url": "Оновлення URL, пов'язаногл з дозволом '{}'", + "log_permission_delete": "Видалення дозволу '{}'", + "log_permission_create": "Створення дозволу '{}'", + "log_letsencrypt_cert_install": "Установлення сертифікату Let's Encrypt на домен '{}'", + "log_dyndns_update": "Оновлення IP, пов'язаного з вашим піддоменом YunoHost '{}'", + "log_dyndns_subscribe": "Підписка на піддомен YunoHost '{}'", + "log_domain_remove": "Вилучення домену '{}' з конфігурації системи", + "log_domain_add": "Додавання домену '{}' в конфігурацію системи", + "log_remove_on_failed_install": "Вилучення '{}' після невдалого встановлення", + "log_remove_on_failed_restore": "Вилучення '{}' після невдалого відновлення з резервного архіву", + "log_backup_restore_app": "Відновлення '{}' з архіву резервних копій", + "log_backup_restore_system": "Відновлення системи з резервного архіву", + "log_backup_create": "Створення резервного архіву", + "log_available_on_yunopaste": "Цей журнал тепер доступний за посиланням {url}", + "log_app_action_run": "Запуск дії застосунку «{}»", + "log_app_makedefault": "Застосунок '{}' зроблено типовим", + "log_app_upgrade": "Оновлення застосунку '{}'", + "log_app_remove": "Вилучення застосунку '{}'", + "log_app_install": "Установлення застосунку '{}'", + "log_app_change_url": "Змінення URL-адреси застосунку «{}»", + "log_operation_unit_unclosed_properly": "Блок операцій не був закритий належним чином", + "log_does_exists": "Немає журналу операцій з назвою '{log}', використовуйте 'yunohost log list', щоб подивитися всі доступні журнали операцій", + "log_help_to_get_failed_log": "Операція '{desc}' не може бути завершена. Будь ласка, поділіться повним журналом цієї операції, використовуючи команду 'yunohost log share {name}', щоб отримати допомогу", + "log_link_to_failed_log": "Не вдалося завершити операцію '{desc}'. Будь ласка, надайте повний журнал цієї операції, натиснувши тут, щоб отримати допомогу", + "log_help_to_get_log": "Щоб переглянути журнал операції '{desc}', використовуйте команду 'yunohost log show {name}'", + "log_link_to_log": "Повний журнал цієї операції: '{desc}'", + "log_corrupted_md_file": "Файл метаданих YAML, пов'язаний з журналами, пошкоджено: '{md_file}\nПомилка: {error}'", + "iptables_unavailable": "Ви не можете грати з iptables тут. Ви перебуваєте або в контейнері, або ваше ядро не підтримує його", + "ip6tables_unavailable": "Ви не можете грати з ip6tables тут. Ви перебуваєте або в контейнері, або ваше ядро не підтримує його", + "invalid_regex": "Неприпустимий regex: '{regex}'", + "installation_complete": "Установлення завершено", + "hook_name_unknown": "Невідома назва хука '{name}'", + "hook_list_by_invalid": "Цю властивість не може бути використано для перерахування хуків (гачків)", + "hook_json_return_error": "Не вдалося розпізнати повернення з хука {path}. Помилка: {msg}. Необроблений контент: {raw_content}", + "hook_exec_not_terminated": "Скрипт не завершився належним чином: {path}", + "hook_exec_failed": "Не вдалося запустити скрипт: {path}", + "group_user_not_in_group": "Користувач {user} не входить в групу {group}", + "group_user_already_in_group": "Користувач {user} вже в групі {group}", + "group_update_failed": "Не вдалося оновити групу '{group}': {error}", + "group_updated": "Групу '{group}' оновлено", + "group_unknown": "Група '{group}' невідома", + "group_deletion_failed": "Не вдалося видалити групу '{group}': {error}", + "group_deleted": "Групу '{group}' видалено", + "group_cannot_be_deleted": "Група {group} не може бути видалена вручну.", + "group_cannot_edit_primary_group": "Група '{group}' не може бути відредагована вручну. Це основна група, призначена тільки для одного конкретного користувача.", + "group_cannot_edit_visitors": "Група 'visitors' не може бути відредагована вручну. Це спеціальна група, що представляє анонімних відвідувачів", + "group_cannot_edit_all_users": "Група 'all_users' не може бути відредагована вручну. Це спеціальна група, призначена для всіх користувачів, зареєстрованих в YunoHost", + "group_creation_failed": "Не вдалося створити групу '{group}': {error}", + "group_created": "Групу '{group}' створено", + "group_already_exist_on_system_but_removing_it": "Група {group} вже існує в групах системи, але YunoHost вилучить її...", + "group_already_exist_on_system": "Група {group} вже існує в групах системи", + "group_already_exist": "Група {group} вже існує", + "good_practices_about_user_password": "Зараз ви збираєтеся поставити новий пароль користувача. Пароль повинен складатися не менше ніж з 8 символів, але хорошою практикою є використання більш довгого пароля (тобто гасла) і/або використання різних символів (великих, малих, цифр і спеціальних символів).", + "good_practices_about_admin_password": "Зараз ви збираєтеся поставити новий пароль адміністрації. Пароль повинен складатися не менше ніж з 8 символів, але хорошою практикою є використання більш довгого пароля (тобто парольного гасла) і/або використання різних символів (великих, малих, цифр і спеціальних символів).", + "global_settings_unknown_type": "Несподівана ситуація, налаштування {setting} має тип {unknown_type}, але це не тип, підтримуваний системою.", + "global_settings_setting_backup_compress_tar_archives": "При створенні нових резервних копій стискати архіви (.tar.gz) замість нестислих архівів (.tar). NB: вмикання цієї опції означає створення легших архівів резервних копій, але початкова процедура резервного копіювання буде значно довшою і важчою для CPU.", + "global_settings_setting_security_webadmin_allowlist": "IP-адреси, яким дозволений доступ до вебадміністрації. Через кому.", + "global_settings_setting_security_webadmin_allowlist_enabled": "Дозволити доступ до вебадміністрації тільки деяким IP-адресам.", + "global_settings_setting_smtp_relay_password": "Пароль хоста SMTP-ретрансляції", + "global_settings_setting_smtp_relay_user": "Обліковий запис користувача SMTP-ретрансляції", + "global_settings_setting_smtp_relay_port": "Порт SMTP-ретрансляції", + "global_settings_setting_smtp_relay_host": "Хост SMTP-ретрансляції, який буде використовуватися для надсилання е-пошти замість цього зразка Yunohost. Корисно, якщо ви знаходитеся в одній із цих ситуацій: ваш 25 порт заблокований вашим провайдером або VPS провайдером, у вас є житловий IP в списку DUHL, ви не можете налаштувати зворотний DNS або цей сервер не доступний безпосередньо в Інтернеті і ви хочете використовувати інший сервер для відправки електронних листів.", + "global_settings_setting_smtp_allow_ipv6": "Дозволити використання IPv6 для отримання і надсилання листів е-пошти", + "global_settings_setting_ssowat_panel_overlay_enabled": "Увімкнути накладення панелі SSOwat", + "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Дозволити використання (застарілого) ключа DSA для конфігурації демона SSH", + "global_settings_unknown_setting_from_settings_file": "Невідомий ключ в налаштуваннях: '{setting_key}', відхиліть його і збережіть у /etc/yunohost/settings-unknown.json", + "global_settings_setting_security_ssh_port": "SSH-порт", + "global_settings_setting_security_postfix_compatibility": "Компроміс між сумісністю і безпекою для сервера Postfix. Впливає на шифри (і інші аспекти, пов'язані з безпекою)", + "global_settings_setting_security_ssh_compatibility": "Компроміс між сумісністю і безпекою для SSH-сервера. Впливає на шифри (і інші аспекти, пов'язані з безпекою)", + "global_settings_setting_security_password_user_strength": "Надійність пароля користувача", + "global_settings_setting_security_password_admin_strength": "Надійність пароля адміністратора", + "global_settings_setting_security_nginx_compatibility": "Компроміс між сумісністю і безпекою для вебсервера NGINX. Впливає на шифри (і інші аспекти, пов'язані з безпекою)", + "global_settings_setting_pop3_enabled": "Увімкніть протокол POP3 для поштового сервера", + "global_settings_reset_success": "Попередні налаштування тепер збережені в {path}", + "global_settings_key_doesnt_exists": "Ключ '{settings_key}' не існує в глобальних налаштуваннях, ви можете побачити всі доступні ключі, виконавши команду 'yunohost settings list'", + "global_settings_cant_write_settings": "Неможливо зберегти файл налаштувань, причина: {reason}", + "global_settings_cant_serialize_settings": "Не вдалося серіалізувати дані налаштувань, причина: {reason}", + "global_settings_cant_open_settings": "Не вдалося відкрити файл налаштувань, причина: {reason}", + "global_settings_bad_type_for_setting": "Поганий тип для налаштування {setting}, отримано {received_type}, а очікується {expected_type}", + "global_settings_bad_choice_for_enum": "Поганий вибір для налаштування {setting}, отримано '{choice}', але доступні наступні варіанти: {available_choices}", + "firewall_rules_cmd_failed": "Деякі команди правил фаєрвола не спрацювали. Подробиці в журналі.", + "firewall_reloaded": "Фаєрвол перезавантажено", + "firewall_reload_failed": "Не вдалося перезавантажити фаєрвол", + "file_does_not_exist": "Файл {path} не існує.", + "field_invalid": "Неприпустиме поле '{}'", + "experimental_feature": "Попередження: Ця функція є експериментальною і не вважається стабільною, ви не повинні використовувати її, якщо не знаєте, що робите.", + "extracting": "Витягнення...", + "dyndns_unavailable": "Домен '{domain}' недоступний.", + "dyndns_domain_not_provided": "DynDNS провайдер {provider} не може надати домен {domain}.", + "dyndns_registration_failed": "Не вдалося зареєструвати домен DynDNS: {error}", + "dyndns_registered": "Домен DynDNS зареєстровано", + "dyndns_provider_unreachable": "Неможливо зв'язатися з провайдером DynDNS {provider}: або ваш YunoHost неправильно під'єднано до Інтернету, або сервер dynette не працює.", + "dyndns_no_domain_registered": "Домен не зареєстровано в DynDNS", + "dyndns_key_not_found": "DNS-ключ для домену не знайдено", + "dyndns_key_generating": "Утворення DNS-ключа... Це може зайняти деякий час.", + "dyndns_ip_updated": "Вашу IP-адресу в DynDNS оновлено", + "dyndns_ip_update_failed": "Не вдалося оновити IP-адресу в DynDNS", + "dyndns_could_not_check_available": "Не вдалося перевірити, чи {domain} доступний у {provider}.", + "dyndns_could_not_check_provide": "Не вдалося перевірити, чи може {provider} надати {domain}.", + "dpkg_lock_not_available": "Ця команда не може бути виконана прямо зараз, тому що інша програма, схоже, використовує блокування dpkg (системного менеджера пакетів)", + "dpkg_is_broken": "Ви не можете зробити це прямо зараз, тому що dpkg/APT (системні менеджери пакетів), схоже, знаходяться в зламаному стані... Ви можете спробувати вирішити цю проблему, під'єднавшись через SSH і виконавши `sudo apt install --fix-broken` та/або `sudo dpkg --configure -a`.", + "downloading": "Завантаження…", + "done": "Готово", + "domains_available": "Доступні домени:", + "domain_name_unknown": "Домен '{domain}' невідомий", + "domain_uninstall_app_first": "Ці застосунки все ще встановлені на вашому домені:\n{apps}\n\nВидаліть їх за допомогою 'yunohost app remove the_app_id' або перемістіть їх на інший домен за допомогою 'yunohost app change-url the_app_id', перш ніж приступити до вилучення домену", + "domain_remove_confirm_apps_removal": "Вилучення цього домену призведе до вилучення таких застосунків:\n{apps}\n\nВи впевнені, що хочете це зробити? [{answers}]", + "domain_hostname_failed": "Неможливо встановити нову назву хоста. Це може викликати проблеми в подальшому (можливо, все буде в порядку).", + "domain_exists": "Цей домен уже існує", + "domain_dyndns_root_unknown": "Невідомий кореневий домен DynDNS", + "domain_dyndns_already_subscribed": "Ви вже підписалися на домен DynDNS", + "domain_dns_conf_is_just_a_recommendation": "Ця команда показує *рекомендовану* конфігурацію. Насправді вона не встановлює конфігурацію DNS для вас. Ви самі повинні налаштувати свою зону DNS у реєстратора відповідно до цих рекомендацій.", + "domain_deletion_failed": "Неможливо видалити домен {domain}: {error}", + "domain_deleted": "Домен видалено", + "domain_creation_failed": "Неможливо створити домен {domain}: {error}", + "domain_created": "Домен створено", + "domain_cert_gen_failed": "Не вдалося утворити сертифікат", + "domain_cannot_remove_main_add_new_one": "Ви не можете видалити '{domain}', так як це основний домен і ваш єдиний домен, вам потрібно спочатку додати інший домен за допомогою 'yunohost domain add ', потім встановити його як основний домен за допомогою 'yunohost domain main-domain -n ' і потім ви можете вилучити домен '{domain}' за допомогою 'yunohost domain remove {domain}'.'", + "domain_cannot_add_xmpp_upload": "Ви не можете додавати домени, що починаються з 'xmpp-upload.'. Таку назву зарезервовано для функції XMPP upload, вбудованої в YunoHost.", + "domain_cannot_remove_main": "Ви не можете вилучити '{domain}', бо це основний домен, спочатку вам потрібно встановити інший домен в якості основного за допомогою 'yunohost domain main-domain -n '; ось список доменів-кандидатів: {other_domains}", + "disk_space_not_sufficient_update": "Недостатньо місця на диску для оновлення цього застосунку", + "disk_space_not_sufficient_install": "Недостатньо місця на диску для встановлення цього застосунку", + "diagnosis_sshd_config_inconsistent_details": "Будь ласка, виконайте команду yunohost settings set security.ssh.port -v YOUR_SSH_PORT, щоб визначити порт SSH, і перевіртеyunohost tools regen-conf ssh --dry-run --with-diff і yunohost tools regen-conf ssh --force, щоб скинути ваш конфіг на рекомендований YunoHost.", + "diagnosis_sshd_config_inconsistent": "Схоже, що порт SSH був уручну змінений в /etc/ssh/sshd_config. Починаючи з версії YunoHost 4.2, доступний новий глобальний параметр 'security.ssh.port', що дозволяє уникнути ручного редагування конфігурації.", + "diagnosis_sshd_config_insecure": "Схоже, що конфігурація SSH була змінена вручну і є небезпечною, оскільки не містить директив 'AllowGroups' або 'AllowUsers' для обмеження доступу авторизованих користувачів.", + "diagnosis_processes_killed_by_oom_reaper": "Деякі процеси було недавно вбито системою через брак пам'яті. Зазвичай це є симптомом нестачі пам'яті в системі або процесу, який з'їв дуже багато пам'яті. Зведення убитих процесів:\n{kills_summary}", + "diagnosis_never_ran_yet": "Схоже, що цей сервер був налаштований недавно, і поки немає звіту про діагностику. Вам слід почати з повної діагностики, або з вебадміністрації, або використовуючи 'yunohost diagnosis run' з командного рядка.", + "diagnosis_unknown_categories": "Наступні категорії невідомі: {categories}", + "diagnosis_http_nginx_conf_not_up_to_date_details": "Щоб виправити становище, перевірте різницю за допомогою командного рядка, використовуючи yunohost tools regen-conf nginx --dry-run --with-diff, і якщо все в порядку, застосуйте зміни за допомогою команди yunohost tools regen-conf nginx --force.", + "diagnosis_http_nginx_conf_not_up_to_date": "Схоже, що конфігурація nginx цього домену була змінена вручну, що не дозволяє YunoHost визначити, чи доступний він по HTTP.", + "diagnosis_http_partially_unreachable": "Домен {domain} здається недоступним по HTTP поза локальною мережею в IPv{failed}, хоча він працює в IPv{passed}.", + "diagnosis_http_unreachable": "Домен {domain} здається недоступним через HTTP поза локальною мережею.", + "diagnosis_http_bad_status_code": "Схоже, що замість вашого сервера відповіла інша машина (можливо, ваш маршрутизатор).
1. Найбільш поширеною причиною цієї проблеми є те, що порт 80 (і 443) неправильно перенаправлено на ваш сервер .
2. На більш складних установках: переконайтеся, що немає фаєрвола або зворотного проксі.", + "diagnosis_http_connection_error": "Помилка з'єднання: не вдалося з'єднатися із запитуваним доменом, швидше за все, він недоступний.", + "diagnosis_http_timeout": "При спробі зв'язатися з вашим сервером ззовні стався тайм-аут. Він здається недоступним.
1. Найбільш поширеною причиною цієї проблеми є те, що порт 80 (і 443) неправильно перенаправлено на ваш сервер .
2. Ви також повинні переконатися, що служба nginx запущена
3.На більш складних установках: переконайтеся, що немає фаєрвола або зворотного проксі.", + "diagnosis_http_ok": "Домен {domain} доступний по HTTP поза локальною мережею.", + "diagnosis_http_localdomain": "Домен {domain} з .local TLD не може бути доступний ззовні локальної мережі.", + "diagnosis_http_could_not_diagnose_details": "Помилка: {error}", + "diagnosis_http_could_not_diagnose": "Не вдалося діагностувати досяжність доменів ззовні в IPv{ipversion}.", + "diagnosis_http_hairpinning_issue_details": "Можливо, це пов'язано з коробкою/маршрутизатором вашого інтернет-провайдера. В результаті, люди ззовні вашої локальної мережі зможуть отримати доступ до вашого сервера, як і очікувалося, але не люди зсередини локальної мережі (як ви, ймовірно?) При використанні доменного імені або глобального IP. Можливо, ви зможете поліпшити ситуацію, глянувши https://yunohost.org/dns_local_network ", + "diagnosis_http_hairpinning_issue": "Схоже, що у вашій локальній мережі не увімкнено шпилькування (hairpinning).", + "diagnosis_ports_forwarding_tip": "Щоб вирішити цю проблему, вам, швидше за все, потрібно налаштувати пересилання портів на вашому інтернет-маршрутизаторі, як описано в https://yunohost.org/isp_box_config", + "diagnosis_ports_needed_by": "Відкриття цього порту необхідне для функцій {category} (служба {service})", + "diagnosis_ports_ok": "Порт {port} доступний ззовні.", + "diagnosis_ports_partially_unreachable": "Порт {port} не доступний ззовні в IPv{failed}.", + "diagnosis_ports_unreachable": "Порт {port} недоступний ззовні.", + "diagnosis_ports_could_not_diagnose_details": "Помилка: {error}", + "diagnosis_ports_could_not_diagnose": "Не вдалося діагностувати досяжність портів ззовні в IPv{ipversion}.", + "diagnosis_description_regenconf": "Конфігурації системи", + "diagnosis_description_mail": "Е-пошта", + "diagnosis_description_ports": "Виявлення портів", + "diagnosis_description_systemresources": "Системні ресурси", + "diagnosis_description_services": "Перевірка стану служб", + "diagnosis_description_dnsrecords": "DNS-записи", + "diagnosis_description_ip": "Інтернет-з'єднання", + "diagnosis_description_basesystem": "Основна система", + "diagnosis_security_vulnerable_to_meltdown_details": "Щоб виправити це, вам слід оновити систему і перезавантажитися, щоб завантажити нове ядро Linux (або звернутися до вашого серверного провайдера, якщо це не спрацює). Докладніше див. на сайті https://meltdownattack.com/.", + "diagnosis_security_vulnerable_to_meltdown": "Схоже, що ви вразливі до критичної вразливості безпеки Meltdown", + "diagnosis_rootfstotalspace_critical": "Коренева файлова система має тільки {space}, що дуже тривожно! Скоріше за все, дисковий простір закінчиться дуже скоро! Рекомендовано мати не менше 16 ГБ для кореневої файлової системи.", + "diagnosis_rootfstotalspace_warning": "Коренева файлова система має тільки {space}. Можливо це нормально, але будьте обережні, тому що в кінцевому підсумку дисковий простір може швидко закінчитися... Рекомендовано мати не менше 16 ГБ для кореневої файлової системи.", + "diagnosis_regenconf_manually_modified_details": "Можливо це нормально, якщо ви знаєте, що робите! YunoHost перестане оновлювати цей файл автоматично... Але врахуйте, що оновлення YunoHost можуть містити важливі рекомендовані зміни. Якщо ви хочете, ви можете перевірити відмінності за допомогою команди yunohost tools regen-conf {category} --dry-run --with-diff і примусово повернути рекомендовану конфігурацію за допомогою команди yunohost tools regen-conf {category} --force", + "diagnosis_regenconf_manually_modified": "Конфігураційний файл {file}, схоже, було змінено вручну.", + "diagnosis_regenconf_allgood": "Усі конфігураційні файли відповідають рекомендованій конфігурації!", + "diagnosis_mail_queue_too_big": "Занадто багато відкладених листів у поштовій черзі (листів: {nb_pending})", + "diagnosis_mail_queue_unavailable_details": "Помилка: {error}", + "diagnosis_mail_queue_unavailable": "Неможливо дізнатися кількість очікувальних листів у черзі", + "diagnosis_mail_queue_ok": "Відкладених електронних листів у поштових чергах: {nb_pending}", + "diagnosis_mail_blacklist_website": "Після визначення причини, з якої ви потрапили в чорний список, і її усунення, ви можете попросити видалити ваш IP або домен на {blacklist_website}", + "diagnosis_mail_blacklist_reason": "Причина внесення в чорний список: {reason}", + "diagnosis_mail_blacklist_listed_by": "Ваш IP або домен {item} знаходиться в чорному списку {blacklist_name}", + "diagnosis_mail_blacklist_ok": "IP-адреси і домени, які використовуються цим сервером, не внесені в чорний список", + "diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "Поточний зворотний DNS:{rdns_domain}
Очікуване значення: {ehlo_domain}", + "diagnosis_mail_fcrdns_different_from_ehlo_domain": "Зворотний DNS неправильно налаштований в IPv{ipversion}. Деякі електронні листи можуть бути не доставлені або можуть бути відзначені як спам.", + "diagnosis_mail_fcrdns_nok_alternatives_6": "Деякі провайдери не дозволять вам налаштувати зворотний DNS (або їх функція може бути зламана...). Якщо ваш зворотний DNS правильно налаштований для IPv4, ви можете спробувати вимкнути використання IPv6 при надсиланні листів, виконавши команду yunohost settings set smtp.allow_ipv6 -v off. Примітка: останнє рішення означає, що ви не зможете надсилати або отримувати електронні листи з нечисленних серверів, що використовують тільки IPv6.", + "diagnosis_mail_fcrdns_nok_alternatives_4": "Деякі провайдери не дозволять вам налаштувати зворотний DNS (або їх функція може бути зламана...). Якщо ви відчуваєте проблеми через це, розгляньте наступні рішення:
- Деякі провайдери надають альтернативу використання ретранслятора поштового сервера, хоча це має на увазі, що ретранслятор зможе шпигувати за вашим поштовим трафіком.
- Альтернативою для захисту конфіденційності є використання VPN *з виділеним загальнодоступним IP* для обходу подібних обмежень. Дивіться https://yunohost.org/#/vpn_advantage
- Або можна переключитися на іншого провайдера", + "diagnosis_mail_fcrdns_nok_details": "Спочатку спробуйте налаштувати зворотний DNS з {ehlo_domain} в інтерфейсі вашого інтернет-маршрутизатора або в інтерфейсі вашого хостинг-провайдера. (Деякі хостинг-провайдери можуть вимагати, щоб ви відправили їм запит у підтримку для цього).", + "diagnosis_mail_fcrdns_dns_missing": "У IPv{ipversion} не визначений зворотний DNS. Деякі листи можуть не доставлятися або позначатися як спам.", + "diagnosis_mail_fcrdns_ok": "Ваш зворотний DNS налаштовано правильно!", + "diagnosis_mail_ehlo_could_not_diagnose_details": "Помилка: {error}", + "diagnosis_mail_ehlo_could_not_diagnose": "Не вдалося діагностувати, чи доступний поштовий сервер postfix ззовні в IPv{ipversion}.", + "diagnosis_mail_ehlo_wrong_details": "EHLO, отриманий віддаленим діагностичним центром в IPv{ipversion}, відрізняється від домену вашого сервера.
Отриманий EHLO: {wrong_ehlo}
Очікуваний: {right_ehlo}< br>Найпоширенішою причиною цієї проблеми є те, що порт 25 неправильно перенаправлений на ваш сервер. Крім того, переконайтеся, що в роботу сервера не втручається фаєрвол або зворотний проксі-сервер.", + "diagnosis_mail_ehlo_wrong": "Інший поштовий SMTP-сервер відповідає на IPv{ipversion}. Ваш сервер, ймовірно, не зможе отримувати електронні листи.", + "diagnosis_mail_ehlo_bad_answer_details": "Це може бути викликано тим, що замість вашого сервера відповідає інша машина.", + "diagnosis_mail_ehlo_bad_answer": "Не-SMTP служба відповіла на порту 25 на IPv{ipversion}", + "diagnosis_mail_ehlo_unreachable_details": "Не вдалося відкрити з'єднання за портом 25 з вашим сервером на IPv{ipversion}. Він здається недоступним.
1. Найбільш поширеною причиною цієї проблеми є те, що порт 25 неправильно перенаправлений на ваш сервер.
2. Ви також повинні переконатися, що служба postfix запущена.
3. На більш складних установках: переконайтеся, що немає фаєрвола або зворотного проксі.", + "diagnosis_mail_ehlo_unreachable": "Поштовий сервер SMTP недоступний ззовні по IPv{ipversion}. Він не зможе отримувати листи електронної пошти.", + "diagnosis_mail_ehlo_ok": "Поштовий сервер SMTP доступний ззовні і тому може отримувати електронні листи!", + "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "Деякі провайдери не дозволять вам розблокувати вихідний порт 25, тому що вони не піклуються про мережевий нейтралітет (Net Neutrality).
- Деякі з них пропонують альтернативу використання ретранслятора поштового сервера, хоча це має на увазі, що ретранслятор зможе шпигувати за вашим поштовим трафіком.
- Альтернативою для захисту конфіденційності є використання VPN *з виділеним загальнодоступним IP* для обходу такого роду обмежень. Дивіться https://yunohost.org/#/vpn_advantage
- Ви також можете розглянути можливість переходу на більш дружнього до мережевого нейтралітету провайдера", + "diagnosis_mail_outgoing_port_25_blocked_details": "Спочатку спробуйте розблокувати вихідний порт 25 в інтерфейсі вашого інтернет-маршрутизатора або в інтерфейсі вашого хостинг-провайдера. (Деякі хостинг-провайдери можуть вимагати, щоб ви відправили їм заявку в службу підтримки).", + "diagnosis_mail_outgoing_port_25_blocked": "Поштовий сервер SMTP не може відправляти електронні листи на інші сервери, оскільки вихідний порт 25 заблоковано в IPv{ipversion}.", + "app_manifest_install_ask_path": "Оберіть шлях URL (після домену), за яким має бути встановлено цей застосунок", + "yunohost_postinstall_end_tip": "Післявстановлення завершено! Щоб завершити доналаштування, будь ласка, розгляньте наступні варіанти:\n - додавання першого користувача через розділ 'Користувачі' вебадміністрації (або 'yunohost user create ' в командному рядку);\n - діагностика можливих проблем через розділ 'Діагностика' вебадміністрації (або 'yunohost diagnosis run' в командному рядку);\n - прочитання розділів 'Завершення встановлення' і 'Знайомство з YunoHost' у документації адміністратора: https://yunohost.org/admindoc.", + "yunohost_not_installed": "YunoHost установлений неправильно. Будь ласка, запустіть 'yunohost tools postinstall'", + "yunohost_installing": "Установлення YunoHost...", + "yunohost_configured": "YunoHost вже налаштовано", + "yunohost_already_installed": "YunoHost вже встановлено", + "user_updated": "Відомості про користувача змінено", + "user_update_failed": "Не вдалося оновити користувача {user}: {error}", + "user_unknown": "Невідомий користувач: {user}", + "user_home_creation_failed": "Не вдалося створити каталог домівки для користувача", + "user_deletion_failed": "Не вдалося видалити користувача {user}: {error}", + "user_deleted": "Користувача видалено", + "user_creation_failed": "Не вдалося створити користувача {user}: {error}", + "user_created": "Користувача створено", + "user_already_exists": "Користувач '{user}' вже існує", + "upnp_port_open_failed": "Не вдалося відкрити порт через UPnP", + "upnp_enabled": "UPnP увімкнено", + "upnp_disabled": "UPnP вимкнено", + "upnp_dev_not_found": "UPnP-пристрій не знайдено", + "upgrading_packages": "Оновлення пакетів...", + "upgrade_complete": "Оновлення завершено", + "updating_apt_cache": "Завантаження доступних оновлень для системних пакетів...", + "update_apt_cache_warning": "Щось пішло не так при оновленні кеша APT (менеджера пакунків Debian). Ось дамп рядків sources.list, який може допомогти визначити проблемні рядки:\n{sourceslist}", + "update_apt_cache_failed": "Неможливо оновити кеш APT (менеджер пакетів Debian). Ось дамп рядків sources.list, який може допомогти визначити проблемні рядки:\n{sourceslist}", + "unrestore_app": "{app} не буде оновлено", + "unlimit": "Квоти немає", + "unknown_main_domain_path": "Невідомий домен або шлях для '{app}'. Вам необхідно вказати домен і шлях, щоб мати можливість вказати URL для дозволу.", + "unexpected_error": "Щось пішло не так: {error}", + "unbackup_app": "{app} НЕ буде збережено", + "tools_upgrade_special_packages_completed": "Оновлення пакета YunoHost завершено.\nНатисніть [Enter] для повернення до командного рядка", + "tools_upgrade_special_packages_explanation": "Спеціальне оновлення триватиме у тлі. Будь ласка, не запускайте ніяких інших дій на вашому сервері протягом наступних ~ 10 хвилин (в залежності від швидкості обладнання). Після цього вам, можливо, доведеться заново увійти в вебадміністрації. Журнал оновлення буде доступний в Засоби → Журнал (в вебадміністрації) або за допомогою 'yunohost log list' (з командного рядка).", + "tools_upgrade_special_packages": "Тепер оновлюємо 'спеціальні' (пов'язані з yunohost) пакети…", + "tools_upgrade_regular_packages_failed": "Не вдалося оновити пакети: {packages_list}", + "tools_upgrade_regular_packages": "Тепер оновлюємо 'звичайні' (не пов'язані з yunohost) пакети…", + "tools_upgrade_cant_unhold_critical_packages": "Не вдалося розтримати критичні пакети…", + "tools_upgrade_cant_hold_critical_packages": "Не вдалося утримати критичні пакети…", + "tools_upgrade_cant_both": "Неможливо оновити систему і застосунки одночасно", + "tools_upgrade_at_least_one": "Будь ласка, вкажіть 'apps', або 'system'", + "this_action_broke_dpkg": "Ця дія порушила dpkg/APT (системні менеджери пакетів)... Ви можете спробувати вирішити цю проблему, під'єднавшись по SSH і запустивши `sudo apt install --fix-broken` та/або `sudo dpkg --configure -a`.", + "system_username_exists": "Ім'я користувача вже існує в списку користувачів системи", + "system_upgraded": "Систему оновлено", + "ssowat_conf_updated": "Конфігурацію SSOwat оновлено", + "ssowat_conf_generated": "Конфігурацію SSOwat перестворено", + "show_tile_cant_be_enabled_for_regex": "Ви не можете увімкнути 'show_tile' прямо зараз, тому що URL для дозволу '{permission}' являє собою регулярний вираз", + "show_tile_cant_be_enabled_for_url_not_defined": "Ви не можете увімкнути 'show_tile' прямо зараз, тому що спочатку ви повинні визначити URL для дозволу '{permission}'", + "service_unknown": "Невідома служба '{service}'", + "service_stopped": "Службу '{service}' зупинено", + "service_stop_failed": "Неможливо зупинити службу '{service}' \n\nНедавні журнали служби: {logs}", + "service_started": "Службу '{service}' запущено", + "service_start_failed": "Не вдалося запустити службу '{service}' \n\nНедавні журнали служби: {logs}", + "diagnosis_mail_outgoing_port_25_ok": "Поштовий сервер SMTP може відправляти електронні листи (вихідний порт 25 не заблоковано).", + "diagnosis_swap_tip": "Будь ласка, будьте обережні і знайте, що якщо сервер розміщує обсяг підкачки на SD-карті або SSD-накопичувачі, це може різко скоротити строк служби пристрою`.", + "diagnosis_swap_ok": "Система має {total} обсягу підкачки!", + "diagnosis_swap_notsomuch": "Система має тільки {total} обсягу підкачки. Щоб уникнути станоаищ, коли в системі закінчується пам'ять, слід передбачити наявність не менше {recommended} обсягу підкачки.", + "diagnosis_swap_none": "В системі повністю відсутня підкачка. Ви повинні розглянути можливість додавання принаймні {recommended} обсягу підкачки, щоб уникнути ситуацій, коли системі не вистачає пам'яті.", + "diagnosis_ram_ok": "Система все ще має {available} ({available_percent}%) оперативної пам'яті з {total}.", + "diagnosis_ram_low": "У системі наявно {available} ({available_percent}%) оперативної пам'яті (з {total}). Будьте уважні.", + "diagnosis_ram_verylow": "Система має тільки {available} ({available_percent}%) оперативної пам'яті! (з {total})", + "diagnosis_diskusage_ok": "У сховищі {mountpoint} (на пристрої {device}) залишилося {free} ({free_percent}%) вільного місця (з {total})!", + "diagnosis_diskusage_low": "Сховище {mountpoint} (на пристрої {device}) має тільки {free} ({free_percent}%) вільного місця (з {total}). Будьте уважні.", + "diagnosis_diskusage_verylow": "Сховище {mountpoint} (на пристрої {device}) має тільки {free} ({free_percent}%) вільного місця (з {total}). Вам дійсно варто подумати про очищення простору!", + "diagnosis_services_bad_status_tip": "Ви можете спробувати перезапустити службу, а якщо це не допоможе, подивіться журнали служби в вебадміністрації (з командного рядка це можна зробити за допомогою yunohost service restart {service} і yunohost service log {service}).", + "diagnosis_services_bad_status": "Служба {service} у стані {status} :(", + "diagnosis_services_conf_broken": "Для служби {service} порушена конфігурація!", + "diagnosis_services_running": "Службу {service} запущено!", + "diagnosis_domain_expires_in": "Строк дії {domain} спливе через {days} днів.", + "diagnosis_domain_expiration_error": "Строк дії деяких доменів НЕЗАБАРОМ спливе!", + "diagnosis_domain_expiration_warning": "Строк дії деяких доменів спливе найближчим часом!", + "diagnosis_domain_expiration_success": "Ваші домени зареєстровані і не збираються спливати найближчим часом.", + "diagnosis_domain_expiration_not_found_details": "Відомості WHOIS для домену {domain} не містять даних про строк дії?", + "diagnosis_domain_not_found_details": "Домен {domain} не існує в базі даних WHOIS або строк його дії сплив!", + "diagnosis_domain_expiration_not_found": "Неможливо перевірити строк дії деяких доменів", + "diagnosis_dns_specialusedomain": "Домен {domain} заснований на домені верхнього рівня спеціального призначення (TLD) і тому не очікується, що у нього будуть актуальні записи DNS.", + "diagnosis_dns_try_dyndns_update_force": "Конфігурація DNS цього домену повинна автоматично управлятися YunoHost. Якщо це не так, ви можете спробувати примусово оновити її за допомогою команди yunohost dyndns update --force.", + "diagnosis_dns_point_to_doc": "Якщо вам потрібна допомога з налаштування DNS-записів, зверніться до документації на сайті https://yunohost.org/dns_config.", + "diagnosis_dns_discrepancy": "Наступний запис DNS, схоже, не відповідає рекомендованій конфігурації:
Тип: {type}
Назва: {name}
Поточне значення: {current}
Очікуване значення: {value}", + "diagnosis_dns_missing_record": "Згідно рекомендованої конфігурації DNS, ви повинні додати запис DNS з наступними відомостями.
Тип: {type}
Назва: {name}
Значення: {value}", + "diagnosis_dns_bad_conf": "Деякі DNS-записи відсутні або неправильні для домену {domain} (категорія {category})", + "diagnosis_dns_good_conf": "DNS-записи правильно налаштовані для домену {domain} (категорія {category})", + "diagnosis_ip_weird_resolvconf_details": "Файл /etc/resolv.conf повинен бути символічним посиланням на /etc/resolvconf/run/resolv.conf, що вказує на 127.0.0.1(dnsmasq). Якщо ви хочете вручну налаштувати DNS вирішувачі (resolvers), відредагуйте /etc/resolv.dnsmasq.conf.", + "diagnosis_ip_weird_resolvconf": "Роздільність DNS, схоже, працює, але схоже, що ви використовуєте користувацьку /etc/resolv.conf.", + "diagnosis_ip_broken_resolvconf": "Схоже, що роздільність доменних імен на вашому сервері порушено, що пов'язано з тим, що /etc/resolv.conf не вказує на 127.0.0.1.", + "diagnosis_ip_broken_dnsresolution": "Роздільність доменних імен, схоже, з якоїсь причини не працює... Фаєрвол блокує DNS-запити?", + "diagnosis_ip_dnsresolution_working": "Роздільність доменних імен працює!", + "diagnosis_ip_not_connected_at_all": "Здається, сервер взагалі не під'єднаний до Інтернету!?", + "diagnosis_ip_local": "Локальний IP: {local}", + "diagnosis_ip_global": "Глобальний IP: {global}", + "diagnosis_ip_no_ipv6_tip": "Наявність робочого IPv6 не є обов'язковим для роботи вашого сервера, але це краще для здоров'я Інтернету в цілому. IPv6 зазвичай автоматично налаштовується системою або вашим провайдером, якщо він доступний. В іншому випадку вам, можливо, доведеться налаштувати деякі речі вручну, як пояснюється в документації тут: https://yunohost.org/#/ipv6. Якщо ви не можете увімкнути IPv6 або якщо це здається вам занадто технічним, ви також можете сміливо нехтувати цим попередженням.", + "diagnosis_ip_no_ipv6": "Сервер не має робочого IPv6.", + "diagnosis_ip_connected_ipv6": "Сервер під'єднаний до Інтернету через IPv6!", + "diagnosis_ip_no_ipv4": "Сервер не має робочого IPv4.", + "diagnosis_ip_connected_ipv4": "Сервер під'єднаний до Інтернету через IPv4!", + "diagnosis_no_cache": "Для категорії «{category}» ще немає кеша діагностики", + "diagnosis_failed": "Не вдалося отримати результат діагностики для категорії '{category}': {error}", + "diagnosis_everything_ok": "Усе виглядає добре для {category}!", + "diagnosis_found_warnings": "Знайдено {warnings} пунктів, які можна поліпшити для {category}.", + "diagnosis_found_errors_and_warnings": "Знайдено {errors} істотний (і) питання (и) (і {warnings} попередження (я)), що відносяться до {category}!", + "diagnosis_found_errors": "Знайдена {errors} важлива проблема (і), пов'язана з {category}!", + "diagnosis_ignored_issues": "(+ {nb_ignored} знехтувана проблема (проблеми))", + "diagnosis_cant_run_because_of_dep": "Неможливо запустити діагностику для {category}, поки є важливі проблеми, пов'язані з {dep}.", + "diagnosis_cache_still_valid": "(Кеш все ще дійсний для діагностики {category}. Повторна діагностика поки не проводиться!)", + "diagnosis_failed_for_category": "Не вдалося провести діагностику для категорії '{category}': {error}", + "diagnosis_display_tip": "Щоб побачити знайдені проблеми, ви можете перейти в розділ Діагностика в вебадміністрації або виконати команду 'yunohost diagnosis show --issues --human-readable' з командного рядка.", + "diagnosis_package_installed_from_sury_details": "Деякі пакети були ненавмисно встановлені зі стороннього репозиторію під назвою Sury. Команда YunoHost поліпшила стратегію роботи з цими пакетами, але очікується, що в деяких системах, які встановили застосунки PHP7.3 ще на Stretch, залишаться деякі невідповідності. Щоб виправити це становище, спробуйте виконати наступну команду: {cmd_to_fix}", + "diagnosis_package_installed_from_sury": "Деякі системні пакети мають бути зістарені у версії", + "diagnosis_backports_in_sources_list": "Схоже, що apt (менеджер пакетів) налаштований на використання репозиторія backports. Якщо ви не знаєте, що робите, ми наполегливо не радимо встановлювати пакети з backports, тому що це може привести до нестабільності або конфліктів у вашій системі.", + "diagnosis_basesystem_ynh_inconsistent_versions": "Ви використовуєте несумісні версії пакетів YunoHost... швидше за все, через невдале або часткове оновлення.", + "diagnosis_basesystem_ynh_main_version": "Сервер працює під управлінням YunoHost {main_version} ({repo})", + "diagnosis_basesystem_ynh_single_version": "{package} версія: {version} ({repo})", + "diagnosis_basesystem_kernel": "Сервер працює під управлінням ядра Linux {kernel_version}", + "diagnosis_basesystem_host": "Сервер працює під управлінням Debian {debian_version}", + "diagnosis_basesystem_hardware_model": "Модель сервера - {model}", + "diagnosis_basesystem_hardware": "Архітектура апаратного забезпечення сервера - {virt} {arch}", + "custom_app_url_required": "Ви повинні надати URL-адресу для оновлення вашого користувацького застосунку {app}", + "confirm_app_install_thirdparty": "НЕБЕЗПЕЧНО! Цей застосунок не входить у каталог застосунків YunoHost. Установлення сторонніх застосунків може порушити цілісність і безпеку вашої системи. Вам не слід встановлювати його, якщо ви не знаєте, що робите. НІЯКОЇ ПІДТРИМКИ НЕ БУДЕ, якщо цей застосунок не буде працювати або зламає вашу систему... Якщо ви все одно готові піти на такий ризик, введіть '{answers}'", + "confirm_app_install_danger": "НЕБЕЗПЕЧНО! Відомо, що цей застосунок все ще експериментальний (якщо не сказати, що він явно не працює)! Вам не слід встановлювати його, якщо ви не знаєте, що робите. Ніякої підтримки не буде надано, якщо цей застосунок не буде працювати або зламає вашу систему... Якщо ви все одно готові ризикнути, введіть '{answers}'", + "confirm_app_install_warning": "Попередження: Цей застосунок може працювати, але він не дуже добре інтегрований в YunoHost. Деякі функції, такі як єдина реєстрація та резервне копіювання/відновлення, можуть бути недоступні. Все одно встановити? [{answers}]. ", + "certmanager_unable_to_parse_self_CA_name": "Не вдалося розібрати назву самопідписного центру (файл: {file})", + "certmanager_self_ca_conf_file_not_found": "Не вдалося знайти файл конфігурації для самопідписного центру (файл: {file})", + "certmanager_no_cert_file": "Не вдалося розпізнати файл сертифіката для домену {domain} (файл: {file})", + "certmanager_hit_rate_limit": "Для цього набору доменів {domain} недавно було випущено дуже багато сертифікатів. Будь ласка, спробуйте ще раз пізніше. Див. https://letsencrypt.org/docs/rate-limits/ для отримання подробиць", + "certmanager_warning_subdomain_dns_record": "Піддомен '{subdomain}' не дозволяється на тій же IP-адресі, що і '{domain}'. Деякі функції будуть недоступні, поки ви не виправите це і не перестворите сертифікат.", + "certmanager_domain_http_not_working": "Домен {domain}, схоже, не доступний через HTTP. Будь ласка, перевірте категорію 'Мережа' в діагностиці для отримання додаткових даних. (Якщо ви знаєте, що робите, використовуйте '--no-checks', щоб вимкнути ці перевірки).", + "certmanager_domain_dns_ip_differs_from_public_ip": "DNS-записи для домену '{domain}' відрізняються від IP цього сервера. Будь ласка, перевірте категорію 'DNS-записи' (основні) в діагностиці для отримання додаткових даних. Якщо ви недавно змінили запис A, будь ласка, зачекайте, поки він пошириться (деякі програми перевірки поширення DNS доступні в Інтернеті). (Якщо ви знаєте, що робите, використовуйте '--no-checks', щоб вимкнути ці перевірки).", + "certmanager_domain_cert_not_selfsigned": "Сертифікат для домену {domain} не є самопідписаним. Ви впевнені, що хочете замінити його? (Для цього використовуйте '--force').", + "certmanager_domain_not_diagnosed_yet": "Поки немає результатів діагностики для домену {domain}. Будь ласка, повторно проведіть діагностику для категорій 'DNS-записи' і 'Мережа' в розділі діагностики, щоб перевірити, чи готовий домен до Let's Encrypt. (Або, якщо ви знаєте, що робите, використовуйте '--no-checks', щоб вимкнути ці перевірки).", + "certmanager_certificate_fetching_or_enabling_failed": "Спроба використовувати новий сертифікат для {domain} не спрацювала...", + "certmanager_cert_signing_failed": "Не вдалося підписати новий сертифікат", + "certmanager_cert_renew_success": "Сертифікат Let's Encrypt оновлений для домену '{domain}'", + "certmanager_cert_install_success_selfsigned": "Самопідписаний сертифікат тепер встановлений для домену '{domain}'", + "certmanager_cert_install_success": "Сертифікат Let's Encrypt тепер встановлений для домена '{domain}'", + "certmanager_cannot_read_cert": "Щось не так сталося при спробі відкрити поточний сертифікат для домена {domain} (файл: {file}), причина: {reason}", + "certmanager_attempt_to_replace_valid_cert": "Ви намагаєтеся перезаписати хороший дійсний сертифікат для домену {domain}! (Використовуйте --force для обходу)", + "certmanager_attempt_to_renew_valid_cert": "Строк дії сертифіката для домена '{domain}' не закінчується! (Ви можете використовувати --force, якщо знаєте, що робите)", + "certmanager_attempt_to_renew_nonLE_cert": "Сертифікат для домену '{domain}' не випущено Let's Encrypt. Неможливо продовжити його автоматично!", + "certmanager_acme_not_configured_for_domain": "Завдання ACME не може бути запущене для {domain} прямо зараз, тому що в його nginx-конфігурації відсутній відповідний фрагмент коду... Будь ласка, переконайтеся, що конфігурація nginx оновлена за допомогою `yunohost tools regen-conf nginx --dry-run --with-diff`.", + "backup_with_no_restore_script_for_app": "{app} не має скрипта відновлення, ви не зможете автоматично відновити резервну копію цього застосунку.", + "backup_with_no_backup_script_for_app": "Застосунок '{app}' не має скрипта резервного копіювання. Нехтую ним.", + "backup_unable_to_organize_files": "Неможливо використовувати швидкий спосіб для організації файлів в архіві", + "backup_system_part_failed": "Не вдалося створити резервну копію системної частини '{part}'", + "backup_running_hooks": "Запуск гачків (hook) резервного копіювання...", + "backup_permission": "Дозвіл на резервне копіювання для {app}", + "backup_output_symlink_dir_broken": "Ваш архівний каталог '{path}' є неробочим символічним посиланням. Можливо, ви забули перемонтувати або підключити носій, на який вона вказує.", + "backup_output_directory_required": "Ви повинні вказати вихідний каталог для резервного копіювання", + "backup_output_directory_not_empty": "Ви повинні вибрати порожній вихідний каталог", + "backup_output_directory_forbidden": "Виберіть інший вихідний каталог. Резервні копії не можуть бути створені в підкаталогах /bin,/boot,/dev,/etc,/lib,/root,/run,/sbin,/sys,/usr,/var або /home/yunohost.backup/archives", + "backup_nothings_done": "Нема що зберігати", + "backup_no_uncompress_archive_dir": "Немає такого каталогу нестислого архіву", + "backup_mount_archive_for_restore": "Підготовлення архіву для відновлення...", + "backup_method_tar_finished": "Створено архів резервного копіювання TAR", + "backup_method_custom_finished": "Користувацький спосіб резервного копіювання '{method}' завершено", + "backup_method_copy_finished": "Резервне копіювання завершено", + "backup_hook_unknown": "Гачок (hook) резервного копіювання '{hook}' невідомий", + "backup_deleted": "Резервна копія видалена", + "backup_delete_error": "Не вдалося видалити '{path}'", + "backup_custom_mount_error": "Користувацький спосіб резервного копіювання не зміг пройти етап 'монтування'", + "backup_custom_backup_error": "Користувацький спосіб резервного копіювання не зміг пройти етап 'резервне копіювання'", + "backup_csv_creation_failed": "Не вдалося створити CSV-файл, необхідний для відновлення", + "backup_csv_addition_failed": "Не вдалося додати файли для резервного копіювання в CSV-файл", + "backup_creation_failed": "Не вдалося створити архів резервного копіювання", + "backup_create_size_estimation": "Архів буде містити близько {size} даних.", + "backup_created": "Резервна копія створена", + "backup_couldnt_bind": "Не вдалося зв'язати {src} з {dest}.", + "backup_copying_to_organize_the_archive": "Копіювання {size} МБ для організації архіву", + "backup_cleaning_failed": "Не вдалося очистити тимчасовий каталог резервного копіювання", + "backup_cant_mount_uncompress_archive": "Не вдалося змонтувати нестислий архів як захищений від запису", + "backup_ask_for_copying_if_needed": "Ви бажаєте тимчасово виконати резервне копіювання з використанням {size} МБ? (Цей спосіб використовується, оскільки деякі файли не можуть бути підготовлені дієвіше).", + "backup_archive_writing_error": "Не вдалося додати файли '{source}' (названі в архіві '{dest}') для резервного копіювання в стислий архів '{archive}'", + "backup_archive_system_part_not_available": "Системна частина '{part}' недоступна в цій резервній копії", + "backup_archive_corrupted": "Схоже, що архів резервної копії '{archive}' пошкоджений: {error}", + "backup_archive_cant_retrieve_info_json": "Не вдалося завантажити відомості для архіву '{archive}'... info.json не може бути отриманий(або не є правильним json).", + "backup_archive_open_failed": "Не вдалося відкрити архів резервної копії", + "backup_archive_name_unknown": "Невідомий локальний архів резервного копіювання з назвою '{name}'", + "backup_archive_name_exists": "Архів резервного копіювання з такою назвою вже існує.", + "backup_archive_broken_link": "Не вдалося отримати доступ до архіву резервного копіювання (неробоче посилання на {path})", + "backup_archive_app_not_found": "Не вдалося знайти {app} в архіві резервного копіювання", + "backup_applying_method_tar": "Створення резервного TAR-архіву...", + "backup_applying_method_custom": "Виклик користувацького способу резервного копіювання '{method}'...", + "backup_applying_method_copy": "Копіювання всіх файлів у резервну копію...", + "backup_app_failed": "Не вдалося створити резервну копію {app}", + "backup_actually_backuping": "Створення резервного архіву з зібраних файлів...", + "backup_abstract_method": "Цей спосіб резервного копіювання ще не реалізований", + "ask_password": "Пароль", + "ask_new_path": "Новий шлях", + "ask_new_domain": "Новий домен", + "ask_new_admin_password": "Новий пароль адміністрації", + "ask_main_domain": "Основний домен", + "ask_lastname": "Прізвище", + "ask_firstname": "Ім'я", + "ask_user_domain": "Домен для адреси е-пошти користувача і облікового запису XMPP", + "apps_catalog_update_success": "Каталог застосунків був оновлений!", + "apps_catalog_obsolete_cache": "Кеш каталогу застосунків порожній або застарів.", + "apps_catalog_failed_to_download": "Неможливо завантажити каталог застосунків {apps_catalog}: {error}", + "apps_catalog_updating": "Оновлення каталогу застосунків…", + "apps_catalog_init_success": "Систему каталогу застосунків ініціалізовано!", + "apps_already_up_to_date": "Усі застосунки вже оновлено", + "app_packaging_format_not_supported": "Цей застосунок не може бути встановлено, тому що формат його упакування не підтримується вашою версією YunoHost. Можливо, вам слід оновити систему.", + "app_upgraded": "{app} оновлено", + "app_upgrade_some_app_failed": "Деякі застосунки не можуть бути оновлені", + "app_upgrade_script_failed": "Сталася помилка в скрипті оновлення застосунку", + "app_upgrade_failed": "Не вдалося оновити {app}: {error}", + "app_upgrade_app_name": "Зараз оновлюємо {app}...", + "app_upgrade_several_apps": "Наступні застосунки буде оновлено: {apps}", + "app_unsupported_remote_type": "Для застосунку використовується непідтримуваний віддалений тип", + "app_unknown": "Невідомий застосунок", + "app_start_restore": "Відновлення {app}...", + "app_start_backup": "Збирання файлів для резервного копіювання {app}...", + "app_start_remove": "Вилучення {app}...", + "app_start_install": "Установлення {app}...", + "app_sources_fetch_failed": "Не вдалося отримати джерельні файли, URL-адреса правильна?", + "app_restore_script_failed": "Сталася помилка всередині скрипта відновлення застосунку", + "app_restore_failed": "Не вдалося відновити {app}: {error}", + "app_remove_after_failed_install": "Вилучення застосунку після збою встановлення...", + "app_requirements_unmeet": "Вимоги не виконані для {app}, пакет {pkgname} ({version}) повинен бути {spec}", + "app_requirements_checking": "Перевіряння необхідних пакетів для {app}...", + "app_removed": "{app} видалено", + "app_not_properly_removed": "{app} не було видалено належним чином", + "app_not_installed": "Не вдалося знайти {app} в списку встановлених застосунків: {all_apps}", + "app_not_correctly_installed": "{app}, схоже, неправильно встановлено", + "app_not_upgraded": "Застосунок '{failed_app}' не вдалося оновити, і, як наслідок, оновлення таких застосунків було скасовано: {apps}", + "app_manifest_install_ask_is_public": "Чи має цей застосунок бути відкритим для анонімних відвідувачів?", + "app_manifest_install_ask_admin": "Виберіть користувача-адміністратора для цього застосунку", + "app_manifest_install_ask_password": "Виберіть пароль адміністрації для цього застосунку", + "diagnosis_description_apps": "Застосунки", + "user_import_success": "Користувачів успішно імпортовано", + "user_import_nothing_to_do": "Не потрібно імпортувати жодного користувача", + "user_import_failed": "Операція імпорту користувачів цілковито не вдалася", + "user_import_partial_failed": "Операція імпорту користувачів частково не вдалася", + "user_import_missing_columns": "Відсутні такі стовпці: {columns}", + "user_import_bad_file": "Ваш файл CSV неправильно відформатовано, він буде знехтуваний, щоб уникнути потенційної втрати даних", + "user_import_bad_line": "Неправильний рядок {line}: {details}", + "invalid_password": "Недійсний пароль", + "log_user_import": "Імпорт користувачів", + "ldap_server_is_down_restart_it": "Службу LDAP вимкнено, спробуйте перезапустити її...", + "ldap_server_down": "Не вдається під'єднатися до сервера LDAP", + "global_settings_setting_security_experimental_enabled": "Увімкнути експериментальні функції безпеки (не вмикайте це, якщо ви не знаєте, що робите!)", + "diagnosis_apps_deprecated_practices": "Установлена версія цього застосунку все ще використовує деякі надзастарілі практики упакування. Вам дійсно варто подумати про його оновлення.", + "diagnosis_apps_outdated_ynh_requirement": "Установлена версія цього застосунку вимагає лише Yunohost >= 2.x, що, як правило, вказує на те, що воно не відповідає сучасним рекомендаційним практикам упакування та порадникам. Вам дійсно варто подумати про його оновлення.", + "diagnosis_apps_bad_quality": "Цей застосунок наразі позначено як зламаний у каталозі застосунків YunoHost. Це може бути тимчасовою проблемою, поки організатори намагаються вирішити цю проблему. Тим часом оновлення цього застосунку вимкнено.", + "diagnosis_apps_broken": "Цей застосунок наразі позначено як зламаний у каталозі застосунків YunoHost. Це може бути тимчасовою проблемою, поки організатори намагаються вирішити цю проблему. Тим часом оновлення цього застосунку вимкнено.", + "diagnosis_apps_not_in_app_catalog": "Цей застосунок не міститься у каталозі застосунків YunoHost. Якщо він був у минулому і був видалений, вам слід подумати про видалення цього застосунку, оскільки він не отримає оновлення, і це може поставити під загрозу цілісність та безпеку вашої системи.", + "diagnosis_apps_issue": "Виявлено проблему із застосунком {app}", + "diagnosis_apps_allgood": "Усі встановлені застосунки дотримуються основних способів упакування", + "diagnosis_high_number_auth_failures": "Останнім часом сталася підозріло велика кількість помилок автентифікації. Ви можете переконатися, що fail2ban працює і правильно налаштований, або скористатися власним портом для SSH, як описано в https://yunohost.org/security.", + "global_settings_setting_security_nginx_redirect_to_https": "Типово переспрямовувати HTTP-запити до HTTP (НЕ ВИМИКАЙТЕ, якщо ви дійсно не знаєте, що робите!)", + "app_config_unable_to_apply": "Не вдалося застосувати значення панелі конфігурації.", + "app_config_unable_to_read": "Не вдалося розпізнати значення панелі конфігурації.", + "config_apply_failed": "Не вдалося застосувати нову конфігурацію: {error}", + "config_cant_set_value_on_section": "Ви не можете встановити одне значення на весь розділ конфігурації.", + "config_forbidden_keyword": "Ключове слово '{keyword}' зарезервовано, ви не можете створити або використовувати панель конфігурації з запитом із таким ID.", + "config_no_panel": "Панель конфігурації не знайдено.", + "config_unknown_filter_key": "Ключ фільтра '{filter_key}' недійсний.", + "config_validate_color": "Колір RGB має бути дійсним шістнадцятковим кольоровим кодом", + "config_validate_date": "Дата має бути дійсною, наприклад, у форматі РРРР-ММ-ДД", + "config_validate_email": "Е-пошта має бути дійсною", + "config_validate_time": "Час має бути дійсним, наприклад ГГ:ХХ", + "config_validate_url": "Вебадреса має бути дійсною", + "config_version_not_supported": "Версії конфігураційної панелі '{version}' не підтримуються.", + "danger": "Небезпека:", + "file_extension_not_accepted": "Файл '{path}' відхиляється, бо його розширення не входить в число прийнятих розширень: {accept}", + "invalid_number_min": "Має бути більшим за {min}", + "invalid_number_max": "Має бути меншим за {max}", + "log_app_config_set": "Застосувати конфігурацію до застосунку '{}'", + "service_not_reloading_because_conf_broken": "Неможливо перезавантажити/перезапустити службу '{name}', тому що її конфігурацію порушено: {errors}", + "app_argument_password_help_optional": "Введіть один пробіл, щоб очистити пароль", + "app_argument_password_help_keep": "Натисніть Enter, щоб зберегти поточне значення" +} \ No newline at end of file diff --git a/locales/zh_Hans.json b/locales/zh_Hans.json index 0967ef424..9176ebab9 100644 --- a/locales/zh_Hans.json +++ b/locales/zh_Hans.json @@ -1 +1,629 @@ -{} +{ + "password_too_simple_1": "密码长度至少为8个字符", + "backup_created": "备份已创建", + "app_start_remove": "正在删除{app}……", + "admin_password_change_failed": "无法修改密码", + "admin_password_too_long": "请选择一个小于127个字符的密码", + "app_upgrade_failed": "不能升级{app}:{error}", + "app_id_invalid": "无效 app ID", + "app_unknown": "未知应用", + "admin_password_changed": "管理密码已更改", + "aborting": "正在放弃。", + "admin_password": "管理员密码", + "app_start_restore": "正在恢复{app}……", + "action_invalid": "无效操作 '{action}'", + "ask_lastname": "姓", + "diagnosis_everything_ok": "{category}一切看起来不错!", + "diagnosis_found_warnings": "找到{warnings}项,可能需要{category}进行改进。", + "diagnosis_found_errors_and_warnings": "发现与{category}相关的{errors}个重要问题(和{warnings}警告)!", + "diagnosis_found_errors": "发现与{category}相关的{errors}个重要问题!", + "diagnosis_ignored_issues": "(+ {nb_ignored} 个被忽略的问题)", + "diagnosis_cant_run_because_of_dep": "存在与{dep}相关的重要问题时,无法对{category}进行诊断。", + "diagnosis_cache_still_valid": "(高速缓存对于{category}诊断仍然有效。暂时不会对其进行重新诊断!)", + "diagnosis_failed_for_category": "诊断类别 '{category}'失败: {error}", + "diagnosis_display_tip": "要查看发现的问题,您可以转到Webadmin的“诊断”部分,或从命令行运行'yunohost diagnosis show --issues --human-readable'。", + "diagnosis_package_installed_from_sury": "一些系统软件包应降级", + "diagnosis_backports_in_sources_list": "看起来apt(程序包管理器)已配置为使用backports存储库。 除非您真的知道自己在做什么,否则我们强烈建议您不要从backports安装软件包,因为这很可能在您的系统上造成不稳定或冲突。", + "diagnosis_basesystem_ynh_inconsistent_versions": "您运行的YunoHost软件包版本不一致,很可能是由于升级失败或部分升级造成的。", + "diagnosis_basesystem_ynh_main_version": "服务器正在运行YunoHost {main_version} ({repo})", + "diagnosis_basesystem_ynh_single_version": "{package} 版本: {version} ({repo})", + "diagnosis_basesystem_kernel": "服务器正在运行Linux kernel {kernel_version}", + "diagnosis_basesystem_host": "服务器正在运行Debian {debian_version}", + "diagnosis_basesystem_hardware_model": "服务器型号为 {model}", + "diagnosis_basesystem_hardware": "服务器硬件架构为{virt} {arch}", + "custom_app_url_required": "您必须提供URL才能升级自定义应用 {app}", + "confirm_app_install_thirdparty": "危险! 该应用程序不是YunoHost的应用程序目录的一部分。 安装第三方应用程序可能会损害系统的完整性和安全性。 除非您知道自己在做什么,否则可能不应该安装它, 如果此应用无法运行或无法正常使用系统,将不会提供任何支持。如果您仍然愿意承担此风险,请输入'{answers}'", + "confirm_app_install_danger": "危险! 已知此应用仍处于实验阶段(如果未明确无法正常运行)! 除非您知道自己在做什么,否则可能不应该安装它。 如果此应用无法运行或无法正常使用系统,将不会提供任何支持。如果您仍然愿意承担此风险,请输入'{answers}'", + "confirm_app_install_warning": "警告:此应用程序可能可以运行,但未与YunoHost很好地集成。某些功能(例如单点登录和备份/还原)可能不可用, 仍要安装吗? [{answers}] ", + "certmanager_unable_to_parse_self_CA_name": "无法解析自签名授权的名称 (file: {file})", + "certmanager_self_ca_conf_file_not_found": "找不到用于自签名授权的配置文件(file: {file})", + "certmanager_no_cert_file": "无法读取域{domain}的证书文件(file: {file})", + "certmanager_hit_rate_limit": "最近已经为此域{domain}颁发了太多的证书。请稍后再试。有关更多详细信息,请参见https://letsencrypt.org/docs/rate-limits/", + "certmanager_warning_subdomain_dns_record": "子域'{subdomain}' 不能解析为与 '{domain}'相同的IP地址, 在修复此问题并重新生成证书之前,某些功能将不可用。", + "certmanager_domain_http_not_working": "域 {domain}似乎无法通过HTTP访问。请检查诊断中的“网络”类别以获取更多信息。(如果您知道自己在做什么,请使用“ --no-checks”关闭这些检查。)", + "certmanager_domain_dns_ip_differs_from_public_ip": "域'{domain}' 的DNS记录与此服务器的IP不同。请检查诊断中的“ DNS记录”(基本)类别,以获取更多信息。 如果您最近修改了A记录,请等待它传播(某些DNS传播检查器可在线获得)。 (如果您知道自己在做什么,请使用“ --no-checks”关闭这些检查。)", + "certmanager_domain_cert_not_selfsigned": "域 {domain} 的证书不是自签名的, 您确定要更换它吗?(使用“ --force”这样做。)", + "certmanager_domain_not_diagnosed_yet": "尚无域{domain} 的诊断结果。请在诊断部分中针对“ DNS记录”和“ Web”类别重新运行诊断,以检查该域是否已准备好安装“Let's Encrypt”证书。(或者,如果您知道自己在做什么,请使用“ --no-checks”关闭这些检查。)", + "certmanager_certificate_fetching_or_enabling_failed": "尝试将新证书用于 {domain}无效...", + "certmanager_cert_signing_failed": "无法签署新证书", + "certmanager_cert_install_success_selfsigned": "为域 '{domain}'安装了自签名证书", + "certmanager_cert_renew_success": "为域 '{domain}'续订“Let's Encrypt”证书", + "certmanager_cert_install_success": "为域'{domain}'安装“Let's Encrypt”证书", + "certmanager_cannot_read_cert": "尝试为域 {domain}(file: {file})打开当前证书时发生错误,原因: {reason}", + "certmanager_attempt_to_replace_valid_cert": "您正在尝试覆盖域{domain}的有效证书!(使用--force绕过)", + "certmanager_attempt_to_renew_valid_cert": "域'{domain}'的证书不会过期!(如果知道自己在做什么,则可以使用--force)", + "certmanager_attempt_to_renew_nonLE_cert": "“Let's Encrypt”未颁发域'{domain}'的证书,无法自动续订!", + "certmanager_acme_not_configured_for_domain": "目前无法针对{domain}运行ACME挑战,因为其nginx conf缺少相应的代码段...请使用“yunohost tools regen-conf nginx --dry-run --with-diff”确保您的nginx配置是最新的。", + "backup_with_no_restore_script_for_app": "{app} 没有还原脚本,您将无法自动还原该应用程序的备份。", + "backup_with_no_backup_script_for_app": "应用'{app}'没有备份脚本。无视。", + "backup_unable_to_organize_files": "无法使用快速方法来组织档案中的文件", + "backup_system_part_failed": "无法备份'{part}'系统部分", + "backup_running_hooks": "正在运行备份挂钩...", + "backup_permission": "{app}的备份权限", + "backup_output_symlink_dir_broken": "您的存档目录'{path}' 是断开的符号链接。 也许您忘记了重新安装/装入或插入它指向的存储介质。", + "backup_output_directory_required": "您必须提供备份的输出目录", + "backup_output_directory_not_empty": "您应该选择一个空的输出目录", + "backup_output_directory_forbidden": "选择一个不同的输出目录。无法在/bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var或/home/yunohost.backup/archives子文件夹中创建备份", + "backup_nothings_done": "没什么可保存的", + "backup_no_uncompress_archive_dir": "没有这样的未压缩存档目录", + "backup_mount_archive_for_restore": "正在准备存档以进行恢复...", + "backup_method_tar_finished": "TAR备份存档已创建", + "backup_method_custom_finished": "自定义备份方法'{method}' 已完成", + "backup_method_copy_finished": "备份副本已完成", + "backup_hook_unknown": "备用挂钩'{hook}'未知", + "backup_deleted": "备份已删除", + "backup_delete_error": "无法删除'{path}'", + "backup_custom_mount_error": "自定义备份方法无法通过“挂载”步骤", + "backup_custom_backup_error": "自定义备份方法无法通过“备份”步骤", + "backup_csv_creation_failed": "无法创建还原所需的CSV文件", + "backup_csv_addition_failed": "无法将文件添加到CSV文件中进行备份", + "backup_creation_failed": "无法创建备份存档", + "backup_create_size_estimation": "归档文件将包含约{size}个数据。", + "backup_couldnt_bind": "无法将 {src} 绑定到{dest}.", + "backup_copying_to_organize_the_archive": "复制{size} MB来整理档案", + "backup_cleaning_failed": "无法清理临时备份文件夹", + "backup_cant_mount_uncompress_archive": "无法将未压缩的归档文件挂载为写保护", + "backup_ask_for_copying_if_needed": "您是否要临时使用{size} MB进行备份?(由于无法使用更有效的方法准备某些文件,因此使用这种方式。)", + "backup_archive_writing_error": "无法将要备份的文件'{source}'(在归档文'{dest}'中命名)添加到压缩归档文件 '{archive}'s}”中", + "backup_archive_system_part_not_available": "该备份中系统部分'{part}'不可用", + "backup_archive_corrupted": "备份存档'{archive}' 似乎已损坏 : {error}", + "backup_archive_cant_retrieve_info_json": "无法加载档案'{archive}'的信息...无法检索到info.json(或者它不是有效的json)。", + "backup_archive_open_failed": "无法打开备份档案", + "backup_archive_name_unknown": "未知的本地备份档案名为'{name}'", + "backup_archive_name_exists": "具有该名称的备份存档已经存在。", + "backup_archive_broken_link": "无法访问备份存档(指向{path}的链接断开)", + "backup_archive_app_not_found": "在备份档案中找不到 {app}", + "backup_applying_method_tar": "创建备份TAR存档...", + "backup_applying_method_custom": "调用自定义备份方法'{method}'...", + "backup_applying_method_copy": "正在将所有文件复制到备份...", + "backup_app_failed": "无法备份{app}", + "backup_actually_backuping": "根据收集的文件创建备份档案...", + "backup_abstract_method": "此备份方法尚未实现", + "ask_password": "密码", + "ask_new_path": "新路径", + "ask_new_domain": "新域名", + "ask_new_admin_password": "新的管理密码", + "ask_main_domain": "主域", + "ask_firstname": "名", + "ask_user_domain": "用户的电子邮件地址和XMPP帐户要使用的域", + "apps_catalog_update_success": "应用程序目录已更新!", + "apps_catalog_obsolete_cache": "应用程序目录缓存为空或已过时。", + "apps_catalog_failed_to_download": "无法下载{apps_catalog} 应用目录: {error}", + "apps_catalog_updating": "正在更新应用程序目录…", + "apps_catalog_init_success": "应用目录系统已初始化!", + "apps_already_up_to_date": "所有应用程序都是最新的", + "app_packaging_format_not_supported": "无法安装此应用,因为您的YunoHost版本不支持其打包格式。 您应该考虑升级系统。", + "app_upgraded": "{app}upgraded", + "app_upgrade_some_app_failed": "某些应用无法升级", + "app_upgrade_script_failed": "应用升级脚本内部发生错误", + "app_upgrade_app_name": "现在升级{app} ...", + "app_upgrade_several_apps": "以下应用将被升级: {apps}", + "app_unsupported_remote_type": "应用程序使用的远程类型不受支持", + "app_start_backup": "正在收集要备份的文件,用于{app} ...", + "app_start_install": "{app}安装中...", + "app_sources_fetch_failed": "无法获取源文件,URL是否正确?", + "app_restore_script_failed": "应用还原脚本内部发生错误", + "app_restore_failed": "无法还原 {app}: {error}", + "app_remove_after_failed_install": "安装失败后删除应用程序...", + "app_requirements_unmeet": "{app}不符合要求,软件包{pkgname}({version}) 必须为{spec}", + "app_requirements_checking": "正在检查{app}所需的软件包...", + "app_removed": "{app} 已删除", + "app_not_properly_removed": "{app} 未正确删除", + "app_not_correctly_installed": "{app} 似乎安装不正确", + "app_not_upgraded": "应用程序'{failed_app}'升级失败,因此以下应用程序的升级已被取消: {apps}", + "app_manifest_install_ask_is_public": "该应用是否应该向匿名访问者公开?", + "app_manifest_install_ask_admin": "选择此应用的管理员用户", + "app_manifest_install_ask_password": "选择此应用的管理密码", + "additional_urls_already_removed": "权限'{permission}'的其他URL中已经删除了附加URL'{url}'", + "app_manifest_install_ask_path": "选择安装此应用的路径", + "app_manifest_install_ask_domain": "选择应安装此应用程序的域", + "app_manifest_invalid": "应用清单错误: {error}", + "app_location_unavailable": "该URL不可用,或与已安装的应用冲突:\n{apps}", + "app_label_deprecated": "不推荐使用此命令!请使用新命令 'yunohost user permission update'来管理应用标签。", + "app_make_default_location_already_used": "无法将'{app}' 设置为域上的默认应用,'{other_app}'已在使用'{domain}'", + "app_install_script_failed": "应用安装脚本内发生错误", + "app_install_failed": "无法安装 {app}: {error}", + "app_install_files_invalid": "这些文件无法安装", + "additional_urls_already_added": "附加URL '{url}' 已添加到权限'{permission}'的附加URL中", + "app_full_domain_unavailable": "抱歉,此应用必须安装在其自己的域中,但其他应用已安装在域“ {domain}”上。 您可以改用专用于此应用程序的子域。", + "app_extraction_failed": "无法解压缩安装文件", + "app_change_url_success": "{app} URL现在为 {domain}{path}", + "app_change_url_no_script": "应用程序'{app_name}'尚不支持URL修改. 也许您应该升级它。", + "app_change_url_identical_domains": "新旧domain / url_path是相同的('{domain}{path}'),无需执行任何操作。", + "app_argument_required": "参数'{name}'为必填项", + "app_argument_password_no_default": "解析密码参数'{name}'时出错:出于安全原因,密码参数不能具有默认值", + "app_argument_invalid": "为参数'{name}'选择一个有效值: {error}", + "app_argument_choice_invalid": "对参数'{name}'使用以下选项之一'{choices}'", + "app_already_up_to_date": "{app} 已经是最新的", + "app_already_installed": "{app}已安装", + "app_action_broke_system": "该操作似乎破坏了以下重要服务:{services}", + "app_action_cannot_be_ran_because_required_services_down": "这些必需的服务应该正在运行以执行以下操作:{services},尝试重新启动它们以继续操作(考虑调查为什么它们出现故障)。", + "already_up_to_date": "无事可做。一切都已经是最新的了。", + "postinstall_low_rootfsspace": "根文件系统的总空间小于10 GB,这非常令人担忧!您可能很快就会用完磁盘空间!建议根文件系统至少有16GB, 如果尽管出现此警告仍要安装YunoHost,请使用--force-diskspace重新运行postinstall", + "port_already_opened": "{ip_version}个连接的端口 {port} 已打开", + "port_already_closed": "{ip_version}个连接的端口 {port} 已关闭", + "permission_require_account": "权限{permission}只对有账户的用户有意义,因此不能对访客启用。", + "permission_protected": "权限{permission}是受保护的。你不能向/从这个权限添加或删除访问者组。", + "permission_updated": "权限 '{permission}' 已更新", + "permission_update_failed": "无法更新权限 '{permission}': {error}", + "permission_not_found": "找不到权限'{permission}'", + "permission_deletion_failed": "无法删除权限 '{permission}': {error}", + "permission_deleted": "权限'{permission}' 已删除", + "permission_cant_add_to_all_users": "权限{permission}不能添加到所有用户。", + "regenconf_file_copy_failed": "无法将新的配置文件'{new}' 复制到'{conf}'", + "regenconf_file_backed_up": "将配置文件 '{conf}' 备份到 '{backup}'", + "regenconf_failed": "无法重新生成类别的配置: {categories}", + "regenconf_dry_pending_applying": "正在检查将应用于类别 '{category}'的待定配置…", + "regenconf_would_be_updated": "配置已更新为类别 '{category}'", + "regenconf_updated": "配置已针对'{category}'进行了更新", + "regenconf_now_managed_by_yunohost": "现在,配置文件'{conf}'由YunoHost(类别{category})管理。", + "regenconf_file_updated": "配置文件'{conf}' 已更新", + "regenconf_file_removed": "配置文件 '{conf}'已删除", + "regenconf_file_remove_failed": "无法删除配置文件 '{conf}'", + "regenconf_file_manually_removed": "配置文件'{conf}' 已手动删除,因此不会创建", + "regenconf_file_manually_modified": "配置文件'{conf}' 已被手动修改,不会被更新", + "regenconf_need_to_explicitly_specify_ssh": "ssh配置已被手动修改,但是您需要使用--force明确指定类别“ ssh”才能实际应用更改。", + "restore_nothings_done": "什么都没有恢复", + "restore_may_be_not_enough_disk_space": "您的系统似乎没有足够的空间(可用空间: {free_space} B,所需空间: {needed_space} B,安全系数: {margin} B)", + "restore_hook_unavailable": "'{part}'的恢复脚本在您的系统上和归档文件中均不可用", + "restore_failed": "无法还原系统", + "restore_extracting": "正在从存档中提取所需文件…", + "restore_confirm_yunohost_installed": "您真的要还原已经安装的系统吗? [{answers}]", + "restore_complete": "恢复完成", + "restore_cleaning_failed": "无法清理临时还原目录", + "restore_backup_too_old": "无法还原此备份存档,因为它来自过旧的YunoHost版本。", + "restore_already_installed_apps": "以下应用已安装,因此无法还原: {apps}", + "restore_already_installed_app": "已安装ID为'{app}' 的应用", + "regex_with_only_domain": "您不能将正则表达式用于域,而只能用于路径", + "regex_incompatible_with_tile": "/!\\ 打包者!权限“ {permission}”的show_tile设置为“ true”,因此您不能将正则表达式URL定义为主URL", + "service_cmd_exec_failed": "无法执行命令'{command}'", + "service_already_stopped": "服务'{service}'已被停止", + "service_already_started": "服务'{service}' 已在运行", + "service_added": "服务 '{service}'已添加", + "service_add_failed": "无法添加服务 '{service}'", + "server_reboot_confirm": "服务器会立即重启,确定吗? [{answers}]", + "server_reboot": "服务器将重新启动", + "server_shutdown_confirm": "服务器会立即关闭,确定吗?[{answers}]", + "server_shutdown": "服务器将关闭", + "root_password_replaced_by_admin_password": "您的root密码已替换为您的管理员密码。", + "root_password_desynchronized": "管理员密码已更改,但是YunoHost无法将此密码传播到root密码!", + "restore_system_part_failed": "无法还原 '{part}'系统部分", + "restore_running_hooks": "运行修复挂钩…", + "restore_running_app_script": "正在还原应用'{app}'…", + "restore_removing_tmp_dir_failed": "无法删除旧的临时目录", + "service_description_yunohost-firewall": "管理打开和关闭服务的连接端口", + "service_description_yunohost-api": "管理YunoHost Web界面与系统之间的交互", + "service_description_ssh": "允许您通过终端(SSH协议)远程连接到服务器", + "service_description_slapd": "存储用户、域名和相关信息", + "service_description_rspamd": "过滤垃圾邮件和其他与电子邮件相关的功能", + "service_description_redis-server": "用于快速数据访问,任务队列和程序之间通信的专用数据库", + "service_description_postfix": "用于发送和接收电子邮件", + "service_description_php7.3-fpm": "使用NGINX运行用PHP编写的应用程序", + "service_description_nginx": "为你的服务器上托管的所有网站提供服务或访问", + "service_description_mysql": "存储应用程序数据(SQL数据库)", + "service_description_metronome": "管理XMPP即时消息传递帐户", + "service_description_fail2ban": "防止来自互联网的暴力攻击和其他类型的攻击", + "service_description_dovecot": "允许电子邮件客户端访问/获取电子邮件(通过IMAP和POP3)", + "service_description_dnsmasq": "处理域名解析(DNS)", + "service_started": "服务 '{service}' 已启动", + "service_start_failed": "无法启动服务 '{service}'\n\n最近的服务日志:{logs}", + "service_reloaded_or_restarted": "服务'{service}'已重新加载或重新启动", + "service_reload_or_restart_failed": "无法重新加载或重新启动服务'{service}'\n\n最近的服务日志:{logs}", + "service_restarted": "服务'{service}' 已重新启动", + "service_restart_failed": "无法重新启动服务 '{service}'\n\n最近的服务日志:{logs}", + "service_reloaded": "服务 '{service}' 已重新加载", + "service_reload_failed": "无法重新加载服务'{service}'\n\n最近的服务日志:{logs}", + "service_removed": "服务 '{service}' 已删除", + "service_remove_failed": "无法删除服务'{service}'", + "service_regen_conf_is_deprecated": "不建议使用'yunohost service regen-conf' ! 请改用'yunohost tools regen-conf'。", + "service_enabled": "现在,服务'{service}' 将在系统引导过程中自动启动。", + "service_enable_failed": "无法使服务 '{service}'在启动时自动启动。\n\n最近的服务日志:{logs}", + "service_disabled": "系统启动时,服务 '{service}' 将不再启动。", + "service_disable_failed": "服务'{service}'在启动时无法启动。\n\n最近的服务日志:{logs}", + "tools_upgrade_regular_packages": "现在正在升级 'regular' (与yunohost无关)的软件包…", + "tools_upgrade_cant_unhold_critical_packages": "无法解压关键软件包…", + "tools_upgrade_cant_hold_critical_packages": "无法保存重要软件包…", + "tools_upgrade_cant_both": "无法同时升级系统和应用程序", + "tools_upgrade_at_least_one": "请指定'apps', 或 'system'", + "this_action_broke_dpkg": "此操作破坏了dpkg / APT(系统软件包管理器)...您可以尝试通过SSH连接并运行`sudo apt install --fix-broken`和/或`sudo dpkg --configure -a`来解决此问题。", + "system_username_exists": "用户名已存在于系统用户列表中", + "system_upgraded": "系统升级", + "ssowat_conf_updated": "SSOwat配置已更新", + "ssowat_conf_generated": "SSOwat配置已重新生成", + "show_tile_cant_be_enabled_for_regex": "你不能启用'show_tile',因为权限'{permission}'的URL是一个重合词", + "show_tile_cant_be_enabled_for_url_not_defined": "您现在无法启用 'show_tile' ,因为您必须先为权限'{permission}'定义一个URL", + "service_unknown": "未知服务 '{service}'", + "service_stopped": "服务'{service}' 已停止", + "service_stop_failed": "无法停止服务'{service}'\n\n最近的服务日志:{logs}", + "upnp_dev_not_found": "找不到UPnP设备", + "upgrading_packages": "升级程序包...", + "upgrade_complete": "升级完成", + "updating_apt_cache": "正在获取系统软件包的可用升级...", + "update_apt_cache_warning": "更新APT缓存(Debian的软件包管理器)时出了点问题。这是sources.list行的转储,这可能有助于确定有问题的行:\n{sourceslist}", + "update_apt_cache_failed": "无法更新APT的缓存(Debian的软件包管理器)。这是sources.list行的转储,这可能有助于确定有问题的行:\n{sourceslist}", + "unrestore_app": "{app} 将不会恢复", + "unlimit": "没有配额", + "unknown_main_domain_path": "'{app}'的域或路径未知。您需要指定一个域和一个路径,以便能够指定用于许可的URL。", + "unexpected_error": "出乎意料的错误: {error}", + "unbackup_app": "{app} 将不会保存", + "tools_upgrade_special_packages_completed": "YunoHost软件包升级完成。\n按[Enter]返回命令行", + "tools_upgrade_special_packages_explanation": "特殊升级将在后台继续。请在接下来的10分钟内(取决于硬件速度)在服务器上不要执行任何其他操作。此后,您可能必须重新登录Webadmin。升级日志将在“工具”→“日志”(在Webadmin中)或使用'yunohost log list'(从命令行)中可用。", + "tools_upgrade_special_packages": "现在正在升级'special'(与yunohost相关的)程序包…", + "tools_upgrade_regular_packages_failed": "无法升级软件包: {packages_list}", + "yunohost_installing": "正在安装YunoHost ...", + "yunohost_configured": "现在已配置YunoHost", + "yunohost_already_installed": "YunoHost已经安装", + "user_updated": "用户信息已更改", + "user_update_failed": "无法更新用户{user}: {error}", + "user_unknown": "未知用户: {user}", + "user_home_creation_failed": "无法为用户创建'home'文件夹", + "user_deletion_failed": "无法删除用户 {user}: {error}", + "user_deleted": "用户已删除", + "user_creation_failed": "无法创建用户 {user}: {error}", + "user_created": "用户创建", + "user_already_exists": "用户'{user}' 已存在", + "upnp_port_open_failed": "无法通过UPnP打开端口", + "upnp_enabled": "UPnP已启用", + "upnp_disabled": "UPnP已禁用", + "yunohost_not_installed": "YunoHost没有正确安装,请运行 'yunohost tools postinstall'", + "yunohost_postinstall_end_tip": "后期安装完成! 为了最终完成你的设置,请考虑:\n -通过webadmin的“用户”部分添加第一个用户(或在命令行中'yunohost user create ' );\n -通过网络管理员的“诊断”部分(或命令行中的'yunohost diagnosis run')诊断潜在问题;\n -阅读管理文档中的“完成安装设置”和“了解YunoHost”部分: https://yunohost.org/admindoc.", + "operation_interrupted": "该操作是否被手动中断?", + "invalid_regex": "无效的正则表达式:'{regex}'", + "installation_complete": "安装完成", + "hook_name_unknown": "未知的钩子名称 '{name}'", + "hook_list_by_invalid": "此属性不能用于列出钩子", + "hook_json_return_error": "无法读取来自钩子 {path}的返回,错误: {msg}。原始内容: {raw_content}", + "hook_exec_not_terminated": "脚本未正确完成: {path}", + "hook_exec_failed": "无法运行脚本: {path}", + "group_user_not_in_group": "用户{user}不在组{group}中", + "group_user_already_in_group": "用户{user}已在组{group}中", + "group_update_failed": "无法更新群组'{group}': {error}", + "group_updated": "群组 '{group}' 已更新", + "group_unknown": "群组 '{group}' 未知", + "group_deletion_failed": "无法删除群组'{group}': {error}", + "group_deleted": "群组'{group}' 已删除", + "group_cannot_be_deleted": "无法手动删除组{group}。", + "group_cannot_edit_primary_group": "不能手动编辑 '{group}' 组。它是旨在仅包含一个特定用户的主要组。", + "group_cannot_edit_visitors": "组“访客”不能手动编辑。这是一个代表匿名访问者的特殊小组", + "group_cannot_edit_all_users": "组“ all_users”不能手动编辑。这是一个特殊的组,旨在包含所有在YunoHost中注册的用户", + "group_creation_failed": "无法创建组'{group}': {error}", + "group_created": "创建了 '{group}'组", + "group_already_exist_on_system_but_removing_it": "系统组中已经存在组{group},但是YunoHost会将其删除...", + "group_already_exist_on_system": "系统组中已经存在组{group}", + "group_already_exist": "群组{group}已经存在", + "good_practices_about_admin_password": "现在,您将设置一个新的管理员密码。 密码至少应包含8个字符。并且出于安全考虑建议使用较长的密码同时尽可能使用各种字符(大写,小写,数字和特殊字符)。", + "global_settings_unknown_type": "意外的情况,设置{setting}似乎具有类型 {unknown_type} ,但是系统不支持该类型。", + "global_settings_setting_backup_compress_tar_archives": "创建新备份时,请压缩档案(.tar.gz) ,而不要压缩未压缩的档案(.tar)。注意:启用此选项意味着创建较小的备份存档,但是初始备份过程将明显更长且占用大量CPU。", + "global_settings_setting_smtp_relay_password": "SMTP中继主机密码", + "global_settings_setting_smtp_relay_user": "SMTP中继用户帐户", + "global_settings_setting_smtp_relay_port": "SMTP中继端口", + "global_settings_setting_smtp_allow_ipv6": "允许使用IPv6接收和发送邮件", + "global_settings_setting_ssowat_panel_overlay_enabled": "启用SSOwat面板覆盖", + "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "允许使用DSA主机密钥进行SSH守护程序配置(不建议使用)", + "global_settings_unknown_setting_from_settings_file": "设置中的未知密钥:'{setting_key}',将其丢弃并保存在/etc/yunohost/settings-unknown.json中", + "global_settings_setting_security_ssh_port": "SSH端口", + "global_settings_setting_security_postfix_compatibility": "Postfix服务器的兼容性与安全性的权衡。影响密码(以及其他与安全性有关的方面)", + "global_settings_setting_security_ssh_compatibility": "SSH服务器的兼容性与安全性的权衡。影响密码(以及其他与安全性有关的方面)", + "global_settings_setting_security_password_user_strength": "用户密码强度", + "global_settings_setting_security_password_admin_strength": "管理员密码强度", + "global_settings_setting_security_nginx_compatibility": "Web服务器NGINX的兼容性与安全性的权衡,影响密码(以及其他与安全性有关的方面)", + "global_settings_setting_pop3_enabled": "为邮件服务器启用POP3协议", + "global_settings_reset_success": "以前的设置现在已经备份到{path}", + "global_settings_key_doesnt_exists": "全局设置中不存在键'{settings_key}',您可以通过运行 'yunohost settings list'来查看所有可用键", + "global_settings_cant_write_settings": "无法保存设置文件,原因: {reason}", + "global_settings_cant_serialize_settings": "无法序列化设置数据,原因: {reason}", + "global_settings_cant_open_settings": "无法打开设置文件,原因: {reason}", + "global_settings_bad_type_for_setting": "设置 {setting},的类型错误,已收到{received_type},预期{expected_type}", + "global_settings_bad_choice_for_enum": "设置 {setting}的错误选择,收到了 '{choice}',但可用的选择有: {available_choices}", + "firewall_rules_cmd_failed": "某些防火墙规则命令失败。日志中的更多信息。", + "firewall_reloaded": "重新加载防火墙", + "firewall_reload_failed": "无法重新加载防火墙", + "file_does_not_exist": "文件{path} 不存在。", + "field_invalid": "无效的字段'{}'", + "experimental_feature": "警告:此功能是实验性的,不稳定,请不要使用它,除非您知道自己在做什么。", + "extracting": "提取中...", + "dyndns_unavailable": "域'{domain}' 不可用。", + "dyndns_domain_not_provided": "DynDNS提供者 {provider} 无法提供域 {domain}。", + "dyndns_registration_failed": "无法注册DynDNS域: {error}", + "dyndns_registered": "DynDNS域已注册", + "dyndns_provider_unreachable": "无法联系DynDNS提供者 {provider}: 您的YunoHost未正确连接到Internet或dynette服务器已关闭。", + "dyndns_no_domain_registered": "没有在DynDNS中注册的域", + "dyndns_key_not_found": "找不到该域的DNS密钥", + "dyndns_key_generating": "正在生成DNS密钥...可能需要一段时间。", + "dyndns_ip_updated": "在DynDNS上更新了您的IP", + "dyndns_ip_update_failed": "无法将IP地址更新到DynDNS", + "dyndns_could_not_check_available": "无法检查{provider}上是否可用 {domain}。", + "dyndns_could_not_check_provide": "无法检查{provider}是否可以提供 {domain}.", + "dpkg_lock_not_available": "该命令现在无法运行,因为另一个程序似乎正在使用dpkg锁(系统软件包管理器)", + "dpkg_is_broken": "您现在不能执行此操作,因为dpkg / APT(系统软件包管理器)似乎处于损坏状态……您可以尝试通过SSH连接并运行sudo apt install --fix-broken和/或 sudo dpkg --configure-a 来解决此问题.", + "downloading": "下载中…", + "done": "完成", + "domains_available": "可用域:", + "domain_name_unknown": "域'{domain}'未知", + "domain_uninstall_app_first": "这些应用程序仍安装在您的域中:\n{apps}\n\n请先使用 'yunohost app remove the_app_id' 将其卸载,或使用 'yunohost app change-url the_app_id'将其移至另一个域,然后再继续删除域", + "domain_remove_confirm_apps_removal": "删除该域将删除这些应用程序:\n{apps}\n\n您确定要这样做吗? [{answers}]", + "domain_hostname_failed": "无法设置新的主机名。稍后可能会引起问题(可能没问题)。", + "domain_exists": "该域已存在", + "domain_dyndns_root_unknown": "未知的DynDNS根域", + "domain_dyndns_already_subscribed": "您已经订阅了DynDNS域", + "domain_dns_conf_is_just_a_recommendation": "本页向你展示了*推荐的*配置。它并*不*为你配置DNS。你有责任根据该建议在你的DNS注册商处配置你的DNS区域。", + "domain_deletion_failed": "无法删除域 {domain}: {error}", + "domain_deleted": "域已删除", + "domain_creation_failed": "无法创建域 {domain}: {error}", + "domain_created": "域已创建", + "domain_cert_gen_failed": "无法生成证书", + "diagnosis_sshd_config_inconsistent": "看起来SSH端口是在/etc/ssh/sshd_config中手动修改, 从YunoHost 4.2开始,可以使用新的全局设置“ security.ssh.port”来避免手动编辑配置。", + "diagnosis_sshd_config_insecure": "SSH配置似乎已被手动修改,并且是不安全的,因为它不包含“ AllowGroups”或“ AllowUsers”指令以限制对授权用户的访问。", + "diagnosis_processes_killed_by_oom_reaper": "该系统最近杀死了某些进程,因为内存不足。这通常是系统内存不足或进程占用大量内存的征兆。 杀死进程的摘要:\n{kills_summary}", + "diagnosis_never_ran_yet": "看来这台服务器是最近安装的,还没有诊断报告可以显示。您应该首先从Web管理员运行完整的诊断,或者从命令行使用'yunohost diagnosis run' 。", + "diagnosis_unknown_categories": "以下类别是未知的: {categories}", + "diagnosis_http_nginx_conf_not_up_to_date_details": "要解决这种情况,请使用yunohost tools regen-conf nginx --dry-run --with-diff,如果还可以,请使用yunohost tools regen-conf nginx --force应用更改。", + "diagnosis_http_nginx_conf_not_up_to_date": "该域的nginx配置似乎已被手动修改,并阻止YunoHost诊断它是否可以在HTTP上访问。", + "diagnosis_http_partially_unreachable": "尽管域{domain}可以在 IPv{failed}中工作,但它似乎无法通过HTTP从外部网络通过HTTP到达IPv{passed}。", + "diagnosis_mail_outgoing_port_25_blocked_details": "您应该首先尝试在Internet路由器界面或主机提供商界面中取消阻止传出端口25。(某些托管服务提供商可能会要求您为此发送支持请求)。", + "diagnosis_mail_outgoing_port_25_blocked": "由于传出端口25在IPv{ipversion}中被阻止,因此SMTP邮件服务器无法向其他服务器发送电子邮件。", + "diagnosis_mail_outgoing_port_25_ok": "SMTP邮件服务器能够发送电子邮件(未阻止出站端口25)。", + "diagnosis_swap_tip": "请注意,如果服务器在SD卡或SSD存储器上托管交换,则可能会大大缩短设备的预期寿命。", + "diagnosis_swap_ok": "系统有{total}个交换!", + "diagnosis_swap_notsomuch": "系统只有{total}个交换。您应该考虑至少使用{recommended},以避免系统内存不足的情况。", + "diagnosis_swap_none": "系统根本没有交换分区。您应该考虑至少添加{recommended}交换,以避免系统内存不足的情况。", + "diagnosis_http_unreachable": "网域{domain}从本地网络外通过HTTP无法访问。", + "diagnosis_http_connection_error": "连接错误:无法连接到请求的域,很可能无法访问。", + "diagnosis_http_ok": "域{domain}可以通过HTTP从本地网络外部访问。", + "diagnosis_http_could_not_diagnose_details": "错误: {error}", + "diagnosis_http_could_not_diagnose": "无法诊断域是否可以从IPv{ipversion}中从外部访问。", + "diagnosis_http_hairpinning_issue_details": "这可能是由于您的ISP 光猫/路由器。因此,使用域名或全局IP时,来自本地网络外部的人员将能够按预期访问您的服务器,但无法访问来自本地网络内部的人员(可能与您一样)。您可以通过查看 https://yunohost.org/dns_local_network 来改善这种情况", + "diagnosis_http_hairpinning_issue": "您的本地网络似乎没有启用NAT回环功能。", + "diagnosis_ports_forwarding_tip": "要解决此问题,您很可能需要按照 https://yunohost.org/isp_box_config 中的说明,在Internet路由器上配置端口转发", + "diagnosis_ports_needed_by": "{category}功能(服务{service})需要公开此端口", + "diagnosis_ports_ok": "可以从外部访问端口{port}。", + "diagnosis_ports_partially_unreachable": "无法从外部通过IPv{failed}访问端口{port}。", + "diagnosis_ports_unreachable": "无法从外部访问端口{port}。", + "diagnosis_ports_could_not_diagnose_details": "错误: {error}", + "diagnosis_ports_could_not_diagnose": "无法诊断端口在IPv{ipversion}中是否可以从外部访问。", + "diagnosis_description_regenconf": "系统配置", + "diagnosis_description_mail": "电子邮件", + "diagnosis_description_web": "网页", + "diagnosis_description_ports": "开放端口", + "diagnosis_description_systemresources": "系统资源", + "diagnosis_description_services": "服务状态检查", + "diagnosis_description_dnsrecords": "DNS记录", + "diagnosis_description_ip": "互联网连接", + "diagnosis_description_basesystem": "基本系统", + "diagnosis_security_vulnerable_to_meltdown_details": "要解决此问题,您应该升级系统并重新启动以加载新的Linux内核(如果无法使用,请与您的服务器提供商联系)。有关更多信息,请参见https://meltdownattack.com/。", + "diagnosis_security_vulnerable_to_meltdown": "你似乎容易受到Meltdown关键安全漏洞的影响", + "diagnosis_regenconf_manually_modified": "配置文件 {file} 似乎已被手动修改。", + "diagnosis_regenconf_allgood": "所有配置文件均符合建议的配置!", + "diagnosis_mail_queue_too_big": "邮件队列中的待处理电子邮件过多({nb_pending} emails)", + "diagnosis_mail_queue_unavailable_details": "错误: {error}", + "diagnosis_mail_queue_unavailable": "无法查询队列中待处理电子邮件的数量", + "diagnosis_mail_queue_ok": "邮件队列中有{nb_pending} 个待处理的电子邮件", + "diagnosis_mail_blacklist_website": "确定列出的原因并加以修复后,请随时在{blacklist_website}上要求删除您的IP或域名", + "diagnosis_mail_blacklist_reason": "黑名单的原因是: {reason}", + "diagnosis_mail_blacklist_listed_by": "您的IP或域{item} 已在{blacklist_name}上列入黑名单", + "diagnosis_mail_blacklist_ok": "该服务器使用的IP和域似乎未列入黑名单", + "diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "当前反向DNS值为: {rdns_domain}
期待值:{ehlo_domain}", + "diagnosis_mail_fcrdns_different_from_ehlo_domain": "反向DNS未在 IPv{ipversion}中正确配置。某些电子邮件可能无法传递或可能被标记为垃圾邮件。", + "diagnosis_mail_fcrdns_nok_details": "您应该首先尝试在Internet路由器界面或托管服务提供商界面中使用{ehlo_domain}配置反向DNS。(某些托管服务提供商可能会要求您为此发送支持票)。", + "diagnosis_mail_fcrdns_dns_missing": "IPv{ipversion}中未定义反向DNS。某些电子邮件可能无法传递或可能被标记为垃圾邮件。", + "diagnosis_mail_fcrdns_ok": "您的反向DNS已正确配置!", + "diagnosis_mail_ehlo_could_not_diagnose_details": "错误: {error}", + "diagnosis_mail_ehlo_could_not_diagnose": "无法诊断Postfix邮件服务器是否可以从IPv{ipversion}中从外部访问。", + "diagnosis_mail_ehlo_wrong": "不同的SMTP邮件服务器在IPv{ipversion}上进行应答。您的服务器可能无法接收电子邮件。", + "diagnosis_mail_ehlo_bad_answer_details": "这可能是由于其他计算机而不是您的服务器在应答。", + "diagnosis_mail_ehlo_bad_answer": "一个非SMTP服务在IPv{ipversion}的25端口应答", + "diagnosis_mail_ehlo_unreachable": "SMTP邮件服务器在IPv{ipversion}上无法从外部访问。它将无法接收电子邮件。", + "diagnosis_mail_ehlo_ok": "SMTP邮件服务器可以从外部访问,因此可以接收电子邮件!", + "diagnosis_services_bad_status": "服务{service}为 {status} :(", + "diagnosis_services_conf_broken": "服务{service}的配置已损坏!", + "diagnosis_services_running": "服务{service}正在运行!", + "diagnosis_domain_expires_in": "{domain}在{days}天后到期。", + "diagnosis_domain_expiration_error": "有些域很快就会过期!", + "diagnosis_domain_expiration_warning": "一些域即将过期!", + "diagnosis_domain_expiration_success": "您的域已注册,并且不会很快过期。", + "diagnosis_domain_expiration_not_found_details": "域{domain}的WHOIS信息似乎不包含有关到期日期的信息?", + "diagnosis_domain_not_found_details": "域{domain}在WHOIS数据库中不存在或已过期!", + "diagnosis_domain_expiration_not_found": "无法检查某些域的到期日期", + "diagnosis_dns_missing_record": "根据建议的DNS配置,您应该添加带有以下信息的DNS记录。
类型:{type}
名称:{name}
值:{value}", + "diagnosis_dns_bad_conf": "域{domain}(类别{category})的某些DNS记录丢失或不正确", + "diagnosis_dns_good_conf": "已为域{domain}(类别{category})正确配置了DNS记录", + "diagnosis_ip_weird_resolvconf_details": "文件 /etc/resolv.conf 应该是指向 /etc/resolvconf/run/resolv.conf 本身的符号链接,指向 127.0.0.1 (dnsmasq)。如果要手动配置DNS解析器,请编辑 /etc/resolv.dnsmasq.conf。", + "diagnosis_ip_weird_resolvconf": "DNS解析似乎可以正常工作,但是您似乎正在使用自定义的 /etc/resolv.conf 。", + "diagnosis_ip_broken_resolvconf": "域名解析在您的服务器上似乎已损坏,这似乎与 /etc/resolv.conf 有关,但未指向 127.0.0.1 。", + "diagnosis_ip_broken_dnsresolution": "域名解析似乎由于某种原因而被破坏...防火墙是否阻止了DNS请求?", + "diagnosis_ip_dnsresolution_working": "域名解析正常!", + "diagnosis_ip_not_connected_at_all": "服务器似乎根本没有连接到Internet?", + "diagnosis_ip_local": "本地IP:{local}", + "diagnosis_ip_global": "全局IP: {global}", + "diagnosis_ip_no_ipv6_tip": "正常运行的IPv6并不是服务器正常运行所必需的,但是对于整个Internet的健康而言,则更好。通常,IPv6应该由系统或您的提供商自动配置(如果可用)。否则,您可能需要按照此处的文档中的说明手动配置一些内容: https://yunohost.org/#/ipv6。如果您无法启用IPv6或对您来说太过困难,也可以安全地忽略此警告。", + "diagnosis_ip_no_ipv6": "服务器没有可用的IPv6。", + "diagnosis_ip_connected_ipv6": "服务器通过IPv6连接到Internet!", + "diagnosis_ip_no_ipv4": "服务器没有可用的IPv4。", + "diagnosis_ip_connected_ipv4": "服务器通过IPv4连接到Internet!", + "diagnosis_no_cache": "尚无类别 '{category}'的诊断缓存", + "diagnosis_failed": "无法获取类别 '{category}'的诊断结果: {error}", + "diagnosis_package_installed_from_sury_details": "一些软件包被无意中从一个名为Sury的第三方仓库安装。YunoHost团队改进了处理这些软件包的策略,但预计一些安装了PHP7.3应用程序的设置在仍然使用Stretch的情况下还有一些不一致的地方。为了解决这种情况,你应该尝试运行以下命令:{cmd_to_fix}", + "app_not_installed": "在已安装的应用列表中找不到 {app}:{all_apps}", + "app_already_installed_cant_change_url": "这个应用程序已经被安装。URL不能仅仅通过这个函数来改变。在`app changeurl`中检查是否可用。", + "restore_not_enough_disk_space": "没有足够的空间(空间: {free_space} B,需要的空间: {needed_space} B,安全系数: {margin} B)", + "regenconf_pending_applying": "正在为类别'{category}'应用挂起的配置..", + "regenconf_up_to_date": "类别'{category}'的配置已经是最新的", + "regenconf_file_kept_back": "配置文件'{conf}'预计将被regen-conf(类别{category})删除,但被保留了下来。", + "good_practices_about_user_password": "现在,您将设置一个新的管理员密码。 密码至少应包含8个字符。并且出于安全考虑建议使用较长的密码同时尽可能使用各种字符(大写,小写,数字和特殊字符)", + "global_settings_setting_smtp_relay_host": "使用SMTP中继主机来代替这个YunoHost实例发送邮件。如果你有以下情况,就很有用:你的25端口被你的ISP或VPS提供商封锁,你有一个住宅IP列在DUHL上,你不能配置反向DNS,或者这个服务器没有直接暴露在互联网上,你想使用其他服务器来发送邮件。", + "domain_cannot_remove_main_add_new_one": "你不能删除'{domain}',因为它是主域和你唯一的域,你需要先用'yunohost domain add '添加另一个域,然后用'yunohost domain main-domain -n '设置为主域,然后你可以用'yunohost domain remove {domain}'删除域", + "domain_cannot_add_xmpp_upload": "你不能添加以'xmpp-upload.'开头的域名。这种名称是为YunoHost中集成的XMPP上传功能保留的。", + "domain_cannot_remove_main": "你不能删除'{domain}',因为它是主域,你首先需要用'yunohost domain main-domain -n '设置另一个域作为主域;这里是候选域的列表: {other_domains}", + "diagnosis_sshd_config_inconsistent_details": "请运行yunohost settings set security.ssh.port -v YOUR_SSH_PORT来定义SSH端口,并检查yunohost tools regen-conf ssh --dry-run --with-diffyunohost tools regen-conf ssh --force将您的配置重置为YunoHost建议。", + "diagnosis_http_bad_status_code": "它看起来像另一台机器(也许是你的互联网路由器)回答,而不是你的服务器。
1。这个问题最常见的原因是80端口(和443端口)没有正确转发到您的服务器
2.在更复杂的设置中:确保没有防火墙或反向代理的干扰。", + "diagnosis_http_timeout": "当试图从外部联系你的服务器时,出现了超时。它似乎是不可达的。
1. 这个问题最常见的原因是80端口(和443端口)没有正确转发到你的服务器
2.你还应该确保nginx服务正在运行
3.对于更复杂的设置:确保没有防火墙或反向代理的干扰。", + "diagnosis_rootfstotalspace_critical": "根文件系统总共只有{space},这很令人担忧!您可能很快就会用完磁盘空间!建议根文件系统至少有16 GB。", + "diagnosis_rootfstotalspace_warning": "根文件系统总共只有{space}。这可能没问题,但要小心,因为最终您可能很快会用完磁盘空间...建议根文件系统至少有16 GB。", + "diagnosis_regenconf_manually_modified_details": "如果你知道自己在做什么的话,这可能是可以的! YunoHost会自动停止更新这个文件... 但是请注意,YunoHost的升级可能包含重要的推荐变化。如果你想,你可以用yunohost tools regen-conf {category} --dry-run --with-diff检查差异,然后用yunohost tools regen-conf {category} --force强制设置为推荐配置", + "diagnosis_mail_fcrdns_nok_alternatives_6": "有些供应商不会让你配置你的反向DNS(或者他们的功能可能被破坏......)。如果你的反向DNS正确配置为IPv4,你可以尝试在发送邮件时禁用IPv6,方法是运yunohost settings set smtp.allow_ipv6 -v off。注意:这应视为最后一个解决方案因为这意味着你将无法从少数只使用IPv6的服务器发送或接收电子邮件。", + "diagnosis_mail_fcrdns_nok_alternatives_4": "有些供应商不会让你配置你的反向DNS(或者他们的功能可能被破坏......)。如果您因此而遇到问题,请考虑以下解决方案:
- 一些ISP提供了使用邮件服务器中转的选择,尽管这意味着中转将能够监视您的电子邮件流量。
- 一个有利于隐私的选择是使用VPN*与专用公共IP*来绕过这类限制。见https://yunohost.org/#/vpn_advantage
- 或者可以切换到另一个供应商", + "diagnosis_mail_ehlo_wrong_details": "远程诊断器在IPv{ipversion}中收到的EHLO与你的服务器的域名不同。
收到的EHLO: {wrong_ehlo}
预期的: {right_ehlo}
这个问题最常见的原因是端口25没有正确转发到你的服务器。另外,请确保没有防火墙或反向代理的干扰。", + "diagnosis_mail_ehlo_unreachable_details": "在IPv{ipversion}中无法打开与您服务器的25端口连接。它似乎是不可达的。
1. 这个问题最常见的原因是端口25没有正确转发到你的服务器
2.你还应该确保postfix服务正在运行。
3.在更复杂的设置中:确保没有防火墙或反向代理的干扰。", + "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "一些供应商不会让你解除对出站端口25的封锁,因为他们不关心网络中立性。
- 其中一些供应商提供了使用邮件服务器中继的替代方案,尽管这意味着中继将能够监视你的电子邮件流量。
- 一个有利于隐私的替代方案是使用VPN*,用一个专用的公共IP*绕过这种限制。见https://yunohost.org/#/vpn_advantage
- 你也可以考虑切换到一个更有利于网络中立的供应商", + "diagnosis_ram_ok": "系统在{total}中仍然有 {available} ({available_percent}%) RAM可用。", + "diagnosis_ram_low": "系统有 {available} ({available_percent}%) RAM可用(共{total}个)可用。小心。", + "diagnosis_ram_verylow": "系统只有 {available} ({available_percent}%) 内存可用! (在{total}中)", + "diagnosis_diskusage_ok": "存储器{mountpoint}(在设备{device}上)仍有 {free} ({free_percent}%) 空间(在{total}中)!", + "diagnosis_diskusage_low": "存储器{mountpoint}(在设备{device}上)只有{free} ({free_percent}%) 的空间。({free_percent}%)的剩余空间(在{total}中)。要小心。", + "diagnosis_diskusage_verylow": "存储器{mountpoint}(在设备{device}上)仅剩余{free} ({free_percent}%) (剩余{total})个空间。您应该真正考虑清理一些空间!", + "diagnosis_services_bad_status_tip": "你可以尝试重新启动服务,如果没有效果,可以看看webadmin中的服务日志(从命令行,你可以用yunohost service restart {service}yunohost service log {service})来做。", + "diagnosis_dns_try_dyndns_update_force": "该域的DNS配置应由YunoHost自动管理,如果不是这种情况,您可以尝试使用 yunohost dyndns update --force强制进行更新。", + "diagnosis_dns_point_to_doc": "如果您需要有关配置DNS记录的帮助,请查看 https://yunohost.org/dns_config 上的文档。", + "diagnosis_dns_discrepancy": "以下DNS记录似乎未遵循建议的配置:
类型: {type}
名称: {name}
代码> 当前值: {current}期望值: {value}", + "log_backup_create": "创建备份档案", + "log_available_on_yunopaste": "现在可以通过{url}使用此日志", + "log_app_action_run": "运行 '{}' 应用的操作", + "log_app_makedefault": "将 '{}' 设为默认应用", + "log_app_upgrade": "升级 '{}' 应用", + "log_app_remove": "删除 '{}' 应用", + "log_app_install": "安装 '{}' 应用", + "log_app_change_url": "更改'{}'应用的网址", + "log_operation_unit_unclosed_properly": "操作单元未正确关闭", + "log_does_exists": "没有名称为'{log}'的操作日志,请使用 'yunohost log list' 查看所有可用的操作日志", + "log_help_to_get_failed_log": "操作'{desc}'无法完成。请使用命令'yunohost log share {name}' 共享此操作的完整日志以获取帮助", + "log_link_to_failed_log": "无法完成操作 '{desc}'。请通过单击此处提供此操作的完整日志以获取帮助", + "log_help_to_get_log": "要查看操作'{desc}'的日志,请使用命令'yunohost log show {name}'", + "log_link_to_log": "此操作的完整日志: '{desc}'", + "log_corrupted_md_file": "与日志关联的YAML元数据文件已损坏: '{md_file}\n错误: {error}'", + "iptables_unavailable": "你不能在这里使用iptables。你要么在一个容器中,要么你的内核不支持它", + "ip6tables_unavailable": "你不能在这里使用ip6tables。你要么在一个容器中,要么你的内核不支持它", + "log_regen_conf": "重新生成系统配置'{}'", + "log_letsencrypt_cert_renew": "续订'{}'的“Let's Encrypt”证书", + "log_selfsigned_cert_install": "在 '{}'域上安装自签名证书", + "log_permission_url": "更新与权限'{}'相关的网址", + "log_permission_delete": "删除权限'{}'", + "log_permission_create": "创建权限'{}'", + "log_letsencrypt_cert_install": "在'{}'域上安装“Let's Encrypt”证书", + "log_dyndns_update": "更新与您的YunoHost子域'{}'关联的IP", + "log_dyndns_subscribe": "订阅YunoHost子域'{}'", + "log_domain_remove": "从系统配置中删除 '{}' 域", + "log_domain_add": "将 '{}'域添加到系统配置中", + "log_remove_on_failed_install": "安装失败后删除 '{}'", + "log_remove_on_failed_restore": "从备份存档还原失败后,删除 '{}'", + "log_backup_restore_app": "从备份存档还原 '{}'", + "log_backup_restore_system": "从备份档案还原系统", + "permission_currently_allowed_for_all_users": "这个权限目前除了授予其他组以外,还授予所有用户。你可能想删除'all_users'权限或删除目前授予它的其他组。", + "permission_creation_failed": "无法创建权限'{permission}': {error}", + "permission_created": "权限'{permission}'已创建", + "permission_cannot_remove_main": "不允许删除主要权限", + "permission_already_up_to_date": "权限没有被更新,因为添加/删除请求已经符合当前状态。", + "permission_already_exist": "权限 '{permission}'已存在", + "permission_already_disallowed": "群组'{group}'已禁用权限'{permission}'", + "permission_already_allowed": "群组 '{group}' 已启用权限'{permission}'", + "pattern_password_app": "抱歉,密码不能包含以下字符: {forbidden_chars}", + "pattern_username": "只能为小写字母数字和下划线字符", + "pattern_port_or_range": "必须是有效的端口号(即0-65535)或端口范围(例如100:200)", + "pattern_password": "必须至少3个字符长", + "pattern_mailbox_quota": "必须为带b/k/M/G/T 后缀的大小或0,才能没有配额", + "pattern_lastname": "必须是有效的姓氏", + "pattern_firstname": "必须是有效的名字", + "pattern_email": "必须是有效的电子邮件地址,没有'+'符号(例如someone @ example.com)", + "pattern_email_forward": "必须是有效的电子邮件地址,接受 '+' 符号(例如someone + tag @ example.com)", + "pattern_domain": "必须是有效的域名(例如my-domain.org)", + "pattern_backup_archive_name": "必须是一个有效的文件名,最多30个字符,只有-_.和字母数字", + "password_too_simple_4": "密码长度至少为12个字符,并且包含数字,大写,小写和特殊字符", + "password_too_simple_3": "密码长度至少为8个字符,并且包含数字,大写,小写和特殊字符", + "password_too_simple_2": "密码长度至少为8个字符,并且包含数字,大写和小写字符", + "password_listed": "该密码是世界上最常用的密码之一。 请选择一些更独特的东西。", + "packages_upgrade_failed": "无法升级所有软件包", + "invalid_number": "必须是数字", + "not_enough_disk_space": "'{path}'上的可用空间不足", + "migrations_to_be_ran_manually": "迁移{id}必须手动运行。请转到webadmin页面上的工具→迁移,或运行`yunohost tools migrations run`。", + "migrations_success_forward": "迁移 {id} 已完成", + "migrations_skip_migration": "正在跳过迁移{id}...", + "migrations_running_forward": "正在运行迁移{id}...", + "migrations_pending_cant_rerun": "这些迁移仍处于待处理状态,因此无法再次运行: {ids}", + "migrations_not_pending_cant_skip": "这些迁移没有待处理,因此不能跳过: {ids}", + "migrations_no_such_migration": "没有称为 '{id}'的迁移", + "migrations_no_migrations_to_run": "无需迁移即可运行", + "migrations_need_to_accept_disclaimer": "要运行迁移{id},您必须接受以下免责声明:\n---\n{disclaimer}\n---\n如果您接受并继续运行迁移,请使用选项'--accept-disclaimer'重新运行该命令。", + "migrations_must_provide_explicit_targets": "使用'--skip'或'--force-rerun'时必须提供明确的目标", + "migrations_migration_has_failed": "迁移{id}尚未完成,正在中止。错误: {exception}", + "migrations_loading_migration": "正在加载迁移{id}...", + "migrations_list_conflict_pending_done": "您不能同时使用'--previous' 和'--done'。", + "migrations_exclusive_options": "'--auto', '--skip',和'--force-rerun'是互斥的选项。", + "migrations_failed_to_load_migration": "无法加载迁移{id}: {error}", + "migrations_dependencies_not_satisfied": "在迁移{id}之前运行以下迁移: '{dependencies_id}'。", + "migrations_cant_reach_migration_file": "无法访问路径'%s'处的迁移文件", + "migrations_already_ran": "这些迁移已经完成: {ids}", + "migration_0019_slapd_config_will_be_overwritten": "好像您手动编辑了slapd配置。对于此关键迁移,YunoHost需要强制更新slapd配置。原始文件将备份在{conf_backup_folder}中。", + "migration_0019_add_new_attributes_in_ldap": "在LDAP数据库中添加权限的新属性", + "migration_0018_failed_to_reset_legacy_rules": "无法重置旧版iptables规则: {error}", + "migration_0018_failed_to_migrate_iptables_rules": "无法将旧的iptables规则迁移到nftables: {error}", + "migration_0017_not_enough_space": "在{path}中提供足够的空间来运行迁移。", + "migration_0017_postgresql_11_not_installed": "已安装PostgreSQL 9.6,但未安装PostgreSQL11?您的系统上可能发生了一些奇怪的事情:(...", + "migration_0017_postgresql_96_not_installed": "PostgreSQL未安装在您的系统上。无事可做。", + "migration_0015_weak_certs": "发现以下证书仍然使用弱签名算法,并且必须升级以与下一版本的nginx兼容: {certs}", + "migration_0015_cleaning_up": "清理不再有用的缓存和软件包...", + "migration_0015_specific_upgrade": "开始升级需要独立升级的系统软件包...", + "migration_0015_modified_files": "请注意,发现以下文件是手动修改的,并且在升级后可能会被覆盖: {manually_modified_files}", + "migration_0015_problematic_apps_warning": "请注意,已检测到以下可能有问题的已安装应用程序。看起来好像那些不是从YunoHost应用程序目录中安装的,或者没有标记为“正在运行”。因此,不能保证它们在升级后仍然可以使用: {problematic_apps}", + "migration_0015_general_warning": "请注意,此迁移是一项微妙的操作。YunoHost团队竭尽全力对其进行检查和测试,但迁移仍可能会破坏系统或其应用程序的某些部分。\n\n因此,建议:\n -对任何关键数据或应用程序执行备份。有关更多信息,请访问https://yunohost.org/backup;\n -启动迁移后要耐心:根据您的Internet连接和硬件,升级所有内容最多可能需要几个小时。", + "migration_0015_system_not_fully_up_to_date": "您的系统不是最新的。请先执行常规升级,然后再运行向Buster的迁移。", + "migration_0015_not_enough_free_space": "/var/中的可用空间非常低!您应该至少有1GB的可用空间来运行此迁移。", + "migration_0015_not_stretch": "当前的Debian发行版不是Stretch!", + "migration_0015_yunohost_upgrade": "正在启动YunoHost核心升级...", + "migration_0015_still_on_stretch_after_main_upgrade": "在主要升级期间出了点问题,系统似乎仍在Debian Stretch上", + "migration_0015_main_upgrade": "正在开始主要升级...", + "migration_0015_patching_sources_list": "修补sources.lists ...", + "migration_0015_start": "开始迁移至Buster", + "migration_update_LDAP_schema": "正在更新LDAP模式...", + "migration_ldap_rollback_success": "系统回滚。", + "migration_ldap_migration_failed_trying_to_rollback": "无法迁移...试图回滚系统。", + "migration_ldap_can_not_backup_before_migration": "迁移失败之前,无法完成系统的备份。错误: {error}", + "migration_ldap_backup_before_migration": "在实际迁移之前,请创建LDAP数据库和应用程序设置的备份。", + "migration_description_0020_ssh_sftp_permissions": "添加SSH和SFTP权限支持", + "migration_description_0019_extend_permissions_features": "扩展/修改应用程序的权限管理系统", + "migration_description_0018_xtable_to_nftable": "将旧的网络流量规则迁移到新的nftable系统", + "migration_description_0017_postgresql_9p6_to_11": "将数据库从PostgreSQL 9.6迁移到11", + "migration_description_0016_php70_to_php73_pools": "将php7.0-fpm'pool'conf文件迁移到php7.3", + "migration_description_0015_migrate_to_buster": "将系统升级到Debian Buster和YunoHost 4.x", + "migrating_legacy_permission_settings": "正在迁移旧版权限设置...", + "main_domain_changed": "主域已更改", + "main_domain_change_failed": "无法更改主域", + "mail_unavailable": "该电子邮件地址是保留的,并且将自动分配给第一个用户", + "mailbox_used_space_dovecot_down": "如果要获取使用过的邮箱空间,则必须启动Dovecot邮箱服务", + "mailbox_disabled": "用户{user}的电子邮件已关闭", + "mail_forward_remove_failed": "无法删除电子邮件转发'{mail}'", + "mail_domain_unknown": "域'{domain}'的电子邮件地址无效。请使用本服务器管理的域。", + "mail_alias_remove_failed": "无法删除电子邮件别名'{mail}'", + "log_tools_reboot": "重新启动服务器", + "log_tools_shutdown": "关闭服务器", + "log_tools_upgrade": "升级系统软件包", + "log_tools_postinstall": "安装好你的YunoHost服务器后", + "log_tools_migrations_migrate_forward": "运行迁移", + "log_domain_main_domain": "将 '{}' 设为主要域", + "log_user_permission_reset": "重置权限'{}'", + "log_user_permission_update": "更新权限'{}'的访问权限", + "log_user_update": "更新用户'{}'的信息", + "log_user_group_update": "更新组'{}'", + "log_user_group_delete": "删除组'{}'", + "log_user_group_create": "创建组'{}'", + "log_user_delete": "删除用户'{}'", + "log_user_create": "添加用户'{}'" +} \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..27d690435 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,16 @@ +[pytest] +addopts = -s -v +norecursedirs = dist doc build .tox .eggs +testpaths = tests/ +markers = + with_system_archive_from_3p8 + with_backup_recommended_app_installed + clean_opt_dir + with_wordpress_archive_from_3p8 + with_legacy_app_installed + with_backup_recommended_app_installed_with_ynh_restore + with_permission_app_installed + other_domains + with_custom_domain +filterwarnings = + ignore::urllib3.exceptions.InsecureRequestWarning \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..db1dde69d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[flake8] +ignore = E501,E128,E731,E722 diff --git a/src/yunohost/__init__.py b/src/yunohost/__init__.py index e69de29bb..dad73e2a4 100644 --- a/src/yunohost/__init__.py +++ b/src/yunohost/__init__.py @@ -0,0 +1,161 @@ +#! /usr/bin/python +# -*- coding: utf-8 -*- + +import os +import sys + +import moulinette +from moulinette import m18n +from moulinette.utils.log import configure_logging +from moulinette.interfaces.cli import colorize, get_locale + + +def is_installed(): + return os.path.isfile("/etc/yunohost/installed") + + +def cli(debug, quiet, output_as, timeout, args, parser): + + init_logging(interface="cli", debug=debug, quiet=quiet) + + # Check that YunoHost is installed + if not is_installed(): + check_command_is_valid_before_postinstall(args) + + ret = moulinette.cli(args, output_as=output_as, timeout=timeout, top_parser=parser) + sys.exit(ret) + + +def api(debug, host, port): + + init_logging(interface="api", debug=debug) + + def is_installed_api(): + return {"installed": is_installed()} + + # FIXME : someday, maybe find a way to disable route /postinstall if + # postinstall already done ... + + ret = moulinette.api( + host=host, + port=port, + routes={("GET", "/installed"): is_installed_api}, + ) + sys.exit(ret) + + +def check_command_is_valid_before_postinstall(args): + + allowed_if_not_postinstalled = [ + "tools postinstall", + "tools versions", + "tools shell", + "backup list", + "backup restore", + "log display", + ] + + if len(args) < 2 or (args[0] + " " + args[1] not in allowed_if_not_postinstalled): + init_i18n() + print(colorize(m18n.g("error"), "red") + " " + m18n.n("yunohost_not_installed")) + sys.exit(1) + + +def init(interface="cli", debug=False, quiet=False, logdir="/var/log/yunohost"): + """ + This is a small util function ONLY meant to be used to initialize a Yunohost + context when ran from tests or from scripts. + """ + init_logging(interface=interface, debug=debug, quiet=quiet, logdir=logdir) + init_i18n() + from moulinette.core import MoulinetteLock + + lock = MoulinetteLock("yunohost", timeout=30) + lock.acquire() + return lock + + +def init_i18n(): + # This should only be called when not willing to go through moulinette.cli + # or moulinette.api but still willing to call m18n.n/g... + m18n.load_namespace("yunohost") + m18n.set_locale(get_locale()) + + +def init_logging(interface="cli", debug=False, quiet=False, logdir="/var/log/yunohost"): + + logfile = os.path.join(logdir, "yunohost-%s.log" % interface) + + if not os.path.isdir(logdir): + os.makedirs(logdir, 0o750) + + logging_configuration = { + "version": 1, + "disable_existing_loggers": True, + "formatters": { + "console": { + "format": "%(relativeCreated)-5d %(levelname)-8s %(name)s %(funcName)s - %(fmessage)s" + }, + "tty-debug": {"format": "%(relativeCreated)-4d %(fmessage)s"}, + "precise": { + "format": "%(asctime)-15s %(levelname)-8s %(name)s %(funcName)s - %(fmessage)s" + }, + }, + "filters": { + "action": { + "()": "moulinette.utils.log.ActionFilter", + }, + }, + "handlers": { + "cli": { + "level": "DEBUG" if debug else "INFO", + "class": "moulinette.interfaces.cli.TTYHandler", + "formatter": "tty-debug" if debug else "", + }, + "api": { + "level": "DEBUG" if debug else "INFO", + "class": "moulinette.interfaces.api.APIQueueHandler", + }, + "file": { + "class": "logging.FileHandler", + "formatter": "precise", + "filename": logfile, + "filters": ["action"], + }, + }, + "loggers": { + "yunohost": { + "level": "DEBUG", + "handlers": ["file", interface] if not quiet else ["file"], + "propagate": False, + }, + "moulinette": { + "level": "DEBUG", + "handlers": ["file", interface] if not quiet else ["file"], + "propagate": False, + }, + }, + "root": { + "level": "DEBUG", + "handlers": ["file", interface] if debug else ["file"], + }, + } + + # Logging configuration for CLI (or any other interface than api...) # + if interface != "api": + configure_logging(logging_configuration) + + # Logging configuration for API # + else: + # We use a WatchedFileHandler instead of regular FileHandler to possibly support log rotation etc + logging_configuration["handlers"]["file"][ + "class" + ] = "logging.handlers.WatchedFileHandler" + + # This is for when launching yunohost-api in debug mode, we want to display stuff in the console + if debug: + logging_configuration["loggers"]["yunohost"]["handlers"].append("cli") + logging_configuration["loggers"]["moulinette"]["handlers"].append("cli") + logging_configuration["root"]["handlers"].append("cli") + + configure_logging(logging_configuration) diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 4831f050c..0013fcd82 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -30,424 +30,388 @@ import shutil import yaml import time import re -import urlparse import subprocess import glob -import pwd -import grp -import urllib +import tempfile from collections import OrderedDict -from datetime import datetime +from typing import List -from moulinette import msignals, m18n, msettings +from moulinette import Moulinette, m18n +from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger -from moulinette.utils.filesystem import read_json, read_toml +from moulinette.utils.network import download_json +from moulinette.utils.process import run_commands, check_output +from moulinette.utils.filesystem import ( + read_file, + read_json, + read_toml, + read_yaml, + write_to_file, + write_to_json, + write_to_yaml, + mkdir, +) -from yunohost.service import service_log, service_status, _run_service_command from yunohost.utils import packages -from yunohost.utils.error import YunohostError +from yunohost.utils.config import ( + ConfigPanel, + ask_questions_and_parse_answers, + Question, + DomainQuestion, + PathQuestion, +) +from yunohost.utils.i18n import _value_for_locale +from yunohost.utils.error import YunohostError, YunohostValidationError +from yunohost.utils.filesystem import free_space_in_directory from yunohost.log import is_unit_operation, OperationLogger -logger = getActionLogger('yunohost.app') +logger = getActionLogger("yunohost.app") -REPO_PATH = '/var/cache/yunohost/repo' -APPS_PATH = '/usr/share/yunohost/apps' -APPS_SETTING_PATH = '/etc/yunohost/apps/' -INSTALL_TMP = '/var/cache/yunohost' -APP_TMP_FOLDER = INSTALL_TMP + '/from_file' -APPSLISTS_JSON = '/etc/yunohost/appslists.json' +APPS_SETTING_PATH = "/etc/yunohost/apps/" +APP_TMP_WORKDIRS = "/var/cache/yunohost/app_tmp_work_dirs" -re_github_repo = re.compile( - r'^(http[s]?://|git@)github.com[/:]' - '(?P[\w\-_]+)/(?P[\w\-_]+)(.git)?' - '(/tree/(?P.+))?' -) +APPS_CATALOG_CACHE = "/var/cache/yunohost/repo" +APPS_CATALOG_CONF = "/etc/yunohost/apps_catalog.yml" +APPS_CATALOG_API_VERSION = 2 +APPS_CATALOG_DEFAULT_URL = "https://app.yunohost.org/default" re_app_instance_name = re.compile( - r'^(?P[\w-]+?)(__(?P[1-9][0-9]*))?$' + r"^(?P[\w-]+?)(__(?P[1-9][0-9]*))?$" ) -def app_listlists(): +def app_catalog(full=False, with_categories=False): """ - List fetched lists - + Return a dict of apps available to installation from Yunohost's app catalog """ - # Migrate appslist system if needed - # XXX move to a migration when those are implemented - if _using_legacy_appslist_system(): - _migrate_appslist_system() + # Get app list from catalog cache + catalog = _load_apps_catalog() + installed_apps = set(_installed_apps()) - # Get the list - appslist_list = _read_appslist_list() + # Trim info for apps if not using --full + for app, infos in catalog["apps"].items(): + infos["installed"] = app in installed_apps - # Convert 'lastUpdate' timestamp to datetime - for name, infos in appslist_list.items(): - if infos["lastUpdate"] is None: - infos["lastUpdate"] = 0 - infos["lastUpdate"] = datetime.utcfromtimestamp(infos["lastUpdate"]) + infos["manifest"]["description"] = _value_for_locale( + infos["manifest"]["description"] + ) - return appslist_list - - -def app_fetchlist(url=None, name=None): - """ - Fetch application list(s) from app server. By default, fetch all lists. - - Keyword argument: - name -- Name of the list - url -- URL of remote JSON list - """ - if url and not url.endswith(".json"): - raise YunohostError("This is not a valid application list url. It should end with .json.") - - # If needed, create folder where actual appslists are stored - if not os.path.exists(REPO_PATH): - os.makedirs(REPO_PATH) - - # Migrate appslist system if needed - # XXX move that to a migration once they are finished - if _using_legacy_appslist_system(): - _migrate_appslist_system() - - # Read the list of appslist... - appslists = _read_appslist_list() - - # Determine the list of appslist to be fetched - appslists_to_be_fetched = [] - - # If a url and and a name is given, try to register new list, - # the fetch only this list - if url is not None: - if name: - operation_logger = OperationLogger('app_fetchlist') - operation_logger.start() - _register_new_appslist(url, name) - # Refresh the appslists dict - appslists = _read_appslist_list() - appslists_to_be_fetched = [name] - operation_logger.success() + if not full: + catalog["apps"][app] = { + "description": infos["manifest"]["description"], + "level": infos["level"], + } else: - raise YunohostError('custom_appslist_name_required') + infos["manifest"]["arguments"] = _set_default_ask_questions( + infos["manifest"].get("arguments", {}) + ) - # If a name is given, look for an appslist with that name and fetch it - elif name is not None: - if name not in appslists.keys(): - raise YunohostError('appslist_unknown', appslist=name) - else: - appslists_to_be_fetched = [name] + # Trim info for categories if not using --full + for category in catalog["categories"]: + category["title"] = _value_for_locale(category["title"]) + category["description"] = _value_for_locale(category["description"]) + for subtags in category.get("subtags", []): + subtags["title"] = _value_for_locale(subtags["title"]) - # Otherwise, fetch all lists + if not full: + catalog["categories"] = [ + {"id": c["id"], "description": c["description"]} + for c in catalog["categories"] + ] + + if not with_categories: + return {"apps": catalog["apps"]} else: - appslists_to_be_fetched = appslists.keys() - - import requests # lazy loading this module for performance reasons - # Fetch all appslists to be fetched - for name in appslists_to_be_fetched: - - url = appslists[name]["url"] - - logger.debug("Attempting to fetch list %s at %s" % (name, url)) - - # Download file - try: - appslist_request = requests.get(url, timeout=30) - except requests.exceptions.SSLError: - logger.error(m18n.n('appslist_retrieve_error', - appslist=name, - error="SSL connection error")) - continue - except Exception as e: - logger.error(m18n.n('appslist_retrieve_error', - appslist=name, - error=str(e))) - continue - if appslist_request.status_code != 200: - logger.error(m18n.n('appslist_retrieve_error', - appslist=name, - error="Server returned code %s " % - str(appslist_request.status_code))) - continue - - # Validate app list format - # TODO / Possible improvement : better validation for app list (check - # that json fields actually look like an app list and not any json - # file) - appslist = appslist_request.text - try: - json.loads(appslist) - except ValueError as e: - logger.error(m18n.n('appslist_retrieve_bad_format', - appslist=name)) - continue - - # Write app list to file - list_file = '%s/%s.json' % (REPO_PATH, name) - try: - with open(list_file, "w") as f: - f.write(appslist) - except Exception as e: - raise YunohostError("Error while writing appslist %s: %s" % (name, str(e)), raw_msg=True) - - now = int(time.time()) - appslists[name]["lastUpdate"] = now - - logger.success(m18n.n('appslist_fetched', appslist=name)) - - # Write updated list of appslist - _write_appslist_list(appslists) + return {"apps": catalog["apps"], "categories": catalog["categories"]} -@is_unit_operation() -def app_removelist(operation_logger, name): +def app_search(string): """ - Remove list from the repositories - - Keyword argument: - name -- Name of the list to remove - + Return a dict of apps whose description or name match the search string """ - appslists = _read_appslist_list() - # Make sure we know this appslist - if name not in appslists.keys(): - raise YunohostError('appslist_unknown', appslist=name) + # Retrieve a simple dict listing all apps + catalog_of_apps = app_catalog() - operation_logger.start() - - # Remove json - json_path = '%s/%s.json' % (REPO_PATH, name) - if os.path.exists(json_path): - os.remove(json_path) - - # Forget about this appslist - del appslists[name] - _write_appslist_list(appslists) - - logger.success(m18n.n('appslist_removed', appslist=name)) - - -def app_list(filter=None, raw=False, installed=False, with_backup=False): - """ - List apps - - Keyword argument: - filter -- Name filter of app_id or app_name - offset -- Starting number for app fetching - limit -- Maximum number of app fetched - raw -- Return the full app_dict - installed -- Return only installed apps - with_backup -- Return only apps with backup feature (force --installed filter) - - """ - installed = with_backup or installed - - app_dict = {} - list_dict = {} if raw else [] - - appslists = _read_appslist_list() - - for appslist in appslists.keys(): - - json_path = "%s/%s.json" % (REPO_PATH, appslist) - - # If we don't have the json yet, try to fetch it - if not os.path.exists(json_path): - app_fetchlist(name=appslist) - - # If it now exist - if os.path.exists(json_path): - appslist_content = read_json(json_path) - for app, info in appslist_content.items(): - if app not in app_dict: - info['repository'] = appslist - app_dict[app] = info - else: - logger.warning("Uh there's no data for applist '%s' ... (That should be just a temporary issue?)" % appslist) - - # Get app list from the app settings directory - for app in os.listdir(APPS_SETTING_PATH): - if app not in app_dict: - # Handle multi-instance case like wordpress__2 - if '__' in app: - original_app = app[:app.index('__')] - if original_app in app_dict: - app_dict[app] = app_dict[original_app] - continue - # FIXME : What if it's not !?!? - - manifest = _get_manifest_of_app(os.path.join(APPS_SETTING_PATH, app)) - app_dict[app] = {"manifest": manifest} - - app_dict[app]['repository'] = None - - # Sort app list - sorted_app_list = sorted(app_dict.keys()) - - for app_id in sorted_app_list: - - app_info_dict = app_dict[app_id] - - # Apply filter if there's one - if (filter and - (filter not in app_id) and - (filter not in app_info_dict['manifest']['name'])): - continue - - # Ignore non-installed app if user wants only installed apps - app_installed = _is_installed(app_id) - if installed and not app_installed: - continue - - # Ignore apps which don't have backup/restore script if user wants - # only apps with backup features - if with_backup and ( - not os.path.isfile(APPS_SETTING_PATH + app_id + '/scripts/backup') or - not os.path.isfile(APPS_SETTING_PATH + app_id + '/scripts/restore') + # Selecting apps according to a match in app name or description + matching_apps = {"apps": {}} + for app in catalog_of_apps["apps"].items(): + if re.search(string, app[0], flags=re.IGNORECASE) or re.search( + string, app[1]["description"], flags=re.IGNORECASE ): + matching_apps["apps"][app[0]] = app[1] + + return matching_apps + + +# Old legacy function... +def app_fetchlist(): + logger.warning( + "'yunohost app fetchlist' is deprecated. Please use 'yunohost tools update --apps' instead" + ) + from yunohost.tools import tools_update + + tools_update(target="apps") + + +def app_list(full=False, installed=False, filter=None): + """ + List installed apps + """ + + # Old legacy argument ... app_list was a combination of app_list and + # app_catalog before 3.8 ... + if installed: + logger.warning( + "Argument --installed ain't needed anymore when using 'yunohost app list'. It directly returns the list of installed apps.." + ) + + # Filter is a deprecated option... + if filter: + logger.warning( + "Using -f $appname in 'yunohost app list' is deprecated. Just use 'yunohost app list | grep -q 'id: $appname' to check a specific app is installed" + ) + + out = [] + for app_id in sorted(_installed_apps()): + + if filter and not app_id.startswith(filter): continue - if raw: - app_info_dict['installed'] = app_installed - if app_installed: - app_info_dict['status'] = _get_app_status(app_id) + try: + app_info_dict = app_info(app_id, full=full) + except Exception as e: + logger.error("Failed to read info for %s : %s" % (app_id, e)) + continue + app_info_dict["id"] = app_id + out.append(app_info_dict) - # dirty: we used to have manifest containing multi_instance value in form of a string - # but we've switched to bool, this line ensure retrocompatibility - app_info_dict["manifest"]["multi_instance"] = is_true(app_info_dict["manifest"].get("multi_instance", False)) - - list_dict[app_id] = app_info_dict - - else: - label = None - if app_installed: - app_info_dict_raw = app_info(app=app_id, raw=True) - label = app_info_dict_raw['settings']['label'] - - list_dict.append({ - 'id': app_id, - 'name': app_info_dict['manifest']['name'], - 'label': label, - 'description': _value_for_locale(app_info_dict['manifest']['description']), - # FIXME: Temporarly allow undefined license - 'license': app_info_dict['manifest'].get('license', m18n.n('license_undefined')), - 'installed': app_installed - }) - - return {'apps': list_dict} if not raw else list_dict + return {"apps": out} -def app_info(app, show_status=False, raw=False): +def app_info(app, full=False): """ - Get app info - - Keyword argument: - app -- Specific app ID - show_status -- Show app installation status - raw -- Return the full app_dict - + Get info for a specific app """ - if not _is_installed(app): - raise YunohostError('app_not_installed', app=app, all_apps=_get_all_installed_apps_id()) + from yunohost.permission import user_permission_list - app_setting_path = APPS_SETTING_PATH + app + _assert_is_installed(app) - if raw: - ret = app_list(filter=app, raw=True)[app] - ret['settings'] = _get_app_settings(app) + setting_path = os.path.join(APPS_SETTING_PATH, app) + local_manifest = _get_manifest_of_app(setting_path) + permissions = user_permission_list(full=True, absolute_urls=True, apps=[app])[ + "permissions" + ] - # Determine upgradability - # In case there is neither update_time nor install_time, we assume the app can/has to be upgraded - local_update_time = ret['settings'].get('update_time', ret['settings'].get('install_time', 0)) + settings = _get_app_settings(app) - if 'lastUpdate' not in ret or 'git' not in ret: - upgradable = "url_required" - elif ret['lastUpdate'] > local_update_time: - upgradable = "yes" - else: - upgradable = "no" + ret = { + "description": _value_for_locale(local_manifest["description"]), + "name": permissions.get(app + ".main", {}).get("label", local_manifest["name"]), + "version": local_manifest.get("version", "-"), + } - ret['upgradable'] = upgradable - ret['change_url'] = os.path.exists(os.path.join(app_setting_path, "scripts", "change_url")) - - manifest = _get_manifest_of_app(os.path.join(APPS_SETTING_PATH, app)) - - ret['version'] = manifest.get('version', '-') + if "domain" in settings and "path" in settings: + ret["domain_path"] = settings["domain"] + settings["path"] + if not full: return ret - # Retrieve manifest and status - manifest = _get_manifest_of_app(app_setting_path) - status = _get_app_status(app, format_date=True) + ret["setting_path"] = setting_path + ret["manifest"] = local_manifest + ret["manifest"]["arguments"] = _set_default_ask_questions( + ret["manifest"].get("arguments", {}) + ) + ret["settings"] = settings - info = { - 'name': manifest['name'], - 'description': _value_for_locale(manifest['description']), - # FIXME: Temporarly allow undefined license - 'license': manifest.get('license', m18n.n('license_undefined')), - # FIXME: Temporarly allow undefined version - 'version': manifest.get('version', '-'), - # TODO: Add more info - } - if show_status: - info['status'] = status - return info + absolute_app_name, _ = _parse_app_instance_name(app) + ret["from_catalog"] = _load_apps_catalog()["apps"].get(absolute_app_name, {}) + ret["upgradable"] = _app_upgradable(ret) + ret["supports_change_url"] = os.path.exists( + os.path.join(setting_path, "scripts", "change_url") + ) + ret["supports_backup_restore"] = os.path.exists( + os.path.join(setting_path, "scripts", "backup") + ) and os.path.exists(os.path.join(setting_path, "scripts", "restore")) + ret["supports_multi_instance"] = is_true( + local_manifest.get("multi_instance", False) + ) + ret["supports_config_panel"] = os.path.exists( + os.path.join(setting_path, "config_panel.toml") + ) + + ret["permissions"] = permissions + ret["label"] = permissions.get(app + ".main", {}).get("label") + + if not ret["label"]: + logger.warning("Failed to get label for app %s ?" % app) + return ret + + +def _app_upgradable(app_infos): + from packaging import version + + # Determine upgradability + + app_in_catalog = app_infos.get("from_catalog") + installed_version = version.parse(app_infos.get("version", "0~ynh0")) + version_in_catalog = version.parse( + app_infos.get("from_catalog", {}).get("manifest", {}).get("version", "0~ynh0") + ) + + if not app_in_catalog: + return "url_required" + + # Do not advertise upgrades for bad-quality apps + level = app_in_catalog.get("level", -1) + if ( + not (isinstance(level, int) and level >= 5) + or app_in_catalog.get("state") != "working" + ): + return "bad_quality" + + # If the app uses the standard version scheme, use it to determine + # upgradability + if "~ynh" in str(installed_version) and "~ynh" in str(version_in_catalog): + if installed_version < version_in_catalog: + return "yes" + else: + return "no" + + # Legacy stuff for app with old / non-standard version numbers... + + # In case there is neither update_time nor install_time, we assume the app can/has to be upgraded + if not app_infos["from_catalog"].get("lastUpdate") or not app_infos[ + "from_catalog" + ].get("git"): + return "url_required" + + settings = app_infos["settings"] + local_update_time = settings.get("update_time", settings.get("install_time", 0)) + if app_infos["from_catalog"]["lastUpdate"] > local_update_time: + return "yes" + else: + return "no" def app_map(app=None, raw=False, user=None): """ - List apps by domain + Returns a map of url <-> app id such as : - Keyword argument: - user -- Allowed app map for a user - raw -- Return complete dict - app -- Specific app to map + { + "domain.tld/foo": "foo__2", + "domain.tld/mail: "rainloop", + "other.tld/": "bar", + "sub.other.tld/pwet": "pwet", + } + When using "raw", the structure changes to : + + { + "domain.tld": { + "/foo": {"label": "App foo", "id": "foo__2"}, + "/mail": {"label": "Rainloop", "id: "rainloop"}, + }, + "other.tld": { + "/": {"label": "Bar", "id": "bar"}, + }, + "sub.other.tld": { + "/pwet": {"label": "Pwet", "id": "pwet"} + } + } """ + from yunohost.permission import user_permission_list - from yunohost.utils.ldap import _get_ldap_interface apps = [] result = {} if app is not None: if not _is_installed(app): - raise YunohostError('app_not_installed', app=app, all_apps=_get_all_installed_apps_id()) - apps = [app, ] + raise YunohostValidationError( + "app_not_installed", app=app, all_apps=_get_all_installed_apps_id() + ) + apps = [ + app, + ] else: - apps = os.listdir(APPS_SETTING_PATH) + apps = _installed_apps() + permissions = user_permission_list(full=True, absolute_urls=True, apps=apps)[ + "permissions" + ] for app_id in apps: app_settings = _get_app_settings(app_id) if not app_settings: continue - if 'domain' not in app_settings: + if "domain" not in app_settings: continue - if 'path' not in app_settings: + if "path" not in app_settings: # we assume that an app that doesn't have a path doesn't have an HTTP api continue - if 'no_sso' in app_settings: # I don't think we need to check for the value here + # This 'no_sso' settings sound redundant to not having $path defined .... + # At least from what I can see, all apps using it don't have a path defined ... + if ( + "no_sso" in app_settings + ): # I don't think we need to check for the value here continue - if user is not None: - ldap = _get_ldap_interface() - if not ldap.search(base='ou=permission,dc=yunohost,dc=org', - filter='(&(objectclass=permissionYnh)(cn=main.%s)(inheritPermission=uid=%s,ou=users,dc=yunohost,dc=org))' % (app_id, user), - attrs=['cn']): + # Users must at least have access to the main permission to have access to extra permissions + if user: + if not app_id + ".main" in permissions: + logger.warning( + "Uhoh, no main permission was found for app %s ... sounds like an app was only partially removed due to another bug :/" + % app_id + ) + continue + main_perm = permissions[app_id + ".main"] + if user not in main_perm["corresponding_users"]: continue - domain = app_settings['domain'] - path = app_settings['path'] + this_app_perms = { + p: i + for p, i in permissions.items() + if p.startswith(app_id + ".") and (i["url"] or i["additional_urls"]) + } - if raw: - if domain not in result: - result[domain] = {} - result[domain][path] = { - 'label': app_settings['label'], - 'id': app_settings['id'] - } - else: - result[domain + path] = app_settings['label'] + for perm_name, perm_info in this_app_perms.items(): + # If we're building the map for a specific user, check the user + # actually is allowed for this specific perm + if user and user not in perm_info["corresponding_users"]: + continue + + perm_label = perm_info["label"] + perm_all_urls = ( + [] + + ([perm_info["url"]] if perm_info["url"] else []) + + perm_info["additional_urls"] + ) + + for url in perm_all_urls: + + # Here, we decide to completely ignore regex-type urls ... + # Because : + # - displaying them in regular "yunohost app map" output creates + # a pretty big mess when there are multiple regexes for the same + # app ? (c.f. for example lufi) + # - it doesn't really make sense when checking app conflicts to + # compare regexes ? (Or it could in some cases but ugh ?) + # + if url.startswith("re:"): + continue + + if not raw: + result[url] = perm_label + else: + if "/" in url: + perm_domain, perm_path = url.split("/", 1) + perm_path = "/" + perm_path + else: + perm_domain = url + perm_path = "/" + if perm_domain not in result: + result[perm_domain] = {} + result[perm_domain][perm_path] = {"label": perm_label, "id": app_id} return result @@ -464,83 +428,57 @@ def app_change_url(operation_logger, app, domain, path): """ from yunohost.hook import hook_exec, hook_callback - from yunohost.domain import _normalize_domain_path, _get_conflicting_apps - from yunohost.permission import permission_update + from yunohost.service import service_reload_or_restart installed = _is_installed(app) if not installed: - raise YunohostError('app_not_installed', app=app, all_apps=_get_all_installed_apps_id()) + raise YunohostValidationError( + "app_not_installed", app=app, all_apps=_get_all_installed_apps_id() + ) - if not os.path.exists(os.path.join(APPS_SETTING_PATH, app, "scripts", "change_url")): - raise YunohostError("app_change_no_change_url_script", app_name=app) + if not os.path.exists( + os.path.join(APPS_SETTING_PATH, app, "scripts", "change_url") + ): + raise YunohostValidationError("app_change_url_no_script", app_name=app) old_domain = app_setting(app, "domain") old_path = app_setting(app, "path") # Normalize path and domain format - old_domain, old_path = _normalize_domain_path(old_domain, old_path) - domain, path = _normalize_domain_path(domain, path) + + domain = DomainQuestion.normalize(domain) + old_domain = DomainQuestion.normalize(old_domain) + path = PathQuestion.normalize(path) + old_path = PathQuestion.normalize(old_path) if (domain, path) == (old_domain, old_path): - raise YunohostError("app_change_url_identical_domains", domain=domain, path=path) + raise YunohostValidationError( + "app_change_url_identical_domains", domain=domain, path=path + ) # Check the url is available - conflicts = _get_conflicting_apps(domain, path, ignore_app=app) - if conflicts: - apps = [] - for path, app_id, app_label in conflicts: - apps.append(" * {domain:s}{path:s} → {app_label:s} ({app_id:s})".format( - domain=domain, - path=path, - app_id=app_id, - app_label=app_label, - )) - raise YunohostError('app_location_unavailable', apps="\n".join(apps)) + _assert_no_conflicting_apps(domain, path, ignore_app=app) - manifest = _get_manifest_of_app(os.path.join(APPS_SETTING_PATH, app)) - - # Retrieve arguments list for change_url script - # TODO: Allow to specify arguments - args_odict = _parse_args_from_manifest(manifest, 'change_url') - args_list = [ value[0] for value in args_odict.values() ] - args_list.append(app) + tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app) # Prepare env. var. to pass to script - env_dict = _make_environment_dict(args_odict) - app_id, app_instance_nb = _parse_app_instance_name(app) - env_dict["YNH_APP_ID"] = app_id - env_dict["YNH_APP_INSTANCE_NAME"] = app - env_dict["YNH_APP_INSTANCE_NUMBER"] = str(app_instance_nb) - + env_dict = _make_environment_for_app_script(app) env_dict["YNH_APP_OLD_DOMAIN"] = old_domain env_dict["YNH_APP_OLD_PATH"] = old_path env_dict["YNH_APP_NEW_DOMAIN"] = domain env_dict["YNH_APP_NEW_PATH"] = path + env_dict["YNH_APP_BASEDIR"] = tmp_workdir_for_app if domain != old_domain: - operation_logger.related_to.append(('domain', old_domain)) - operation_logger.extra.update({'env': env_dict}) + operation_logger.related_to.append(("domain", old_domain)) + operation_logger.extra.update({"env": env_dict}) operation_logger.start() - if os.path.exists(os.path.join(APP_TMP_FOLDER, "scripts")): - shutil.rmtree(os.path.join(APP_TMP_FOLDER, "scripts")) - - 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")) + change_url_script = os.path.join(tmp_workdir_for_app, "scripts/change_url") # 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"))) - os.system('chmod +x %s' % os.path.join(os.path.join(APP_TMP_FOLDER, "scripts", "change_url"))) - - if hook_exec(os.path.join(APP_TMP_FOLDER, 'scripts/change_url'), - args=args_list, env=env_dict)[0] != 0: + ret = hook_exec(change_url_script, env=env_dict)[0] + if ret != 0: msg = "Failed to change '%s' url." % app logger.error(msg) operation_logger.error(msg) @@ -550,31 +488,22 @@ def app_change_url(operation_logger, app, domain, path): app_setting(app, "domain", value=old_domain) app_setting(app, "path", value=old_path) return + shutil.rmtree(tmp_workdir_for_app) # this should idealy be done in the change_url script but let's avoid common mistakes - app_setting(app, 'domain', value=domain) - app_setting(app, 'path', value=path) + app_setting(app, "domain", value=domain) + app_setting(app, "path", value=path) - permission_update(app, permission="main", add_url=[domain+path], remove_url=[old_domain+old_path], sync_perm=True) + app_ssowatconf() - # avoid common mistakes - if _run_service_command("reload", "nginx") is False: - # grab nginx errors - # the "exit 0" is here to avoid check_output to fail because 'nginx -t' - # will return != 0 since we are in a failed state - nginx_errors = subprocess.check_output("nginx -t; exit 0", - stderr=subprocess.STDOUT, - shell=True).rstrip() + service_reload_or_restart("nginx") - raise YunohostError("app_change_url_failed_nginx_reload", nginx_errors=nginx_errors) + logger.success(m18n.n("app_change_url_success", app=app, domain=domain, path=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) + hook_callback("post_app_change_url", env=env_dict) -def app_upgrade(app=[], url=None, file=None): +def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False): """ Upgrade app @@ -582,146 +511,280 @@ def app_upgrade(app=[], url=None, file=None): file -- Folder or tarball for upgrade app -- App(s) to upgrade (default all) url -- Git url to fetch for upgrade + no_safety_backup -- Disable the safety backup during upgrade """ - if packages.dpkg_is_broken(): - raise YunohostError("dpkg_is_broken") - - from yunohost.hook import hook_add, hook_remove, hook_exec, hook_callback + from packaging import version + from yunohost.hook import ( + hook_add, + hook_remove, + hook_callback, + hook_exec_with_script_debug_if_failure, + ) from yunohost.permission import permission_sync_to_user - - # Retrieve interface - is_api = msettings.get('interface') == 'api' - - try: - app_list() - except YunohostError: - raise YunohostError('app_no_upgrade') - - not_upgraded_apps = [] + from yunohost.regenconf import manually_modified_files apps = app + # Check if disk space available + if free_space_in_directory("/") <= 512 * 1000 * 1000: + raise YunohostValidationError("disk_space_not_sufficient_update") # If no app is specified, upgrade all apps if not apps: # FIXME : not sure what's supposed to happen if there is a url and a file but no apps... if not url and not file: - apps = [app["id"] for app in app_list(installed=True)["apps"]] + apps = _installed_apps() elif not isinstance(app, list): apps = [app] # Remove possible duplicates - apps = [app for i,app in enumerate(apps) if apps not in apps[:i]] + apps = [app_ for i, app_ in enumerate(apps) if app_ not in apps[:i]] # Abort if any of those app is in fact not installed.. - for app in [app for app in apps if not _is_installed(app)]: - raise YunohostError('app_not_installed', app=app, all_apps=_get_all_installed_apps_id()) + for app_ in apps: + _assert_is_installed(app_) if len(apps) == 0: - raise YunohostError('app_no_upgrade') + raise YunohostValidationError("apps_already_up_to_date") if len(apps) > 1: logger.info(m18n.n("app_upgrade_several_apps", apps=", ".join(apps))) - for app_instance_name in apps: - logger.info(m18n.n('app_upgrade_app_name', app=app_instance_name)) + for number, app_instance_name in enumerate(apps): + logger.info(m18n.n("app_upgrade_app_name", app=app_instance_name)) - app_dict = app_info(app_instance_name, raw=True) + app_dict = app_info(app_instance_name, full=True) - if file: + if file and isinstance(file, dict): + # We use this dirty hack to test chained upgrades in unit/functional tests + manifest, extracted_app_folder = _extract_app_from_file( + file[app_instance_name] + ) + elif file: manifest, extracted_app_folder = _extract_app_from_file(file) elif url: manifest, extracted_app_folder = _fetch_app_from_git(url) elif app_dict["upgradable"] == "url_required": - logger.warning(m18n.n('custom_app_url_required', app=app_instance_name)) + logger.warning(m18n.n("custom_app_url_required", app=app_instance_name)) continue - elif app_dict["upgradable"] == "yes": + elif app_dict["upgradable"] == "yes" or force: manifest, extracted_app_folder = _fetch_app_from_git(app_instance_name) else: - logger.success(m18n.n('app_already_up_to_date', app=app_instance_name)) + logger.success(m18n.n("app_already_up_to_date", app=app_instance_name)) continue + # Manage upgrade type and avoid any upgrade if there is nothing to do + upgrade_type = "UNKNOWN" + # Get current_version and new version + app_new_version = version.parse(manifest.get("version", "?")) + app_current_version = version.parse(app_dict.get("version", "?")) + if "~ynh" in str(app_current_version) and "~ynh" in str(app_new_version): + if app_current_version >= app_new_version and not force: + # In case of upgrade from file or custom repository + # No new version available + logger.success(m18n.n("app_already_up_to_date", app=app_instance_name)) + # Save update time + now = int(time.time()) + app_setting(app_instance_name, "update_time", now) + app_setting( + app_instance_name, + "current_revision", + manifest.get("remote", {}).get("revision", "?"), + ) + continue + elif app_current_version > app_new_version: + upgrade_type = "DOWNGRADE_FORCED" + elif app_current_version == app_new_version: + upgrade_type = "UPGRADE_FORCED" + else: + app_current_version_upstream, app_current_version_pkg = str( + app_current_version + ).split("~ynh") + app_new_version_upstream, app_new_version_pkg = str( + app_new_version + ).split("~ynh") + if app_current_version_upstream == app_new_version_upstream: + upgrade_type = "UPGRADE_PACKAGE" + elif app_current_version_pkg == app_new_version_pkg: + upgrade_type = "UPGRADE_APP" + else: + upgrade_type = "UPGRADE_FULL" + # Check requirements _check_manifest_requirements(manifest, app_instance_name=app_instance_name) - _check_services_status_for_app(manifest.get("services", [])) + _assert_system_is_sane_for_app(manifest, "pre") - app_setting_path = APPS_SETTING_PATH + '/' + app_instance_name - - # Retrieve current app status - status = _get_app_status(app_instance_name) - status['remote'] = manifest.get('remote', None) - - # Retrieve arguments list for upgrade script - # TODO: Allow to specify arguments - args_odict = _parse_args_from_manifest(manifest, 'upgrade') - args_list = [ value[0] for value in args_odict.values() ] - args_list.append(app_instance_name) + app_setting_path = os.path.join(APPS_SETTING_PATH, app_instance_name) # Prepare env. var. to pass to script - env_dict = _make_environment_dict(args_odict) - app_id, app_instance_nb = _parse_app_instance_name(app_instance_name) - env_dict["YNH_APP_ID"] = app_id - env_dict["YNH_APP_INSTANCE_NAME"] = app_instance_name - env_dict["YNH_APP_INSTANCE_NUMBER"] = str(app_instance_nb) + env_dict = _make_environment_for_app_script(app_instance_name) + env_dict["YNH_APP_UPGRADE_TYPE"] = upgrade_type + env_dict["YNH_APP_MANIFEST_VERSION"] = str(app_new_version) + env_dict["YNH_APP_CURRENT_VERSION"] = str(app_current_version) + env_dict["NO_BACKUP_UPGRADE"] = "1" if no_safety_backup else "0" + env_dict["YNH_APP_BASEDIR"] = extracted_app_folder - # Start register change on system - related_to = [('app', app_instance_name)] - operation_logger = OperationLogger('app_upgrade', related_to, env=env_dict) - operation_logger.start() + # We'll check that the app didn't brutally edit some system configuration + manually_modified_files_before_install = manually_modified_files() + + # Attempt to patch legacy helpers ... + _patch_legacy_helpers(extracted_app_folder) # Apply dirty patch to make php5 apps compatible with php7 - _patch_php5(extracted_app_folder) + _patch_legacy_php_versions(extracted_app_folder) - # Execute App upgrade script - os.system('chown -hR admin: %s' % INSTALL_TMP) - if hook_exec(extracted_app_folder + '/scripts/upgrade', - args=args_list, env=env_dict)[0] != 0: - msg = m18n.n('app_upgrade_failed', app=app_instance_name) - not_upgraded_apps.append(app_instance_name) - logger.error(msg) - operation_logger.error(msg) - else: + # Start register change on system + related_to = [("app", app_instance_name)] + operation_logger = OperationLogger("app_upgrade", related_to, env=env_dict) + operation_logger.start() + + # Execute the app upgrade script + upgrade_failed = True + try: + ( + upgrade_failed, + failure_message_with_debug_instructions, + ) = hook_exec_with_script_debug_if_failure( + extracted_app_folder + "/scripts/upgrade", + env=env_dict, + operation_logger=operation_logger, + error_message_if_script_failed=m18n.n("app_upgrade_script_failed"), + error_message_if_failed=lambda e: m18n.n( + "app_upgrade_failed", app=app_instance_name, error=e + ), + ) + finally: + # Whatever happened (install success or failure) we check if it broke the system + # and warn the user about it + try: + broke_the_system = False + _assert_system_is_sane_for_app(manifest, "post") + except Exception as e: + broke_the_system = True + logger.error( + m18n.n("app_upgrade_failed", app=app_instance_name, error=str(e)) + ) + failure_message_with_debug_instructions = operation_logger.error(str(e)) + + # We'll check that the app didn't brutally edit some system configuration + manually_modified_files_after_install = manually_modified_files() + manually_modified_files_by_app = set( + manually_modified_files_after_install + ) - set(manually_modified_files_before_install) + if manually_modified_files_by_app: + logger.error( + "Packagers /!\\ This app manually modified some system configuration files! This should not happen! If you need to do so, you should implement a proper conf_regen hook. Those configuration were affected:\n - " + + "\n -".join(manually_modified_files_by_app) + ) + + # If upgrade failed or broke the system, + # raise an error and interrupt all other pending upgrades + if upgrade_failed or broke_the_system: + + # display this if there are remaining apps + if apps[number + 1 :]: + not_upgraded_apps = apps[number:] + logger.error( + m18n.n( + "app_not_upgraded", + failed_app=app_instance_name, + apps=", ".join(not_upgraded_apps), + ) + ) + + raise YunohostError( + failure_message_with_debug_instructions, raw_msg=True + ) + + # Otherwise we're good and keep going ! now = int(time.time()) - # TODO: Move install_time away from app_setting - app_setting(app_instance_name, 'update_time', now) - status['upgraded_at'] = now + app_setting(app_instance_name, "update_time", now) + app_setting( + app_instance_name, + "current_revision", + manifest.get("remote", {}).get("revision", "?"), + ) # Clean hooks and add new ones hook_remove(app_instance_name) - if 'hooks' in os.listdir(extracted_app_folder): - for hook in os.listdir(extracted_app_folder + '/hooks'): - hook_add(app_instance_name, extracted_app_folder + '/hooks/' + hook) - - # Store app status - with open(app_setting_path + '/status.json', 'w+') as f: - json.dump(status, f) + if "hooks" in os.listdir(extracted_app_folder): + for hook in os.listdir(extracted_app_folder + "/hooks"): + hook_add(app_instance_name, extracted_app_folder + "/hooks/" + hook) # Replace scripts and manifest and conf (if exists) - os.system('rm -rf "%s/scripts" "%s/manifest.toml %s/manifest.json %s/conf"' % (app_setting_path, app_setting_path, app_setting_path)) + os.system( + 'rm -rf "%s/scripts" "%s/manifest.toml %s/manifest.json %s/conf"' + % ( + app_setting_path, + app_setting_path, + app_setting_path, + app_setting_path, + ) + ) if os.path.exists(os.path.join(extracted_app_folder, "manifest.json")): - os.system('mv "%s/manifest.json" "%s/scripts" %s' % (extracted_app_folder, extracted_app_folder, app_setting_path)) + os.system( + 'mv "%s/manifest.json" "%s/scripts" %s' + % (extracted_app_folder, extracted_app_folder, app_setting_path) + ) if os.path.exists(os.path.join(extracted_app_folder, "manifest.toml")): - os.system('mv "%s/manifest.toml" "%s/scripts" %s' % (extracted_app_folder, extracted_app_folder, app_setting_path)) + os.system( + 'mv "%s/manifest.toml" "%s/scripts" %s' + % (extracted_app_folder, extracted_app_folder, app_setting_path) + ) - for file_to_copy in ["actions.json", "actions.toml", "config_panel.json", "config_panel.toml", "conf"]: + for file_to_copy in [ + "actions.json", + "actions.toml", + "config_panel.toml", + "conf", + ]: if os.path.exists(os.path.join(extracted_app_folder, file_to_copy)): - os.system('cp -R %s/%s %s' % (extracted_app_folder, file_to_copy, app_setting_path)) + os.system( + "cp -R %s/%s %s" + % (extracted_app_folder, file_to_copy, app_setting_path) + ) + + # Clean and set permissions + shutil.rmtree(extracted_app_folder) + os.system("chmod 600 %s" % app_setting_path) + os.system("chmod 400 %s/settings.yml" % app_setting_path) + os.system("chown -R root: %s" % app_setting_path) # So much win - logger.success(m18n.n('app_upgraded', app=app_instance_name)) + logger.success(m18n.n("app_upgraded", app=app_instance_name)) - hook_callback('post_app_upgrade', args=args_list, env=env_dict) + hook_callback("post_app_upgrade", env=env_dict) operation_logger.success() - if not_upgraded_apps: - raise YunohostError('app_not_upgraded', apps=', '.join(not_upgraded_apps)) - permission_sync_to_user() - logger.success(m18n.n('upgrade_complete')) + logger.success(m18n.n("upgrade_complete")) + + +def app_manifest(app): + + raw_app_list = _load_apps_catalog()["apps"] + + if app in raw_app_list or ("@" in app) or ("http://" in app) or ("https://" in app): + manifest, extracted_app_folder = _fetch_app_from_git(app) + elif os.path.exists(app): + manifest, extracted_app_folder = _extract_app_from_file(app) + else: + raise YunohostValidationError("app_unknown") + + shutil.rmtree(extracted_app_folder) + + return manifest @is_unit_operation() -def app_install(operation_logger, app, label=None, args=None, no_remove_on_failure=False, force=False): +def app_install( + operation_logger, + app, + label=None, + args=None, + no_remove_on_failure=False, + force=False, +): """ Install apps @@ -732,47 +795,70 @@ def app_install(operation_logger, app, label=None, args=None, no_remove_on_failu no_remove_on_failure -- Debug option to avoid removing the app on a failed installation force -- Do not ask for confirmation when installing experimental / low-quality apps """ - if packages.dpkg_is_broken(): - raise YunohostError("dpkg_is_broken") - from yunohost.utils.ldap import _get_ldap_interface - from yunohost.hook import hook_add, hook_remove, hook_exec, hook_callback + from yunohost.hook import ( + hook_add, + hook_remove, + hook_callback, + hook_exec, + hook_exec_with_script_debug_if_failure, + ) from yunohost.log import OperationLogger - from yunohost.permission import permission_add, permission_update, permission_remove, permission_sync_to_user - ldap = _get_ldap_interface() - - # Fetch or extract sources - if not os.path.exists(INSTALL_TMP): - os.makedirs(INSTALL_TMP) - - status = { - 'installed_at': int(time.time()), - 'upgraded_at': None, - 'remote': { - 'type': None, - }, - } + from yunohost.permission import ( + user_permission_list, + permission_create, + permission_delete, + permission_sync_to_user, + ) + from yunohost.regenconf import manually_modified_files def confirm_install(confirm): # Ignore if there's nothing for confirm (good quality app), if --force is used # or if request on the API (confirm already implemented on the API side) - if confirm is None or force or msettings.get('interface') == 'api': + if confirm is None or force or Moulinette.interface.type == "api": return - answer = msignals.prompt(m18n.n('confirm_app_install_' + confirm, - answers='Y/N')) - if answer.upper() != "Y": - raise YunohostError("aborting") + # i18n: confirm_app_install_warning + # i18n: confirm_app_install_danger + # i18n: confirm_app_install_thirdparty - raw_app_list = app_list(raw=True) + if confirm in ["danger", "thirdparty"]: + answer = Moulinette.prompt( + m18n.n("confirm_app_install_" + confirm, answers="Yes, I understand"), + color="red", + ) + if answer != "Yes, I understand": + raise YunohostError("aborting") - if app in raw_app_list or ('@' in app) or ('http://' in app) or ('https://' in app): + else: + answer = Moulinette.prompt( + m18n.n("confirm_app_install_" + confirm, answers="Y/N"), color="yellow" + ) + if answer.upper() != "Y": + raise YunohostError("aborting") + + raw_app_list = _load_apps_catalog()["apps"] + + if app in raw_app_list or ("@" in app) or ("http://" in app) or ("https://" in app): + + # If we got an app name directly (e.g. just "wordpress"), we gonna test this name if app in raw_app_list: - state = raw_app_list[app].get("state", "notworking") - level = raw_app_list[app].get("level", None) + app_name_to_test = app + # If we got an url like "https://github.com/foo/bar_ynh, we want to + # extract "bar" and test if we know this app + elif ("http://" in app) or ("https://" in app): + app_name_to_test = app.strip("/").split("/")[-1].replace("_ynh", "") + else: + # FIXME : watdo if '@' in app ? + app_name_to_test = None + + if app_name_to_test in raw_app_list: + + state = raw_app_list[app_name_to_test].get("state", "notworking") + level = raw_app_list[app_name_to_test].get("level", None) confirm = "danger" if state in ["working", "validated"]: - if isinstance(level, int) and level >= 3: + if isinstance(level, int) and level >= 5: confirm = None elif isinstance(level, int) and level > 0: confirm = "warning" @@ -786,54 +872,59 @@ def app_install(operation_logger, app, label=None, args=None, no_remove_on_failu confirm_install("thirdparty") manifest, extracted_app_folder = _extract_app_from_file(app) else: - raise YunohostError('app_unknown') - status['remote'] = manifest.get('remote', {}) + raise YunohostValidationError("app_unknown") + + # Check if disk space available + if free_space_in_directory("/") <= 512 * 1000 * 1000: + raise YunohostValidationError("disk_space_not_sufficient_install") # Check ID - if 'id' not in manifest or '__' in manifest['id']: - raise YunohostError('app_id_invalid') + if "id" not in manifest or "__" in manifest["id"] or "." in manifest["id"]: + raise YunohostValidationError("app_id_invalid") - app_id = manifest['id'] + app_id = manifest["id"] + label = label if label else manifest["name"] # Check requirements _check_manifest_requirements(manifest, app_id) - _check_services_status_for_app(manifest.get("services", [])) + _assert_system_is_sane_for_app(manifest, "pre") # Check if app can be forked instance_number = _installed_instance_number(app_id, last=True) + 1 if instance_number > 1: - if 'multi_instance' not in manifest or not is_true(manifest['multi_instance']): - raise YunohostError('app_already_installed', app=app_id) + if "multi_instance" not in manifest or not is_true(manifest["multi_instance"]): + raise YunohostValidationError("app_already_installed", app=app_id) # Change app_id to the forked app id - app_instance_name = app_id + '__' + str(instance_number) + app_instance_name = app_id + "__" + str(instance_number) else: app_instance_name = app_id # Retrieve arguments list for install script - args_dict = {} if not args else \ - dict(urlparse.parse_qsl(args, keep_blank_values=True)) - args_odict = _parse_args_from_manifest(manifest, 'install', args=args_dict) - args_list = [ value[0] for value in args_odict.values() ] - args_list.append(app_instance_name) + raw_questions = manifest.get("arguments", {}).get("install", {}) + questions = ask_questions_and_parse_answers(raw_questions, prefilled_answers=args) + args = { + question.name: question.value + for question in questions + if question.value is not None + } - # Prepare env. var. to pass to script - env_dict = _make_environment_dict(args_odict) - env_dict["YNH_APP_ID"] = app_id - env_dict["YNH_APP_INSTANCE_NAME"] = app_instance_name - env_dict["YNH_APP_INSTANCE_NUMBER"] = str(instance_number) + # Validate domain / path availability for webapps + path_requirement = _guess_webapp_path_requirement(questions, extracted_app_folder) + _validate_webpath_requirement(questions, path_requirement) - # Start register change on system - operation_logger.extra.update({'env': env_dict}) + # Attempt to patch legacy helpers ... + _patch_legacy_helpers(extracted_app_folder) - # Tell the operation_logger to redact all password-type args - # Also redact the % escaped version of the password that might appear in - # the 'args' section of metadata (relevant for password with non-alphanumeric char) - data_to_redact = [ value[0] for value in args_odict.values() if value[1] == "password" ] - data_to_redact += [ urllib.quote(data) for data in data_to_redact if urllib.quote(data) != data ] - operation_logger.data_to_redact.extend(data_to_redact) + # Apply dirty patch to make php5 apps compatible with php7 + _patch_legacy_php_versions(extracted_app_folder) - operation_logger.related_to = [s for s in operation_logger.related_to if s[0] != "app"] + # We'll check that the app didn't brutally edit some system configuration + manually_modified_files_before_install = manually_modified_files() + + operation_logger.related_to = [ + s for s in operation_logger.related_to if s[0] != "app" + ] operation_logger.related_to.append(("app", app_id)) operation_logger.start() @@ -847,80 +938,155 @@ def app_install(operation_logger, app, label=None, args=None, no_remove_on_failu # Set initial app settings app_settings = { - 'id': app_instance_name, - 'label': label if label else manifest['name'], + "id": app_instance_name, + "install_time": int(time.time()), + "current_revision": manifest.get("remote", {}).get("revision", "?"), } - # TODO: Move install_time away from app settings - app_settings['install_time'] = status['installed_at'] _set_app_settings(app_instance_name, app_settings) - # Apply dirty patch to make php5 apps compatible with php7 - _patch_php5(extracted_app_folder) - - os.system('chown -R admin: ' + extracted_app_folder) - - # Execute App install script - os.system('chown -hR admin: %s' % INSTALL_TMP) # Move scripts and manifest to the right place if os.path.exists(os.path.join(extracted_app_folder, "manifest.json")): - os.system('cp %s/manifest.json %s' % (extracted_app_folder, app_setting_path)) + os.system("cp %s/manifest.json %s" % (extracted_app_folder, app_setting_path)) if os.path.exists(os.path.join(extracted_app_folder, "manifest.toml")): - os.system('cp %s/manifest.toml %s' % (extracted_app_folder, app_setting_path)) - os.system('cp -R %s/scripts %s' % (extracted_app_folder, app_setting_path)) + os.system("cp %s/manifest.toml %s" % (extracted_app_folder, app_setting_path)) + os.system("cp -R %s/scripts %s" % (extracted_app_folder, app_setting_path)) - for file_to_copy in ["actions.json", "actions.toml", "config_panel.json", "config_panel.toml", "conf"]: + for file_to_copy in [ + "actions.json", + "actions.toml", + "config_panel.toml", + "conf", + ]: if os.path.exists(os.path.join(extracted_app_folder, file_to_copy)): - os.system('cp -R %s/%s %s' % (extracted_app_folder, file_to_copy, app_setting_path)) + os.system( + "cp -R %s/%s %s" + % (extracted_app_folder, file_to_copy, app_setting_path) + ) - # Create permission before the install (useful if the install script redefine the permission) - # Note that sync_perm is disabled to avoid triggering a whole bunch of code and messages - # can't be sure that we don't have one case when it's needed - permission_add(app=app_instance_name, permission="main", sync_perm=False) + # Initialize the main permission for the app + # The permission is initialized with no url associated, and with tile disabled + # For web app, the root path of the app will be added as url and the tile + # will be enabled during the app install. C.f. 'app_register_url()' below. + permission_create( + app_instance_name + ".main", + allowed=["all_users"], + label=label, + show_tile=False, + protected=False, + ) + + # Prepare env. var. to pass to script + env_dict = _make_environment_for_app_script(app_instance_name, args=args) + env_dict["YNH_APP_BASEDIR"] = extracted_app_folder + + env_dict_for_logging = env_dict.copy() + for question in questions: + # Or should it be more generally question.redact ? + if question.type == "password": + del env_dict_for_logging["YNH_APP_ARG_%s" % question.name.upper()] + + operation_logger.extra.update({"env": env_dict_for_logging}) # Execute the app install script - install_retcode = 1 + install_failed = True try: - install_retcode = hook_exec( - os.path.join(extracted_app_folder, 'scripts/install'), - args=args_list, env=env_dict - )[0] - except (KeyboardInterrupt, EOFError): - install_retcode = -1 - except Exception: - import traceback - logger.exception(m18n.n('unexpected_error', error=u"\n" + traceback.format_exc())) + ( + install_failed, + failure_message_with_debug_instructions, + ) = hook_exec_with_script_debug_if_failure( + os.path.join(extracted_app_folder, "scripts/install"), + env=env_dict, + operation_logger=operation_logger, + error_message_if_script_failed=m18n.n("app_install_script_failed"), + error_message_if_failed=lambda e: m18n.n( + "app_install_failed", app=app_id, error=e + ), + ) finally: - if install_retcode != 0: - error_msg = operation_logger.error(m18n.n('unexpected_error', error='shell command return code: %s' % install_retcode)) - if not no_remove_on_failure: - # Setup environment for remove script - env_dict_remove = {} - env_dict_remove["YNH_APP_ID"] = app_id - env_dict_remove["YNH_APP_INSTANCE_NAME"] = app_instance_name - env_dict_remove["YNH_APP_INSTANCE_NUMBER"] = str(instance_number) + # If success so far, validate that app didn't break important stuff + if not install_failed: + try: + broke_the_system = False + _assert_system_is_sane_for_app(manifest, "post") + except Exception as e: + broke_the_system = True + logger.error(m18n.n("app_install_failed", app=app_id, error=str(e))) + failure_message_with_debug_instructions = operation_logger.error(str(e)) - # Execute remove script - operation_logger_remove = OperationLogger('remove_on_failed_install', - [('app', app_instance_name)], - env=env_dict_remove) - operation_logger_remove.start() + # We'll check that the app didn't brutally edit some system configuration + manually_modified_files_after_install = manually_modified_files() + manually_modified_files_by_app = set( + manually_modified_files_after_install + ) - set(manually_modified_files_before_install) + if manually_modified_files_by_app: + logger.error( + "Packagers /!\\ This app manually modified some system configuration files! This should not happen! If you need to do so, you should implement a proper conf_regen hook. Those configuration were affected:\n - " + + "\n -".join(manually_modified_files_by_app) + ) + # If the install failed or broke the system, we remove it + if install_failed or broke_the_system: + + # This option is meant for packagers to debug their apps more easily + if no_remove_on_failure: + raise YunohostError( + "The installation of %s failed, but was not cleaned up as requested by --no-remove-on-failure." + % app_id, + raw_msg=True, + ) + else: + logger.warning(m18n.n("app_remove_after_failed_install")) + + # Setup environment for remove script + env_dict_remove = {} + env_dict_remove["YNH_APP_ID"] = app_id + env_dict_remove["YNH_APP_INSTANCE_NAME"] = app_instance_name + env_dict_remove["YNH_APP_INSTANCE_NUMBER"] = str(instance_number) + env_dict_remove["YNH_APP_MANIFEST_VERSION"] = manifest.get("version", "?") + env_dict_remove["YNH_APP_BASEDIR"] = extracted_app_folder + + # Execute remove script + operation_logger_remove = OperationLogger( + "remove_on_failed_install", + [("app", app_instance_name)], + env=env_dict_remove, + ) + operation_logger_remove.start() + + # Try to remove the app + try: remove_retcode = hook_exec( - os.path.join(extracted_app_folder, 'scripts/remove'), - args=[app_instance_name], env=env_dict_remove + os.path.join(extracted_app_folder, "scripts/remove"), + args=[app_instance_name], + env=env_dict_remove, )[0] - # Remove all permission in LDAP - result = ldap.search(base='ou=permission,dc=yunohost,dc=org', - filter='(&(objectclass=permissionYnh)(cn=*.%s))' % app_instance_name, attrs=['cn']) - permission_list = [p['cn'][0] for p in result] - for l in permission_list: - permission_remove(app_instance_name, l.split('.')[0], force=True) - if remove_retcode != 0: - msg = m18n.n('app_not_properly_removed', - app=app_instance_name) - logger.warning(msg) - operation_logger_remove.error(msg) + # Here again, calling hook_exec could fail miserably, or get + # manually interrupted (by mistake or because script was stuck) + # In that case we still want to proceed with the rest of the + # removal (permissions, /etc/yunohost/apps/{app} ...) + except (KeyboardInterrupt, EOFError, Exception): + remove_retcode = -1 + import traceback + + logger.error( + m18n.n("unexpected_error", error="\n" + traceback.format_exc()) + ) + + # Remove all permission in LDAP + for permission_name in user_permission_list()["permissions"].keys(): + if permission_name.startswith(app_instance_name + "."): + permission_delete(permission_name, force=True, sync_perm=False) + + if remove_retcode != 0: + msg = m18n.n("app_not_properly_removed", app=app_instance_name) + logger.warning(msg) + operation_logger_remove.error(msg) + else: + try: + _assert_system_is_sane_for_app(manifest, "post") + except Exception as e: + operation_logger_remove.error(e) else: operation_logger_remove.success() @@ -928,119 +1094,112 @@ def app_install(operation_logger, app, label=None, args=None, no_remove_on_failu shutil.rmtree(app_setting_path) shutil.rmtree(extracted_app_folder) - app_ssowatconf() + permission_sync_to_user() - if packages.dpkg_is_broken(): - logger.error(m18n.n("this_action_broke_dpkg")) - - if install_retcode == -1: - msg = m18n.n('operation_interrupted') + " " + error_msg - raise YunohostError(msg, raw_msg=True) - msg = error_msg - raise YunohostError(msg, raw_msg=True) + raise YunohostError(failure_message_with_debug_instructions, raw_msg=True) # Clean hooks and add new ones hook_remove(app_instance_name) - if 'hooks' in os.listdir(extracted_app_folder): - for file in os.listdir(extracted_app_folder + '/hooks'): - hook_add(app_instance_name, extracted_app_folder + '/hooks/' + file) - - # Store app status - with open(app_setting_path + '/status.json', 'w+') as f: - json.dump(status, f) + if "hooks" in os.listdir(extracted_app_folder): + for file in os.listdir(extracted_app_folder + "/hooks"): + hook_add(app_instance_name, extracted_app_folder + "/hooks/" + file) # Clean and set permissions shutil.rmtree(extracted_app_folder) - os.system('chmod -R 400 %s' % app_setting_path) - os.system('chown -R root: %s' % app_setting_path) - os.system('chown -R admin: %s/scripts' % app_setting_path) + os.system("chmod 600 %s" % app_setting_path) + os.system("chmod 400 %s/settings.yml" % app_setting_path) + os.system("chown -R root: %s" % app_setting_path) - # Add path in permission if it's defined in the app install script - app_settings = _get_app_settings(app_instance_name) - domain = app_settings.get('domain', None) - path = app_settings.get('path', None) - if domain and path: - permission_update(app_instance_name, permission="main", add_url=[domain+path], sync_perm=False) + logger.success(m18n.n("installation_complete")) - permission_sync_to_user() - - logger.success(m18n.n('installation_complete')) - - hook_callback('post_app_install', args=args_list, env=env_dict) + hook_callback("post_app_install", env=env_dict) @is_unit_operation() -def app_remove(operation_logger, app): +def app_remove(operation_logger, app, purge=False): """ Remove app - Keyword argument: + Keyword arguments: app -- App(s) to delete + purge -- Remove with all app data """ - from yunohost.utils.ldap import _get_ldap_interface from yunohost.hook import hook_exec, hook_remove, hook_callback - from yunohost.permission import permission_remove, permission_sync_to_user + from yunohost.permission import ( + user_permission_list, + permission_delete, + permission_sync_to_user, + ) + if not _is_installed(app): - raise YunohostError('app_not_installed', app=app, all_apps=_get_all_installed_apps_id()) + raise YunohostValidationError( + "app_not_installed", app=app, all_apps=_get_all_installed_apps_id() + ) operation_logger.start() logger.info(m18n.n("app_start_remove", app=app)) - app_setting_path = APPS_SETTING_PATH + app + app_setting_path = os.path.join(APPS_SETTING_PATH, app) - # TODO: display fail messages from script - try: - shutil.rmtree('/tmp/yunohost_remove') - except: - pass + # Attempt to patch legacy helpers ... + _patch_legacy_helpers(app_setting_path) # Apply dirty patch to make php5 apps compatible with php7 (e.g. the remove # script might date back from jessie install) - _patch_php5(app_setting_path) + _patch_legacy_php_versions(app_setting_path) - os.system('cp -a %s /tmp/yunohost_remove && chown -hR admin: /tmp/yunohost_remove' % app_setting_path) - os.system('chown -R admin: /tmp/yunohost_remove') - os.system('chmod -R u+rX /tmp/yunohost_remove') - - args_list = [app] + manifest = _get_manifest_of_app(app_setting_path) + tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app) + remove_script = f"{tmp_workdir_for_app}/scripts/remove" env_dict = {} app_id, app_instance_nb = _parse_app_instance_name(app) env_dict["YNH_APP_ID"] = app_id env_dict["YNH_APP_INSTANCE_NAME"] = app env_dict["YNH_APP_INSTANCE_NUMBER"] = str(app_instance_nb) - operation_logger.extra.update({'env': env_dict}) + env_dict["YNH_APP_MANIFEST_VERSION"] = manifest.get("version", "?") + env_dict["YNH_APP_PURGE"] = str(purge) + env_dict["YNH_APP_BASEDIR"] = tmp_workdir_for_app + + operation_logger.extra.update({"env": env_dict}) operation_logger.flush() - if hook_exec('/tmp/yunohost_remove/scripts/remove', args=args_list, - env=env_dict)[0] == 0: - logger.success(m18n.n('app_removed', app=app)) + try: + ret = hook_exec(remove_script, env=env_dict)[0] + # Here again, calling hook_exec could fail miserably, or get + # manually interrupted (by mistake or because script was stuck) + # In that case we still want to proceed with the rest of the + # removal (permissions, /etc/yunohost/apps/{app} ...) + except (KeyboardInterrupt, EOFError, Exception): + ret = -1 + import traceback - hook_callback('post_app_remove', args=args_list, env=env_dict) + logger.error(m18n.n("unexpected_error", error="\n" + traceback.format_exc())) + finally: + shutil.rmtree(tmp_workdir_for_app) + + if ret == 0: + logger.success(m18n.n("app_removed", app=app)) + hook_callback("post_app_remove", env=env_dict) + else: + logger.warning(m18n.n("app_not_properly_removed", app=app)) + + # Remove all permission in LDAP + for permission_name in user_permission_list(apps=[app])["permissions"].keys(): + permission_delete(permission_name, force=True, sync_perm=False) if os.path.exists(app_setting_path): shutil.rmtree(app_setting_path) - shutil.rmtree('/tmp/yunohost_remove') + hook_remove(app) - # Remove all permission in LDAP - ldap = _get_ldap_interface() - result = ldap.search(base='ou=permission,dc=yunohost,dc=org', - filter='(&(objectclass=permissionYnh)(cn=*.%s))' % app, attrs=['cn']) - permission_list = [p['cn'][0] for p in result] - for l in permission_list: - permission_remove(app, l.split('.')[0], force=True, sync_perm=False) - permission_sync_to_user() - - if packages.dpkg_is_broken(): - raise YunohostError("this_action_broke_dpkg") + _assert_system_is_sane_for_app(manifest, "post") -@is_unit_operation(['permission','app']) -def app_addaccess(operation_logger, apps, users=[]): +def app_addaccess(apps, users=[]): """ Grant access right to users (everyone by default) @@ -1051,15 +1210,17 @@ def app_addaccess(operation_logger, apps, users=[]): """ from yunohost.permission import user_permission_update - permission = user_permission_update(operation_logger, app=apps, permission="main", add_username=users) + output = {} + for app in apps: + permission = user_permission_update( + app + ".main", add=users, remove="all_users" + ) + output[app] = permission["corresponding_users"] - result = {p : v['main']['allowed_users'] for p, v in permission['permissions'].items()} - - return {'allowed_users': result} + return {"allowed_users": output} -@is_unit_operation(['permission','app']) -def app_removeaccess(operation_logger, apps, users=[]): +def app_removeaccess(apps, users=[]): """ Revoke access right to users (everyone by default) @@ -1070,15 +1231,15 @@ def app_removeaccess(operation_logger, apps, users=[]): """ from yunohost.permission import user_permission_update - permission = user_permission_update(operation_logger, app=apps, permission="main", del_username=users) + output = {} + for app in apps: + permission = user_permission_update(app + ".main", remove=users) + output[app] = permission["corresponding_users"] - result = {p : v['main']['allowed_users'] for p, v in permission['permissions'].items()} - - return {'allowed_users': result} + return {"allowed_users": output} -@is_unit_operation(['permission','app']) -def app_clearaccess(operation_logger, apps): +def app_clearaccess(apps): """ Reset access rights for the app @@ -1086,34 +1247,14 @@ def app_clearaccess(operation_logger, apps): apps """ - from yunohost.permission import user_permission_clear + from yunohost.permission import user_permission_reset - permission = user_permission_clear(operation_logger, app=apps, permission="main") + output = {} + for app in apps: + permission = user_permission_reset(app + ".main") + output[app] = permission["corresponding_users"] - result = {p : v['main']['allowed_users'] for p, v in permission['permissions'].items()} - - return {'allowed_users': result} - -def app_debug(app): - """ - Display debug informations for an app - - Keyword argument: - app - """ - manifest = _get_manifest_of_app(os.path.join(APPS_SETTING_PATH, app)) - - return { - 'name': manifest['id'], - 'label': manifest['name'], - 'services': [{ - "name": x, - "logs": [{ - "file_name": y, - "file_content": "\n".join(z), - } for (y, z) in sorted(service_log(x).items(), key=lambda x: x[0])], - } for x in sorted(manifest.get("services", []))] - } + return {"allowed_users": output} @is_unit_operation() @@ -1126,45 +1267,49 @@ def app_makedefault(operation_logger, app, domain=None): domain """ - from yunohost.domain import domain_list + from yunohost.domain import _assert_domain_exists app_settings = _get_app_settings(app) - app_domain = app_settings['domain'] - app_path = app_settings['path'] + app_domain = app_settings["domain"] + app_path = app_settings["path"] if domain is None: domain = app_domain - operation_logger.related_to.append(('domain', domain)) - elif domain not in domain_list()['domains']: - raise YunohostError('domain_unknown') + + _assert_domain_exists(domain) + + operation_logger.related_to.append(("domain", domain)) + + if "/" in app_map(raw=True)[domain]: + raise YunohostValidationError( + "app_make_default_location_already_used", + app=app, + domain=app_domain, + other_app=app_map(raw=True)[domain]["/"]["id"], + ) operation_logger.start() - if '/' in app_map(raw=True)[domain]: - raise YunohostError('app_make_default_location_already_used', app=app, domain=app_domain, - other_app=app_map(raw=True)[domain]["/"]["id"]) - try: - with open('/etc/ssowat/conf.json.persistent') as json_conf: - ssowat_conf = json.loads(str(json_conf.read())) - except ValueError as e: - raise YunohostError('ssowat_persistent_conf_read_error', error=e) - except IOError: + # TODO / FIXME : current trick is to add this to conf.json.persisten + # This is really not robust and should be improved + # e.g. have a flag in /etc/yunohost/apps/$app/ to say that this is the + # default app or idk... + if not os.path.exists("/etc/ssowat/conf.json.persistent"): ssowat_conf = {} + else: + ssowat_conf = read_json("/etc/ssowat/conf.json.persistent") - if 'redirected_urls' not in ssowat_conf: - ssowat_conf['redirected_urls'] = {} + if "redirected_urls" not in ssowat_conf: + ssowat_conf["redirected_urls"] = {} - ssowat_conf['redirected_urls'][domain + '/'] = app_domain + app_path + ssowat_conf["redirected_urls"][domain + "/"] = app_domain + app_path - try: - with open('/etc/ssowat/conf.json.persistent', 'w+') as f: - json.dump(ssowat_conf, f, sort_keys=True, indent=4) - except IOError as e: - raise YunohostError('ssowat_persistent_conf_write_error', error=e) + write_to_json( + "/etc/ssowat/conf.json.persistent", ssowat_conf, sort_keys=True, indent=4 + ) + os.system("chmod 644 /etc/ssowat/conf.json.persistent") - os.system('chmod 644 /etc/ssowat/conf.json.persistent') - - logger.success(m18n.n('ssowat_conf_updated')) + logger.success(m18n.n("ssowat_conf_updated")) def app_setting(app, key, value=None, delete=False): @@ -1180,39 +1325,137 @@ def app_setting(app, key, value=None, delete=False): """ app_settings = _get_app_settings(app) or {} - if value is None and not delete: - try: - return app_settings[key] - except Exception as e: - logger.debug("cannot get app setting '%s' for '%s' (%s)", key, app, e) - return None - else: - if delete and key in app_settings: - del app_settings[key] + # + # Legacy permission setting management + # (unprotected, protected, skipped_uri/regex) + # + + is_legacy_permission_setting = any( + key.startswith(word + "_") for word in ["unprotected", "protected", "skipped"] + ) + + if is_legacy_permission_setting: + + from yunohost.permission import ( + user_permission_list, + user_permission_update, + permission_create, + permission_delete, + permission_url, + ) + + permissions = user_permission_list(full=True, apps=[app])["permissions"] + permission_name = "%s.legacy_%s_uris" % (app, key.split("_")[0]) + permission = permissions.get(permission_name) + + # GET + if value is None and not delete: + return ( + ",".join(permission.get("uris", []) + permission["additional_urls"]) + if permission + else None + ) + + # DELETE + if delete: + # If 'is_public' setting still exists, we interpret this as + # coming from a legacy app (because new apps shouldn't manage the + # is_public state themselves anymore...) + # + # In that case, we interpret the request for "deleting + # unprotected/skipped" setting as willing to make the app + # private + if ( + "is_public" in app_settings + and "visitors" in permissions[app + ".main"]["allowed"] + ): + if key.startswith("unprotected_") or key.startswith("skipped_"): + user_permission_update(app + ".main", remove="visitors") + + if permission: + permission_delete(permission_name) + + # SET else: - # FIXME: Allow multiple values for some keys? - if key in ['redirected_urls', 'redirected_regex']: - value = yaml.load(value) - app_settings[key] = value - _set_app_settings(app, app_settings) + urls = value + # If the request is about the root of the app (/), ( = the vast majority of cases) + # we interpret this as a change for the main permission + # (i.e. allowing/disallowing visitors) + if urls == "/": + if key.startswith("unprotected_") or key.startswith("skipped_"): + permission_url(app + ".main", url="/", sync_perm=False) + user_permission_update(app + ".main", add="visitors") + else: + user_permission_update(app + ".main", remove="visitors") + else: -def app_checkport(port): - """ - Check availability of a local port + urls = urls.split(",") + if key.endswith("_regex"): + urls = ["re:" + url for url in urls] - Keyword argument: - port -- Port to check + if permission: + # In case of new regex, save the urls, to add a new time in the additional_urls + # In case of new urls, we do the same thing but inversed + if key.endswith("_regex"): + # List of urls to save + current_urls_or_regex = [ + url + for url in permission["additional_urls"] + if not url.startswith("re:") + ] + else: + # List of regex to save + current_urls_or_regex = [ + url + for url in permission["additional_urls"] + if url.startswith("re:") + ] - """ + new_urls = urls + current_urls_or_regex + # We need to clear urls because in the old setting the new setting override the old one and dont just add some urls + permission_url(permission_name, clear_urls=True, sync_perm=False) + permission_url(permission_name, add_url=new_urls) + else: + from yunohost.utils.legacy import legacy_permission_label - # This import cannot be moved on top of file because it create a recursive - # import... - from yunohost.tools import tools_port_available - if tools_port_available(port): - logger.success(m18n.n('port_available', port=int(port))) + # Let's create a "special" permission for the legacy settings + permission_create( + permission=permission_name, + # FIXME find a way to limit to only the user allowed to the main permission + allowed=["all_users"] + if key.startswith("protected_") + else ["all_users", "visitors"], + url=None, + additional_urls=urls, + auth_header=not key.startswith("skipped_"), + label=legacy_permission_label(app, key.split("_")[0]), + show_tile=False, + protected=True, + ) + + return + + # + # Regular setting management + # + + # GET + if value is None and not delete: + return app_settings.get(key, None) + + # DELETE + if delete: + if key in app_settings: + del app_settings[key] + + # SET else: - raise YunohostError('port_unavailable', port=int(port)) + if key in ["redirected_urls", "redirected_regex"]: + value = yaml.safe_load(value) + app_settings[key] = value + + _set_app_settings(app, app_settings) def app_register_url(app, domain, path): @@ -1224,125 +1467,38 @@ def app_register_url(app, domain, path): domain -- The domain on which the app should be registered (e.g. your.domain.tld) path -- The path to be registered (e.g. /coffee) """ + from yunohost.permission import ( + permission_url, + user_permission_update, + permission_sync_to_user, + ) - # This line can't be moved on top of file, otherwise it creates an infinite - # loop of import with tools.py... - from .domain import _get_conflicting_apps, _normalize_domain_path - - domain, path = _normalize_domain_path(domain, path) + domain = DomainQuestion.normalize(domain) + path = PathQuestion.normalize(path) # We cannot change the url of an app already installed simply by changing # the settings... - installed = app in app_list(installed=True, raw=True).keys() - if installed: + if _is_installed(app): settings = _get_app_settings(app) if "path" in settings.keys() and "domain" in settings.keys(): - raise YunohostError('app_already_installed_cant_change_url') + raise YunohostValidationError("app_already_installed_cant_change_url") # Check the url is available - conflicts = _get_conflicting_apps(domain, path) - if conflicts: - apps = [] - for path, app_id, app_label in conflicts: - apps.append(" * {domain:s}{path:s} → {app_label:s} ({app_id:s})".format( - domain=domain, - path=path, - app_id=app_id, - app_label=app_label, - )) + _assert_no_conflicting_apps(domain, path) - raise YunohostError('app_location_unavailable', apps="\n".join(apps)) + app_setting(app, "domain", value=domain) + app_setting(app, "path", value=path) - app_setting(app, 'domain', value=domain) - app_setting(app, 'path', value=path) - - -def app_checkurl(url, app=None): - """ - Check availability of a web path - - Keyword argument: - url -- Url to check - app -- Write domain & path to app settings for further checks - - """ - - logger.error("Packagers /!\\ : 'app checkurl' is deprecated ! Please use the helper 'ynh_webpath_register' instead !") - - from yunohost.domain import domain_list, _normalize_domain_path - - if "https://" == url[:8]: - url = url[8:] - elif "http://" == url[:7]: - url = url[7:] - - if url[-1:] != '/': - url = url + '/' - - domain = url[:url.index('/')] - path = url[url.index('/'):] - installed = False - - domain, path = _normalize_domain_path(domain, path) - - apps_map = app_map(raw=True) - - if domain not in domain_list()['domains']: - raise YunohostError('domain_unknown') - - if domain in apps_map: - # Loop through apps - for p, a in apps_map[domain].items(): - # Skip requested app checking - if app is not None and a['id'] == app: - installed = True - continue - if path == p: - raise YunohostError('app_location_already_used', app=a["id"], path=path) - # can't install "/a/b/" if "/a/" exists - elif path.startswith(p) or p.startswith(path): - raise YunohostError('app_location_install_failed', other_path=p, other_app=a['id']) - - if app is not None and not installed: - app_setting(app, 'domain', value=domain) - app_setting(app, 'path', value=path) - - -def app_initdb(user, password=None, db=None, sql=None): - """ - Create database and initialize it with optionnal attached script - - Keyword argument: - db -- DB name (user unless set) - user -- Name of the DB user - password -- Password of the DB (generated unless set) - sql -- Initial SQL file - - """ - - logger.error("Packagers /!\\ : 'app initdb' is deprecated ! Please use the helper 'ynh_mysql_setup_db' instead !") - - if db is None: - db = user - - return_pwd = False - if password is None: - password = random_password(12) - return_pwd = True - - mysql_root_pwd = open('/etc/yunohost/mysql').read().rstrip() - mysql_command = 'mysql -u root -p%s -e "CREATE DATABASE %s ; GRANT ALL PRIVILEGES ON %s.* TO \'%s\'@localhost IDENTIFIED BY \'%s\';"' % (mysql_root_pwd, db, db, user, password) - if os.system(mysql_command) != 0: - raise YunohostError('mysql_db_creation_failed') - if sql is not None: - if os.system('mysql -u %s -p%s %s < %s' % (user, password, db, sql)) != 0: - raise YunohostError('mysql_db_init_failed') - - if return_pwd: - return password - - logger.success(m18n.n('mysql_db_initialized')) + # Initially, the .main permission is created with no url at all associated + # When the app register/books its web url, we also add the url '/' + # (meaning the root of the app, domain.tld/path/) + # and enable the tile to the SSO, and both of this should match 95% of apps + # For more specific cases, the app is free to change / add urls or disable + # the tile using the permission helpers. + permission_url(app + ".main", url="/", sync_perm=False) + user_permission_update(app + ".main", show_tile=True, sync_perm=False) + permission_sync_to_user() def app_ssowatconf(): @@ -1352,119 +1508,109 @@ def app_ssowatconf(): """ from yunohost.domain import domain_list, _get_maindomain - from yunohost.user import user_list from yunohost.permission import user_permission_list main_domain = _get_maindomain() - domains = domain_list()['domains'] + domains = domain_list()["domains"] + all_permissions = user_permission_list( + full=True, ignore_system_perms=True, absolute_urls=True + )["permissions"] - skipped_urls = [] - skipped_regex = [] - unprotected_urls = [] - unprotected_regex = [] - protected_urls = [] - protected_regex = [] - redirected_regex = {main_domain + '/yunohost[\/]?$': 'https://' + main_domain + '/yunohost/sso/'} + permissions = { + "core_skipped": { + "users": [], + "label": "Core permissions - skipped", + "show_tile": False, + "auth_header": False, + "public": True, + "uris": [domain + "/yunohost/admin" for domain in domains] + + [domain + "/yunohost/api" for domain in domains] + + [ + "re:^[^/]*/%.well%-known/ynh%-diagnosis/.*$", + "re:^[^/]*/%.well%-known/acme%-challenge/.*$", + "re:^[^/]*/%.well%-known/autoconfig/mail/config%-v1%.1%.xml.*$", + ], + } + } + redirected_regex = { + main_domain + r"/yunohost[\/]?$": "https://" + main_domain + "/yunohost/sso/" + } redirected_urls = {} - try: - apps_list = app_list(installed=True)['apps'] - except Exception as e: - logger.debug("cannot get installed app list because %s", e) - apps_list = [] + for app in _installed_apps(): - def _get_setting(settings, name): - s = settings.get(name, None) - return s.split(',') if s else [] + app_settings = read_yaml(APPS_SETTING_PATH + app + "/settings.yml") - for app in apps_list: - with open(APPS_SETTING_PATH + app['id'] + '/settings.yml') as f: - app_settings = yaml.load(f) + # Redirected + redirected_urls.update(app_settings.get("redirected_urls", {})) + redirected_regex.update(app_settings.get("redirected_regex", {})) - if 'no_sso' in app_settings: - continue + # New permission system + for perm_name, perm_info in all_permissions.items(): - for item in _get_setting(app_settings, 'skipped_uris'): - if item[-1:] == '/': - item = item[:-1] - skipped_urls.append(app_settings['domain'] + app_settings['path'].rstrip('/') + item) - for item in _get_setting(app_settings, 'skipped_regex'): - skipped_regex.append(item) - for item in _get_setting(app_settings, 'unprotected_uris'): - if item[-1:] == '/': - item = item[:-1] - unprotected_urls.append(app_settings['domain'] + app_settings['path'].rstrip('/') + item) - for item in _get_setting(app_settings, 'unprotected_regex'): - unprotected_regex.append(item) - for item in _get_setting(app_settings, 'protected_uris'): - if item[-1:] == '/': - item = item[:-1] - protected_urls.append(app_settings['domain'] + app_settings['path'].rstrip('/') + item) - for item in _get_setting(app_settings, 'protected_regex'): - protected_regex.append(item) - if 'redirected_urls' in app_settings: - redirected_urls.update(app_settings['redirected_urls']) - if 'redirected_regex' in app_settings: - redirected_regex.update(app_settings['redirected_regex']) + uris = ( + [] + + ([perm_info["url"]] if perm_info["url"] else []) + + perm_info["additional_urls"] + ) - for domain in domains: - skipped_urls.extend([domain + '/yunohost/admin', domain + '/yunohost/api']) + # Ignore permissions for which there's no url defined + if not uris: + continue - # Authorize ACME challenge url - skipped_regex.append("^[^/]*/%.well%-known/acme%-challenge/.*$") - skipped_regex.append("^[^/]*/%.well%-known/autoconfig/mail/config%-v1%.1%.xml.*$") - - permission = {} - for a in user_permission_list()['permissions'].values(): - for p in a.values(): - if 'URL' in p: - for u in p['URL']: - permission[u] = p['allowed_users'] + permissions[perm_name] = { + "users": perm_info["corresponding_users"], + "label": perm_info["label"], + "show_tile": perm_info["show_tile"] + and perm_info["url"] + and (not perm_info["url"].startswith("re:")), + "auth_header": perm_info["auth_header"], + "public": "visitors" in perm_info["allowed"], + "uris": uris, + } conf_dict = { - 'portal_domain': main_domain, - 'portal_path': '/yunohost/sso/', - 'additional_headers': { - 'Auth-User': 'uid', - 'Remote-User': 'uid', - 'Name': 'cn', - 'Email': 'mail' + "portal_domain": main_domain, + "portal_path": "/yunohost/sso/", + "additional_headers": { + "Auth-User": "uid", + "Remote-User": "uid", + "Name": "cn", + "Email": "mail", }, - 'domains': domains, - 'skipped_urls': skipped_urls, - 'unprotected_urls': unprotected_urls, - 'protected_urls': protected_urls, - 'skipped_regex': skipped_regex, - 'unprotected_regex': unprotected_regex, - 'protected_regex': protected_regex, - 'redirected_urls': redirected_urls, - 'redirected_regex': redirected_regex, - 'users': {username: app_map(user=username) - for username in user_list()['users'].keys()}, - 'permission': permission, + "domains": domains, + "redirected_urls": redirected_urls, + "redirected_regex": redirected_regex, + "permissions": permissions, } - with open('/etc/ssowat/conf.json', 'w+') as f: - json.dump(conf_dict, f, sort_keys=True, indent=4) + write_to_json("/etc/ssowat/conf.json", conf_dict, sort_keys=True, indent=4) - logger.debug(m18n.n('ssowat_conf_generated')) + from .utils.legacy import translate_legacy_rules_in_ssowant_conf_json_persistent + + translate_legacy_rules_in_ssowant_conf_json_persistent() + + logger.debug(m18n.n("ssowat_conf_generated")) def app_change_label(app, new_label): + from yunohost.permission import user_permission_update + installed = _is_installed(app) if not installed: - raise YunohostError('app_not_installed', app=app, all_apps=_get_all_installed_apps_id()) - - app_setting(app, "label", value=new_label) - - app_ssowatconf() + raise YunohostValidationError( + "app_not_installed", app=app, all_apps=_get_all_installed_apps_id() + ) + logger.warning(m18n.n("app_label_deprecated")) + user_permission_update(app + ".main", label=new_label) # actions todo list: # * docstring + def app_action_list(app): - logger.warning(m18n.n('experimental_feature')) + logger.warning(m18n.n("experimental_feature")) # this will take care of checking if the app is installed app_info_dict = app_info(app) @@ -1472,211 +1618,183 @@ def app_action_list(app): return { "app": app, "app_name": app_info_dict["name"], - "actions": _get_app_actions(app) + "actions": _get_app_actions(app), } @is_unit_operation() def app_action_run(operation_logger, app, action, args=None): - logger.warning(m18n.n('experimental_feature')) + logger.warning(m18n.n("experimental_feature")) from yunohost.hook import hook_exec - import tempfile # will raise if action doesn't exist actions = app_action_list(app)["actions"] actions = {x["id"]: x for x in actions} if action not in actions: - raise YunohostError("action '%s' not available for app '%s', available actions are: %s" % (action, app, ", ".join(actions.keys())), raw_msg=True) + raise YunohostValidationError( + "action '%s' not available for app '%s', available actions are: %s" + % (action, app, ", ".join(actions.keys())), + raw_msg=True, + ) operation_logger.start() action_declaration = actions[action] # Retrieve arguments list for install script - args_dict = dict(urlparse.parse_qsl(args, keep_blank_values=True)) if args else {} - args_odict = _parse_args_for_action(actions[action], args=args_dict) - args_list = [value[0] for value in args_odict.values()] + raw_questions = actions[action].get("arguments", {}) + questions = ask_questions_and_parse_answers(raw_questions, prefilled_answers=args) + args = { + question.name: question.value + for question in questions + if question.value is not None + } - app_id, app_instance_nb = _parse_app_instance_name(app) + tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app) - env_dict = _make_environment_dict(args_odict, prefix="ACTION_") - env_dict["YNH_APP_ID"] = app_id - env_dict["YNH_APP_INSTANCE_NAME"] = app - env_dict["YNH_APP_INSTANCE_NUMBER"] = str(app_instance_nb) + env_dict = _make_environment_for_app_script(app, args=args, args_prefix="ACTION_") env_dict["YNH_ACTION"] = action + env_dict["YNH_APP_BASEDIR"] = tmp_workdir_for_app - _, path = tempfile.mkstemp() + _, action_script = tempfile.mkstemp(dir=tmp_workdir_for_app) - with open(path, "w") as script: + with open(action_script, "w") as script: script.write(action_declaration["command"]) - os.chmod(path, 700) - if action_declaration.get("cwd"): - cwd = action_declaration["cwd"].replace("$app", app_id) + cwd = action_declaration["cwd"].replace("$app", app) else: - cwd = "/etc/yunohost/apps/" + app + cwd = tmp_workdir_for_app - retcode = hook_exec( - path, - args=args_list, - env=env_dict, - chdir=cwd, - user=action_declaration.get("user", "root"), - )[0] + try: + retcode = hook_exec( + action_script, + env=env_dict, + chdir=cwd, + user=action_declaration.get("user", "root"), + )[0] + # Calling hook_exec could fail miserably, or get + # manually interrupted (by mistake or because script was stuck) + # In that case we still want to delete the tmp work dir + except (KeyboardInterrupt, EOFError, Exception): + retcode = -1 + import traceback + + logger.error(m18n.n("unexpected_error", error="\n" + traceback.format_exc())) + finally: + shutil.rmtree(tmp_workdir_for_app) if retcode not in action_declaration.get("accepted_return_codes", [0]): - msg = "Error while executing action '%s' of app '%s': return code %s" % (action, app, retcode) + msg = "Error while executing action '%s' of app '%s': return code %s" % ( + action, + app, + retcode, + ) operation_logger.error(msg) raise YunohostError(msg, raw_msg=True) - os.remove(path) - operation_logger.success() return logger.success("Action successed!") -# Config panel todo list: -# * docstrings -# * merge translations on the json once the workflow is in place -@is_unit_operation() -def app_config_show_panel(operation_logger, app): - logger.warning(m18n.n('experimental_feature')) +def app_config_get(app, key="", full=False, export=False): + """ + Display an app configuration in classic, full or export mode + """ + if full and export: + raise YunohostValidationError( + "You can't use --full and --export together.", raw_msg=True + ) - from yunohost.hook import hook_exec + if full: + mode = "full" + elif export: + mode = "export" + else: + mode = "classic" - # this will take care of checking if the app is installed - app_info_dict = app_info(app) - - operation_logger.start() - config_panel = _get_app_config_panel(app) - config_script = os.path.join(APPS_SETTING_PATH, app, 'scripts', 'config') - - app_id, app_instance_nb = _parse_app_instance_name(app) - - if not config_panel or not os.path.exists(config_script): - return { - "app_id": app_id, - "app": app, - "app_name": app_info_dict["name"], - "config_panel": [], - } - - env = { - "YNH_APP_ID": app_id, - "YNH_APP_INSTANCE_NAME": app, - "YNH_APP_INSTANCE_NUMBER": str(app_instance_nb), - } - - return_code, parsed_values = hook_exec(config_script, - args=["show"], - env=env, - return_format="plain_dict" - ) - - if return_code != 0: - raise Exception("script/config show return value code: %s (considered as an error)", return_code) - - logger.debug("Generating global variables:") - for tab in config_panel.get("panel", []): - tab_id = tab["id"] # this makes things easier to debug on crash - for section in tab.get("sections", []): - section_id = section["id"] - for option in section.get("options", []): - option_name = option["name"] - generated_name = ("YNH_CONFIG_%s_%s_%s" % (tab_id, section_id, option_name)).upper() - option["name"] = generated_name - logger.debug(" * '%s'.'%s'.'%s' -> %s", tab.get("name"), section.get("name"), option.get("name"), generated_name) - - if generated_name in parsed_values: - # code is not adapted for that so we have to mock expected format :/ - if option.get("type") == "boolean": - if parsed_values[generated_name].lower() in ("true", "1", "y"): - option["default"] = parsed_values[generated_name] - else: - del option["default"] - else: - option["default"] = parsed_values[generated_name] - - args_dict = _parse_args_in_yunohost_format( - [{option["name"]: parsed_values[generated_name]}], - [option] - ) - option["default"] = args_dict[option["name"]][0] - else: - logger.debug("Variable '%s' is not declared by config script, using default", generated_name) - # do nothing, we'll use the default if present - - return { - "app_id": app_id, - "app": app, - "app_name": app_info_dict["name"], - "config_panel": config_panel, - "logs": operation_logger.success(), - } + config_ = AppConfigPanel(app) + return config_.get(key, mode) @is_unit_operation() -def app_config_apply(operation_logger, app, args): - logger.warning(m18n.n('experimental_feature')) +def app_config_set( + operation_logger, app, key=None, value=None, args=None, args_file=None +): + """ + Apply a new app configuration + """ - from yunohost.hook import hook_exec + config_ = AppConfigPanel(app) - installed = _is_installed(app) - if not installed: - raise YunohostError('app_not_installed', app=app, all_apps=_get_all_installed_apps_id()) + return config_.set(key, value, args, args_file, operation_logger=operation_logger) - config_panel = _get_app_config_panel(app) - config_script = os.path.join(APPS_SETTING_PATH, app, 'scripts', 'config') - if not config_panel or not os.path.exists(config_script): - # XXX real exception - raise Exception("Not config-panel.json nor scripts/config") +class AppConfigPanel(ConfigPanel): + def __init__(self, app): - operation_logger.start() - app_id, app_instance_nb = _parse_app_instance_name(app) - env = { - "YNH_APP_ID": app_id, - "YNH_APP_INSTANCE_NAME": app, - "YNH_APP_INSTANCE_NUMBER": str(app_instance_nb), - } - args = dict(urlparse.parse_qsl(args, keep_blank_values=True)) if args else {} + # Check app is installed + _assert_is_installed(app) - for tab in config_panel.get("panel", []): - tab_id = tab["id"] # this makes things easier to debug on crash - for section in tab.get("sections", []): - section_id = section["id"] - for option in section.get("options", []): - option_name = option["name"] - generated_name = ("YNH_CONFIG_%s_%s_%s" % (tab_id, section_id, option_name)).upper() + self.app = app + config_path = os.path.join(APPS_SETTING_PATH, app, "config_panel.toml") + super().__init__(config_path=config_path) - if generated_name in args: - logger.debug("include into env %s=%s", generated_name, args[generated_name]) - env[generated_name] = args[generated_name] - else: - logger.debug("no value for key id %s", generated_name) + def _load_current_values(self): + self.values = self._call_config_script("show") - # for debug purpose - for key in args: - if key not in env: - logger.warning("Ignore key '%s' from arguments because it is not in the config", key) + def _apply(self): + env = {key: str(value) for key, value in self.new_values.items()} + return_content = self._call_config_script("apply", env=env) - return_code = hook_exec(config_script, - args=["apply"], - env=env, - )[0] + # If the script returned validation error + # raise a ValidationError exception using + # the first key + if return_content: + for key, message in return_content.get("validation_errors").items(): + raise YunohostValidationError( + "app_argument_invalid", + name=key, + error=message, + ) - if return_code != 0: - msg = "'script/config apply' return value code: %s (considered as an error)" % return_code - operation_logger.error(msg) - raise Exception(msg) + def _call_config_script(self, action, env={}): + from yunohost.hook import hook_exec - logger.success("Config updated as expected") - return { - "logs": operation_logger.success(), - } + # Add default config script if needed + config_script = os.path.join(APPS_SETTING_PATH, self.app, "scripts", "config") + if not os.path.exists(config_script): + logger.debug("Adding a default config script") + default_script = """#!/bin/bash +source /usr/share/yunohost/helpers +ynh_abort_if_errors +ynh_app_config_run $1 +""" + write_to_file(config_script, default_script) + + # Call config script to extract current values + logger.debug(f"Calling '{action}' action from config script") + app_id, app_instance_nb = _parse_app_instance_name(self.app) + settings = _get_app_settings(app_id) + env.update( + { + "app_id": app_id, + "app": self.app, + "app_instance_nb": str(app_instance_nb), + "final_path": settings.get("final_path", ""), + "YNH_APP_BASEDIR": os.path.join(APPS_SETTING_PATH, self.app), + } + ) + + ret, values = hook_exec(config_script, args=[action], env=env) + if ret != 0: + if action == "show": + raise YunohostError("app_config_unable_to_read") + else: + raise YunohostError("app_config_unable_to_apply") + return values def _get_all_installed_apps_id(): @@ -1687,8 +1805,7 @@ def _get_all_installed_apps_id(): * ...' """ - all_apps_ids = [x["id"] for x in app_list(installed=True)["apps"]] - all_apps_ids = sorted(all_apps_ids) + all_apps_ids = sorted(_installed_apps()) all_apps_ids_formatted = "\n * ".join(all_apps_ids) all_apps_ids_formatted = "\n * " + all_apps_ids_formatted @@ -1698,8 +1815,8 @@ def _get_all_installed_apps_id(): def _get_app_actions(app_id): "Get app config panel stored in json or in toml" - actions_toml_path = os.path.join(APPS_SETTING_PATH, app_id, 'actions.toml') - actions_json_path = os.path.join(APPS_SETTING_PATH, app_id, 'actions.json') + actions_toml_path = os.path.join(APPS_SETTING_PATH, app_id, "actions.toml") + actions_json_path = os.path.join(APPS_SETTING_PATH, app_id, "actions.json") # sample data to get an idea of what is going on # this toml extract: @@ -1781,132 +1898,6 @@ def _get_app_actions(app_id): return None -def _get_app_config_panel(app_id): - "Get app config panel stored in json or in toml" - config_panel_toml_path = os.path.join(APPS_SETTING_PATH, app_id, 'config_panel.toml') - config_panel_json_path = os.path.join(APPS_SETTING_PATH, app_id, 'config_panel.json') - - # sample data to get an idea of what is going on - # this toml extract: - # - # version = "0.1" - # name = "Unattended-upgrades configuration panel" - # - # [main] - # name = "Unattended-upgrades configuration" - # - # [main.unattended_configuration] - # name = "50unattended-upgrades configuration file" - # - # [main.unattended_configuration.upgrade_level] - # name = "Choose the sources of packages to automatically upgrade." - # default = "Security only" - # type = "text" - # help = "We can't use a choices field for now. In the meantime please choose between one of this values:
Security only, Security and updates." - # # choices = ["Security only", "Security and updates"] - - # [main.unattended_configuration.ynh_update] - # name = "Would you like to update YunoHost packages automatically ?" - # type = "bool" - # default = true - # - # will be parsed into this: - # - # OrderedDict([(u'version', u'0.1'), - # (u'name', u'Unattended-upgrades configuration panel'), - # (u'main', - # OrderedDict([(u'name', u'Unattended-upgrades configuration'), - # (u'unattended_configuration', - # OrderedDict([(u'name', - # u'50unattended-upgrades configuration file'), - # (u'upgrade_level', - # OrderedDict([(u'name', - # u'Choose the sources of packages to automatically upgrade.'), - # (u'default', - # u'Security only'), - # (u'type', u'text'), - # (u'help', - # u"We can't use a choices field for now. In the meantime please choose between one of this values:
Security only, Security and updates.")])), - # (u'ynh_update', - # OrderedDict([(u'name', - # u'Would you like to update YunoHost packages automatically ?'), - # (u'type', u'bool'), - # (u'default', True)])), - # - # and needs to be converted into this: - # - # {u'name': u'Unattended-upgrades configuration panel', - # u'panel': [{u'id': u'main', - # u'name': u'Unattended-upgrades configuration', - # u'sections': [{u'id': u'unattended_configuration', - # u'name': u'50unattended-upgrades configuration file', - # u'options': [{u'//': u'"choices" : ["Security only", "Security and updates"]', - # u'default': u'Security only', - # u'help': u"We can't use a choices field for now. In the meantime please choose between one of this values:
Security only, Security and updates.", - # u'id': u'upgrade_level', - # u'name': u'Choose the sources of packages to automatically upgrade.', - # u'type': u'text'}, - # {u'default': True, - # u'id': u'ynh_update', - # u'name': u'Would you like to update YunoHost packages automatically ?', - # u'type': u'bool'}, - - if os.path.exists(config_panel_toml_path): - toml_config_panel = toml.load(open(config_panel_toml_path, "r"), _dict=OrderedDict) - - # transform toml format into json format - config_panel = { - "name": toml_config_panel["name"], - "version": toml_config_panel["version"], - "panel": [], - } - - panels = filter(lambda (key, value): key not in ("name", "version") - and isinstance(value, OrderedDict), - toml_config_panel.items()) - - for key, value in panels: - panel = { - "id": key, - "name": value["name"], - "sections": [], - } - - sections = filter(lambda (k, v): k not in ("name",) - and isinstance(v, OrderedDict), - value.items()) - - for section_key, section_value in sections: - section = { - "id": section_key, - "name": section_value["name"], - "options": [], - } - - options = filter(lambda (k, v): k not in ("name",) - and isinstance(v, OrderedDict), - section_value.items()) - - for option_key, option_value in options: - option = dict(option_value) - option["name"] = option_key - option["ask"] = {"en": option["ask"]} - if "help" in option: - option["help"] = {"en": option["help"]} - section["options"].append(option) - - panel["sections"].append(section) - - config_panel["panel"].append(panel) - - return config_panel - - elif os.path.exists(config_panel_json_path): - return json.load(open(config_panel_json_path)) - - return None - - def _get_app_settings(app_id): """ Get settings of an installed app @@ -1916,16 +1907,34 @@ def _get_app_settings(app_id): """ if not _is_installed(app_id): - raise YunohostError('app_not_installed', app=app_id, all_apps=_get_all_installed_apps_id()) + raise YunohostValidationError( + "app_not_installed", app=app_id, all_apps=_get_all_installed_apps_id() + ) try: - with open(os.path.join( - APPS_SETTING_PATH, app_id, 'settings.yml')) as f: - settings = yaml.load(f) - if app_id == settings['id']: + with open(os.path.join(APPS_SETTING_PATH, app_id, "settings.yml")) as f: + settings = yaml.safe_load(f) + # If label contains unicode char, this may later trigger issues when building strings... + # FIXME: this should be propagated to read_yaml so that this fix applies everywhere I think... + settings = {k: v for k, v in settings.items()} + + # Stupid fix for legacy bullshit + # In the past, some setups did not have proper normalization for app domain/path + # Meaning some setups (as of January 2021) still have path=/foobar/ (with a trailing slash) + # resulting in stupid issue unless apps using ynh_app_normalize_path_stuff + # So we yolofix the settings if such an issue is found >_> + # A simple call to `yunohost app list` (which happens quite often) should be enough + # to migrate all app settings ... so this can probably be removed once we're past Bullseye... + if settings.get("path") != "/" and ( + settings.get("path", "").endswith("/") + or not settings.get("path", "/").startswith("/") + ): + settings["path"] = "/" + settings["path"].strip("/") + _set_app_settings(app_id, settings) + + if app_id == settings["id"]: return settings except (IOError, TypeError, KeyError): - logger.exception(m18n.n('app_not_correctly_installed', - app=app_id)) + logger.error(m18n.n("app_not_correctly_installed", app=app_id)) return {} @@ -1938,110 +1947,56 @@ def _set_app_settings(app_id, settings): settings -- Dict with app settings """ - with open(os.path.join( - APPS_SETTING_PATH, app_id, 'settings.yml'), 'w') as f: + with open(os.path.join(APPS_SETTING_PATH, app_id, "settings.yml"), "w") as f: yaml.safe_dump(settings, f, default_flow_style=False) -def _get_app_status(app_id, format_date=False): +def _extract_app_from_file(path): """ - Get app status or create it if needed - - Keyword arguments: - app_id -- The app id - format_date -- Format date fields - - """ - app_setting_path = APPS_SETTING_PATH + app_id - if not os.path.isdir(app_setting_path): - raise YunohostError('app_unknown') - status = {} - - regen_status = True - try: - with open(app_setting_path + '/status.json') as f: - status = json.loads(str(f.read())) - regen_status = False - except IOError: - logger.debug("status file not found for '%s'", app_id, - exc_info=1) - except Exception as e: - logger.warning("could not open or decode %s : %s ... regenerating.", app_setting_path + '/status.json', str(e)) - - if regen_status: - # Create app status - status = { - 'installed_at': app_setting(app_id, 'install_time'), - 'upgraded_at': app_setting(app_id, 'update_time'), - 'remote': {'type': None}, - } - with open(app_setting_path + '/status.json', 'w+') as f: - json.dump(status, f) - - if format_date: - for f in ['installed_at', 'upgraded_at']: - v = status.get(f, None) - if not v: - status[f] = '-' - else: - status[f] = datetime.utcfromtimestamp(v) - return status - - -def _extract_app_from_file(path, remove=False): - """ - Unzip or untar application tarball in APP_TMP_FOLDER, or copy it from a directory + Unzip / untar / copy application tarball or directory to a tmp work directory Keyword arguments: path -- Path of the tarball or directory - remove -- Remove the tarball after extraction - - Returns: - Dict manifest - """ - logger.debug(m18n.n('extracting')) - - if os.path.exists(APP_TMP_FOLDER): - shutil.rmtree(APP_TMP_FOLDER) - os.makedirs(APP_TMP_FOLDER) + logger.debug(m18n.n("extracting")) path = os.path.abspath(path) + extracted_app_folder = _make_tmp_workdir_for_app() + if ".zip" in path: - extract_result = os.system('unzip %s -d %s > /dev/null 2>&1' % (path, APP_TMP_FOLDER)) - if remove: - os.remove(path) + extract_result = os.system( + f"unzip '{path}' -d {extracted_app_folder} > /dev/null 2>&1" + ) elif ".tar" in path: - extract_result = os.system('tar -xf %s -C %s > /dev/null 2>&1' % (path, APP_TMP_FOLDER)) - if remove: - os.remove(path) + extract_result = os.system( + f"tar -xf '{path}' -C {extracted_app_folder} > /dev/null 2>&1" + ) elif os.path.isdir(path): - shutil.rmtree(APP_TMP_FOLDER) - if path[-1] != '/': - path = path + '/' - extract_result = os.system('cp -a "%s" %s' % (path, APP_TMP_FOLDER)) + shutil.rmtree(extracted_app_folder) + if path[-1] != "/": + path = path + "/" + extract_result = os.system(f"cp -a '{path}' {extracted_app_folder}") else: extract_result = 1 if extract_result != 0: - raise YunohostError('app_extraction_failed') + raise YunohostError("app_extraction_failed") try: - extracted_app_folder = APP_TMP_FOLDER if len(os.listdir(extracted_app_folder)) == 1: for folder in os.listdir(extracted_app_folder): - extracted_app_folder = extracted_app_folder + '/' + folder + extracted_app_folder = extracted_app_folder + "/" + folder manifest = _get_manifest_of_app(extracted_app_folder) - manifest['lastUpdate'] = int(time.time()) + manifest["lastUpdate"] = int(time.time()) except IOError: - raise YunohostError('app_install_files_invalid') + raise YunohostError("app_install_files_invalid") except ValueError as e: - raise YunohostError('app_manifest_invalid', error=e) + raise YunohostError("app_manifest_invalid", error=e) - logger.debug(m18n.n('done')) + logger.debug(m18n.n("done")) - manifest['remote'] = {'type': 'file', 'path': path} + manifest["remote"] = {"type": "file", "path": path} return manifest, extracted_app_folder @@ -2156,14 +2111,10 @@ def _get_manifest_of_app(path): manifest = manifest_toml.copy() - if "arguments" not in manifest: - return manifest - - if "install" not in manifest["arguments"]: - return manifest - install_arguments = [] - for name, values in manifest_toml.get("arguments", {}).get("install", {}).items(): + for name, values in ( + manifest_toml.get("arguments", {}).get("install", {}).items() + ): args = values.copy() args["name"] = name @@ -2171,14 +2122,80 @@ def _get_manifest_of_app(path): manifest["arguments"]["install"] = install_arguments - return manifest elif os.path.exists(os.path.join(path, "manifest.json")): - return read_json(os.path.join(path, "manifest.json")) + manifest = read_json(os.path.join(path, "manifest.json")) else: - return None + raise YunohostError( + "There doesn't seem to be any manifest file in %s ... It looks like an app was not correctly installed/removed." + % path, + raw_msg=True, + ) + + manifest["arguments"] = _set_default_ask_questions(manifest.get("arguments", {})) + return manifest -def _get_git_last_commit_hash(repository, reference='HEAD'): +def _set_default_ask_questions(arguments): + + # arguments is something like + # { "install": [ + # { "name": "domain", + # "type": "domain", + # .... + # }, + # { "name": "path", + # "type": "path" + # ... + # }, + # ... + # ], + # "upgrade": [ ... ] + # } + + # We set a default for any question with these matching (type, name) + # type namei + # N.B. : this is only for install script ... should be reworked for other + # scripts if we supports args for other scripts in the future... + questions_with_default = [ + ("domain", "domain"), # i18n: app_manifest_install_ask_domain + ("path", "path"), # i18n: app_manifest_install_ask_path + ("password", "password"), # i18n: app_manifest_install_ask_password + ("user", "admin"), # i18n: app_manifest_install_ask_admin + ("boolean", "is_public"), + ] # i18n: app_manifest_install_ask_is_public + + for script_name, arg_list in arguments.items(): + + # We only support questions for install so far, and for other + if script_name != "install": + continue + + for arg in arg_list: + + # Do not override 'ask' field if provided by app ?... Or shall we ? + # if "ask" in arg: + # continue + + # If this arg corresponds to a question with default ask message... + if any( + (arg.get("type"), arg["name"]) == question + for question in questions_with_default + ): + # The key is for example "app_manifest_install_ask_domain" + key = "app_manifest_%s_ask_%s" % (script_name, arg["name"]) + arg["ask"] = m18n.n(key) + + # Also it in fact doesn't make sense for any of those questions to have an example value nor a default value... + if arg.get("type") in ["domain", "user", "password"]: + if "example" in arg: + del arg["example"] + if "default" in arg: + del arg["domain"] + + return arguments + + +def _get_git_last_commit_hash(repository, reference="HEAD"): """ Attempt to retrieve the last commit hash of a git repository @@ -2187,12 +2204,12 @@ def _get_git_last_commit_hash(repository, reference='HEAD'): """ try: - commit = subprocess.check_output( - "git ls-remote --exit-code {0} {1} | awk '{{print $1}}'".format( - repository, reference), - shell=True) + cmd = "git ls-remote --exit-code {0} {1} | awk '{{print $1}}'".format( + repository, reference + ) + commit = check_output(cmd) except subprocess.CalledProcessError: - logger.exception("unable to get last commit from %s", repository) + logger.error("unable to get last commit from %s", repository) raise ValueError("Unable to get last commit with git") else: return commit.strip() @@ -2200,131 +2217,76 @@ def _get_git_last_commit_hash(repository, reference='HEAD'): def _fetch_app_from_git(app): """ - Unzip or untar application tarball in APP_TMP_FOLDER + Unzip or untar application tarball to a tmp directory Keyword arguments: app -- App_id or git repo URL - - Returns: - Dict manifest - """ - extracted_app_folder = APP_TMP_FOLDER - app_tmp_archive = '{0}.zip'.format(extracted_app_folder) - if os.path.exists(extracted_app_folder): - shutil.rmtree(extracted_app_folder) - if os.path.exists(app_tmp_archive): - os.remove(app_tmp_archive) - - logger.debug(m18n.n('downloading')) - - if ('@' in app) or ('http://' in app) or ('https://' in app): + # Extract URL, branch and revision to download + if ("@" in app) or ("http://" in app) or ("https://" in app): url = app - branch = 'master' - github_repo = re_github_repo.match(app) - if github_repo: - if github_repo.group('tree'): - branch = github_repo.group('tree') - url = "https://github.com/{owner}/{repo}".format( - owner=github_repo.group('owner'), - repo=github_repo.group('repo'), - ) - tarball_url = "{url}/archive/{tree}.zip".format( - url=url, tree=branch - ) - try: - subprocess.check_call([ - 'wget', '-qO', app_tmp_archive, tarball_url]) - except subprocess.CalledProcessError: - logger.exception('unable to download %s', tarball_url) - raise YunohostError('app_sources_fetch_failed') - else: - manifest, extracted_app_folder = _extract_app_from_file( - app_tmp_archive, remove=True) - else: - tree_index = url.rfind('/tree/') - if tree_index > 0: - url = url[:tree_index] - branch = app[tree_index + 6:] - try: - # We use currently git 2.1 so we can't use --shallow-submodules - # option. When git will be in 2.9 (with the new debian version) - # we will be able to use it. Without this option all the history - # of the submodules repo is downloaded. - subprocess.check_call([ - 'git', 'clone', '-b', branch, '--single-branch', '--recursive', '--depth=1', url, - extracted_app_folder]) - subprocess.check_call([ - 'git', 'reset', '--hard', branch - ], cwd=extracted_app_folder) - manifest = _get_manifest_of_app(extracted_app_folder) - except subprocess.CalledProcessError: - raise YunohostError('app_sources_fetch_failed') - except ValueError as e: - raise YunohostError('app_manifest_invalid', error=e) - else: - logger.debug(m18n.n('done')) + branch = "master" + if "/tree/" in url: + url, branch = url.split("/tree/", 1) + revision = "HEAD" + else: + app_dict = _load_apps_catalog()["apps"] - # Store remote repository info into the returned manifest - manifest['remote'] = {'type': 'git', 'url': url, 'branch': branch} + app_id, _ = _parse_app_instance_name(app) + + if app_id not in app_dict: + raise YunohostValidationError("app_unknown") + elif "git" not in app_dict[app_id]: + raise YunohostValidationError("app_unsupported_remote_type") + + app_info = app_dict[app_id] + url = app_info["git"]["url"] + branch = app_info["git"]["branch"] + revision = str(app_info["git"]["revision"]) + + extracted_app_folder = _make_tmp_workdir_for_app() + + logger.debug(m18n.n("downloading")) + + # Download only this commit + try: + # We don't use git clone because, git clone can't download + # a specific revision only + run_commands([["git", "init", extracted_app_folder]], shell=False) + run_commands( + [ + ["git", "remote", "add", "origin", url], + [ + "git", + "fetch", + "--depth=1", + "origin", + branch if revision == "HEAD" else revision, + ], + ["git", "reset", "--hard", "FETCH_HEAD"], + ], + cwd=extracted_app_folder, + shell=False, + ) + manifest = _get_manifest_of_app(extracted_app_folder) + except subprocess.CalledProcessError: + raise YunohostError("app_sources_fetch_failed") + except ValueError as e: + raise YunohostError("app_manifest_invalid", error=e) + else: + logger.debug(m18n.n("done")) + + # Store remote repository info into the returned manifest + manifest["remote"] = {"type": "git", "url": url, "branch": branch} + if revision == "HEAD": try: - revision = _get_git_last_commit_hash(url, branch) + manifest["remote"]["revision"] = _get_git_last_commit_hash(url, branch) except Exception as e: logger.debug("cannot get last commit hash because: %s ", e) - else: - manifest['remote']['revision'] = revision else: - app_dict = app_list(raw=True) - - if app in app_dict: - app_info = app_dict[app] - app_info['manifest']['lastUpdate'] = app_info['lastUpdate'] - manifest = app_info['manifest'] - else: - raise YunohostError('app_unknown') - - if 'git' not in app_info: - raise YunohostError('app_unsupported_remote_type') - url = app_info['git']['url'] - - if 'github.com' in url: - tarball_url = "{url}/archive/{tree}.zip".format( - url=url, tree=app_info['git']['revision'] - ) - try: - subprocess.check_call([ - 'wget', '-qO', app_tmp_archive, tarball_url]) - except subprocess.CalledProcessError: - logger.exception('unable to download %s', tarball_url) - raise YunohostError('app_sources_fetch_failed') - else: - manifest, extracted_app_folder = _extract_app_from_file( - app_tmp_archive, remove=True) - else: - try: - subprocess.check_call([ - 'git', 'clone', app_info['git']['url'], - '-b', app_info['git']['branch'], extracted_app_folder]) - subprocess.check_call([ - 'git', 'reset', '--hard', - str(app_info['git']['revision']) - ], cwd=extracted_app_folder) - manifest = _get_manifest_of_app(extracted_app_folder) - except subprocess.CalledProcessError: - raise YunohostError('app_sources_fetch_failed') - except ValueError as e: - raise YunohostError('app_manifest_invalid', error=e) - else: - logger.debug(m18n.n('done')) - - # Store remote repository info into the returned manifest - manifest['remote'] = { - 'type': 'git', - 'url': url, - 'branch': app_info['git']['branch'], - 'revision': app_info['git']['revision'], - } + manifest["remote"]["revision"] = revision + manifest["lastUpdate"] = app_info["lastUpdate"] return manifest, extracted_app_folder @@ -2352,10 +2314,10 @@ def _installed_instance_number(app, last=False): for installed_app in installed_apps: if number == 0 and app == installed_app: number = 1 - elif '__' in installed_app: - if app == installed_app[:installed_app.index('__')]: - if int(installed_app[installed_app.index('__') + 2:]) > number: - number = int(installed_app[installed_app.index('__') + 2:]) + elif "__" in installed_app: + if app == installed_app[: installed_app.index("__")]: + if int(installed_app[installed_app.index("__") + 2 :]) > number: + number = int(installed_app[installed_app.index("__") + 2 :]) return number @@ -2364,7 +2326,7 @@ def _installed_instance_number(app, last=False): instances_dict = app_map(app=app, raw=True) for key, domain in instances_dict.items(): for key, path in domain.items(): - instance_number_list.append(path['instance']) + instance_number_list.append(path["instance"]) return sorted(instance_number_list) @@ -2383,295 +2345,175 @@ def _is_installed(app): return os.path.isdir(APPS_SETTING_PATH + app) -def _value_for_locale(values): - """ - Return proper value for current locale - - Keyword arguments: - values -- A dict of values associated to their locale - - Returns: - An utf-8 encoded string - - """ - if not isinstance(values, dict): - return values - - for lang in [m18n.locale, m18n.default_locale]: - try: - return _encode_string(values[lang]) - except KeyError: - continue - - # Fallback to first value - return _encode_string(values.values()[0]) +def _assert_is_installed(app): + if not _is_installed(app): + raise YunohostValidationError( + "app_not_installed", app=app, all_apps=_get_all_installed_apps_id() + ) -def _encode_string(value): - """ - Return the string encoded in utf-8 if needed - """ - if isinstance(value, unicode): - return value.encode('utf8') - return value +def _installed_apps(): + return os.listdir(APPS_SETTING_PATH) def _check_manifest_requirements(manifest, app_instance_name): """Check if required packages are met from the manifest""" - requirements = manifest.get('requirements', dict()) - # FIXME: Deprecate min_version key - if 'min_version' in manifest: - requirements['yunohost'] = '>> {0}'.format(manifest['min_version']) - logger.debug("the manifest key 'min_version' is deprecated, " - "use 'requirements' instead.") + packaging_format = int(manifest.get("packaging_format", 0)) + if packaging_format not in [0, 1]: + raise YunohostValidationError("app_packaging_format_not_supported") - # Validate multi-instance app - if is_true(manifest.get('multi_instance', False)): - # Handle backward-incompatible change introduced in yunohost >= 2.3.6 - # See https://github.com/YunoHost/issues/issues/156 - yunohost_req = requirements.get('yunohost', None) - if (not yunohost_req or - not packages.SpecifierSet(yunohost_req) & '>= 2.3.6'): - raise YunohostError('{0}{1}'.format( - m18n.g('colon', m18n.n('app_incompatible'), app=app_instance_name), - m18n.n('app_package_need_update', app=app_instance_name))) - elif not requirements: + requirements = manifest.get("requirements", dict()) + + if not requirements: return - logger.debug(m18n.n('app_requirements_checking', app=app_instance_name)) - - # Retrieve versions of each required package - try: - versions = packages.get_installed_version( - *requirements.keys(), strict=True, as_dict=True) - except packages.PackageException as e: - raise YunohostError('app_requirements_failed', error=str(e), app=app_instance_name) + logger.debug(m18n.n("app_requirements_checking", app=app_instance_name)) # Iterate over requirements for pkgname, spec in requirements.items(): - version = versions[pkgname] - if version not in packages.SpecifierSet(spec): - raise YunohostError('app_requirements_unmeet', - pkgname=pkgname, version=version, - spec=spec, app=app_instance_name) + if not packages.meets_version_specifier(pkgname, spec): + version = packages.ynh_packages_version()[pkgname]["version"] + raise YunohostValidationError( + "app_requirements_unmeet", + pkgname=pkgname, + version=version, + spec=spec, + app=app_instance_name, + ) -def _parse_args_from_manifest(manifest, action, args={}): - """Parse arguments needed for an action from the manifest - - Retrieve specified arguments for the action from the manifest, and parse - given args according to that. If some required arguments are not provided, - its values will be asked if interaction is possible. - Parsed arguments will be returned as an OrderedDict - - Keyword arguments: - manifest -- The app manifest to use - action -- The action to retrieve arguments for - args -- A dictionnary of arguments to parse - - """ - if action not in manifest['arguments']: - logger.debug("no arguments found for '%s' in manifest", action) - return OrderedDict() - - action_args = manifest['arguments'][action] - return _parse_args_in_yunohost_format(args, action_args) - - -def _parse_args_for_action(action, args={}): - """Parse arguments needed for an action from the actions list - - Retrieve specified arguments for the action from the manifest, and parse - given args according to that. If some required arguments are not provided, - its values will be asked if interaction is possible. - Parsed arguments will be returned as an OrderedDict - - Keyword arguments: - action -- The action - args -- A dictionnary of arguments to parse - - """ - args_dict = OrderedDict() - - if 'arguments' not in action: - logger.debug("no arguments found for '%s' in manifest", action) - return args_dict - - action_args = action['arguments'] - - return _parse_args_in_yunohost_format(args, action_args) - - -def _parse_args_in_yunohost_format(args, action_args): - """Parse arguments store in either manifest.json or actions.json - """ - from yunohost.domain import (domain_list, _get_maindomain, - _get_conflicting_apps, _normalize_domain_path) - from yunohost.user import user_info, user_list - - args_dict = OrderedDict() - - for arg in action_args: - arg_name = arg['name'] - arg_type = arg.get('type', 'string') - arg_default = arg.get('default', None) - arg_choices = arg.get('choices', []) - arg_value = None - - # Transpose default value for boolean type and set it to - # false if not defined. - if arg_type == 'boolean': - arg_default = 1 if arg_default else 0 - - # do not print for webadmin - if arg_type == 'display_text' and msettings.get('interface') != 'api': - print(_value_for_locale(arg['ask'])) - continue - - # Attempt to retrieve argument value - if arg_name in args: - arg_value = args[arg_name] - else: - if 'ask' in arg: - # Retrieve proper ask string - ask_string = _value_for_locale(arg['ask']) - - # Append extra strings - if arg_type == 'boolean': - ask_string += ' [yes | no]' - elif arg_choices: - ask_string += ' [{0}]'.format(' | '.join(arg_choices)) - - if arg_default is not None: - if arg_type == 'boolean': - ask_string += ' (default: {0})'.format("yes" if arg_default == 1 else "no") - else: - ask_string += ' (default: {0})'.format(arg_default) - - # Check for a password argument - is_password = True if arg_type == 'password' else False - - if arg_type == 'domain': - arg_default = _get_maindomain() - ask_string += ' (default: {0})'.format(arg_default) - msignals.display(m18n.n('domains_available')) - for domain in domain_list()['domains']: - msignals.display("- {}".format(domain)) - - elif arg_type == 'user': - msignals.display(m18n.n('users_available')) - for user in user_list()['users'].keys(): - msignals.display("- {}".format(user)) - - elif arg_type == 'password': - msignals.display(m18n.n('good_practices_about_user_password')) - - try: - input_string = msignals.prompt(ask_string, is_password) - except NotImplementedError: - input_string = None - if (input_string == '' or input_string is None) \ - and arg_default is not None: - arg_value = arg_default - else: - arg_value = input_string - elif arg_default is not None: - arg_value = arg_default - - # If the value is empty (none or '') - # then check if arg is optional or not - if arg_value is None or arg_value == '': - if arg.get("optional", False): - # Argument is optional, keep an empty value - # and that's all for this arg ! - args_dict[arg_name] = ('', arg_type) - continue - else: - # The argument is required ! - raise YunohostError('app_argument_required', name=arg_name) - - # Validate argument choice - if arg_choices and arg_value not in arg_choices: - raise YunohostError('app_argument_choice_invalid', name=arg_name, choices=', '.join(arg_choices)) - - # Validate argument type - if arg_type == 'domain': - if arg_value not in domain_list()['domains']: - raise YunohostError('app_argument_invalid', name=arg_name, error=m18n.n('domain_unknown')) - elif arg_type == 'user': - try: - user_info(arg_value) - except YunohostError as e: - raise YunohostError('app_argument_invalid', name=arg_name, error=e) - elif arg_type == 'app': - if not _is_installed(arg_value): - raise YunohostError('app_argument_invalid', name=arg_name, error=m18n.n('app_unknown')) - elif arg_type == 'boolean': - if isinstance(arg_value, bool): - arg_value = 1 if arg_value else 0 - else: - if str(arg_value).lower() in ["1", "yes", "y"]: - arg_value = 1 - elif str(arg_value).lower() in ["0", "no", "n"]: - arg_value = 0 - else: - raise YunohostError('app_argument_choice_invalid', name=arg_name, choices='yes, no, y, n, 1, 0') - elif arg_type == 'password': - forbidden_chars = "{}" - if any(char in arg_value for char in forbidden_chars): - raise YunohostError('pattern_password_app', forbidden_chars=forbidden_chars) - from yunohost.utils.password import assert_password_is_strong_enough - assert_password_is_strong_enough('user', arg_value) - args_dict[arg_name] = (arg_value, arg_type) - - # END loop over action_args... +def _guess_webapp_path_requirement(questions: List[Question], app_folder: str) -> str: # If there's only one "domain" and "path", validate that domain/path # is an available url and normalize the path. - domain_args = [ (name, value[0]) for name, value in args_dict.items() if value[1] == "domain" ] - path_args = [ (name, value[0]) for name, value in args_dict.items() if value[1] == "path" ] + domain_questions = [question for question in questions if question.type == "domain"] + path_questions = [question for question in questions if question.type == "path"] - if len(domain_args) == 1 and len(path_args) == 1: + if len(domain_questions) == 0 and len(path_questions) == 0: + return "" + if len(domain_questions) == 1 and len(path_questions) == 1: + return "domain_and_path" + if len(domain_questions) == 1 and len(path_questions) == 0: + # This is likely to be a full-domain app... - domain = domain_args[0][1] - path = path_args[0][1] - domain, path = _normalize_domain_path(domain, path) + # Confirm that this is a full-domain app This should cover most cases + # ... though anyway the proper solution is to implement some mechanism + # in the manifest for app to declare that they require a full domain + # (among other thing) so that we can dynamically check/display this + # requirement on the webadmin form and not miserably fail at submit time - # Check the url is available - conflicts = _get_conflicting_apps(domain, path) - if conflicts: - apps = [] - for path, app_id, app_label in conflicts: - apps.append(" * {domain:s}{path:s} → {app_label:s} ({app_id:s})".format( + # Full-domain apps typically declare something like path_url="/" or path=/ + # and use ynh_webpath_register or yunohost_app_checkurl inside the install script + install_script_content = read_file(os.path.join(app_folder, "scripts/install")) + + if re.search( + r"\npath(_url)?=[\"']?/[\"']?", install_script_content + ) and re.search(r"ynh_webpath_register", install_script_content): + return "full_domain" + + return "?" + + +def _validate_webpath_requirement( + questions: List[Question], path_requirement: str +) -> None: + + domain_questions = [question for question in questions if question.type == "domain"] + path_questions = [question for question in questions if question.type == "path"] + + if path_requirement == "domain_and_path": + + domain = domain_questions[0].value + path = path_questions[0].value + _assert_no_conflicting_apps(domain, path, full_domain=True) + + elif path_requirement == "full_domain": + + domain = domain_questions[0].value + _assert_no_conflicting_apps(domain, "/", full_domain=True) + + +def _get_conflicting_apps(domain, path, ignore_app=None): + """ + Return a list of all conflicting apps with a domain/path (it can be empty) + + Keyword argument: + domain -- The domain for the web path (e.g. your.domain.tld) + path -- The path to check (e.g. /coffee) + ignore_app -- An optional app id to ignore (c.f. the change_url usecase) + """ + + from yunohost.domain import _assert_domain_exists + + domain = DomainQuestion.normalize(domain) + path = PathQuestion.normalize(path) + + # Abort if domain is unknown + _assert_domain_exists(domain) + + # Fetch apps map + apps_map = app_map(raw=True) + + # Loop through all apps to check if path is taken by one of them + conflicts = [] + if domain in apps_map: + # Loop through apps + for p, a in apps_map[domain].items(): + if a["id"] == ignore_app: + continue + if path == p: + conflicts.append((p, a["id"], a["label"])) + # We also don't want conflicts with other apps starting with + # same name + elif path.startswith(p) or p.startswith(path): + conflicts.append((p, a["id"], a["label"])) + + return conflicts + + +def _assert_no_conflicting_apps(domain, path, ignore_app=None, full_domain=False): + + conflicts = _get_conflicting_apps(domain, path, ignore_app) + + if conflicts: + apps = [] + for path, app_id, app_label in conflicts: + apps.append( + " * {domain:s}{path:s} → {app_label:s} ({app_id:s})".format( domain=domain, path=path, app_id=app_id, app_label=app_label, - )) + ) + ) - raise YunohostError('app_location_unavailable', apps="\n".join(apps)) - - # (We save this normalized path so that the install script have a - # standard path format to deal with no matter what the user inputted) - args_dict[path_args[0][0]] = (path, "path") - - return args_dict + if full_domain: + raise YunohostValidationError("app_full_domain_unavailable", domain=domain) + else: + raise YunohostValidationError( + "app_location_unavailable", apps="\n".join(apps) + ) -def _make_environment_dict(args_dict, prefix="APP_ARG_"): - """ - Convert a dictionnary containing manifest arguments - to a dictionnary of env. var. to be passed to scripts +def _make_environment_for_app_script(app, args={}, args_prefix="APP_ARG_"): - Keyword arguments: - arg -- A key/value dictionnary of manifest arguments + app_setting_path = os.path.join(APPS_SETTING_PATH, app) + + manifest = _get_manifest_of_app(app_setting_path) + app_id, app_instance_nb = _parse_app_instance_name(app) + + env_dict = { + "YNH_APP_ID": app_id, + "YNH_APP_INSTANCE_NAME": app, + "YNH_APP_INSTANCE_NUMBER": str(app_instance_nb), + "YNH_APP_MANIFEST_VERSION": manifest.get("version", "?"), + } + + for arg_name, arg_value in args.items(): + env_dict["YNH_%s%s" % (args_prefix, arg_name.upper())] = str(arg_value) - """ - env_dict = {} - for arg_name, arg_value_and_type in args_dict.items(): - env_dict["YNH_%s%s" % (prefix, arg_name.upper())] = arg_value_and_type[0] return env_dict @@ -2699,156 +2541,212 @@ def _parse_app_instance_name(app_instance_name): """ match = re_app_instance_name.match(app_instance_name) assert match, "Could not parse app instance name : %s" % app_instance_name - appid = match.groupdict().get('appid') - app_instance_nb = int(match.groupdict().get('appinstancenb')) if match.groupdict().get('appinstancenb') is not None else 1 + appid = match.groupdict().get("appid") + app_instance_nb = ( + int(match.groupdict().get("appinstancenb")) + if match.groupdict().get("appinstancenb") is not None + else 1 + ) return (appid, app_instance_nb) -def _using_legacy_appslist_system(): +# +# ############################### # +# Applications list management # +# ############################### # +# + + +def _initialize_apps_catalog_system(): """ - Return True if we're using the old fetchlist scheme. - This is determined by the presence of some cron job yunohost-applist-foo + This function is meant to intialize the apps_catalog system with YunoHost's default app catalog. """ - return glob.glob("/etc/cron.d/yunohost-applist-*") != [] + default_apps_catalog_list = [{"id": "default", "url": APPS_CATALOG_DEFAULT_URL}] - -def _migrate_appslist_system(): - """ - Migrate from the legacy fetchlist system to the new one - """ - legacy_crons = glob.glob("/etc/cron.d/yunohost-applist-*") - - for cron_path in legacy_crons: - appslist_name = os.path.basename(cron_path).replace("yunohost-applist-", "") - logger.debug(m18n.n('appslist_migrating', appslist=appslist_name)) - - # Parse appslist url in cron - cron_file_content = open(cron_path).read().strip() - appslist_url_parse = re.search("-u (https?://[^ ]+)", cron_file_content) - - # Abort if we did not find an url - if not appslist_url_parse or not appslist_url_parse.groups(): - # Bkp the old cron job somewhere else - bkp_file = "/etc/yunohost/%s.oldlist.bkp" % appslist_name - os.rename(cron_path, bkp_file) - # Notice the user - logger.warning(m18n.n('appslist_could_not_migrate', - appslist=appslist_name, - bkp_file=bkp_file)) - # Otherwise, register the list and remove the legacy cron - else: - appslist_url = appslist_url_parse.groups()[0] - try: - _register_new_appslist(appslist_url, appslist_name) - # Might get an exception if two legacy cron jobs conflict - # in terms of url... - except Exception as e: - logger.error(str(e)) - # Bkp the old cron job somewhere else - bkp_file = "/etc/yunohost/%s.oldlist.bkp" % appslist_name - os.rename(cron_path, bkp_file) - # Notice the user - logger.warning(m18n.n('appslist_could_not_migrate', - appslist=appslist_name, - bkp_file=bkp_file)) - else: - os.remove(cron_path) - - -def _install_appslist_fetch_cron(): - - cron_job_file = "/etc/cron.daily/yunohost-fetch-appslists" - - logger.debug("Installing appslist fetch cron job") - - cron_job = [] - cron_job.append("#!/bin/bash") - # We add a random delay between 0 and 60 min to avoid every instance fetching - # the appslist at the same time every night - cron_job.append("(sleep $((RANDOM%3600));") - cron_job.append("yunohost app fetchlist > /dev/null 2>&1) &") - - with open(cron_job_file, "w") as f: - f.write('\n'.join(cron_job)) - - _set_permissions(cron_job_file, "root", "root", 0o755) - - -# FIXME - Duplicate from certificate.py, should be moved into a common helper -# thing... -def _set_permissions(path, user, group, permissions): - uid = pwd.getpwnam(user).pw_uid - gid = grp.getgrnam(group).gr_gid - - os.chown(path, uid, gid) - os.chmod(path, permissions) - - -def _read_appslist_list(): - """ - Read the json corresponding to the list of appslists - """ - - # If file does not exists yet, return empty dict - if not os.path.exists(APPSLISTS_JSON): - return {} - - # Read file content - with open(APPSLISTS_JSON, "r") as f: - appslists_json = f.read() - - # Parse json, throw exception if what we got from file is not a valid json try: - appslists = json.loads(appslists_json) - except ValueError: - raise YunohostError('appslist_corrupted_json', filename=APPSLISTS_JSON) - - return appslists - - -def _write_appslist_list(appslist_lists): - """ - Update the json containing list of appslists - """ - - # Write appslist list - try: - with open(APPSLISTS_JSON, "w") as f: - json.dump(appslist_lists, f) + logger.debug( + "Initializing apps catalog system with YunoHost's default app list" + ) + write_to_yaml(APPS_CATALOG_CONF, default_apps_catalog_list) except Exception as e: - raise YunohostError("Error while writing list of appslist %s: %s" % - (APPSLISTS_JSON, str(e)), raw_msg=True) + raise YunohostError( + "Could not initialize the apps catalog system... : %s" % str(e) + ) + + logger.success(m18n.n("apps_catalog_init_success")) -def _register_new_appslist(url, name): +def _read_apps_catalog_list(): """ - Add a new appslist to be fetched regularly. - Raise an exception if url or name conflicts with an existing list. + Read the json corresponding to the list of apps catalogs """ - appslist_list = _read_appslist_list() + try: + list_ = read_yaml(APPS_CATALOG_CONF) + # Support the case where file exists but is empty + # by returning [] if list_ is None + return list_ if list_ else [] + except Exception as e: + raise YunohostError("Could not read the apps_catalog list ... : %s" % str(e)) - # Check if name conflicts with an existing list - if name in appslist_list: - raise YunohostError('appslist_name_already_tracked', name=name) - # Check if url conflicts with an existing list - known_appslist_urls = [appslist["url"] for _, appslist in appslist_list.items()] +def _actual_apps_catalog_api_url(base_url): - if url in known_appslist_urls: - raise YunohostError('appslist_url_already_tracked', url=url) + return "{base_url}/v{version}/apps.json".format( + base_url=base_url, version=APPS_CATALOG_API_VERSION + ) - logger.debug("Registering new appslist %s at %s" % (name, url)) - appslist_list[name] = { - "url": url, - "lastUpdate": None - } +def _update_apps_catalog(): + """ + Fetches the json for each apps_catalog and update the cache - _write_appslist_list(appslist_list) + apps_catalog_list is for example : + [ {"id": "default", "url": "https://app.yunohost.org/default/"} ] - _install_appslist_fetch_cron() + Then for each apps_catalog, the actual json URL to be fetched is like : + https://app.yunohost.org/default/vX/apps.json + + And store it in : + /var/cache/yunohost/repo/default.json + """ + + apps_catalog_list = _read_apps_catalog_list() + + logger.info(m18n.n("apps_catalog_updating")) + + # Create cache folder if needed + if not os.path.exists(APPS_CATALOG_CACHE): + logger.debug("Initialize folder for apps catalog cache") + mkdir(APPS_CATALOG_CACHE, mode=0o750, parents=True, uid="root") + + for apps_catalog in apps_catalog_list: + apps_catalog_id = apps_catalog["id"] + actual_api_url = _actual_apps_catalog_api_url(apps_catalog["url"]) + + # Fetch the json + try: + apps_catalog_content = download_json(actual_api_url) + except Exception as e: + raise YunohostError( + "apps_catalog_failed_to_download", + apps_catalog=apps_catalog_id, + error=str(e), + ) + + # Remember the apps_catalog api version for later + apps_catalog_content["from_api_version"] = APPS_CATALOG_API_VERSION + + # Save the apps_catalog data in the cache + cache_file = "{cache_folder}/{list}.json".format( + cache_folder=APPS_CATALOG_CACHE, list=apps_catalog_id + ) + try: + write_to_json(cache_file, apps_catalog_content) + except Exception as e: + raise YunohostError( + "Unable to write cache data for %s apps_catalog : %s" + % (apps_catalog_id, str(e)) + ) + + logger.success(m18n.n("apps_catalog_update_success")) + + +def _load_apps_catalog(): + """ + Read all the apps catalog cache files and build a single dict (merged_catalog) + corresponding to all known apps and categories + """ + + merged_catalog = {"apps": {}, "categories": []} + + for apps_catalog_id in [L["id"] for L in _read_apps_catalog_list()]: + + # Let's load the json from cache for this catalog + cache_file = "{cache_folder}/{list}.json".format( + cache_folder=APPS_CATALOG_CACHE, list=apps_catalog_id + ) + + try: + apps_catalog_content = ( + read_json(cache_file) if os.path.exists(cache_file) else None + ) + except Exception as e: + raise YunohostError( + "Unable to read cache for apps_catalog %s : %s" % (cache_file, e), + raw_msg=True, + ) + + # Check that the version of the data matches version .... + # ... otherwise it means we updated yunohost in the meantime + # and need to update the cache for everything to be consistent + if ( + not apps_catalog_content + or apps_catalog_content.get("from_api_version") != APPS_CATALOG_API_VERSION + ): + logger.info(m18n.n("apps_catalog_obsolete_cache")) + _update_apps_catalog() + apps_catalog_content = read_json(cache_file) + + del apps_catalog_content["from_api_version"] + + # Add apps from this catalog to the output + for app, info in apps_catalog_content["apps"].items(): + + # (N.B. : there's a small edge case where multiple apps catalog could be listing the same apps ... + # in which case we keep only the first one found) + if app in merged_catalog["apps"]: + logger.warning( + "Duplicate app %s found between apps catalog %s and %s" + % (app, apps_catalog_id, merged_catalog["apps"][app]["repository"]) + ) + continue + + info["repository"] = apps_catalog_id + merged_catalog["apps"][app] = info + + # Annnnd categories + merged_catalog["categories"] += apps_catalog_content["categories"] + + return merged_catalog + + +# +# ############################### # +# Small utilities # +# ############################### # +# + + +def _make_tmp_workdir_for_app(app=None): + + # Create parent dir if it doesn't exists yet + if not os.path.exists(APP_TMP_WORKDIRS): + os.makedirs(APP_TMP_WORKDIRS) + + now = int(time.time()) + + # Cleanup old dirs (if any) + for dir_ in os.listdir(APP_TMP_WORKDIRS): + path = os.path.join(APP_TMP_WORKDIRS, dir_) + # We only delete folders older than an arbitary 12 hours + # This is to cover the stupid case of upgrades + # Where many app will call 'yunohost backup create' + # from the upgrade script itself, + # which will also call this function while the upgrade + # script itself is running in one of those dir... + # It could be that there are other edge cases + # such as app-install-during-app-install + if os.stat(path).st_mtime < now - 12 * 3600: + shutil.rmtree(path) + tmpdir = tempfile.mkdtemp(prefix="app_", dir=APP_TMP_WORKDIRS) + + # Copy existing app scripts, conf, ... if an app arg was provided + if app: + os.system(f"cp -a {APPS_SETTING_PATH}/{app}/* {tmpdir}") + + return tmpdir def is_true(arg): @@ -2864,78 +2762,114 @@ def is_true(arg): """ if isinstance(arg, bool): return arg - elif isinstance(arg, basestring): - true_list = ['yes', 'Yes', 'true', 'True'] - for string in true_list: - if arg == string: - return True - return False + elif isinstance(arg, str): + return arg.lower() in ["yes", "true", "on"] else: - logger.debug('arg should be a boolean or a string, got %r', arg) + logger.debug("arg should be a boolean or a string, got %r", arg) return True if arg else False -def random_password(length=8): - """ - Generate a random string - - Keyword arguments: - length -- The string length to generate - - """ - import string - import random - - char_set = string.ascii_uppercase + string.digits + string.ascii_lowercase - return ''.join([random.SystemRandom().choice(char_set) for x in range(length)]) - - def unstable_apps(): - raw_app_installed = app_list(installed=True, raw=True) output = [] - for app, infos in raw_app_installed.items(): + for infos in app_list(full=True)["apps"]: - repo = infos.get("repository", None) - state = infos.get("state", None) - - if repo is None or state in ["inprogress", "notworking"]: - output.append(app) + if not infos.get("from_catalog") or infos.get("from_catalog").get("state") in [ + "inprogress", + "notworking", + ]: + output.append(infos["id"]) return output -def _check_services_status_for_app(services): +def _assert_system_is_sane_for_app(manifest, when): + + from yunohost.service import service_status logger.debug("Checking that required services are up and running...") + services = manifest.get("services", []) + # Some apps use php-fpm or php5-fpm which is now php7.0-fpm def replace_alias(service): - if service in ["php-fpm", "php5-fpm"]: - return "php7.0-fpm" + if service in ["php-fpm", "php5-fpm", "php7.0-fpm"]: + return "php7.3-fpm" else: return service + services = [replace_alias(s) for s in services] # We only check those, mostly to ignore "custom" services # (added by apps) and because those are the most popular # services - service_filter = ["nginx", "php7.0-fpm", "mysql", "postfix"] + service_filter = ["nginx", "php7.3-fpm", "mysql", "postfix"] services = [str(s) for s in services if s in service_filter] + if "nginx" not in services: + services = ["nginx"] + services + if "fail2ban" not in services: + services.append("fail2ban") + + # Wait if a service is reloading + test_nb = 0 + while test_nb < 16: + if not any(s for s in services if service_status(s)["status"] == "reloading"): + break + time.sleep(0.5) + test_nb += 1 + # List services currently down and raise an exception if any are found - faulty_services = [s for s in services if service_status(s)["active"] != "active"] + services_status = {s: service_status(s) for s in services} + faulty_services = [ + f"{s} ({status['status']})" + for s, status in services_status.items() + if status["status"] != "running" + ] + if faulty_services: - raise YunohostError('app_action_cannot_be_ran_because_required_services_down', - services=', '.join(faulty_services)) + if when == "pre": + raise YunohostValidationError( + "app_action_cannot_be_ran_because_required_services_down", + services=", ".join(faulty_services), + ) + elif when == "post": + raise YunohostError( + "app_action_broke_system", services=", ".join(faulty_services) + ) + + if packages.dpkg_is_broken(): + if when == "pre": + raise YunohostValidationError("dpkg_is_broken") + elif when == "post": + raise YunohostError("this_action_broke_dpkg") -def _patch_php5(app_folder): +LEGACY_PHP_VERSION_REPLACEMENTS = [ + ("/etc/php5", "/etc/php/7.3"), + ("/etc/php/7.0", "/etc/php/7.3"), + ("/var/run/php5-fpm", "/var/run/php/php7.3-fpm"), + ("/var/run/php/php7.0-fpm", "/var/run/php/php7.3-fpm"), + ("php5", "php7.3"), + ("php7.0", "php7.3"), + ( + 'phpversion="${phpversion:-7.0}"', + 'phpversion="${phpversion:-7.3}"', + ), # Many helpers like the composer ones use 7.0 by default ... + ( + '"$phpversion" == "7.0"', + '$(bc <<< "$phpversion >= 7.3") -eq 1', + ), # patch ynh_install_php to refuse installing/removing php <= 7.3 +] + + +def _patch_legacy_php_versions(app_folder): files_to_patch = [] files_to_patch.extend(glob.glob("%s/conf/*" % app_folder)) files_to_patch.extend(glob.glob("%s/scripts/*" % app_folder)) + files_to_patch.extend(glob.glob("%s/scripts/*/*" % app_folder)) files_to_patch.extend(glob.glob("%s/scripts/.*" % app_folder)) files_to_patch.append("%s/manifest.json" % app_folder) files_to_patch.append("%s/manifest.toml" % app_folder) @@ -2946,8 +2880,177 @@ def _patch_php5(app_folder): if not os.path.isfile(filename): continue - c = "sed -i -e 's@/etc/php5@/etc/php/7.0@g' " \ - "-e 's@/var/run/php5-fpm@/var/run/php/php7.0-fpm@g' " \ - "-e 's@php5@php7.0@g' " \ - "%s" % filename + c = ( + "sed -i " + + "".join( + "-e 's@{pattern}@{replace}@g' ".format(pattern=p, replace=r) + for p, r in LEGACY_PHP_VERSION_REPLACEMENTS + ) + + "%s" % filename + ) os.system(c) + + +def _patch_legacy_php_versions_in_settings(app_folder): + + settings = read_yaml(os.path.join(app_folder, "settings.yml")) + + if settings.get("fpm_config_dir") == "/etc/php/7.0/fpm": + settings["fpm_config_dir"] = "/etc/php/7.3/fpm" + if settings.get("fpm_service") == "php7.0-fpm": + settings["fpm_service"] = "php7.3-fpm" + if settings.get("phpversion") == "7.0": + settings["phpversion"] = "7.3" + + # We delete these checksums otherwise the file will appear as manually modified + list_to_remove = ["checksum__etc_php_7.0_fpm_pool", "checksum__etc_nginx_conf.d"] + settings = { + k: v + for k, v in settings.items() + if not any(k.startswith(to_remove) for to_remove in list_to_remove) + } + + write_to_yaml(app_folder + "/settings.yml", settings) + + +def _patch_legacy_helpers(app_folder): + + files_to_patch = [] + files_to_patch.extend(glob.glob("%s/scripts/*" % app_folder)) + files_to_patch.extend(glob.glob("%s/scripts/.*" % app_folder)) + + stuff_to_replace = { + # Replace + # sudo yunohost app initdb $db_user -p $db_pwd + # by + # ynh_mysql_setup_db --db_user=$db_user --db_name=$db_user --db_pwd=$db_pwd + "yunohost app initdb": { + "pattern": r"(sudo )?yunohost app initdb \"?(\$\{?\w+\}?)\"?\s+-p\s\"?(\$\{?\w+\}?)\"?", + "replace": r"ynh_mysql_setup_db --db_user=\2 --db_name=\2 --db_pwd=\3", + "important": True, + }, + # Replace + # sudo yunohost app checkport whaterver + # by + # ynh_port_available whatever + "yunohost app checkport": { + "pattern": r"(sudo )?yunohost app checkport", + "replace": r"ynh_port_available", + "important": True, + }, + # We can't migrate easily port-available + # .. but at the time of writing this code, only two non-working apps are using it. + "yunohost tools port-available": {"important": True}, + # Replace + # yunohost app checkurl "${domain}${path_url}" -a "${app}" + # by + # ynh_webpath_register --app=${app} --domain=${domain} --path_url=${path_url} + "yunohost app checkurl": { + "pattern": r"(sudo )?yunohost app checkurl \"?(\$\{?\w+\}?)\/?(\$\{?\w+\}?)\"?\s+-a\s\"?(\$\{?\w+\}?)\"?", + "replace": r"ynh_webpath_register --app=\4 --domain=\2 --path_url=\3", + "important": True, + }, + # Remove + # Automatic diagnosis data from YunoHost + # __PRE_TAG1__$(yunohost tools diagnosis | ...)__PRE_TAG2__" + # + "yunohost tools diagnosis": { + "pattern": r"(Automatic diagnosis data from YunoHost( *\n)*)? *(__\w+__)? *\$\(yunohost tools diagnosis.*\)(__\w+__)?", + "replace": r"", + "important": False, + }, + # Old $1, $2 in backup/restore scripts... + "app=$2": { + "only_for": ["scripts/backup", "scripts/restore"], + "pattern": r"app=\$2", + "replace": r"app=$YNH_APP_INSTANCE_NAME", + "important": True, + }, + # Old $1, $2 in backup/restore scripts... + "backup_dir=$1": { + "only_for": ["scripts/backup", "scripts/restore"], + "pattern": r"backup_dir=\$1", + "replace": r"backup_dir=.", + "important": True, + }, + # Old $1, $2 in backup/restore scripts... + "restore_dir=$1": { + "only_for": ["scripts/restore"], + "pattern": r"restore_dir=\$1", + "replace": r"restore_dir=.", + "important": True, + }, + # Old $1, $2 in install scripts... + # We ain't patching that shit because it ain't trivial to patch all args... + "domain=$1": {"only_for": ["scripts/install"], "important": True}, + } + + for helper, infos in stuff_to_replace.items(): + infos["pattern"] = ( + re.compile(infos["pattern"]) if infos.get("pattern") else None + ) + infos["replace"] = infos.get("replace") + + for filename in files_to_patch: + + # Ignore non-regular files + if not os.path.isfile(filename): + continue + + try: + content = read_file(filename) + except MoulinetteError: + continue + + replaced_stuff = False + show_warning = False + + for helper, infos in stuff_to_replace.items(): + + # Ignore if not relevant for this file + if infos.get("only_for") and not any( + filename.endswith(f) for f in infos["only_for"] + ): + continue + + # If helper is used, attempt to patch the file + if helper in content and infos["pattern"]: + content = infos["pattern"].sub(infos["replace"], content) + replaced_stuff = True + if infos["important"]: + show_warning = True + + # If the helper is *still* in the content, it means that we + # couldn't patch the deprecated helper in the previous lines. In + # that case, abort the install or whichever step is performed + if helper in content and infos["important"]: + raise YunohostValidationError( + "This app is likely pretty old and uses deprecated / outdated helpers that can't be migrated easily. It can't be installed anymore.", + raw_msg=True, + ) + + if replaced_stuff: + + # Check the app do load the helper + # If it doesn't, add the instruction ourselve (making sure it's after the #!/bin/bash if it's there... + if filename.split("/")[-1] in [ + "install", + "remove", + "upgrade", + "backup", + "restore", + ]: + source_helpers = "source /usr/share/yunohost/helpers" + if source_helpers not in content: + content.replace("#!/bin/bash", "#!/bin/bash\n" + source_helpers) + if source_helpers not in content: + content = source_helpers + "\n" + content + + # Actually write the new content in the file + write_to_file(filename, content) + + if show_warning: + # And complain about those damn deprecated helpers + logger.error( + r"/!\ Packagers ! This app uses a very old deprecated helpers ... Yunohost automatically patched the helpers to use the new recommended practice, but please do consider fixing the upstream code right now ..." + ) diff --git a/src/yunohost/authenticators/ldap_admin.py b/src/yunohost/authenticators/ldap_admin.py new file mode 100644 index 000000000..94d68a8db --- /dev/null +++ b/src/yunohost/authenticators/ldap_admin.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- + +import os +import logging +import ldap +import ldap.sasl +import time + +from moulinette import m18n +from moulinette.authentication import BaseAuthenticator +from yunohost.utils.error import YunohostError + +logger = logging.getLogger("yunohost.authenticators.ldap_admin") + + +class Authenticator(BaseAuthenticator): + + name = "ldap_admin" + + def __init__(self, *args, **kwargs): + self.uri = "ldap://localhost:389" + self.basedn = "dc=yunohost,dc=org" + self.admindn = "cn=admin,dc=yunohost,dc=org" + + def _authenticate_credentials(self, credentials=None): + + # TODO : change authentication format + # to support another dn to support multi-admins + + def _reconnect(): + con = ldap.ldapobject.ReconnectLDAPObject( + self.uri, retry_max=10, retry_delay=0.5 + ) + con.simple_bind_s(self.admindn, credentials) + return con + + try: + con = _reconnect() + except ldap.INVALID_CREDENTIALS: + raise YunohostError("invalid_password") + except ldap.SERVER_DOWN: + # ldap is down, attempt to restart it before really failing + logger.warning(m18n.n("ldap_server_is_down_restart_it")) + os.system("systemctl restart slapd") + time.sleep(10) # waits 10 secondes so we are sure that slapd has restarted + + try: + con = _reconnect() + except ldap.SERVER_DOWN: + raise YunohostError("ldap_server_down") + + # Check that we are indeed logged in with the expected identity + try: + # whoami_s return dn:..., then delete these 3 characters + who = con.whoami_s()[3:] + except Exception as e: + logger.warning("Error during ldap authentication process: %s", e) + raise + else: + if who != self.admindn: + raise YunohostError( + f"Not logged with the appropriate identity ? Found {who}, expected {self.admindn} !?", + raw_msg=True, + ) + finally: + # Free the connection, we don't really need it to keep it open as the point is only to check authentication... + if con: + con.unbind_s() diff --git a/src/yunohost/backup.py b/src/yunohost/backup.py index 8f256491d..b02b23966 100644 --- a/src/yunohost/backup.py +++ b/src/yunohost/backup.py @@ -34,34 +34,55 @@ import tempfile from datetime import datetime from glob import glob from collections import OrderedDict +from functools import reduce +from packaging import version -from moulinette import msignals, m18n -from yunohost.utils.error import YunohostError +from moulinette import Moulinette, m18n from moulinette.utils import filesystem from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger -from moulinette.utils.filesystem import read_file, mkdir +from moulinette.utils.filesystem import read_file, mkdir, write_to_yaml, read_yaml +from moulinette.utils.process import check_output +import yunohost.domain from yunohost.app import ( - app_info, _is_installed, _parse_app_instance_name, _patch_php5 + app_info, + _is_installed, + _make_environment_for_app_script, + _patch_legacy_helpers, + _patch_legacy_php_versions, + _patch_legacy_php_versions_in_settings, + LEGACY_PHP_VERSION_REPLACEMENTS, + _make_tmp_workdir_for_app, ) from yunohost.hook import ( - hook_list, hook_info, hook_callback, hook_exec, CUSTOM_HOOK_FOLDER + hook_list, + hook_info, + hook_callback, + hook_exec, + hook_exec_with_script_debug_if_failure, + CUSTOM_HOOK_FOLDER, +) +from yunohost.tools import ( + tools_postinstall, + _tools_migrations_run_after_system_restore, + _tools_migrations_run_before_app_restore, ) -from yunohost.monitor import binary_to_human -from yunohost.tools import tools_postinstall from yunohost.regenconf import regen_conf -from yunohost.log import OperationLogger +from yunohost.log import OperationLogger, is_unit_operation from yunohost.repository import BackupRepository -from functools import reduce +from yunohost.utils.error import YunohostError, YunohostValidationError +from yunohost.utils.packages import ynh_packages_version +from yunohost.utils.filesystem import free_space_in_directory +from yunohost.settings import settings_get -BACKUP_PATH = '/home/yunohost.backup' -ARCHIVES_PATH = '%s/archives' % BACKUP_PATH +BACKUP_PATH = "/home/yunohost.backup" +ARCHIVES_PATH = "%s/archives" % BACKUP_PATH APP_MARGIN_SPACE_SIZE = 100 # In MB CONF_MARGIN_SPACE_SIZE = 10 # IN MB POSTINSTALL_ESTIMATE_SPACE_SIZE = 5 # In MB MB_ALLOWED_TO_ORGANIZE = 10 -logger = getActionLogger('yunohost.backup') +logger = getActionLogger("yunohost.backup") class BackupRestoreTargetsManager(object): @@ -73,10 +94,7 @@ class BackupRestoreTargetsManager(object): def __init__(self): self.targets = {} - self.results = { - "system": {}, - "apps": {} - } + self.results = {"system": {}, "apps": {}} def set_result(self, category, element, value): """ @@ -99,14 +117,18 @@ class BackupRestoreTargetsManager(object): self.results[category][element] = value else: currentValue = self.results[category][element] - if (levels.index(currentValue) > levels.index(value)): + if levels.index(currentValue) > levels.index(value): return else: self.results[category][element] = value - def set_wanted(self, category, - wanted_targets, available_targets, - error_if_wanted_target_is_unavailable): + def set_wanted( + self, + category, + wanted_targets, + available_targets, + error_if_wanted_target_is_unavailable, + ): """ Define and validate targets to be backuped or to be restored (list of system parts, apps..). The wanted targets are compared and filtered @@ -138,13 +160,15 @@ class BackupRestoreTargetsManager(object): # If the user manually specified which targets to backup, we need to # validate that each target is actually available else: - self.targets[category] = [part for part in wanted_targets - if part in available_targets] + self.targets[category] = [ + part for part in wanted_targets if part in available_targets + ] # Display an error for each target asked by the user but which is # unknown - unavailable_targets = [part for part in wanted_targets - if part not in available_targets] + unavailable_targets = [ + part for part in wanted_targets if part not in available_targets + ] for target in unavailable_targets: self.set_result(category, target, "Skipped") @@ -165,19 +189,26 @@ class BackupRestoreTargetsManager(object): with respect to the current 'result' of the target. """ - assert (include and isinstance(include, list) and not exclude) \ - or (exclude and isinstance(exclude, list) and not include) + assert (include and isinstance(include, list) and not exclude) or ( + exclude and isinstance(exclude, list) and not include + ) if include: - return [target.encode("Utf-8") for target in self.targets[category] - if self.results[category][target] in include] + return [ + target + for target in self.targets[category] + if self.results[category][target] in include + ] if exclude: - return [target.encode("Utf-8") for target in self.targets[category] - if self.results[category][target] not in exclude] + return [ + target + for target in self.targets[category] + if self.results[category][target] not in exclude + ] -class BackupManager(): +class BackupManager: """ This class collect files to backup in a list and apply one or several @@ -219,8 +250,8 @@ class BackupManager(): backup_manager = BackupManager(name="mybackup", description="bkp things") # Add backup method to apply - backup_manager.add(BackupMethod.create('copy','/mnt/local_fs')) - backup_manager.add(BackupMethod.create('tar','/mnt/remote_fs')) + backup_manager.add('copy', output_directory='/mnt/local_fs') + backup_manager.add('tar', output_directory='/mnt/remote_fs') # Define targets to be backuped backup_manager.set_system_targets(["data"]) @@ -233,7 +264,7 @@ class BackupManager(): backup_manager.backup() """ - def __init__(self, name=None, description='', work_dir=None): + def __init__(self, name=None, description="", methods=[], work_dir=None): """ BackupManager constructor @@ -247,16 +278,12 @@ class BackupManager(): work_dir -- (None|string) A path where prepare the archive. If None, temporary work_dir will be created (default: None) """ - self.description = description or '' + self.description = description or "" self.created_at = int(time.time()) self.apps_return = {} self.system_return = {} - self.methods = [] self.paths_to_backup = [] - self.size_details = { - 'system': {}, - 'apps': {} - } + self.size_details = {"system": {}, "apps": {}} self.targets = BackupRestoreTargetsManager() # Define backup name if needed @@ -267,9 +294,14 @@ class BackupManager(): # Define working directory if needed and initialize it self.work_dir = work_dir if self.work_dir is None: - self.work_dir = os.path.join(BACKUP_PATH, 'tmp', name) + self.work_dir = os.path.join(BACKUP_PATH, "tmp", name) self._init_work_dir() + # Initialize backup methods + self.methods = [ + BackupMethod.create(method, self, repo=work_dir) for method in methods + ] + # # Misc helpers # # @@ -278,19 +310,20 @@ class BackupManager(): def info(self): """(Getter) Dict containing info about the archive being created""" return { - 'description': self.description, - 'created_at': self.created_at, - 'size': self.size, - 'size_details': self.size_details, - 'apps': self.apps_return, - 'system': self.system_return + "description": self.description, + "created_at": self.created_at, + "size": self.size, + "size_details": self.size_details, + "apps": self.apps_return, + "system": self.system_return, + "from_yunohost_version": ynh_packages_version()["yunohost"]["version"], } @property def is_tmp_work_dir(self): """(Getter) Return true if the working directory is temporary and should be clean at the end of the backup""" - return self.work_dir == os.path.join(BACKUP_PATH, 'tmp', self.name) + return self.work_dir == os.path.join(BACKUP_PATH, "tmp", self.name) def __repr__(self): return json.dumps(self.info) @@ -302,43 +335,34 @@ class BackupManager(): (string) A backup name created from current date 'YYMMDD-HHMMSS' """ # FIXME: case where this name already exist - return time.strftime('%Y%m%d-%H%M%S', time.gmtime()) + return time.strftime("%Y%m%d-%H%M%S", time.gmtime()) def _init_work_dir(self): """Initialize preparation directory Ensure the working directory exists and is empty - - exception: - backup_output_directory_not_empty -- (YunohostError) Raised if the - directory was given by the user and isn't empty - - (TODO) backup_cant_clean_tmp_working_directory -- (YunohostError) - Raised if the working directory isn't empty, is temporary and can't - be automaticcaly cleaned - - (TODO) backup_cant_create_working_directory -- (YunohostError) Raised - if iyunohost can't create the working directory """ # FIXME replace isdir by exists ? manage better the case where the path # exists if not os.path.isdir(self.work_dir): - filesystem.mkdir(self.work_dir, 0o750, parents=True, uid='admin') + filesystem.mkdir(self.work_dir, 0o750, parents=True, uid="admin") elif self.is_tmp_work_dir: - logger.debug("temporary directory for backup '%s' already exists... attempting to clean it", - self.work_dir) + logger.debug( + "temporary directory for backup '%s' already exists... attempting to clean it", + self.work_dir, + ) # Try to recursively unmount stuff (from a previously failed backup ?) if not _recursive_umount(self.work_dir): - raise YunohostError('backup_output_directory_not_empty') + raise YunohostValidationError("backup_output_directory_not_empty") else: # If umount succeeded, remove the directory (we checked that # we're in /home/yunohost.backup/tmp so that should be okay... # c.f. method clean() which also does this) filesystem.rm(self.work_dir, recursive=True, force=True) - filesystem.mkdir(self.work_dir, 0o750, parents=True, uid='admin') + filesystem.mkdir(self.work_dir, 0o750, parents=True, uid="admin") # # Backup target management # @@ -353,12 +377,13 @@ class BackupManager(): If empty list, all system will be backuped. If None, no system parts will be backuped. """ - def unknown_error(part): - logger.error(m18n.n('backup_hook_unknown', hook=part)) - self.targets.set_wanted("system", - system_parts, hook_list('backup')["hooks"], - unknown_error) + def unknown_error(part): + logger.error(m18n.n("backup_hook_unknown", hook=part)) + + self.targets.set_wanted( + "system", system_parts, hook_list("backup")["hooks"], unknown_error + ) def set_apps_targets(self, apps=[]): """ @@ -369,12 +394,13 @@ class BackupManager(): list, all apps will be backuped. If given None, no apps will be backuped. """ - def unknown_error(app): - logger.error(m18n.n('unbackup_app', app=app)) - target_list = self.targets.set_wanted("apps", apps, - os.listdir('/etc/yunohost/apps'), - unknown_error) + def unknown_error(app): + logger.error(m18n.n("unbackup_app", app=app)) + + target_list = self.targets.set_wanted( + "apps", apps, os.listdir("/etc/yunohost/apps"), unknown_error + ) # Additionnaly, we need to check that each targetted app has a # backup and restore scripts @@ -385,11 +411,11 @@ class BackupManager(): restore_script_path = os.path.join(app_script_folder, "restore") if not os.path.isfile(backup_script_path): - logger.warning(m18n.n('backup_with_no_backup_script_for_app', app=app)) + logger.warning(m18n.n("backup_with_no_backup_script_for_app", app=app)) self.targets.set_result("apps", app, "Skipped") elif not os.path.isfile(restore_script_path): - logger.warning(m18n.n('backup_with_no_restore_script_for_app', app=app)) + logger.warning(m18n.n("backup_with_no_restore_script_for_app", app=app)) self.targets.set_result("apps", app, "Warning") # @@ -434,7 +460,7 @@ class BackupManager(): source = os.path.join(self.work_dir, source) if dest.endswith("/"): dest = os.path.join(dest, os.path.basename(source)) - self.paths_to_backup.append({'source': source, 'dest': dest}) + self.paths_to_backup.append({"source": source, "dest": dest}) def _write_csv(self): """ @@ -461,20 +487,21 @@ class BackupManager(): backup_csv_creation_failed -- Raised if the CSV couldn't be created backup_csv_addition_failed -- Raised if we can't write in the CSV """ - self.csv_path = os.path.join(self.work_dir, 'backup.csv') + self.csv_path = os.path.join(self.work_dir, "backup.csv") try: - self.csv_file = open(self.csv_path, 'a') - self.fieldnames = ['source', 'dest'] - self.csv = csv.DictWriter(self.csv_file, fieldnames=self.fieldnames, - quoting=csv.QUOTE_ALL) + self.csv_file = open(self.csv_path, "a") + self.fieldnames = ["source", "dest"] + self.csv = csv.DictWriter( + self.csv_file, fieldnames=self.fieldnames, quoting=csv.QUOTE_ALL + ) except (IOError, OSError, csv.Error): - logger.error(m18n.n('backup_csv_creation_failed')) + logger.error(m18n.n("backup_csv_creation_failed")) for row in self.paths_to_backup: try: self.csv.writerow(row) except csv.Error: - logger.error(m18n.n('backup_csv_addition_failed')) + logger.error(m18n.n("backup_csv_addition_failed")) self.csv_file.close() # @@ -502,10 +529,6 @@ class BackupManager(): files to backup hooks/ -- restore scripts associated to system backup scripts are copied here - - Exceptions: - "backup_nothings_done" -- (YunohostError) This exception is raised if - nothing has been listed. """ self._collect_system_files() @@ -517,17 +540,17 @@ class BackupManager(): if not successfull_apps and not successfull_system: filesystem.rm(self.work_dir, True, True) - raise YunohostError('backup_nothings_done') + raise YunohostError("backup_nothings_done") # Add unlisted files from backup tmp dir - self._add_to_list_to_backup('backup.csv') - self._add_to_list_to_backup('info.json') - if len(self.apps_return) > 0: - self._add_to_list_to_backup('apps') - if os.path.isdir(os.path.join(self.work_dir, 'conf')): - self._add_to_list_to_backup('conf') - if os.path.isdir(os.path.join(self.work_dir, 'data')): - self._add_to_list_to_backup('data') + self._add_to_list_to_backup("backup.csv") + self._add_to_list_to_backup("info.json") + for app in self.apps_return.keys(): + self._add_to_list_to_backup(f"apps/{app}") + if os.path.isdir(os.path.join(self.work_dir, "conf")): + self._add_to_list_to_backup("conf") + if os.path.isdir(os.path.join(self.work_dir, "data")): + self._add_to_list_to_backup("data") # Write CSV file self._write_csv() @@ -536,7 +559,7 @@ class BackupManager(): self._compute_backup_size() # Create backup info file - with open("%s/info.json" % self.work_dir, 'w') as f: + with open("%s/info.json" % self.work_dir, "w") as f: f.write(json.dumps(self.info)) def _get_env_var(self, app=None): @@ -553,18 +576,15 @@ class BackupManager(): """ env_var = {} - _, tmp_csv = tempfile.mkstemp(prefix='backupcsv_') - env_var['YNH_BACKUP_DIR'] = self.work_dir - env_var['YNH_BACKUP_CSV'] = tmp_csv + _, tmp_csv = tempfile.mkstemp(prefix="backupcsv_") + env_var["YNH_BACKUP_DIR"] = self.work_dir + env_var["YNH_BACKUP_CSV"] = tmp_csv if app is not None: - app_id, app_instance_nb = _parse_app_instance_name(app) - env_var["YNH_APP_ID"] = app_id - env_var["YNH_APP_INSTANCE_NAME"] = app - env_var["YNH_APP_INSTANCE_NUMBER"] = str(app_instance_nb) - tmp_app_dir = os.path.join('apps/', app) - tmp_app_bkp_dir = os.path.join(self.work_dir, tmp_app_dir, 'backup') - env_var["YNH_APP_BACKUP_DIR"] = tmp_app_bkp_dir + env_var.update(_make_environment_for_app_script(app)) + env_var["YNH_APP_BACKUP_DIR"] = os.path.join( + self.work_dir, "apps", app, "backup" + ) return env_var @@ -589,27 +609,37 @@ class BackupManager(): if system_targets == []: return - logger.debug(m18n.n('backup_running_hooks')) + logger.debug(m18n.n("backup_running_hooks")) # Prepare environnement env_dict = self._get_env_var() # Actual call to backup scripts/hooks - ret = hook_callback('backup', - system_targets, - args=[self.work_dir], - env=env_dict, - chdir=self.work_dir) + ret = hook_callback( + "backup", + system_targets, + args=[self.work_dir], + env=env_dict, + chdir=self.work_dir, + ) - ret_succeed = {hook: {path:result["state"] for path, result in infos.items()} - for hook, infos in ret.items() - if any(result["state"] == "succeed" for result in infos.values())} - ret_failed = {hook: {path:result["state"] for path, result in infos.items.items()} - for hook, infos in ret.items() - if any(result["state"] == "failed" for result in infos.values())} + ret_succeed = { + hook: [ + path for path, result in infos.items() if result["state"] == "succeed" + ] + for hook, infos in ret.items() + if any(result["state"] == "succeed" for result in infos.values()) + } + ret_failed = { + hook: [ + path for path, result in infos.items() if result["state"] == "failed" + ] + for hook, infos in ret.items() + if any(result["state"] == "failed" for result in infos.values()) + } - if ret_succeed.keys() != []: + if list(ret_succeed.keys()) != []: self.system_return = ret_succeed # Add files from targets (which they put in the CSV) to the list of @@ -621,8 +651,7 @@ class BackupManager(): restore_hooks_dir = os.path.join(self.work_dir, "hooks", "restore") if not os.path.exists(restore_hooks_dir): - filesystem.mkdir(restore_hooks_dir, mode=0o750, - parents=True, uid='admin') + filesystem.mkdir(restore_hooks_dir, mode=0o700, parents=True, uid="root") restore_hooks = hook_list("restore")["hooks"] @@ -633,15 +662,15 @@ class BackupManager(): self._add_to_list_to_backup(hook["path"], "hooks/restore/") self.targets.set_result("system", part, "Success") else: - logger.warning(m18n.n('restore_hook_unavailable', hook=part)) + logger.warning(m18n.n("restore_hook_unavailable", hook=part)) self.targets.set_result("system", part, "Warning") for part in ret_failed.keys(): - logger.error(m18n.n('backup_system_part_failed', part=part)) + logger.error(m18n.n("backup_system_part_failed", part=part)) self.targets.set_result("system", part, "Error") def _collect_apps_files(self): - """ Prepare backup for each selected apps """ + """Prepare backup for each selected apps""" apps_targets = self.targets.list("apps", exclude=["Skipped"]) @@ -672,84 +701,74 @@ class BackupManager(): Args: app -- (string) an app instance name (already installed) to backup - - Exceptions: - backup_app_failed -- Raised at the end if the app backup script - execution failed """ - app_setting_path = os.path.join('/etc/yunohost/apps/', app) + from yunohost.permission import user_permission_list + + app_setting_path = os.path.join("/etc/yunohost/apps/", app) # Prepare environment env_dict = self._get_env_var(app) + env_dict["YNH_APP_BASEDIR"] = os.path.join( + self.work_dir, "apps", app, "settings" + ) tmp_app_bkp_dir = env_dict["YNH_APP_BACKUP_DIR"] - settings_dir = os.path.join(self.work_dir, 'apps', app, 'settings') + settings_dir = os.path.join(self.work_dir, "apps", app, "settings") logger.info(m18n.n("app_start_backup", app=app)) + tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app) try: # Prepare backup directory for the app - filesystem.mkdir(tmp_app_bkp_dir, 0o750, True, uid='admin') + filesystem.mkdir(tmp_app_bkp_dir, 0o700, True, uid="root") # Copy the app settings to be able to call _common.sh shutil.copytree(app_setting_path, settings_dir) - # Copy app backup script in a temporary folder and execute it - _, tmp_script = tempfile.mkstemp(prefix='backup_') - app_script = os.path.join(app_setting_path, 'scripts/backup') - subprocess.call(['install', '-Dm555', app_script, tmp_script]) - - hook_exec(tmp_script, args=[tmp_app_bkp_dir, app], - raise_on_error=True, chdir=tmp_app_bkp_dir, env=env_dict)[0] + hook_exec( + f"{tmp_workdir_for_app}/scripts/backup", + raise_on_error=True, + chdir=tmp_app_bkp_dir, + env=env_dict, + )[0] self._import_to_list_to_backup(env_dict["YNH_BACKUP_CSV"]) # backup permissions - logger.debug(m18n.n('backup_permission', app=app)) - ldap_url = "ldap:///dc=yunohost,dc=org???(&(objectClass=permissionYnh)(cn=*.%s))" % app - os.system("slapcat -b dc=yunohost,dc=org -H '%s' -l '%s/permission.ldif'" % (ldap_url, settings_dir)) + logger.debug(m18n.n("backup_permission", app=app)) + permissions = user_permission_list(full=True, apps=[app])["permissions"] + this_app_permissions = {name: infos for name, infos in permissions.items()} + write_to_yaml("%s/permissions.yml" % settings_dir, this_app_permissions) - except: - abs_tmp_app_dir = os.path.join(self.work_dir, 'apps/', app) + except Exception: + abs_tmp_app_dir = os.path.join(self.work_dir, "apps/", app) shutil.rmtree(abs_tmp_app_dir, ignore_errors=True) - logger.exception(m18n.n('backup_app_failed', app=app)) + logger.error(m18n.n("backup_app_failed", app=app)) self.targets.set_result("apps", app, "Error") else: # Add app info i = app_info(app) self.apps_return[app] = { - 'version': i['version'], - 'name': i['name'], - 'description': i['description'], + "version": i["version"], + "name": i["name"], + "description": i["description"], } self.targets.set_result("apps", app, "Success") # Remove tmp files in all situations finally: - filesystem.rm(tmp_script, force=True) + shutil.rmtree(tmp_workdir_for_app) filesystem.rm(env_dict["YNH_BACKUP_CSV"], force=True) # # Actual backup archive creation / method management # # - def add(self, method): - """ - Add a backup method that will be applied after the files collection step - - Args: - method -- (BackupMethod) A backup method. Currently, you can use those: - TarBackupMethod - CopyBackupMethod - CustomBackupMethod - """ - self.methods.append(method) - def backup(self): """Apply backup methods""" for method in self.methods: - logger.debug(m18n.n('backup_applying_method_' + method.method_name)) - method.mount_and_backup(self) - logger.debug(m18n.n('backup_method_' + method.method_name + '_finished')) + logger.debug(m18n.n("backup_applying_method_" + method.method_name)) + method.mount_and_backup() + logger.debug(m18n.n("backup_method_" + method.method_name + "_finished")) def _compute_backup_size(self): """ @@ -772,40 +791,43 @@ class BackupManager(): # size info self.size = 0 for system_key in self.system_return: - self.size_details['system'][system_key] = 0 + self.size_details["system"][system_key] = 0 for app_key in self.apps_return: - self.size_details['apps'][app_key] = 0 + self.size_details["apps"][app_key] = 0 for row in self.paths_to_backup: - if row['dest'] != "info.json": - size = disk_usage(row['source']) + if row["dest"] == "info.json": + continue - # Add size to apps details - splitted_dest = row['dest'].split('/') - category = splitted_dest[0] - if category == 'apps': - for app_key in self.apps_return: - if row['dest'].startswith('apps/' + app_key): - self.size_details['apps'][app_key] += size - break - # OR Add size to the correct system element - elif category == 'data' or category == 'conf': - for system_key in self.system_return: - if row['dest'].startswith(system_key.replace('_', '/')): - self.size_details['system'][system_key] += size - break + size = disk_usage(row["source"]) - self.size += size + # Add size to apps details + splitted_dest = row["dest"].split("/") + category = splitted_dest[0] + if category == "apps": + for app_key in self.apps_return: + if row["dest"].startswith("apps/" + app_key): + self.size_details["apps"][app_key] += size + break + + # OR Add size to the correct system element + elif category == "data" or category == "conf": + for system_key in self.system_return: + if row["dest"].startswith(system_key.replace("_", "/")): + self.size_details["system"][system_key] += size + break + + self.size += size return self.size -class RestoreManager(): +class RestoreManager: """ RestoreManager allow to restore a past backup archive - Currently it's a tar.gz file, but it could be another kind of archive + Currently it's a tar file, but it could be another kind of archive Public properties: info (getter)i # FIXME @@ -831,23 +853,26 @@ class RestoreManager(): return restore_manager.result """ - def __init__(self, name, repo=None, method='tar'): + def __init__(self, name, method="tar"): """ RestoreManager constructor Args: name -- (string) Archive name - repo -- (string|None) Repository where is this archive, it could be a - path (default: /home/yunohost.backup/archives) method -- (string) Method name to use to mount the archive """ # Retrieve and open the archive # FIXME this way to get the info is not compatible with copy or custom # backup methods self.info = backup_info(name, with_details=True) - self.archive_path = self.info['path'] + if not self.info["from_yunohost_version"] or version.parse( + self.info["from_yunohost_version"] + ) < version.parse("3.8.0"): + raise YunohostValidationError("restore_backup_too_old") + + self.archive_path = self.info["path"] self.name = name - self.method = BackupMethod.create(method) + self.method = BackupMethod.create(method, self) self.targets = BackupRestoreTargetsManager() # @@ -860,20 +885,16 @@ class RestoreManager(): successful_apps = self.targets.list("apps", include=["Success", "Warning"]) successful_system = self.targets.list("system", include=["Success", "Warning"]) - return len(successful_apps) != 0 \ - or len(successful_system) != 0 + return len(successful_apps) != 0 or len(successful_system) != 0 def _read_info_files(self): """ Read the info file from inside an archive - - Exceptions: - backup_invalid_archive -- Raised if we can't read the info """ # Retrieve backup info info_file = os.path.join(self.work_dir, "info.json") try: - with open(info_file, 'r') as f: + with open(info_file, "r") as f: self.info = json.load(f) # Historically, "system" was "hooks" @@ -881,50 +902,52 @@ class RestoreManager(): self.info["system"] = self.info["hooks"] except IOError: logger.debug("unable to load '%s'", info_file, exc_info=1) - raise YunohostError('backup_invalid_archive') + raise YunohostError( + "backup_archive_cant_retrieve_info_json", archive=self.archive_path + ) else: - logger.debug("restoring from backup '%s' created on %s", self.name, - datetime.utcfromtimestamp(self.info['created_at'])) + logger.debug( + "restoring from backup '%s' created on %s", + self.name, + datetime.utcfromtimestamp(self.info["created_at"]), + ) def _postinstall_if_needed(self): """ Post install yunohost if needed - - Exceptions: - backup_invalid_archive -- Raised if the current_host isn't in the - archive """ # Check if YunoHost is installed - if not os.path.isfile('/etc/yunohost/installed'): + if not os.path.isfile("/etc/yunohost/installed"): # Retrieve the domain from the backup try: - with open("%s/conf/ynh/current_host" % self.work_dir, 'r') as f: + with open("%s/conf/ynh/current_host" % self.work_dir, "r") as f: domain = f.readline().rstrip() except IOError: - logger.debug("unable to retrieve current_host from the backup", - exc_info=1) + logger.debug( + "unable to retrieve current_host from the backup", exc_info=1 + ) # FIXME include the current_host by default ? - raise YunohostError('backup_invalid_archive') + raise YunohostError( + "The main domain name cannot be retrieved from inside the archive, and is needed to perform the postinstall", + raw_msg=True, + ) logger.debug("executing the post-install...") - tools_postinstall(domain, 'Yunohost', True) - + tools_postinstall(domain, "Yunohost", True) def clean(self): """ End a restore operations by cleaning the working directory and regenerate ssowat conf (if some apps were restored) """ - from permission import permission_sync_to_user + from .permission import permission_sync_to_user - successfull_apps = self.targets.list("apps", include=["Success", "Warning"]) - - permission_sync_to_user(force=False) + permission_sync_to_user() if os.path.ismount(self.work_dir): ret = subprocess.call(["umount", self.work_dir]) if ret != 0: - logger.warning(m18n.n('restore_cleaning_failed')) + logger.warning(m18n.n("restore_cleaning_failed")) filesystem.rm(self.work_dir, recursive=True, force=True) # @@ -942,13 +965,11 @@ class RestoreManager(): """ def unknown_error(part): - logger.error(m18n.n("backup_archive_system_part_not_available", - part=part)) + logger.error(m18n.n("backup_archive_system_part_not_available", part=part)) - target_list = self.targets.set_wanted("system", - system_parts, - self.info['system'].keys(), - unknown_error) + target_list = self.targets.set_wanted( + "system", system_parts, self.info["system"].keys(), unknown_error + ) # Now we need to check that the restore hook is actually available for # all targets we want to restore @@ -956,6 +977,9 @@ class RestoreManager(): # These are the hooks on the current installation available_restore_system_hooks = hook_list("restore")["hooks"] + custom_restore_hook_folder = os.path.join(CUSTOM_HOOK_FOLDER, "restore") + filesystem.mkdir(custom_restore_hook_folder, 755, parents=True, force=True) + for system_part in target_list: # By default, we'll use the restore hooks on the current install # if available @@ -967,24 +991,30 @@ class RestoreManager(): continue # Otherwise, attempt to find it (or them?) in the archive - hook_paths = '{:s}/hooks/restore/*-{:s}'.format(self.work_dir, system_part) - hook_paths = glob(hook_paths) # If we didn't find it, we ain't gonna be able to restore it - if len(hook_paths) == 0: - logger.exception(m18n.n('restore_hook_unavailable', part=system_part)) + if ( + system_part not in self.info["system"] + or "paths" not in self.info["system"][system_part] + or len(self.info["system"][system_part]["paths"]) == 0 + ): + logger.error(m18n.n("restore_hook_unavailable", part=system_part)) self.targets.set_result("system", system_part, "Skipped") continue + hook_paths = self.info["system"][system_part]["paths"] + hook_paths = ["hooks/restore/%s" % os.path.basename(p) for p in hook_paths] + # Otherwise, add it from the archive to the system # FIXME: Refactor hook_add and use it instead - custom_restore_hook_folder = os.path.join(CUSTOM_HOOK_FOLDER, 'restore') - filesystem.mkdir(custom_restore_hook_folder, 755, True) for hook_path in hook_paths: - logger.debug("Adding restoration script '%s' to the system " - "from the backup archive '%s'", hook_path, - self.archive_path) - shutil.copy(hook_path, custom_restore_hook_folder) + logger.debug( + "Adding restoration script '%s' to the system " + "from the backup archive '%s'", + hook_path, + self.archive_path, + ) + self.method.copy(hook_path, custom_restore_hook_folder) def set_apps_targets(self, apps=[]): """ @@ -997,13 +1027,28 @@ class RestoreManager(): """ def unknown_error(app): - logger.error(m18n.n('backup_archive_app_not_found', - app=app)) + logger.error(m18n.n("backup_archive_app_not_found", app=app)) - self.targets.set_wanted("apps", - apps, - self.info['apps'].keys(), - unknown_error) + to_be_restored = self.targets.set_wanted( + "apps", apps, self.info["apps"].keys(), unknown_error + ) + + # If all apps to restore are already installed, stop right here. + # Otherwise, if at least one app can be restored, we keep going on + # because those which can be restored will indeed be restored + already_installed = [app for app in to_be_restored if _is_installed(app)] + if already_installed != []: + if already_installed == to_be_restored: + raise YunohostValidationError( + "restore_already_installed_apps", apps=", ".join(already_installed) + ) + else: + logger.warning( + m18n.n( + "restore_already_installed_apps", + apps=", ".join(already_installed), + ) + ) # # Archive mounting # @@ -1016,35 +1061,31 @@ class RestoreManager(): Use the mount method from the BackupMethod instance and read info about this archive - - Exceptions: - restore_removing_tmp_dir_failed -- Raised if it's not possible to remove - the working directory """ self.work_dir = os.path.join(BACKUP_PATH, "tmp", self.name) if os.path.ismount(self.work_dir): - logger.debug("An already mounting point '%s' already exists", - self.work_dir) - ret = subprocess.call(['umount', self.work_dir]) + logger.debug("An already mounting point '%s' already exists", self.work_dir) + ret = subprocess.call(["umount", self.work_dir]) if ret == 0: - subprocess.call(['rmdir', self.work_dir]) + subprocess.call(["rmdir", self.work_dir]) logger.debug("Unmount dir: {}".format(self.work_dir)) else: - raise YunohostError('restore_removing_tmp_dir_failed') + raise YunohostError("restore_removing_tmp_dir_failed") elif os.path.isdir(self.work_dir): - logger.debug("temporary restore directory '%s' already exists", - self.work_dir) - ret = subprocess.call(['rm', '-Rf', self.work_dir]) + logger.debug( + "temporary restore directory '%s' already exists", self.work_dir + ) + ret = subprocess.call(["rm", "-Rf", self.work_dir]) if ret == 0: logger.debug("Delete dir: {}".format(self.work_dir)) else: - raise YunohostError('restore_removing_tmp_dir_failed') + raise YunohostError("restore_removing_tmp_dir_failed") filesystem.mkdir(self.work_dir, parents=True) - self.method.mount(self) + self.method.mount() self._read_info_files() @@ -1063,41 +1104,38 @@ class RestoreManager(): """ system = self.targets.list("system", exclude=["Skipped"]) apps = self.targets.list("apps", exclude=["Skipped"]) - restore_all_system = (system == self.info['system'].keys()) - restore_all_apps = (apps == self.info['apps'].keys()) + restore_all_system = system == self.info["system"].keys() + restore_all_apps = apps == self.info["apps"].keys() # If complete restore operations (or legacy archive) margin = CONF_MARGIN_SPACE_SIZE * 1024 * 1024 - if (restore_all_system and restore_all_apps) or 'size_details' not in self.info: - size = self.info['size'] - if 'size_details' not in self.info or \ - self.info['size_details']['apps'] != {}: + if (restore_all_system and restore_all_apps) or "size_details" not in self.info: + size = self.info["size"] + if ( + "size_details" not in self.info + or self.info["size_details"]["apps"] != {} + ): margin = APP_MARGIN_SPACE_SIZE * 1024 * 1024 # Partial restore don't need all backup size else: size = 0 if system is not None: for system_element in system: - size += self.info['size_details']['system'][system_element] + size += self.info["size_details"]["system"][system_element] # TODO how to know the dependencies size ? if apps is not None: for app in apps: - size += self.info['size_details']['apps'][app] + size += self.info["size_details"]["apps"][app] margin = APP_MARGIN_SPACE_SIZE * 1024 * 1024 - if not os.path.isfile('/etc/yunohost/installed'): + if not os.path.isfile("/etc/yunohost/installed"): size += POSTINSTALL_ESTIMATE_SPACE_SIZE * 1024 * 1024 return (size, margin) def assert_enough_free_space(self): """ Check available disk space - - Exceptions: - restore_may_be_not_enough_disk_space -- Raised if there isn't enough - space to cover the security margin space - restore_not_enough_disk_space -- Raised if there isn't enough space """ free_space = free_space_in_directory(BACKUP_PATH) @@ -1107,9 +1145,19 @@ class RestoreManager(): return True elif free_space > needed_space: # TODO Add --force options to avoid the error raising - raise YunohostError('restore_may_be_not_enough_disk_space', free_space=free_space, needed_space=needed_space, margin=margin) + raise YunohostValidationError( + "restore_may_be_not_enough_disk_space", + free_space=free_space, + needed_space=needed_space, + margin=margin, + ) else: - raise YunohostError('restore_not_enough_disk_space', free_space=free_space, needed_space=needed_space, margin=margin) + raise YunohostValidationError( + "restore_not_enough_disk_space", + free_space=free_space, + needed_space=needed_space, + margin=margin, + ) # # "Actual restore" (reverse step of the backup collect part) # @@ -1127,55 +1175,51 @@ class RestoreManager(): self._postinstall_if_needed() # Apply dirty patch to redirect php5 file on php7 - self._patch_backup_csv_file() + self._patch_legacy_php_versions_in_csv_file() self._restore_system() self._restore_apps() + except Exception as e: + raise YunohostError( + "The following critical error happened during restoration: %s" % e + ) finally: self.clean() - def _patch_backup_csv_file(self): + def _patch_legacy_php_versions_in_csv_file(self): """ - Apply dirty patch to redirect php5 file on php7 + Apply dirty patch to redirect php5 and php7.0 files to php7.3 """ - backup_csv = os.path.join(self.work_dir, 'backup.csv') + backup_csv = os.path.join(self.work_dir, "backup.csv") if not os.path.isfile(backup_csv): return - try: - contains_php5 = False - with open(backup_csv) as csvfile: - reader = csv.DictReader(csvfile, fieldnames=['source', 'dest']) - newlines = [] - for row in reader: - if 'php5' in row['source']: - contains_php5 = True - row['source'] = row['source'].replace('/etc/php5', '/etc/php/7.0') \ - .replace('/var/run/php5-fpm', '/var/run/php/php7.0-fpm') \ - .replace('php5', 'php7') + replaced_something = False + with open(backup_csv) as csvfile: + reader = csv.DictReader(csvfile, fieldnames=["source", "dest"]) + newlines = [] + for row in reader: + for pattern, replace in LEGACY_PHP_VERSION_REPLACEMENTS: + if pattern in row["source"]: + replaced_something = True + row["source"] = row["source"].replace(pattern, replace) - newlines.append(row) - except (IOError, OSError, csv.Error) as e: - raise YunohostError('error_reading_file', file=backup_csv, error=str(e)) + newlines.append(row) - if not contains_php5: + if not replaced_something: return - try: - with open(backup_csv, 'w') as csvfile: - writer = csv.DictWriter(csvfile, - fieldnames=['source', 'dest'], - quoting=csv.QUOTE_ALL) - for row in newlines: - writer.writerow(row) - except (IOError, OSError, csv.Error) as e: - logger.warning(m18n.n('backup_php5_to_php7_migration_may_fail', - error=str(e))) + with open(backup_csv, "w") as csvfile: + writer = csv.DictWriter( + csvfile, fieldnames=["source", "dest"], quoting=csv.QUOTE_ALL + ) + for row in newlines: + writer.writerow(row) def _restore_system(self): - """ Restore user and system parts """ + """Restore user and system parts""" system_targets = self.targets.list("system", exclude=["Skipped"]) @@ -1183,87 +1227,99 @@ class RestoreManager(): if system_targets == []: return - from yunohost.utils.ldap import _get_ldap_interface - ldap = _get_ldap_interface() + from yunohost.permission import ( + permission_create, + permission_delete, + user_permission_list, + permission_sync_to_user, + ) # Backup old permission for apps # We need to do that because in case of an app is installed we can't remove the permission for this app - old_apps_permission = [] - try: - old_apps_permission = ldap.search('ou=permission,dc=yunohost,dc=org', - '(&(objectClass=permissionYnh)(!(cn=main.mail))(!(cn=main.metronome))(!(cn=main.sftp)))', - ['cn', 'objectClass', 'groupPermission', 'URL', 'gidNumber']) - except: - logger.info(m18n.n('apps_permission_not_found')) + old_apps_permission = user_permission_list(ignore_system_perms=True, full=True)[ + "permissions" + ] # Start register change on system - operation_logger = OperationLogger('backup_restore_system') + operation_logger = OperationLogger("backup_restore_system") operation_logger.start() - logger.debug(m18n.n('restore_running_hooks')) + logger.debug(m18n.n("restore_running_hooks")) - env_dict = self._get_env_var() - operation_logger.extra['env'] = env_dict + env_dict = { + "YNH_BACKUP_DIR": self.work_dir, + "YNH_BACKUP_CSV": os.path.join(self.work_dir, "backup.csv"), + } + operation_logger.extra["env"] = env_dict operation_logger.flush() - ret = hook_callback('restore', - system_targets, - args=[self.work_dir], - env=env_dict, - chdir=self.work_dir) + ret = hook_callback( + "restore", + system_targets, + args=[self.work_dir], + env=env_dict, + chdir=self.work_dir, + ) - ret_succeed = [hook for hook, infos in ret.items() - if any(result["state"] == "succeed" for result in infos.values())] - ret_failed = [hook for hook, infos in ret.items() - if any(result["state"] == "failed" for result in infos.values())] + ret_succeed = [ + hook + for hook, infos in ret.items() + if any(result["state"] == "succeed" for result in infos.values()) + ] + ret_failed = [ + hook + for hook, infos in ret.items() + if any(result["state"] == "failed" for result in infos.values()) + ] for part in ret_succeed: self.targets.set_result("system", part, "Success") error_part = [] for part in ret_failed: - logger.error(m18n.n('restore_system_part_failed', part=part)) + logger.error(m18n.n("restore_system_part_failed", part=part)) self.targets.set_result("system", part, "Error") error_part.append(part) if ret_failed: - operation_logger.error(m18n.n('restore_system_part_failed', part=', '.join(error_part))) + operation_logger.error( + m18n.n("restore_system_part_failed", part=", ".join(error_part)) + ) else: operation_logger.success() + yunohost.domain.domain_list_cache = {} + regen_conf() - # Check if we need to do the migration 0009 : setup group and permission - # Legacy code - result = ldap.search('ou=groups,dc=yunohost,dc=org', - '(&(objectclass=groupOfNamesYnh)(cn=all_users))', - ['cn']) - if not result: - from yunohost.tools import _get_migration_by_name - setup_group_permission = _get_migration_by_name("setup_group_permission") - # Update LDAP schema restart slapd - logger.info(m18n.n("migration_0011_update_LDAP_schema")) - regen_conf(names=['slapd'], force=True) - setup_group_permission.migrate_LDAP_db() + _tools_migrations_run_after_system_restore( + backup_version=self.info["from_yunohost_version"] + ) - # Remove all permission for all app which sill in the LDAP - for per in ldap.search('ou=permission,dc=yunohost,dc=org', - '(&(objectClass=permissionYnh)(!(cn=main.mail))(!(cn=main.metronome))(!(cn=main.sftp)))', - ['cn']): - if not ldap.remove('cn=%s,ou=permission' % per['cn'][0]): - raise YunohostError('permission_deletion_failed', - permission=per['cn'][0].split('.')[0], - app=per['cn'][0].split('.')[1]) + # Remove all permission for all app still in the LDAP + for permission_name in user_permission_list(ignore_system_perms=True)[ + "permissions" + ].keys(): + permission_delete(permission_name, force=True, sync_perm=False) - # Restore permission for the app which is installed - for per in old_apps_permission: - try: - permission_name, app_name = per['cn'][0].split('.') - except: - logger.warning(m18n.n('permission_name_not_valid', permission=per['cn'][0])) + # Restore permission for apps installed + for permission_name, permission_infos in old_apps_permission.items(): + app_name, perm_name = permission_name.split(".") if _is_installed(app_name): - if not ldap.add('cn=%s,ou=permission' % per['cn'][0], per): - raise YunohostError('apps_permission_restoration_failed', permission=permission_name, app=app_name) + permission_create( + permission_name, + allowed=permission_infos["allowed"], + url=permission_infos["url"], + additional_urls=permission_infos["additional_urls"], + auth_header=permission_infos["auth_header"], + label=permission_infos["label"] + if perm_name == "main" + else permission_infos["sublabel"], + show_tile=permission_infos["show_tile"], + protected=permission_infos["protected"], + sync_perm=False, + ) + permission_sync_to_user() def _restore_apps(self): """Restore all apps targeted""" @@ -1271,7 +1327,6 @@ class RestoreManager(): apps_targets = self.targets.list("apps", exclude=["Skipped"]) for app in apps_targets: - print(app) self._restore_app(app) def _restore_app(self, app_instance_name): @@ -1295,17 +1350,14 @@ class RestoreManager(): Args: app_instance_name -- (string) The app name to restore (no app with this name should be already install) - - Exceptions: - restore_already_installed_app -- Raised if an app with this app instance - name already exists - restore_app_failed -- Raised if the restore bash script failed """ - from moulinette.utils.filesystem import read_ldif from yunohost.user import user_group_list - from yunohost.permission import permission_remove - from yunohost.utils.ldap import _get_ldap_interface - ldap = _get_ldap_interface() + from yunohost.permission import ( + permission_create, + permission_delete, + user_permission_list, + permission_sync_to_user, + ) def copytree(src, dst, symlinks=False, ignore=None): for item in os.listdir(src): @@ -1316,166 +1368,206 @@ class RestoreManager(): else: shutil.copy2(s, d) + # Check if the app is not already installed + if _is_installed(app_instance_name): + logger.error(m18n.n("restore_already_installed_app", app=app_instance_name)) + self.targets.set_result("apps", app_instance_name, "Error") + return + # Start register change on system - related_to = [('app', app_instance_name)] - operation_logger = OperationLogger('backup_restore_app', related_to) + related_to = [("app", app_instance_name)] + operation_logger = OperationLogger("backup_restore_app", related_to) operation_logger.start() logger.info(m18n.n("app_start_restore", app=app_instance_name)) - # Check if the app is not already installed - if _is_installed(app_instance_name): - logger.error(m18n.n('restore_already_installed_app', - app=app_instance_name)) - self.targets.set_result("apps", app_instance_name, "Error") - return + app_dir_in_archive = os.path.join(self.work_dir, "apps", app_instance_name) + app_backup_in_archive = os.path.join(app_dir_in_archive, "backup") + app_settings_in_archive = os.path.join(app_dir_in_archive, "settings") + app_scripts_in_archive = os.path.join(app_settings_in_archive, "scripts") - app_dir_in_archive = os.path.join(self.work_dir, 'apps', app_instance_name) - app_backup_in_archive = os.path.join(app_dir_in_archive, 'backup') - app_settings_in_archive = os.path.join(app_dir_in_archive, 'settings') - app_scripts_in_archive = os.path.join(app_settings_in_archive, 'scripts') + # Attempt to patch legacy helpers... + _patch_legacy_helpers(app_settings_in_archive) # Apply dirty patch to make php5 apps compatible with php7 - _patch_php5(app_settings_in_archive) + _patch_legacy_php_versions(app_settings_in_archive) + _patch_legacy_php_versions_in_settings(app_settings_in_archive) # Delete _common.sh file in backup - common_file = os.path.join(app_backup_in_archive, '_common.sh') + common_file = os.path.join(app_backup_in_archive, "_common.sh") filesystem.rm(common_file, force=True) # Check if the app has a restore script - app_restore_script_in_archive = os.path.join(app_scripts_in_archive, - 'restore') + app_restore_script_in_archive = os.path.join(app_scripts_in_archive, "restore") if not os.path.isfile(app_restore_script_in_archive): - logger.warning(m18n.n('unrestore_app', app=app_instance_name)) + logger.warning(m18n.n("unrestore_app", app=app_instance_name)) self.targets.set_result("apps", app_instance_name, "Warning") return - logger.debug(m18n.n('restore_running_app_script', app=app_instance_name)) try: # Restore app settings - app_settings_new_path = os.path.join('/etc/yunohost/apps/', - app_instance_name) - app_scripts_new_path = os.path.join(app_settings_new_path, 'scripts') + app_settings_new_path = os.path.join( + "/etc/yunohost/apps/", app_instance_name + ) + app_scripts_new_path = os.path.join(app_settings_new_path, "scripts") shutil.copytree(app_settings_in_archive, app_settings_new_path) filesystem.chmod(app_settings_new_path, 0o400, 0o400, True) - filesystem.chown(app_scripts_new_path, 'admin', None, True) + filesystem.chown(app_scripts_new_path, "root", None, True) # Copy the app scripts to a writable temporary folder - # FIXME : use 'install -Dm555' or something similar to what's done - # in the backup method ? - tmp_folder_for_app_restore = tempfile.mkdtemp(prefix='restore') - copytree(app_scripts_in_archive, tmp_folder_for_app_restore) - filesystem.chmod(tmp_folder_for_app_restore, 0o550, 0o550, True) - filesystem.chown(tmp_folder_for_app_restore, 'admin', None, True) - restore_script = os.path.join(tmp_folder_for_app_restore, 'restore') + tmp_workdir_for_app = _make_tmp_workdir_for_app() + copytree(app_scripts_in_archive, tmp_workdir_for_app) + filesystem.chmod(tmp_workdir_for_app, 0o700, 0o700, True) + filesystem.chown(tmp_workdir_for_app, "root", None, True) + restore_script = os.path.join(tmp_workdir_for_app, "restore") # Restore permissions - if os.path.isfile(app_settings_in_archive + '/permission.ldif'): - filtred_entries = ['entryUUID', 'creatorsName', 'createTimestamp', 'entryCSN', 'structuralObjectClass', - 'modifiersName', 'modifyTimestamp', 'inheritPermission', 'memberUid'] - entries = read_ldif('%s/permission.ldif' % app_settings_in_archive, filtred_entries) - group_list = user_group_list(['cn'])['groups'] - for dn, entry in entries: - # Remove the group which has been removed - for group in entry['groupPermission']: - group_name = group.split(',')[0].split('=')[1] - if group_name not in group_list: - entry['groupPermission'].remove(group) - if not ldap.add('cn=%s,ou=permission' % entry['cn'][0], entry): - raise YunohostError('apps_permission_restoration_failed', - permission=entry['cn'][0].split('.')[0], - app=entry['cn'][0].split('.')[1]) - else: - from yunohost.tools import _get_migration_by_name - setup_group_permission = _get_migration_by_name("setup_group_permission") - setup_group_permission.migrate_app_permission(app=app_instance_name) + if not os.path.isfile("%s/permissions.yml" % app_settings_new_path): + raise YunohostError( + "Didnt find a permssions.yml for the app !?", raw_msg=True + ) - # Prepare env. var. to pass to script - env_dict = self._get_env_var(app_instance_name) + permissions = read_yaml("%s/permissions.yml" % app_settings_new_path) + existing_groups = user_group_list()["groups"] - operation_logger.extra['env'] = env_dict - operation_logger.flush() + for permission_name, permission_infos in permissions.items(): - # Execute app restore script - hook_exec(restore_script, - args=[app_backup_in_archive, app_instance_name], - chdir=app_backup_in_archive, - raise_on_error=True, - env=env_dict)[0] - except: - msg = m18n.n('restore_app_failed', app=app_instance_name) - logger.exception(msg) + if "allowed" not in permission_infos: + logger.warning( + "'allowed' key corresponding to allowed groups for permission %s not found when restoring app %s … You might have to reconfigure permissions yourself." + % (permission_name, app_instance_name) + ) + should_be_allowed = ["all_users"] + else: + should_be_allowed = [ + g for g in permission_infos["allowed"] if g in existing_groups + ] + + perm_name = permission_name.split(".")[1] + permission_create( + permission_name, + allowed=should_be_allowed, + url=permission_infos.get("url"), + additional_urls=permission_infos.get("additional_urls"), + auth_header=permission_infos.get("auth_header"), + label=permission_infos.get("label") + if perm_name == "main" + else permission_infos.get("sublabel"), + show_tile=permission_infos.get("show_tile", True), + protected=permission_infos.get("protected", False), + sync_perm=False, + ) + + permission_sync_to_user() + + os.remove("%s/permissions.yml" % app_settings_new_path) + + _tools_migrations_run_before_app_restore( + backup_version=self.info["from_yunohost_version"], + app_id=app_instance_name, + ) + except Exception: + import traceback + + error = m18n.n("unexpected_error", error="\n" + traceback.format_exc()) + msg = m18n.n("app_restore_failed", app=app_instance_name, error=error) + logger.error(msg) operation_logger.error(msg) self.targets.set_result("apps", app_instance_name, "Error") - remove_script = os.path.join(app_scripts_in_archive, 'remove') - - # Setup environment for remove script - app_id, app_instance_nb = _parse_app_instance_name(app_instance_name) - env_dict_remove = {} - env_dict_remove["YNH_APP_ID"] = app_id - env_dict_remove["YNH_APP_INSTANCE_NAME"] = app_instance_name - env_dict_remove["YNH_APP_INSTANCE_NUMBER"] = str(app_instance_nb) - - operation_logger = OperationLogger('remove_on_failed_restore', - [('app', app_instance_name)], - env=env_dict_remove) - operation_logger.start() - - # Execute remove script - # TODO: call app_remove instead - if hook_exec(remove_script, args=[app_instance_name], - env=env_dict_remove)[0] != 0: - msg = m18n.n('app_not_properly_removed', app=app_instance_name) - logger.warning(msg) - operation_logger.error(msg) - else: - operation_logger.success() - - # Cleaning app directory + # Cleanup shutil.rmtree(app_settings_new_path, ignore_errors=True) + shutil.rmtree(tmp_workdir_for_app, ignore_errors=True) - # Remove all permission in LDAP - result = ldap.search(base='ou=permission,dc=yunohost,dc=org', - filter='(&(objectclass=permissionYnh)(cn=*.%s))' % app_instance_name, attrs=['cn']) - permission_list = [p['cn'][0] for p in result] - for l in permission_list: - permission_remove(app_instance_name, l.split('.')[0], force=True) + return - # TODO Cleaning app hooks - else: - self.targets.set_result("apps", app_instance_name, "Success") - operation_logger.success() + logger.debug(m18n.n("restore_running_app_script", app=app_instance_name)) + + # Prepare env. var. to pass to script + env_dict = _make_environment_for_app_script(app_instance_name) + env_dict.update( + { + "YNH_BACKUP_DIR": self.work_dir, + "YNH_BACKUP_CSV": os.path.join(self.work_dir, "backup.csv"), + "YNH_APP_BACKUP_DIR": os.path.join( + self.work_dir, "apps", app_instance_name, "backup" + ), + "YNH_APP_BASEDIR": os.path.join( + self.work_dir, "apps", app_instance_name, "settings" + ), + } + ) + + operation_logger.extra["env"] = env_dict + operation_logger.flush() + + # Execute the app install script + restore_failed = True + try: + ( + restore_failed, + failure_message_with_debug_instructions, + ) = hook_exec_with_script_debug_if_failure( + restore_script, + chdir=app_backup_in_archive, + env=env_dict, + operation_logger=operation_logger, + error_message_if_script_failed=m18n.n("app_restore_script_failed"), + error_message_if_failed=lambda e: m18n.n( + "app_restore_failed", app=app_instance_name, error=e + ), + ) finally: # Cleaning temporary scripts directory - shutil.rmtree(tmp_folder_for_app_restore, ignore_errors=True) + shutil.rmtree(tmp_workdir_for_app, ignore_errors=True) - def _get_env_var(self, app=None): - """ Define environment variable for hooks call """ - env_var = {} - env_var['YNH_BACKUP_DIR'] = self.work_dir - env_var['YNH_BACKUP_CSV'] = os.path.join(self.work_dir, "backup.csv") + if not restore_failed: + self.targets.set_result("apps", app_instance_name, "Success") + operation_logger.success() + else: - if app is not None: - app_dir_in_archive = os.path.join(self.work_dir, 'apps', app) - app_backup_in_archive = os.path.join(app_dir_in_archive, 'backup') + self.targets.set_result("apps", app_instance_name, "Error") - # Parse app instance name and id - app_id, app_instance_nb = _parse_app_instance_name(app) + remove_script = os.path.join(app_scripts_in_archive, "remove") - env_var["YNH_APP_ID"] = app_id - env_var["YNH_APP_INSTANCE_NAME"] = app - env_var["YNH_APP_INSTANCE_NUMBER"] = str(app_instance_nb) - env_var["YNH_APP_BACKUP_DIR"] = app_backup_in_archive + # Setup environment for remove script + env_dict_remove = _make_environment_for_app_script(app_instance_name) + env_dict_remove["YNH_APP_BASEDIR"] = os.path.join( + self.work_dir, "apps", app_instance_name, "settings" + ) + + remove_operation_logger = OperationLogger( + "remove_on_failed_restore", + [("app", app_instance_name)], + env=env_dict_remove, + ) + remove_operation_logger.start() + + # Execute remove script + if hook_exec(remove_script, env=env_dict_remove)[0] != 0: + msg = m18n.n("app_not_properly_removed", app=app_instance_name) + logger.warning(msg) + remove_operation_logger.error(msg) + else: + remove_operation_logger.success() + + # Cleaning app directory + shutil.rmtree(app_settings_new_path, ignore_errors=True) + + # Remove all permission in LDAP for this app + for permission_name in user_permission_list()["permissions"].keys(): + if permission_name.startswith(app_instance_name + "."): + permission_delete(permission_name, force=True) + + # TODO Cleaning app hooks + + logger.error(failure_message_with_debug_instructions) - return env_var # # Backup methods # # - - class BackupMethod(object): """ @@ -1497,7 +1589,7 @@ class BackupMethod(object): TarBackupMethod --------------- - This method compresses all files to backup in a .tar.gz archive. When + This method compresses all files to backup in a .tar archive. When restoring, it untars the required parts. CustomBackupMethod @@ -1510,20 +1602,37 @@ class BackupMethod(object): method_name Public methods: - mount_and_backup(self, backup_manager) - mount(self, restore_manager) + mount_and_backup(self) + mount(self) create(cls, method, **kwargs) info(archive_name) Usage: - method = BackupMethod.create("tar") - method.mount_and_backup(backup_manager) + method = BackupMethod.create("tar", backup_manager) + method.mount_and_backup() #or - method = BackupMethod.create("copy") - method.mount(restore_manager) + method = BackupMethod.create("copy", restore_manager) + method.mount() """ - def __init__(self, repo=None): + @classmethod + def create(cls, method, manager, **kwargs): + """ + Factory method to create instance of BackupMethod + + Args: + method -- (string) The method name of an existing BackupMethod. If the + name is unknown the CustomBackupMethod will be tried + *args -- Specific args for the method, could be the repo target by the + method + + Return a BackupMethod instance + """ + known_methods = {c.method_name: c for c in BackupMethod.__subclasses__()} + backup_method = known_methods.get(method, CustomBackupMethod) + return backup_method(manager, method=method, **kwargs) + + def __init__(self, manager, repo=None, **kwargs): """ BackupMethod constructors @@ -1536,6 +1645,7 @@ class BackupMethod(object): BackupRepository object. If None, the default repo is used : /home/yunohost.backup/archives/ """ + self.manager = manager if not repo or isinstance(repo, basestring): repo = BackupRepository.get_or_create(ARCHIVES_PATH) self.repo = repo @@ -1543,13 +1653,13 @@ class BackupMethod(object): @property def method_name(self): """Return the string name of a BackupMethod (eg "tar" or "copy")""" - raise YunohostError('backup_abstract_method') + raise YunohostError("backup_abstract_method") @property def archive_path(self): """Return the archive path""" return self.repo.location + '::' + self.name - + @property def name(self): """Return the backup name""" @@ -1588,18 +1698,13 @@ class BackupMethod(object): """ return False - def mount_and_backup(self, backup_manager): + def mount_and_backup(self): """ Run the backup on files listed by the BackupManager instance This method shouldn't be overrided, prefer overriding self.backup() and self.clean() - - Args: - backup_manager -- (BackupManager) A backup manager instance that has - already done the files collection step. """ - self.manager = backup_manager if self.need_mount(): self._organize_files() @@ -1608,21 +1713,17 @@ class BackupMethod(object): finally: self.clean() - def mount(self, restore_manager): + def mount(self): """ Mount the archive from RestoreManager instance in the working directory This method should be extended. - - Args: - restore_manager -- (RestoreManager) A restore manager instance - contains an archive to restore. """ - self.manager = restore_manager + pass def info(self, name): self._assert_archive_exists() - + info_json = self._get_info_string() if not self._info_json: raise YunohostError('backup_info_json_not_implemented') @@ -1631,20 +1732,16 @@ class BackupMethod(object): except: logger.debug("unable to load info json", exc_info=1) raise YunohostError('backup_invalid_archive') - + return info - + def clean(self): """ Umount sub directories of working dirextories and delete it if temporary - - Exceptions: - backup_cleaning_failed -- Raise if we were not able to unmount sub - directories of the working directories """ if self.need_mount(): if not _recursive_umount(self.work_dir): - raise YunohostError('backup_cleaning_failed') + raise YunohostError("backup_cleaning_failed") if self.manager.is_tmp_work_dir: filesystem.rm(self.work_dir, True, True) @@ -1652,9 +1749,6 @@ class BackupMethod(object): def _check_is_enough_free_space(self): """ Check free space in repository or output directory before to backup - - Exceptions: - not_enough_disk_space -- Raise if there isn't enough space. """ # TODO How to do with distant repo or with deduplicated backup ? backup_size = self.manager.size @@ -1662,9 +1756,13 @@ class BackupMethod(object): free_space = free_space_in_directory(self.repo) if free_space < backup_size: - logger.debug('Not enough space at %s (free: %s / needed: %d)', - self.repo, free_space, backup_size) - raise YunohostError('not_enough_disk_space', path=self.repo) + logger.debug( + "Not enough space at %s (free: %s / needed: %d)", + self.repo, + free_space, + backup_size, + ) + raise YunohostValidationError("not_enough_disk_space", path=self.repo) def _organize_files(self): """ @@ -1676,13 +1774,10 @@ class BackupMethod(object): The usage of binding could be strange for a user because the du -sb command will return that the working directory is big. - - Exceptions: - backup_unable_to_organize_files """ paths_needed_to_be_copied = [] for path in self.manager.paths_to_backup: - src = path['source'] + src = path["source"] if self.manager is RestoreManager: # TODO Support to run this before a restore (and not only before @@ -1690,7 +1785,7 @@ class BackupMethod(object): # be implemented src = os.path.join(self.unorganized_work_dir, src) - dest = os.path.join(self.work_dir, path['dest']) + dest = os.path.join(self.work_dir, path["dest"]) if dest == src: continue dest_dir = os.path.dirname(dest) @@ -1710,7 +1805,7 @@ class BackupMethod(object): logger.warning(m18n.n("backup_couldnt_bind", src=src, dest=dest)) # To check if dest is mounted, use /proc/mounts that # escape spaces as \040 - raw_mounts = read_file("/proc/mounts").strip().split('\n') + raw_mounts = read_file("/proc/mounts").strip().split("\n") mounts = [m.split()[1] for m in raw_mounts] mounts = [m.replace("\\040", " ") for m in mounts] if dest in mounts: @@ -1726,7 +1821,7 @@ class BackupMethod(object): if os.stat(src).st_dev == os.stat(dest_dir).st_dev: # 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') + '.' + cron_path = os.path.abspath("/etc/cron") + "." if not os.path.abspath(src).startswith(cron_path): try: os.link(src, dest) @@ -1736,7 +1831,10 @@ class BackupMethod(object): # E.g. this happens when running an encrypted hard drive # where everything is mapped to /dev/mapper/some-stuff # yet there are different devices behind it or idk ... - logger.warning("Could not link %s to %s (%s) ... falling back to regular copy." % (src, dest, str(e))) + logger.warning( + "Could not link %s to %s (%s) ... falling back to regular copy." + % (src, dest, str(e)) + ) else: # Success, go to next file to organize continue @@ -1752,59 +1850,33 @@ class BackupMethod(object): # to mounting error # Compute size to copy - size = sum(disk_usage(path['source']) for path in paths_needed_to_be_copied) - size /= (1024 * 1024) # Convert bytes to megabytes + size = sum(disk_usage(path["source"]) for path in paths_needed_to_be_copied) + size /= 1024 * 1024 # Convert bytes to megabytes # Ask confirmation for copying if size > MB_ALLOWED_TO_ORGANIZE: try: - i = msignals.prompt(m18n.n('backup_ask_for_copying_if_needed', - answers='y/N', size=str(size))) + i = Moulinette.prompt( + m18n.n( + "backup_ask_for_copying_if_needed", + answers="y/N", + size=str(size), + ) + ) except NotImplemented: - raise YunohostError('backup_unable_to_organize_files') + raise YunohostError("backup_unable_to_organize_files") else: - if i != 'y' and i != 'Y': - raise YunohostError('backup_unable_to_organize_files') + if i != "y" and i != "Y": + raise YunohostError("backup_unable_to_organize_files") # Copy unbinded path - logger.debug(m18n.n('backup_copying_to_organize_the_archive', - size=str(size))) + logger.debug(m18n.n("backup_copying_to_organize_the_archive", size=str(size))) for path in paths_needed_to_be_copied: - dest = os.path.join(self.work_dir, path['dest']) - if os.path.isdir(path['source']): - shutil.copytree(path['source'], dest, symlinks=True) + dest = os.path.join(self.work_dir, path["dest"]) + if os.path.isdir(path["source"]): + shutil.copytree(path["source"], dest, symlinks=True) else: - shutil.copy(path['source'], dest) - - @classmethod - def create(cls, method, *args): - """ - Factory method to create instance of BackupMethod - - Args: - method -- (string) The method name of an existing BackupMethod. If the - name is unknown the CustomBackupMethod will be tried - - ... -- Specific args for the method, could be the repo target by the - method - - Return a BackupMethod instance - """ - if not isinstance(method, basestring): - methods = [] - for m in method: - methods.append(BackupMethod.create(m, *args)) - return methods - - bm_class = { - 'copy': CopyBackupMethod, - 'tar': TarBackupMethod, - 'borg': BorgBackupMethod - } - if method in ["copy", "tar", "borg"]: - return bm_class[method](*args) - else: - return CustomBackupMethod(method=method, *args) + shutil.copy(path["source"], dest) class CopyBackupMethod(BackupMethod): @@ -1814,29 +1886,25 @@ class CopyBackupMethod(BackupMethod): could be the inverse for restoring """ - def __init__(self, repo=None): - super(CopyBackupMethod, self).__init__(repo) - filesystem.mkdir(self.repo.path, parent=True) + # FIXME: filesystem.mkdir(self.repo.path, parent=True) - @property - def method_name(self): - return 'copy' + method_name = "copy" def backup(self): - """ Copy prepared files into a the repo """ + """Copy prepared files into a the repo""" # Check free space in output self._check_is_enough_free_space() for path in self.manager.paths_to_backup: - source = path['source'] - dest = os.path.join(self.repo.path, path['dest']) + source = path["source"] + dest = os.path.join(self.repo.path, path["dest"]) if source == dest: logger.debug("Files already copyed") return dest_parent = os.path.dirname(dest) if not os.path.exists(dest_parent): - filesystem.mkdir(dest_parent, 0o750, True, uid='admin') + filesystem.mkdir(dest_parent, 0o700, True, uid="admin") if os.path.isdir(source): shutil.copytree(source, dest) @@ -1846,17 +1914,13 @@ class CopyBackupMethod(BackupMethod): def mount(self): """ Mount the uncompress backup in readonly mode to the working directory - - Exceptions: - backup_no_uncompress_archive_dir -- Raised if the repo doesn't exists - backup_cant_mount_uncompress_archive -- Raised if the binding failed """ # FIXME: This code is untested because there is no way to run it from # the ynh cli super(CopyBackupMethod, self).mount() if not os.path.isdir(self.repo.path): - raise YunohostError('backup_no_uncompress_archive_dir') + raise YunohostError("backup_no_uncompress_archive_dir") filesystem.mkdir(self.work_dir, parent=True) ret = subprocess.call(["mount", "-r", "--rbind", self.repo.path, @@ -1867,27 +1931,38 @@ class CopyBackupMethod(BackupMethod): logger.warning(m18n.n("bind_mouting_disable")) subprocess.call(["mountpoint", "-q", self.repo.path, "&&", "umount", "-R", self.repo.path]) - raise YunohostError('backup_cant_mount_uncompress_archive') + raise YunohostError("backup_cant_mount_uncompress_archive") + + logger.warning( + "Could not mount the backup in readonly mode with --rbind ... Unmounting" + ) + # FIXME : Does this stuff really works ? '&&' is going to be interpreted as an argument for mounpoint here ... Not as a classical '&&' ... + subprocess.call( + ["mountpoint", "-q", self.work_dir, "&&", "umount", "-R", self.work_dir] + ) + raise YunohostError("backup_cant_mount_uncompress_archive") + + def copy(self, file, target): + shutil.copy(file, target) class TarBackupMethod(BackupMethod): - """ - This class compress all files to backup in archive. - """ - - def __init__(self, repo=None): - super(TarBackupMethod, self).__init__(repo) - filesystem.mkdir(self.repo.path, parent=True) - - @property - def method_name(self): - return 'tar' + # FIXME: filesystem.mkdir(self.repo.path, parent=True) + method_name = "tar" @property def archive_path(self): - """Return the compress archive path""" - return os.path.join(self.repo.path, self.name + '.tar.gz') + + if isinstance(self.manager, BackupManager) and settings_get( + "backup.compress_tar_archives" + ): + return os.path.join(self.repo.path, self.name + ".tar.gz") + + f = os.path.join(self.repo.path, self.name + ".tar") + if os.path.exists(f + ".gz"): + f += ".gz" + return f def backup(self): """ @@ -1895,94 +1970,103 @@ class TarBackupMethod(BackupMethod): It adds the info.json in /home/yunohost.backup/archives and if the compress archive isn't located here, add a symlink to the archive to. - - Exceptions: - backup_archive_open_failed -- Raised if we can't open the archive - backup_creation_failed -- Raised if we can't write in the - compress archive """ if not os.path.exists(self.repo.path): - filesystem.mkdir(self.repo.path, 0o750, parents=True, uid='admin') + filesystem.mkdir(self.repo.path, 0o750, parents=True, uid="admin") # Check free space in output self._check_is_enough_free_space() # Open archive file for writing try: - tar = tarfile.open(self.archive_path, "w:gz") - except: - logger.debug("unable to open '%s' for writing", - self.archive_path, exc_info=1) - raise YunohostError('backup_archive_open_failed') + tar = tarfile.open( + self.archive_path, + "w:gz" if self.archive_path.endswith(".gz") else "w", + ) + except Exception: + logger.debug( + "unable to open '%s' for writing", self.archive_path, exc_info=1 + ) + raise YunohostError("backup_archive_open_failed") # Add files to the archive try: for path in self.manager.paths_to_backup: # Add the "source" into the archive and transform the path into # "dest" - tar.add(path['source'], arcname=path['dest']) + tar.add(path["source"], arcname=path["dest"]) except IOError: - logger.error(m18n.n('backup_archive_writing_error', source=path['source'], archive=self._archive_file, dest=path['dest']), exc_info=1) - raise YunohostError('backup_creation_failed') + logger.error( + m18n.n( + "backup_archive_writing_error", + source=path["source"], + archive=self._archive_file, + dest=path["dest"], + ), + exc_info=1, + ) + raise YunohostError("backup_creation_failed") finally: tar.close() # Move info file - shutil.copy(os.path.join(self.work_dir, 'info.json'), - os.path.join(self.repo.path, self.name + '.info.json')) + shutil.copy( + os.path.join(self.work_dir, "info.json"), + os.path.join(ARCHIVES_PATH, self.name + ".info.json"), + ) # If backuped to a non-default location, keep a symlink of the archive # to that location - link = os.path.join(self.repo.path, self.name + '.tar.gz') + link = os.path.join(self.repo.path, self.name + ".tar") if not os.path.isfile(link): os.symlink(self.archive_path, link) - def mount(self, restore_manager): + def mount(self): """ - Mount the archive. We avoid copy to be able to restore on system without - too many space. - - Exceptions: - backup_archive_open_failed -- Raised if the archive can't be open + Mount the archive. We avoid intermediate copies to be able to restore on system with low free space. """ - super(TarBackupMethod, self).mount(restore_manager) - - # Check file exist and it's not a broken link - self._assert_archive_exists() - - # Check the archive can be open - try: - tar = tarfile.open(self.archive_path, "r:gz") - except: - logger.debug("cannot open backup archive '%s'", - self.archive_path, exc_info=1) - raise YunohostError('backup_archive_open_failed') - - # FIXME : Is this really useful to close the archive just to - # reopen it right after this with the same options ...? - tar.close() + super(TarBackupMethod, self).mount() # Mount the tarball logger.debug(m18n.n("restore_extracting")) - tar = tarfile.open(self._archive_file, "r:gz") + try: + tar = tarfile.open( + self.archive_path, + "r:gz" if self.archive_path.endswith(".gz") else "r", + ) + except Exception: + logger.debug( + "cannot open backup archive '%s'", self.archive_path, exc_info=1 + ) + raise YunohostError("backup_archive_open_failed") + + try: + files_in_archive = tar.getnames() + except (IOError, EOFError, tarfile.ReadError) as e: + raise YunohostError( + "backup_archive_corrupted", archive=self.archive_path, error=str(e) + ) if "info.json" in tar.getnames(): leading_dot = "" - tar.extract('info.json', path=self.work_dir) - elif "./info.json" in tar.getnames(): + tar.extract("info.json", path=self.work_dir) + elif "./info.json" in files_in_archive: leading_dot = "./" - tar.extract('./info.json', path=self.work_dir) + tar.extract("./info.json", path=self.work_dir) else: - logger.debug("unable to retrieve 'info.json' inside the archive", - exc_info=1) + logger.debug( + "unable to retrieve 'info.json' inside the archive", exc_info=1 + ) tar.close() - raise YunohostError('backup_invalid_archive') + raise YunohostError( + "backup_archive_cant_retrieve_info_json", archive=self.archive_path + ) - if "backup.csv" in tar.getnames(): - tar.extract('backup.csv', path=self.work_dir) - elif "./backup.csv" in tar.getnames(): - tar.extract('./backup.csv', path=self.work_dir) + if "backup.csv" in files_in_archive: + tar.extract("backup.csv", path=self.work_dir) + elif "./backup.csv" in files_in_archive: + tar.extract("./backup.csv", path=self.work_dir) else: # Old backup archive have no backup.csv file pass @@ -2004,47 +2088,58 @@ class TarBackupMethod(BackupMethod): else: system_part = system_part.replace("_", "/") + "/" subdir_and_files = [ - tarinfo for tarinfo in tar.getmembers() - if tarinfo.name.startswith(leading_dot+system_part) + tarinfo + for tarinfo in tar.getmembers() + if tarinfo.name.startswith(leading_dot + system_part) ] tar.extractall(members=subdir_and_files, path=self.work_dir) subdir_and_files = [ - tarinfo for tarinfo in tar.getmembers() - if tarinfo.name.startswith(leading_dot+"hooks/restore/") + tarinfo + for tarinfo in tar.getmembers() + if tarinfo.name.startswith(leading_dot + "hooks/restore/") ] tar.extractall(members=subdir_and_files, path=self.work_dir) # Extract apps backup for app in apps_targets: subdir_and_files = [ - tarinfo for tarinfo in tar.getmembers() - if tarinfo.name.startswith(leading_dot+"apps/" + app) + tarinfo + for tarinfo in tar.getmembers() + if tarinfo.name.startswith(leading_dot + "apps/" + app) ] tar.extractall(members=subdir_and_files, path=self.work_dir) + tar.close() + + def copy(self, file, target): + tar = tarfile.open( + self._archive_file, "r:gz" if self._archive_file.endswith(".gz") else "r" + ) + file_to_extract = tar.getmember(file) + # Remove the path + file_to_extract.name = os.path.basename(file_to_extract.name) + tar.extract(file_to_extract, path=target) + tar.close() + def list(self): - result = [] + # Get local archives sorted according to last modification time + # (we do a realpath() to resolve symlinks) + archives = glob("%s/*.tar.gz" % self.repo.path) + glob("%s/*.tar" % self.repo.path) + archives = set([os.path.realpath(archive) for archive in archives]) + archives = sorted(archives, key=lambda x: os.path.getctime(x)) + # Extract only filename without the extension - try: - # Retrieve local archives - archives = os.listdir(self.repo.path) - except OSError: - logger.debug("unable to iterate over local archives", exc_info=1) - else: - # Iterate over local archives - for f in archives: - try: - name = f[:f.rindex('.tar.gz')] - except ValueError: - continue - result.append(name) - result.sort(key=lambda x: os.path.getctime(self.archive_path)) + def remove_extension(f): + if f.endswith(".tar.gz"): + return os.path.basename(f)[: -len(".tar.gz")] + else: + return os.path.basename(f)[: -len(".tar")] - return result + return [remove_extension(f) for f in archives] def _archive_exists(self): return os.path.lexists(self.archive_path) - + def _assert_archive_exists(self): if not self._archive_exists(): raise YunohostError('backup_archive_name_unknown', name=self.name) @@ -2091,7 +2186,7 @@ class BorgBackupMethod(BackupMethod): if not self.repo.domain: filesystem.mkdir(self.repo.path, parent=True) - + cmd = ['borg', 'init', self.repo.location] if self.repo.quota: @@ -2101,8 +2196,8 @@ class BorgBackupMethod(BackupMethod): @property def method_name(self): return 'borg' - - + + def need_mount(self): return True @@ -2115,7 +2210,7 @@ class BorgBackupMethod(BackupMethod): def mount(self, restore_manager): """ Extract and mount needed files with borg """ super(BorgBackupMethod, self).mount(restore_manager) - + # Export as tar needed files through a pipe cmd = ['borg', 'export-tar', self.archive_path, '-'] borg = self._run_borg_command(cmd, stdout=subprocess.PIPE) @@ -2138,7 +2233,7 @@ class BorgBackupMethod(BackupMethod): out = self._call('list', cmd) result = out.strip().splitlines() return result - + def _assert_archive_exists(self): """ Trigger an error if archive is missing @@ -2165,26 +2260,26 @@ class BorgBackupMethod(BackupMethod): if self.repo.domain: # TODO Use the best/good key private_key = "/root/.ssh/ssh_host_ed25519_key" - + # Don't check ssh fingerprint strictly the first time # TODO improve this by publishing and checking this with DNS strict = 'yes' if self.repo.domain in open('/root/.ssh/known_hosts').read() else 'no' env['BORG_RSH'] = "ssh -i %s -oStrictHostKeyChecking=%s" env['BORG_RSH'] = env['BORG_RSH'] % (private_key, strict) - + # In case, borg need a passphrase to get access to the repo if self.repo.passphrase: cmd += ['-e', 'repokey'] env['BORG_PASSPHRASE'] = self.repo.passphrase return subprocess.Popen(cmd, env=env, stdout=stdout) - + def _call(self, action, cmd): borg = self._run_borg_command(cmd) return_code = borg.wait() if return_code: raise YunohostError('backup_borg_' + action + '_error') - + out, _ = borg.communicate() return out @@ -2199,57 +2294,38 @@ class CustomBackupMethod(BackupMethod): /etc/yunohost/hooks.d/backup_method/ """ - def __init__(self, repo=None, method=None, **kwargs): - super(CustomBackupMethod, self).__init__(repo) + method_name = "custom" + + def __init__(self, manager, repo=None, method=None, **kwargs): + super(CustomBackupMethod, self).__init__(manager, repo) self.args = kwargs self.method = method self._need_mount = None - @property - def method_name(self): - return 'custom' - def need_mount(self): - """Call the backup_method hook to know if we need to organize files - - Exceptions: - backup_custom_need_mount_error -- Raised if the hook failed - """ + """Call the backup_method hook to know if we need to organize files""" if self._need_mount is not None: return self._need_mount - ret = hook_callback('backup_method', [self.method], - args=['need_mount']) - - ret_succeed = [hook for hook, infos in ret.items() - if any(result["state"] == "succeed" for result in infos.values())] - self._need_mount = True if ret_succeed else False - return self._need_mount + try: + self._call('nedd_mount') + except YunohostError: + return False + return True def backup(self): """ Launch a custom script to backup - - Exceptions: - backup_custom_backup_error -- Raised if the custom script failed """ self._call('backup', self.work_dir, self.name, self.repo.location, self.manager.size, self.manager.description) - ret_failed = [hook for hook, infos in ret.items() - if any(result["state"] == "failed" for result in infos.values())] - if ret_failed: - raise YunohostError('backup_custom_backup_error') - - def mount(self, restore_manager): + def mount(self): """ Launch a custom script to mount the custom archive - - Exceptions: - backup_custom_mount_error -- Raised if the custom script failed """ - super(CustomBackupMethod, self).mount(restore_manager) + super().mount() self._call('mount', self.work_dir, self.name, self.repo.location, self.manager.size, self.manager.description) @@ -2262,7 +2338,7 @@ class CustomBackupMethod(BackupMethod): out = self._call('list', self.repo.location) result = out.strip().splitlines() return result - + def _assert_archive_exists(self): """ Trigger an error if archive is missing @@ -2278,30 +2354,52 @@ class CustomBackupMethod(BackupMethod): backup_custom_info_error -- Raised if the custom script failed """ return self._call('info', self.name, self.repo.location) - + def _call(self, *args): """ Call a submethod of backup method hook Exceptions: backup_custom_ACTION_error -- Raised if the custom script failed """ - ret = hook_callback('backup_method', [self.method], + ret = hook_callback("backup_method", [self.method], args=args) - ret_failed = [hook for hook, infos in ret.items() - if any(result["state"] == "failed" for result in infos.values())] - if ret['failed']: - raise YunohostError('backup_custom_' + args[0] + '_error') + ret_failed = [ + hook + for hook, infos in ret.items() + if any(result["state"] == "failed" for result in infos.values()) + ] + if ret_failed: + raise YunohostError("backup_custom_" + args[0] + "_error") - return ret['succeed'][self.method]['stdreturn'] + return ret["succeed"][self.method]["stdreturn"] + + def _get_args(self, action): + """Return the arguments to give to the custom script""" + return [ + action, + self.work_dir, + self.name, + self.repo, + self.manager.size, + self.manager.description, + ] # # "Front-end" # # -def backup_create(name=None, description=None, repos=[], - system=[], apps=[]): +@is_unit_operation() +def backup_create( + operation_logger, + name=None, + description=None, + repos=[], + system=[], + apps=[], + dry_run=False, +): """ Create a backup local archive @@ -2310,7 +2408,6 @@ def backup_create(name=None, description=None, repos=[], description -- Short description of the backup method -- Method of backup to use output_directory -- Output directory for the backup - no_compress -- Do not create an archive file system -- List of system elements to backup apps -- List of application names to backup """ @@ -2322,8 +2419,12 @@ def backup_create(name=None, description=None, repos=[], # # Validate there is no archive with the same name - if name and name in backup_list()['archives']: - raise YunohostError('backup_archive_name_exists') + if name and name in backup_list()["archives"]: + raise YunohostValidationError("backup_archive_name_exists") + + # By default we backup using the tar method + if not methods: + methods = ["tar"] # If no --system or --apps given, backup everything if system is None and apps is None: @@ -2334,6 +2435,8 @@ def backup_create(name=None, description=None, repos=[], # Intialize # # + operation_logger.start() + # Create yunohost archives directory if it does not exists _create_archive_dir() @@ -2342,16 +2445,21 @@ def backup_create(name=None, description=None, repos=[], # Add backup methods if repos == []: - repos = ['/home/yunohost.backup/archives'] + repos = ["/home/yunohost.backup/archives"] for repo in repos: repo = BackupRepository.get(repo) backup_manager.add(repo.method) # Add backup targets (system and apps) + backup_manager.set_system_targets(system) backup_manager.set_apps_targets(apps) + for app in backup_manager.targets.list("apps", exclude=["Skipped"]): + operation_logger.related_to.append(("app", app)) + operation_logger.flush() + # # Collect files and put them in the archive # # @@ -2359,16 +2467,29 @@ def backup_create(name=None, description=None, repos=[], # Collect files to be backup (by calling app backup script / system hooks) backup_manager.collect_files() + if dry_run: + return { + "size": backup_manager.size, + "size_details": backup_manager.size_details, + } + # Apply backup methods on prepared files logger.info(m18n.n("backup_actually_backuping")) + logger.info( + m18n.n( + "backup_create_size_estimation", + size=binary_to_human(backup_manager.size) + "B", + ) + ) backup_manager.backup() - logger.success(m18n.n('backup_created')) + logger.success(m18n.n("backup_created")) + operation_logger.success() return { - 'name': backup_manager.name, - 'size': backup_manager.size, - 'results': backup_manager.targets.results + "name": backup_manager.name, + "size": backup_manager.size, + "results": backup_manager.targets.results, } @@ -2392,32 +2513,15 @@ def backup_restore(name, system=[], apps=[], force=False): system = [] apps = [] - # TODO don't ask this question when restoring apps only and certain system - # parts - - # Check if YunoHost is installed - if system is not None and os.path.isfile('/etc/yunohost/installed'): - logger.warning(m18n.n('yunohost_already_installed')) - if not force: - try: - # Ask confirmation for restoring - i = msignals.prompt(m18n.n('restore_confirm_yunohost_installed', - answers='y/N')) - except NotImplemented: - pass - else: - if i == 'y' or i == 'Y': - force = True - if not force: - raise YunohostError('restore_failed') - - # TODO Partial app restore could not work if ldap is not restored before - # TODO repair mysql if broken and it's a complete restore - # # Initialize # # + if name.endswith(".tar.gz"): + name = name[: -len(".tar.gz")] + elif name.endswith(".tar"): + name = name[: -len(".tar")] + restore_manager = RestoreManager(name) restore_manager.set_system_targets(system) @@ -2425,6 +2529,28 @@ def backup_restore(name, system=[], apps=[], force=False): restore_manager.assert_enough_free_space() + # + # Add validation if restoring system parts on an already-installed system + # + + if restore_manager.targets.targets["system"] != [] and os.path.isfile( + "/etc/yunohost/installed" + ): + logger.warning(m18n.n("yunohost_already_installed")) + if not force: + try: + # Ask confirmation for restoring + i = Moulinette.prompt( + m18n.n("restore_confirm_yunohost_installed", answers="y/N") + ) + except NotImplemented: + pass + else: + if i == "y" or i == "Y": + force = True + if not force: + raise YunohostError("restore_failed") + # # Mount the archive then call the restore for each system part / app # # @@ -2435,9 +2561,9 @@ def backup_restore(name, system=[], apps=[], force=False): # Check if something has been restored if restore_manager.success: - logger.success(m18n.n('restore_complete')) + logger.success(m18n.n("restore_complete")) else: - raise YunohostError('restore_nothings_done') + raise YunohostError("restore_nothings_done") return restore_manager.targets.results @@ -2453,7 +2579,7 @@ def backup_list(repos=[], with_info=False, human_readable=False): """ result = OrderedDict() - + if repos == []: repos = BackupRepository.all() else: @@ -2463,7 +2589,7 @@ def backup_list(repos=[], with_info=False, human_readable=False): for repo in repos: result[repo.name] = repo.list(with_info) - + # Add details if result[repo.name] and with_info: d = OrderedDict() @@ -2471,12 +2597,52 @@ def backup_list(repos=[], with_info=False, human_readable=False): try: d[a] = backup_info(a, repo=repo.location, human_readable=human_readable) except YunohostError as e: - logger.warning('%s: %s' % (a, e.strerror)) + logger.warning(str(e)) + except Exception: + import traceback + + logger.warning( + "Could not check infos for archive %s: %s" + % (archive, "\n" + traceback.format_exc()) + ) result[repo.name] = d - + return result +def backup_download(name): + # TODO Integrate in backup methods + if Moulinette.interface.type != "api": + logger.error( + "This option is only meant for the API/webadmin and doesn't make sense for the command line." + ) + return + + archive_file = "%s/%s.tar" % (ARCHIVES_PATH, name) + + # Check file exist (even if it's a broken symlink) + if not os.path.lexists(archive_file): + archive_file += ".gz" + if not os.path.lexists(archive_file): + raise YunohostValidationError("backup_archive_name_unknown", name=name) + + # If symlink, retrieve the real path + if os.path.islink(archive_file): + archive_file = os.path.realpath(archive_file) + + # Raise exception if link is broken (e.g. on unmounted external storage) + if not os.path.exists(archive_file): + raise YunohostValidationError( + "backup_archive_broken_link", path=archive_file + ) + + # We return a raw bottle HTTPresponse (instead of serializable data like + # list/dict, ...), which is gonna be picked and used directly by moulinette + from bottle import static_file + + archive_folder, archive_file_name = archive_file.rsplit("/", 1) + return static_file(archive_file_name, archive_folder, download=archive_file_name) + def backup_info(name, repo=None, with_details=False, human_readable=False): """ @@ -2494,15 +2660,15 @@ def backup_info(name, repo=None, with_details=False, human_readable=False): repo = BackupRepository.get(repo) info = repo.method.info(name) - + # Historically backup size was not here, in that case we know it's a tar archive size = info.get('size', 0) if not size: - tar = tarfile.open(repo.archive_path, "r:gz") + tar = tarfile.open(repo.archive_path, "r:gz" if archive_file.endswith(".gz") else "r") size = reduce(lambda x, y: getattr(x, 'size', x) + getattr(y, 'size', y), tar.getmembers()) tar.close() - + result = { 'path': repo.archive_path, 'created_at': datetime.utcfromtimestamp(info['created_at']), @@ -2521,10 +2687,19 @@ def backup_info(name, repo=None, with_details=False, human_readable=False): if "size_details" in info.keys(): for category in ["apps", "system"]: for name, key_info in info[category].items(): + + if category == "system": + # Stupid legacy fix for weird format between 3.5 and 3.6 + if isinstance(key_info, dict): + key_info = key_info.keys() + info[category][name] = key_info = {"paths": key_info} + else: + info[category][name] = key_info + if name in info["size_details"][category].keys(): key_info["size"] = info["size_details"][category][name] if human_readable: - key_info["size"] = binary_to_human(key_info["size"]) + 'B' + key_info["size"] = binary_to_human(key_info["size"]) + "B" else: key_info["size"] = -1 if human_readable: @@ -2532,6 +2707,7 @@ def backup_info(name, repo=None, with_details=False, human_readable=False): result["apps"] = info["apps"] result["system"] = info[system_key] + result["from_yunohost_version"] = info.get("from_yunohost_version") return result @@ -2545,12 +2721,13 @@ def backup_delete(name): """ if name not in backup_list()["archives"]: - raise YunohostError('backup_archive_name_unknown', - name=name) + raise YunohostValidationError("backup_archive_name_unknown", name=name) - hook_callback('pre_backup_delete', args=[name]) + hook_callback("pre_backup_delete", args=[name]) - archive_file = '%s/%s.tar.gz' % (ARCHIVES_PATH, name) + archive_file = "%s/%s.tar" % (ARCHIVES_PATH, name) + if os.path.exists(archive_file + ".gz"): + archive_file += ".gz" info_file = "%s/%s.info.json" % (ARCHIVES_PATH, name) files_to_delete = [archive_file, info_file] @@ -2561,15 +2738,18 @@ def backup_delete(name): files_to_delete.append(actual_archive) for backup_file in files_to_delete: + if not os.path.exists(backup_file): + continue try: os.remove(backup_file) - except: + except Exception: logger.debug("unable to delete '%s'", backup_file, exc_info=1) - logger.warning(m18n.n('backup_delete_error', path=backup_file)) + logger.warning(m18n.n("backup_delete_error", path=backup_file)) - hook_callback('post_backup_delete', args=[name]) + hook_callback("post_backup_delete", args=[name]) + + logger.success(m18n.n("backup_deleted")) - logger.success(m18n.n('backup_deleted')) # @@ -2610,10 +2790,10 @@ def backup_repository_remove(name, purge): def _create_archive_dir(): - """ Create the YunoHost archives directory if doesn't exist """ + """Create the YunoHost archives directory if doesn't exist""" if not os.path.isdir(ARCHIVES_PATH): if os.path.lexists(ARCHIVES_PATH): - raise YunohostError('backup_output_symlink_dir_broken', path=ARCHIVES_PATH) + raise YunohostError("backup_output_symlink_dir_broken", path=ARCHIVES_PATH) # Create the archive folder, with 'admin' as owner, such that # people can scp archives out of the server @@ -2621,13 +2801,13 @@ def _create_archive_dir(): def _call_for_each_path(self, callback, csv_path=None): - """ Call a callback for each path in csv """ + """Call a callback for each path in csv""" if csv_path is None: csv_path = self.csv_path with open(csv_path, "r") as backup_file: - backup_csv = csv.DictReader(backup_file, fieldnames=['source', 'dest']) + backup_csv = csv.DictReader(backup_file, fieldnames=["source", "dest"]) for row in backup_csv: - callback(self, row['source'], row['dest']) + callback(self, row["source"], row["dest"]) def _recursive_umount(directory): @@ -2637,31 +2817,48 @@ def _recursive_umount(directory): Args: directory -- a directory path """ - mount_lines = subprocess.check_output("mount").split("\n") + mount_lines = check_output("mount").split("\n") - points_to_umount = [line.split(" ")[2] - for line in mount_lines - if len(line) >= 3 and line.split(" ")[2].startswith(directory)] + points_to_umount = [ + line.split(" ")[2] + for line in mount_lines + if len(line) >= 3 and line.split(" ")[2].startswith(os.path.realpath(directory)) + ] everything_went_fine = True for point in reversed(points_to_umount): ret = subprocess.call(["umount", point]) if ret != 0: everything_went_fine = False - logger.warning(m18n.n('backup_cleaning_failed', point)) + logger.warning(m18n.n("backup_cleaning_failed", point)) continue return everything_went_fine -def free_space_in_directory(dirpath): - stat = os.statvfs(dirpath) - return stat.f_frsize * stat.f_bavail - - def disk_usage(path): # We don't do this in python with os.stat because we don't want # to follow symlinks - du_output = subprocess.check_output(['du', '-sb', path]) - return int(du_output.split()[0].decode('utf-8')) + du_output = check_output(["du", "-sb", path], shell=False) + return int(du_output.split()[0]) + + +def binary_to_human(n, customary=False): + """ + Convert bytes or bits into human readable format with binary prefix + Keyword argument: + n -- Number to convert + customary -- Use customary symbol instead of IEC standard + """ + symbols = ("Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi", "Yi") + if customary: + symbols = ("K", "M", "G", "T", "P", "E", "Z", "Y") + prefix = {} + for i, s in enumerate(symbols): + prefix[s] = 1 << (i + 1) * 10 + for s in reversed(symbols): + if n >= prefix[s]: + value = float(n) / prefix[s] + return "%.1f%s" % (value, s) + return "%s" % n diff --git a/src/yunohost/certificate.py b/src/yunohost/certificate.py index d141ac8e5..817f9d57a 100644 --- a/src/yunohost/certificate.py +++ b/src/yunohost/certificate.py @@ -27,27 +27,25 @@ import sys import shutil import pwd import grp -import smtplib import subprocess -import dns.resolver import glob from datetime import datetime -from yunohost.vendor.acme_tiny.acme_tiny import get_crt as sign_certificate - -from yunohost.utils.error import YunohostError +from moulinette import m18n from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import read_file +from yunohost.vendor.acme_tiny.acme_tiny import get_crt as sign_certificate +from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.network import get_public_ip -from moulinette import m18n -from yunohost.app import app_ssowatconf +from yunohost.diagnosis import Diagnoser from yunohost.service import _run_service_command from yunohost.regenconf import regen_conf from yunohost.log import OperationLogger -logger = getActionLogger('yunohost.certmanager') +logger = getActionLogger("yunohost.certmanager") CERT_FOLDER = "/etc/yunohost/certs/" TMP_FOLDER = "/tmp/acme-challenge-private/" @@ -56,31 +54,17 @@ WEBROOT_FOLDER = "/tmp/acme-challenge-public/" SELF_CA_FILE = "/etc/ssl/certs/ca-yunohost_crt.pem" ACCOUNT_KEY_FILE = "/etc/yunohost/letsencrypt_account.pem" -SSL_DIR = '/usr/share/yunohost/yunohost-config/ssl/yunoCA' +SSL_DIR = "/usr/share/yunohost/yunohost-config/ssl/yunoCA" KEY_SIZE = 3072 VALIDITY_LIMIT = 15 # days # For tests -STAGING_CERTIFICATION_AUTHORITY = "https://acme-staging.api.letsencrypt.org" +STAGING_CERTIFICATION_AUTHORITY = "https://acme-staging-v02.api.letsencrypt.org" # For prod PRODUCTION_CERTIFICATION_AUTHORITY = "https://acme-v02.api.letsencrypt.org" -INTERMEDIATE_CERTIFICATE_URL = "https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem" - -DNS_RESOLVERS = [ - # FFDN DNS resolvers - # See https://www.ffdn.org/wiki/doku.php?id=formations:dns - "80.67.169.12", # FDN - "80.67.169.40", # - "89.234.141.66", # ARN - "141.255.128.100", # Aquilenet - "141.255.128.101", - "89.234.186.18", # Grifon - "80.67.188.188" # LDN -] - # # Front-end stuff # # @@ -99,14 +83,11 @@ def certificate_status(domain_list, full=False): # If no domains given, consider all yunohost domains if domain_list == []: - domain_list = yunohost.domain.domain_list()['domains'] + domain_list = yunohost.domain.domain_list()["domains"] # Else, validate that yunohost knows the domains given else: - yunohost_domains_list = yunohost.domain.domain_list()['domains'] for domain in domain_list: - # Is it in Yunohost domain list? - if domain not in yunohost_domains_list: - raise YunohostError('certmanager_domain_unknown', domain=domain) + yunohost.domain._assert_domain_exists(domain) certificates = {} @@ -116,17 +97,25 @@ def certificate_status(domain_list, full=False): if not full: del status["subject"] del status["CA_name"] - del status["ACME_eligible"] status["CA_type"] = status["CA_type"]["verbose"] status["summary"] = status["summary"]["verbose"] + if full: + try: + _check_domain_is_ready_for_ACME(domain) + status["ACME_eligible"] = True + except Exception: + status["ACME_eligible"] = False + del status["domain"] certificates[domain] = status return {"certificates": certificates} -def certificate_install(domain_list, force=False, no_checks=False, self_signed=False, staging=False): +def certificate_install( + domain_list, force=False, no_checks=False, self_signed=False, staging=False +): """ Install a Let's Encrypt certificate for given domains (all by default) @@ -141,21 +130,24 @@ def certificate_install(domain_list, force=False, no_checks=False, self_signed=F if self_signed: _certificate_install_selfsigned(domain_list, force) else: - _certificate_install_letsencrypt( - domain_list, force, no_checks, staging) + _certificate_install_letsencrypt(domain_list, force, no_checks, staging) def _certificate_install_selfsigned(domain_list, force=False): for domain in domain_list: - operation_logger = OperationLogger('selfsigned_cert_install', [('domain', domain)], - args={'force': force}) + operation_logger = OperationLogger( + "selfsigned_cert_install", [("domain", domain)], args={"force": force} + ) # Paths of files and folder we'll need date_tag = datetime.utcnow().strftime("%Y%m%d.%H%M%S") new_cert_folder = "%s/%s-history/%s-selfsigned" % ( - CERT_FOLDER, domain, date_tag) + CERT_FOLDER, + domain, + date_tag, + ) conf_template = os.path.join(SSL_DIR, "openssl.cnf") @@ -170,8 +162,10 @@ def _certificate_install_selfsigned(domain_list, force=False): if not force and os.path.isfile(current_cert_file): status = _get_status(domain) - if status["summary"]["code"] in ('good', 'great'): - raise YunohostError('certmanager_attempt_to_replace_valid_cert', domain=domain) + if status["summary"]["code"] in ("good", "great"): + raise YunohostValidationError( + "certmanager_attempt_to_replace_valid_cert", domain=domain + ) operation_logger.start() @@ -195,13 +189,16 @@ def _certificate_install_selfsigned(domain_list, force=False): for command in commands: p = subprocess.Popen( - command.split(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + command.split(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) out, _ = p.communicate() + out = out.decode("utf-8") + if p.returncode != 0: logger.warning(out) - raise YunohostError('domain_cert_gen_failed') + raise YunohostError("domain_cert_gen_failed") else: logger.debug(out) @@ -227,17 +224,27 @@ def _certificate_install_selfsigned(domain_list, force=False): # Check new status indicate a recently created self-signed certificate status = _get_status(domain) - if status and status["CA_type"]["code"] == "self-signed" and status["validity"] > 3648: + if ( + status + and status["CA_type"]["code"] == "self-signed" + and status["validity"] > 3648 + ): logger.success( - m18n.n("certmanager_cert_install_success_selfsigned", domain=domain)) + m18n.n("certmanager_cert_install_success_selfsigned", domain=domain) + ) operation_logger.success() else: - msg = "Installation of self-signed certificate installation for %s failed !" % (domain) + msg = ( + "Installation of self-signed certificate installation for %s failed !" + % (domain) + ) logger.error(msg) operation_logger.error(msg) -def _certificate_install_letsencrypt(domain_list, force=False, no_checks=False, staging=False): +def _certificate_install_letsencrypt( + domain_list, force=False, no_checks=False, staging=False +): import yunohost.domain if not os.path.exists(ACCOUNT_KEY_FILE): @@ -246,7 +253,7 @@ def _certificate_install_letsencrypt(domain_list, force=False, no_checks=False, # If no domains given, consider all yunohost domains with self-signed # certificates if domain_list == []: - for domain in yunohost.domain.domain_list()['domains']: + for domain in yunohost.domain.domain_list()["domains"]: status = _get_status(domain) if status["CA_type"]["code"] != "self-signed": @@ -257,50 +264,62 @@ def _certificate_install_letsencrypt(domain_list, force=False, no_checks=False, # Else, validate that yunohost knows the domains given else: for domain in domain_list: - yunohost_domains_list = yunohost.domain.domain_list()['domains'] - if domain not in yunohost_domains_list: - raise YunohostError('certmanager_domain_unknown', domain=domain) + yunohost.domain._assert_domain_exists(domain) # Is it self-signed? status = _get_status(domain) if not force and status["CA_type"]["code"] != "self-signed": - raise YunohostError('certmanager_domain_cert_not_selfsigned', domain=domain) + raise YunohostValidationError( + "certmanager_domain_cert_not_selfsigned", domain=domain + ) if staging: logger.warning( - "Please note that you used the --staging option, and that no new certificate will actually be enabled !") + "Please note that you used the --staging option, and that no new certificate will actually be enabled !" + ) # Actual install steps for domain in domain_list: - operation_logger = OperationLogger('letsencrypt_cert_install', [('domain', domain)], - args={'force': force, 'no_checks': no_checks, - 'staging': staging}) - logger.info( - "Now attempting install of certificate for domain %s!", domain) + if not no_checks: + try: + _check_domain_is_ready_for_ACME(domain) + except Exception as e: + logger.error(e) + continue + + logger.info("Now attempting install of certificate for domain %s!", domain) + + operation_logger = OperationLogger( + "letsencrypt_cert_install", + [("domain", domain)], + args={"force": force, "no_checks": no_checks, "staging": staging}, + ) + operation_logger.start() try: - if not no_checks: - _check_domain_is_ready_for_ACME(domain) - - operation_logger.start() - - _configure_for_acme_challenge(domain) _fetch_and_enable_new_certificate(domain, staging, no_checks=no_checks) - _install_cron(no_checks=no_checks) - - logger.success( - m18n.n("certmanager_cert_install_success", domain=domain)) - - operation_logger.success() except Exception as e: - _display_debug_information(domain) - msg = "Certificate installation for %s failed !\nException: %s" % (domain, e) + msg = "Certificate installation for %s failed !\nException: %s" % ( + domain, + e, + ) logger.error(msg) operation_logger.error(msg) + if no_checks: + logger.error( + "Please consider checking the 'DNS records' (basic) and 'Web' categories of the diagnosis to check for possible issues that may prevent installing a Let's Encrypt certificate on domain %s." + % domain + ) + else: + logger.success(m18n.n("certmanager_cert_install_success", domain=domain)) + + operation_logger.success() -def certificate_renew(domain_list, force=False, no_checks=False, email=False, staging=False): +def certificate_renew( + domain_list, force=False, no_checks=False, email=False, staging=False +): """ Renew Let's Encrypt certificate for given domains (all by default) @@ -317,7 +336,7 @@ def certificate_renew(domain_list, force=False, no_checks=False, email=False, st # If no domains given, consider all yunohost domains with Let's Encrypt # certificates if domain_list == []: - for domain in yunohost.domain.domain_list()['domains']: + for domain in yunohost.domain.domain_list()["domains"]: # Does it have a Let's Encrypt cert? status = _get_status(domain) @@ -330,8 +349,9 @@ def certificate_renew(domain_list, force=False, no_checks=False, email=False, st # Check ACME challenge configured for given domain if not _check_acme_challenge_configuration(domain): - logger.warning(m18n.n( - 'certmanager_acme_not_configured_for_domain', domain=domain)) + logger.warning( + m18n.n("certmanager_acme_not_configured_for_domain", domain=domain) + ) continue domain_list.append(domain) @@ -343,57 +363,75 @@ def certificate_renew(domain_list, force=False, no_checks=False, email=False, st else: for domain in domain_list: - # Is it in Yunohost dmomain list? - if domain not in yunohost.domain.domain_list()['domains']: - raise YunohostError('certmanager_domain_unknown', domain=domain) + # Is it in Yunohost domain list? + yunohost.domain._assert_domain_exists(domain) status = _get_status(domain) # Does it expire soon? if status["validity"] > VALIDITY_LIMIT and not force: - raise YunohostError('certmanager_attempt_to_renew_valid_cert', domain=domain) + raise YunohostValidationError( + "certmanager_attempt_to_renew_valid_cert", domain=domain + ) # Does it have a Let's Encrypt cert? if status["CA_type"]["code"] != "lets-encrypt": - raise YunohostError('certmanager_attempt_to_renew_nonLE_cert', domain=domain) + raise YunohostValidationError( + "certmanager_attempt_to_renew_nonLE_cert", domain=domain + ) # Check ACME challenge configured for given domain if not _check_acme_challenge_configuration(domain): - raise YunohostError('certmanager_acme_not_configured_for_domain', domain=domain) + raise YunohostValidationError( + "certmanager_acme_not_configured_for_domain", domain=domain + ) if staging: logger.warning( - "Please note that you used the --staging option, and that no new certificate will actually be enabled !") + "Please note that you used the --staging option, and that no new certificate will actually be enabled !" + ) # Actual renew steps for domain in domain_list: - operation_logger = OperationLogger('letsencrypt_cert_renew', [('domain', domain)], - args={'force': force, 'no_checks': no_checks, - 'staging': staging, 'email': email}) + if not no_checks: + try: + _check_domain_is_ready_for_ACME(domain) + except Exception as e: + logger.error(e) + if email: + logger.error("Sending email with details to root ...") + _email_renewing_failed(domain, e) + continue - logger.info( - "Now attempting renewing of certificate for domain %s !", domain) + logger.info("Now attempting renewing of certificate for domain %s !", domain) + + operation_logger = OperationLogger( + "letsencrypt_cert_renew", + [("domain", domain)], + args={ + "force": force, + "no_checks": no_checks, + "staging": staging, + "email": email, + }, + ) + operation_logger.start() try: - if not no_checks: - _check_domain_is_ready_for_ACME(domain) - - operation_logger.start() - _fetch_and_enable_new_certificate(domain, staging, no_checks=no_checks) - - logger.success( - m18n.n("certmanager_cert_renew_success", domain=domain)) - - operation_logger.success() - except Exception as e: import traceback - from StringIO import StringIO + from io import StringIO + stack = StringIO() traceback.print_exc(file=stack) - msg = "Certificate renewing for %s failed !" % (domain) + msg = "Certificate renewing for %s failed!" % (domain) + if no_checks: + msg += ( + "\nPlease consider checking the 'DNS records' (basic) and 'Web' categories of the diagnosis to check for possible issues that may prevent installing a Let's Encrypt certificate on domain %s." + % domain + ) logger.error(msg) operation_logger.error(msg) logger.error(stack.getvalue()) @@ -401,39 +439,18 @@ def certificate_renew(domain_list, force=False, no_checks=False, email=False, st if email: logger.error("Sending email with details to root ...") - _email_renewing_failed(domain, e, stack.getvalue()) + _email_renewing_failed(domain, msg + "\n" + str(e), stack.getvalue()) + else: + logger.success(m18n.n("certmanager_cert_renew_success", domain=domain)) + operation_logger.success() + # # Back-end stuff # # -def _install_cron(no_checks=False): - cron_job_file = "/etc/cron.daily/yunohost-certificate-renew" - - # we need to check if "--no-checks" isn't already put inside the existing - # crontab, if it's the case it's probably because another domain needed it - # at some point so we keep it - if not no_checks and os.path.exists(cron_job_file): - with open(cron_job_file, "r") as f: - # no the best test in the world but except if we uses a shell - # script parser I'm not expected a much more better way to do that - no_checks = "--no-checks" in f.read() - - command = "yunohost domain cert-renew --email\n" - - if no_checks: - # handle trailing "\n with ":-1" - command = command[:-1] + " --no-checks\n" - - with open(cron_job_file, "w") as f: - f.write("#!/bin/bash\n") - f.write(command) - - _set_permissions(cron_job_file, "root", "root", 0o755) - - -def _email_renewing_failed(domain, exception_message, stack): +def _email_renewing_failed(domain, exception_message, stack=""): from_ = "certmanager@%s (Certificate Manager)" % domain to_ = "root" subject_ = "Certificate renewing attempt for %s failed!" % domain @@ -453,7 +470,12 @@ investigate : -- Certificate Manager -""" % (domain, exception_message, stack, logs) +""" % ( + domain, + exception_message, + stack, + logs, + ) message = """\ From: %s @@ -461,71 +483,37 @@ To: %s Subject: %s %s -""" % (from_, to_, subject_, text) +""" % ( + from_, + to_, + subject_, + text, + ) + + import smtplib smtp = smtplib.SMTP("localhost") - smtp.sendmail(from_, [to_], message) + smtp.sendmail(from_, [to_], message.encode("utf-8")) smtp.quit() -def _configure_for_acme_challenge(domain): - - nginx_conf_folder = "/etc/nginx/conf.d/%s.d" % domain - nginx_conf_file = "%s/000-acmechallenge.conf" % nginx_conf_folder - - nginx_configuration = ''' -location ^~ '/.well-known/acme-challenge/' -{ - default_type "text/plain"; - alias %s; -} - ''' % WEBROOT_FOLDER - - # Check there isn't a conflicting file for the acme-challenge well-known - # uri - for path in glob.glob('%s/*.conf' % nginx_conf_folder): - - if path == nginx_conf_file: - continue - - with open(path) as f: - contents = f.read() - - if '/.well-known/acme-challenge' in contents: - raise YunohostError('certmanager_conflicting_nginx_file', filepath=path) - - # Write the conf - if os.path.exists(nginx_conf_file): - logger.debug( - "Nginx configuration file for ACME challenge already exists for domain, skipping.") - return - - logger.debug( - "Adding Nginx configuration file for Acme challenge for domain %s.", domain) - - with open(nginx_conf_file, "w") as f: - f.write(nginx_configuration) - - # Assume nginx conf is okay, and reload it - # (FIXME : maybe add a check that it is, using nginx -t, haven't found - # any clean function already implemented in yunohost to do this though) - _run_service_command("reload", "nginx") - - app_ssowatconf() - - def _check_acme_challenge_configuration(domain): - # Check nginx conf file exists - nginx_conf_folder = "/etc/nginx/conf.d/%s.d" % domain - nginx_conf_file = "%s/000-acmechallenge.conf" % nginx_conf_folder - if not os.path.exists(nginx_conf_file): - return False - else: + domain_conf = "/etc/nginx/conf.d/%s.conf" % domain + if "include /etc/nginx/conf.d/acme-challenge.conf.inc" in read_file(domain_conf): return True + else: + # This is for legacy setups which haven't updated their domain conf to + # the new conf that include the acme snippet... + legacy_acme_conf = "/etc/nginx/conf.d/%s.d/000-acmechallenge.conf" % domain + return os.path.exists(legacy_acme_conf) def _fetch_and_enable_new_certificate(domain, staging=False, no_checks=False): + + if not os.path.exists(ACCOUNT_KEY_FILE): + _generate_account_key() + # Make sure tmp folder exists logger.debug("Making sure tmp folders exists...") @@ -542,8 +530,7 @@ def _fetch_and_enable_new_certificate(domain, staging=False, no_checks=False): _regen_dnsmasq_if_needed() # Prepare certificate signing request - logger.debug( - "Prepare key and certificate signing request (CSR) for %s...", domain) + logger.debug("Prepare key and certificate signing request (CSR) for %s...", domain) domain_key_file = "%s/%s.pem" % (TMP_FOLDER, domain) _generate_key(domain_key_file) @@ -562,30 +549,25 @@ def _fetch_and_enable_new_certificate(domain, staging=False, no_checks=False): certification_authority = PRODUCTION_CERTIFICATION_AUTHORITY try: - signed_certificate = sign_certificate(ACCOUNT_KEY_FILE, - domain_csr_file, - WEBROOT_FOLDER, - log=logger, - disable_check=no_checks, - CA=certification_authority) + signed_certificate = sign_certificate( + ACCOUNT_KEY_FILE, + domain_csr_file, + WEBROOT_FOLDER, + log=logger, + disable_check=no_checks, + CA=certification_authority, + ) except ValueError as e: if "urn:acme:error:rateLimited" in str(e): - raise YunohostError('certmanager_hit_rate_limit', domain=domain) + raise YunohostError("certmanager_hit_rate_limit", domain=domain) else: logger.error(str(e)) - _display_debug_information(domain) - raise YunohostError('certmanager_cert_signing_failed') + raise YunohostError("certmanager_cert_signing_failed") except Exception as e: logger.error(str(e)) - raise YunohostError('certmanager_cert_signing_failed') - - import requests # lazy loading this module for performance reasons - try: - intermediate_certificate = requests.get(INTERMEDIATE_CERTIFICATE_URL, timeout=30).text - except requests.exceptions.Timeout as e: - raise YunohostError('certmanager_couldnt_fetch_intermediate_cert') + raise YunohostError("certmanager_cert_signing_failed") # Now save the key and signed certificate logger.debug("Saving the key and signed certificate...") @@ -599,7 +581,11 @@ def _fetch_and_enable_new_certificate(domain, staging=False, no_checks=False): folder_flag = "letsencrypt" new_cert_folder = "%s/%s-history/%s-%s" % ( - CERT_FOLDER, domain, date_tag, folder_flag) + CERT_FOLDER, + domain, + date_tag, + folder_flag, + ) os.makedirs(new_cert_folder) @@ -615,7 +601,6 @@ def _fetch_and_enable_new_certificate(domain, staging=False, no_checks=False): with open(domain_cert_file, "w") as f: f.write(signed_certificate) - f.write(intermediate_certificate) _set_permissions(domain_cert_file, "root", "ssl-cert", 0o640) @@ -628,19 +613,52 @@ def _fetch_and_enable_new_certificate(domain, staging=False, no_checks=False): status_summary = _get_status(domain)["summary"] if status_summary["code"] != "great": - raise YunohostError('certmanager_certificate_fetching_or_enabling_failed', domain=domain) + raise YunohostError( + "certmanager_certificate_fetching_or_enabling_failed", domain=domain + ) def _prepare_certificate_signing_request(domain, key_file, output_folder): from OpenSSL import crypto # lazy loading this module for performance reasons + # Init a request csr = crypto.X509Req() # Set the domain csr.get_subject().CN = domain + from yunohost.domain import domain_list + + # For "parent" domains, include xmpp-upload subdomain in subject alternate names + if domain in domain_list(exclude_subdomains=True)["domains"]: + subdomain = "xmpp-upload." + domain + xmpp_records = ( + Diagnoser.get_cached_report( + "dnsrecords", item={"domain": domain, "category": "xmpp"} + ).get("data") + or {} + ) + if xmpp_records.get("CNAME:xmpp-upload") == "OK": + csr.add_extensions( + [ + crypto.X509Extension( + "subjectAltName".encode("utf8"), + False, + ("DNS:" + subdomain).encode("utf8"), + ) + ] + ) + else: + logger.warning( + m18n.n( + "certmanager_warning_subdomain_dns_record", + subdomain=subdomain, + domain=domain, + ) + ) + # Set the key - with open(key_file, 'rt') as f: + with open(key_file, "rt") as f: key = crypto.load_privatekey(crypto.FILETYPE_PEM, f.read()) csr.set_pubkey(key) @@ -652,7 +670,7 @@ def _prepare_certificate_signing_request(domain, key_file, output_folder): csr_file = output_folder + domain + ".csr" logger.debug("Saving to %s.", csr_file) - with open(csr_file, "w") as f: + with open(csr_file, "wb") as f: f.write(crypto.dump_certificate_request(crypto.FILETYPE_PEM, csr)) @@ -661,29 +679,38 @@ def _get_status(domain): cert_file = os.path.join(CERT_FOLDER, domain, "crt.pem") if not os.path.isfile(cert_file): - raise YunohostError('certmanager_no_cert_file', domain=domain, file=cert_file) + raise YunohostError("certmanager_no_cert_file", domain=domain, file=cert_file) from OpenSSL import crypto # lazy loading this module for performance reasons + try: - cert = crypto.load_certificate( - crypto.FILETYPE_PEM, open(cert_file).read()) + cert = crypto.load_certificate(crypto.FILETYPE_PEM, open(cert_file).read()) except Exception as exception: import traceback + traceback.print_exc(file=sys.stdout) - raise YunohostError('certmanager_cannot_read_cert', domain=domain, file=cert_file, reason=exception) + raise YunohostError( + "certmanager_cannot_read_cert", + domain=domain, + file=cert_file, + reason=exception, + ) cert_subject = cert.get_subject().CN cert_issuer = cert.get_issuer().CN - valid_up_to = datetime.strptime(cert.get_notAfter(), "%Y%m%d%H%M%SZ") + organization_name = cert.get_issuer().O + valid_up_to = datetime.strptime( + cert.get_notAfter().decode("utf-8"), "%Y%m%d%H%M%SZ" + ) days_remaining = (valid_up_to - datetime.utcnow()).days - if cert_issuer == _name_self_CA(): + if cert_issuer == "yunohost.org" or cert_issuer == _name_self_CA(): CA_type = { "code": "self-signed", "verbose": "Self-signed", } - elif cert_issuer.startswith("Let's Encrypt"): + elif organization_name == "Let's Encrypt": CA_type = { "code": "lets-encrypt", "verbose": "Let's Encrypt", @@ -737,12 +764,6 @@ def _get_status(domain): "verbose": "Unknown?", } - try: - _check_domain_is_ready_for_ACME(domain) - ACME_eligible = True - except: - ACME_eligible = False - return { "domain": domain, "subject": cert_subject, @@ -750,9 +771,9 @@ def _get_status(domain): "CA_type": CA_type, "validity": days_remaining, "summary": status_summary, - "ACME_eligible": ACME_eligible } + # # Misc small stuff ... # # @@ -766,10 +787,11 @@ def _generate_account_key(): def _generate_key(destination_path): from OpenSSL import crypto # lazy loading this module for performance reasons + k = crypto.PKey() k.generate_key(crypto.TYPE_RSA, KEY_SIZE) - with open(destination_path, "w") as f: + with open(destination_path, "wb") as f: f.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, k)) @@ -804,15 +826,16 @@ def _enable_certificate(domain, new_cert_folder): for service in ("postfix", "dovecot", "metronome"): _run_service_command("restart", service) - if os.path.isfile('/etc/yunohost/installed'): + if os.path.isfile("/etc/yunohost/installed"): # regen nginx conf to be sure it integrates OCSP Stapling # (We don't do this yet if postinstall is not finished yet) - regen_conf(names=['nginx']) + regen_conf(names=["nginx"]) _run_service_command("reload", "nginx") from yunohost.hook import hook_callback - hook_callback('post_cert_update', args=[domain]) + + hook_callback("post_cert_update", args=[domain]) def _backup_current_cert(domain): @@ -827,68 +850,41 @@ def _backup_current_cert(domain): def _check_domain_is_ready_for_ACME(domain): - public_ip = get_public_ip() + + dnsrecords = ( + Diagnoser.get_cached_report( + "dnsrecords", + item={"domain": domain, "category": "basic"}, + warn_if_no_cache=False, + ) + or {} + ) + httpreachable = ( + Diagnoser.get_cached_report( + "web", item={"domain": domain}, warn_if_no_cache=False + ) + or {} + ) + + if not dnsrecords or not httpreachable: + raise YunohostValidationError( + "certmanager_domain_not_diagnosed_yet", domain=domain + ) # Check if IP from DNS matches public IP - if not _dns_ip_match_public_ip(public_ip, domain): - raise YunohostError('certmanager_domain_dns_ip_differs_from_public_ip', domain=domain) + if not dnsrecords.get("status") in [ + "SUCCESS", + "WARNING", + ]: # Warning is for missing IPv6 record which ain't critical for ACME + raise YunohostValidationError( + "certmanager_domain_dns_ip_differs_from_public_ip", domain=domain + ) # Check if domain seems to be accessible through HTTP? - if not _domain_is_accessible_through_HTTP(public_ip, domain): - raise YunohostError('certmanager_domain_http_not_working', domain=domain) - - -def _get_dns_ip(domain): - try: - resolver = dns.resolver.Resolver() - resolver.nameservers = DNS_RESOLVERS - answers = resolver.query(domain, "A") - except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN): - raise YunohostError('certmanager_error_no_A_record', domain=domain) - - return str(answers[0]) - - -def _dns_ip_match_public_ip(public_ip, domain): - return _get_dns_ip(domain) == public_ip - - -def _domain_is_accessible_through_HTTP(ip, domain): - import requests # lazy loading this module for performance reasons - try: - requests.head("http://" + ip, headers={"Host": domain}, timeout=10) - except requests.exceptions.Timeout as e: - logger.warning(m18n.n('certmanager_http_check_timeout', domain=domain, ip=ip)) - return False - except Exception as e: - logger.debug("Couldn't reach domain '%s' by requesting this ip '%s' because: %s" % (domain, ip, e)) - return False - - return True - - -def _get_local_dns_ip(domain): - try: - resolver = dns.resolver.Resolver() - answers = resolver.query(domain, "A") - except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN): - logger.warning("Failed to resolved domain '%s' locally", domain) - return None - - return str(answers[0]) - - -def _display_debug_information(domain): - dns_ip = _get_dns_ip(domain) - public_ip = get_public_ip() - local_dns_ip = _get_local_dns_ip(domain) - - logger.warning("""\ -Debug information: - - domain ip from DNS %s - - domain ip from local DNS %s - - public ip of the server %s -""", dns_ip, local_dns_ip, public_ip) + if not httpreachable.get("status") == "SUCCESS": + raise YunohostValidationError( + "certmanager_domain_http_not_working", domain=domain + ) # FIXME / TODO : ideally this should not be needed. There should be a proper @@ -909,11 +905,11 @@ def _regen_dnsmasq_if_needed(): for domainconf in domainsconf: # Look for the IP, it's in the lines with this format : - # address=/the.domain.tld/11.22.33.44 + # host-record=the.domain.tld,11.22.33.44 for line in open(domainconf).readlines(): - if not line.startswith("address"): + if not line.startswith("host-record"): continue - ip = line.strip().split("/")[2] + ip = line.strip().split(",")[-1] # Compared found IP to current IPv4 / IPv6 # IPv6 IPv4 @@ -932,7 +928,7 @@ def _name_self_CA(): ca_conf = os.path.join(SSL_DIR, "openssl.ca.cnf") if not os.path.exists(ca_conf): - logger.warning(m18n.n('certmanager_self_ca_conf_file_not_found', file=ca_conf)) + logger.warning(m18n.n("certmanager_self_ca_conf_file_not_found", file=ca_conf)) return "" with open(ca_conf) as f: @@ -942,16 +938,11 @@ def _name_self_CA(): if line.startswith("commonName_default"): return line.split()[2] - logger.warning(m18n.n('certmanager_unable_to_parse_self_CA_name', file=ca_conf)) + logger.warning(m18n.n("certmanager_unable_to_parse_self_CA_name", file=ca_conf)) return "" def _tail(n, file_path): - stdin, stdout = os.popen2("tail -n %s '%s'" % (n, file_path)) + from moulinette.utils.process import check_output - stdin.close() - - lines = stdout.readlines() - stdout.close() - - return "".join(lines) + return check_output(f"tail -n {n} '{file_path}'") diff --git a/src/yunohost/data_migrations/0001_change_cert_group_to_sslcert.py b/src/yunohost/data_migrations/0001_change_cert_group_to_sslcert.py deleted file mode 100644 index 6485861b7..000000000 --- a/src/yunohost/data_migrations/0001_change_cert_group_to_sslcert.py +++ /dev/null @@ -1,19 +0,0 @@ -import subprocess -import glob -from yunohost.tools import Migration -from moulinette.utils.filesystem import chown - - -class MyMigration(Migration): - - "Change certificates group permissions from 'metronome' to 'ssl-cert'" - - all_certificate_files = glob.glob("/etc/yunohost/certs/*/*.pem") - - def forward(self): - for filename in self.all_certificate_files: - chown(filename, uid="root", gid="ssl-cert") - - def backward(self): - for filename in self.all_certificate_files: - chown(filename, uid="root", gid="metronome") diff --git a/src/yunohost/data_migrations/0002_migrate_to_tsig_sha256.py b/src/yunohost/data_migrations/0002_migrate_to_tsig_sha256.py deleted file mode 100644 index 824245c82..000000000 --- a/src/yunohost/data_migrations/0002_migrate_to_tsig_sha256.py +++ /dev/null @@ -1,90 +0,0 @@ -import glob -import os -import requests -import base64 -import time -import json - -from moulinette import m18n -from yunohost.utils.error import YunohostError -from moulinette.utils.log import getActionLogger - -from yunohost.tools import Migration -from yunohost.dyndns import _guess_current_dyndns_domain - -logger = getActionLogger('yunohost.migration') - - -class MyMigration(Migration): - - "Migrate Dyndns stuff from MD5 TSIG to SHA512 TSIG" - - def backward(self): - # Not possible because that's a non-reversible operation ? - pass - - def migrate(self, dyn_host="dyndns.yunohost.org", domain=None, private_key_path=None): - - if domain is None or private_key_path is None: - try: - (domain, private_key_path) = _guess_current_dyndns_domain(dyn_host) - assert "+157" in private_key_path - except (YunohostError, AssertionError): - logger.info(m18n.n("migrate_tsig_not_needed")) - return - - logger.info(m18n.n('migrate_tsig_start', domain=domain)) - public_key_path = private_key_path.rsplit(".private", 1)[0] + ".key" - public_key_md5 = open(public_key_path).read().strip().split(' ')[-1] - - os.system('cd /etc/yunohost/dyndns && ' - 'dnssec-keygen -a hmac-sha512 -b 512 -r /dev/urandom -n USER %s' % domain) - os.system('chmod 600 /etc/yunohost/dyndns/*.key /etc/yunohost/dyndns/*.private') - - # +165 means that this file store a hmac-sha512 key - new_key_path = glob.glob('/etc/yunohost/dyndns/*+165*.key')[0] - public_key_sha512 = open(new_key_path).read().strip().split(' ', 6)[-1] - - try: - r = requests.put('https://%s/migrate_key_to_sha512/' % (dyn_host), - data={ - 'public_key_md5': base64.b64encode(public_key_md5), - 'public_key_sha512': base64.b64encode(public_key_sha512), - }, timeout=30) - except requests.ConnectionError: - raise YunohostError('no_internet_connection') - - if r.status_code != 201: - try: - error = json.loads(r.text)['error'] - except Exception: - # failed to decode json - error = r.text - - import traceback - from StringIO import StringIO - stack = StringIO() - traceback.print_stack(file=stack) - logger.error(stack.getvalue()) - - # Migration didn't succeed, so we rollback and raise an exception - os.system("mv /etc/yunohost/dyndns/*+165* /tmp") - - raise YunohostError('migrate_tsig_failed', domain=domain, - error_code=str(r.status_code), error=error) - - # remove old certificates - os.system("mv /etc/yunohost/dyndns/*+157* /tmp") - - # sleep to wait for dyndns cache invalidation - logger.info(m18n.n('migrate_tsig_wait')) - time.sleep(60) - logger.info(m18n.n('migrate_tsig_wait_2')) - time.sleep(60) - logger.info(m18n.n('migrate_tsig_wait_3')) - time.sleep(30) - logger.info(m18n.n('migrate_tsig_wait_4')) - time.sleep(30) - - logger.info(m18n.n('migrate_tsig_end')) - return diff --git a/src/yunohost/data_migrations/0003_migrate_to_stretch.py b/src/yunohost/data_migrations/0003_migrate_to_stretch.py deleted file mode 100644 index 0db719e15..000000000 --- a/src/yunohost/data_migrations/0003_migrate_to_stretch.py +++ /dev/null @@ -1,383 +0,0 @@ -import glob -import os -from shutil import copy2 - -from moulinette import m18n, msettings -from yunohost.utils.error import YunohostError -from moulinette.utils.log import getActionLogger -from moulinette.utils.process import check_output, call_async_output -from moulinette.utils.filesystem import read_file - -from yunohost.tools import Migration -from yunohost.app import unstable_apps -from yunohost.service import _run_service_command -from yunohost.regenconf import (manually_modified_files, - manually_modified_files_compared_to_debian_default) -from yunohost.utils.filesystem import free_space_in_directory -from yunohost.utils.packages import get_installed_version -from yunohost.utils.network import get_network_interfaces -from yunohost.firewall import firewall_allow, firewall_disallow - -logger = getActionLogger('yunohost.migration') - -YUNOHOST_PACKAGES = ["yunohost", "yunohost-admin", "moulinette", "ssowat"] - - -class MyMigration(Migration): - - "Upgrade the system to Debian Stretch and Yunohost 3.0" - - mode = "manual" - - def backward(self): - - raise YunohostError("migration_0003_backward_impossible") - - def migrate(self): - - self.logfile = "/var/log/yunohost/{}.log".format(self.name) - - self.check_assertions() - - logger.info(m18n.n("migration_0003_start", logfile=self.logfile)) - - # Preparing the upgrade - self.restore_original_nginx_conf_if_needed() - - logger.info(m18n.n("migration_0003_patching_sources_list")) - self.patch_apt_sources_list() - self.backup_files_to_keep() - self.apt_update() - apps_packages = self.get_apps_equivs_packages() - self.unhold(["metronome"]) - self.hold(YUNOHOST_PACKAGES + apps_packages + ["fail2ban"]) - - # Main dist-upgrade - logger.info(m18n.n("migration_0003_main_upgrade")) - _run_service_command("stop", "mysql") - self.apt_dist_upgrade(conf_flags=["old", "miss", "def"]) - _run_service_command("start", "mysql") - if self.debian_major_version() == 8: - raise YunohostError("migration_0003_still_on_jessie_after_main_upgrade", log=self.logfile) - - # Specific upgrade for fail2ban... - logger.info(m18n.n("migration_0003_fail2ban_upgrade")) - self.unhold(["fail2ban"]) - # Don't move this if folder already exists. If it does, we probably are - # running this script a 2nd, 3rd, ... time but /etc/fail2ban will - # be re-created only for the first dist-upgrade of fail2ban - if not os.path.exists("/etc/fail2ban.old"): - os.system("mv /etc/fail2ban /etc/fail2ban.old") - self.apt_dist_upgrade(conf_flags=["new", "miss", "def"]) - _run_service_command("restart", "fail2ban") - - self.disable_predicable_interface_names() - - # Clean the mess - os.system("apt autoremove --assume-yes") - os.system("apt clean --assume-yes") - - # We moved to port 587 for SMTP - # https://busylog.net/smtp-tls-ssl-25-465-587/ - firewall_allow("Both", 587) - firewall_disallow("Both", 465) - - # Upgrade yunohost packages - logger.info(m18n.n("migration_0003_yunohost_upgrade")) - self.restore_files_to_keep() - self.unhold(YUNOHOST_PACKAGES + apps_packages) - self.upgrade_yunohost_packages() - - def debian_major_version(self): - # The python module "platform" and lsb_release are not reliable because - # on some setup, they still return Release=8 even after upgrading to - # stretch ... (Apparently this is related to OVH overriding some stuff - # with /etc/lsb-release for instance -_-) - # Instead, we rely on /etc/os-release which should be the raw info from - # the distribution... - return int(check_output("grep VERSION_ID /etc/os-release | head -n 1 | tr '\"' ' ' | cut -d ' ' -f2")) - - def yunohost_major_version(self): - return int(get_installed_version("yunohost").split('.')[0]) - - def check_assertions(self): - - # Be on jessie (8.x) and yunohost 2.x - # NB : we do both check to cover situations where the upgrade crashed - # in the middle and debian version could be >= 9.x but yunohost package - # would still be in 2.x... - if not self.debian_major_version() == 8 \ - and not self.yunohost_major_version() == 2: - raise YunohostError("migration_0003_not_jessie") - - # Have > 1 Go free space on /var/ ? - if free_space_in_directory("/var/") / (1024**3) < 1.0: - raise YunohostError("migration_0003_not_enough_free_space") - - # Check system is up to date - # (but we don't if 'stretch' is already in the sources.list ... - # which means maybe a previous upgrade crashed and we're re-running it) - if " stretch " not in read_file("/etc/apt/sources.list"): - self.apt_update() - apt_list_upgradable = check_output("apt list --upgradable -a") - if "upgradable" in apt_list_upgradable: - raise YunohostError("migration_0003_system_not_fully_up_to_date") - - @property - def disclaimer(self): - - # Avoid having a super long disclaimer + uncessary check if we ain't - # on jessie / yunohost 2.x anymore - # NB : we do both check to cover situations where the upgrade crashed - # in the middle and debian version could be >= 9.x but yunohost package - # would still be in 2.x... - if not self.debian_major_version() == 8 \ - and not self.yunohost_major_version() == 2: - return None - - # Get list of problematic apps ? I.e. not official or community+working - problematic_apps = unstable_apps() - problematic_apps = "".join(["\n - " + app for app in problematic_apps]) - - # Manually modified files ? (c.f. yunohost service regen-conf) - modified_files = manually_modified_files() - # We also have a specific check for nginx.conf which some people - # modified and needs to be upgraded... - if "/etc/nginx/nginx.conf" in manually_modified_files_compared_to_debian_default(): - modified_files.append("/etc/nginx/nginx.conf") - modified_files = "".join(["\n - " + f for f in modified_files]) - - message = m18n.n("migration_0003_general_warning") - - if problematic_apps: - message += "\n\n" + m18n.n("migration_0003_problematic_apps_warning", problematic_apps=problematic_apps) - - if modified_files: - message += "\n\n" + m18n.n("migration_0003_modified_files", manually_modified_files=modified_files) - - return message - - def patch_apt_sources_list(self): - - sources_list = glob.glob("/etc/apt/sources.list.d/*.list") - sources_list.append("/etc/apt/sources.list") - - # This : - # - replace single 'jessie' occurence by 'stretch' - # - comments lines containing "backports" - # - replace 'jessie/updates' by 'strech/updates' (or same with a -) - # - switch yunohost's repo to forge - for f in sources_list: - command = "sed -i -e 's@ jessie @ stretch @g' " \ - "-e '/backports/ s@^#*@#@' " \ - "-e 's@ jessie/updates @ stretch/updates @g' " \ - "-e 's@ jessie-updates @ stretch-updates @g' " \ - "-e 's@repo.yunohost@forge.yunohost@g' " \ - "{}".format(f) - os.system(command) - - def get_apps_equivs_packages(self): - - command = "dpkg --get-selections" \ - " | grep -v deinstall" \ - " | awk '{print $1}'" \ - " | { grep 'ynh-deps$' || true; }" - - output = check_output(command).strip() - - return output.split('\n') if output else [] - - def hold(self, packages): - for package in packages: - os.system("apt-mark hold {}".format(package)) - - def unhold(self, packages): - for package in packages: - os.system("apt-mark unhold {}".format(package)) - - def apt_update(self): - - command = "apt-get update" - logger.debug("Running apt command :\n{}".format(command)) - command += " 2>&1 | tee -a {}".format(self.logfile) - - os.system(command) - - def upgrade_yunohost_packages(self): - - # - # Here we use a dirty hack to run a command after the current - # "yunohost tools migrations migrate", because the upgrade of - # yunohost will also trigger another "yunohost tools migrations migrate" - # (also the upgrade of the package, if executed from the webadmin, is - # likely to kill/restart the api which is in turn likely to kill this - # command before it ends...) - # - - MOULINETTE_LOCK = "/var/run/moulinette_yunohost.lock" - - upgrade_command = "" - upgrade_command += " DEBIAN_FRONTEND=noninteractive" - upgrade_command += " APT_LISTCHANGES_FRONTEND=none" - upgrade_command += " apt-get install" - upgrade_command += " --assume-yes " - upgrade_command += " ".join(YUNOHOST_PACKAGES) - # We also install php-zip and php7.0-acpu to fix an issue with - # nextcloud and kanboard that need it when on stretch. - upgrade_command += " php-zip php7.0-apcu" - upgrade_command += " 2>&1 | tee -a {}".format(self.logfile) - - wait_until_end_of_yunohost_command = "(while [ -f {} ]; do sleep 2; done)".format(MOULINETTE_LOCK) - - command = "({} && {}; echo 'Migration complete!') &".format(wait_until_end_of_yunohost_command, - upgrade_command) - - logger.debug("Running command :\n{}".format(command)) - - os.system(command) - - def apt_dist_upgrade(self, conf_flags): - - # Make apt-get happy - os.system("echo 'libc6 libraries/restart-without-asking boolean true' | debconf-set-selections") - # Don't send an email to root about the postgresql migration. It should be handled automatically after. - os.system("echo 'postgresql-common postgresql-common/obsolete-major seen true' | debconf-set-selections") - - command = "" - command += " DEBIAN_FRONTEND=noninteractive" - command += " APT_LISTCHANGES_FRONTEND=none" - command += " apt-get" - command += " --fix-broken --show-upgraded --assume-yes" - for conf_flag in conf_flags: - command += ' -o Dpkg::Options::="--force-conf{}"'.format(conf_flag) - command += " dist-upgrade" - - logger.debug("Running apt command :\n{}".format(command)) - - command += " 2>&1 | tee -a {}".format(self.logfile) - - is_api = msettings.get('interface') == 'api' - if is_api: - callbacks = ( - lambda l: logger.info(l.rstrip()), - lambda l: logger.warning(l.rstrip()), - ) - call_async_output(command, callbacks, shell=True) - else: - # We do this when running from the cli to have the output of the - # command showing in the terminal, since 'info' channel is only - # enabled if the user explicitly add --verbose ... - os.system(command) - - # Those are files that should be kept and restored before the final switch - # to yunohost 3.x... They end up being modified by the various dist-upgrades - # (or need to be taken out momentarily), which then blocks the regen-conf - # as they are flagged as "manually modified"... - files_to_keep = [ - "/etc/mysql/my.cnf", - "/etc/nslcd.conf", - "/etc/postfix/master.cf", - "/etc/fail2ban/filter.d/yunohost.conf" - ] - - def backup_files_to_keep(self): - - logger.debug("Backuping specific files to keep ...") - - # Create tmp directory if it does not exists - tmp_dir = os.path.join("/tmp/", self.name) - if not os.path.exists(tmp_dir): - os.mkdir(tmp_dir, 0o700) - - for f in self.files_to_keep: - dest_file = f.strip('/').replace("/", "_") - - # If the file is already there, we might be re-running the migration - # because it previously crashed. Hence we keep the existing file. - if os.path.exists(os.path.join(tmp_dir, dest_file)): - continue - - copy2(f, os.path.join(tmp_dir, dest_file)) - - def restore_files_to_keep(self): - - logger.debug("Restoring specific files to keep ...") - - tmp_dir = os.path.join("/tmp/", self.name) - - for f in self.files_to_keep: - dest_file = f.strip('/').replace("/", "_") - copy2(os.path.join(tmp_dir, dest_file), f) - - # On some setups, /etc/nginx/nginx.conf got edited. But this file needs - # to be upgraded because of the way the new module system works for nginx. - # (in particular, having the line that include the modules at the top) - # - # So here, if it got edited, we force the restore of the original conf - # *before* starting the actual upgrade... - # - # An alternative strategy that was attempted was to hold the nginx-common - # package and have a specific upgrade for it like for fail2ban, but that - # leads to apt complaining about not being able to upgrade for shitty - # reasons >.> - def restore_original_nginx_conf_if_needed(self): - if "/etc/nginx/nginx.conf" not in manually_modified_files_compared_to_debian_default(): - return - - if not os.path.exists("/etc/nginx/nginx.conf"): - return - - # If stretch is in the sources.list, we already started migrating on - # stretch so we don't re-do this - if " stretch " in read_file("/etc/apt/sources.list"): - return - - backup_dest = "/home/yunohost.conf/backup/nginx.conf.bkp_before_stretch" - - logger.warning(m18n.n("migration_0003_restoring_origin_nginx_conf", - backup_dest=backup_dest)) - - os.system("mv /etc/nginx/nginx.conf %s" % backup_dest) - - command = "" - command += " DEBIAN_FRONTEND=noninteractive" - command += " APT_LISTCHANGES_FRONTEND=none" - command += " apt-get" - command += " --fix-broken --show-upgraded --assume-yes" - command += ' -o Dpkg::Options::="--force-confmiss"' - command += " install --reinstall" - command += " nginx-common" - - logger.debug("Running apt command :\n{}".format(command)) - - command += " 2>&1 | tee -a {}".format(self.logfile) - - is_api = msettings.get('interface') == 'api' - if is_api: - callbacks = ( - lambda l: logger.info(l.rstrip()), - lambda l: logger.warning(l.rstrip()), - ) - call_async_output(command, callbacks, shell=True) - else: - # We do this when running from the cli to have the output of the - # command showing in the terminal, since 'info' channel is only - # enabled if the user explicitly add --verbose ... - os.system(command) - - def disable_predicable_interface_names(self): - - # Try to see if currently used interface names are predictable ones or not... - # If we ain't using "eth0" or "wlan0", assume we are using predictable interface - # names and therefore they shouldnt be disabled - network_interfaces = get_network_interfaces().keys() - if "eth0" not in network_interfaces and "wlan0" not in network_interfaces: - return - - interfaces_config = read_file("/etc/network/interfaces") - if "eth0" not in interfaces_config and "wlan0" not in interfaces_config: - return - - # Disable predictive interface names - # c.f. https://unix.stackexchange.com/a/338730 - os.system("ln -s /dev/null /etc/systemd/network/99-default.link") diff --git a/src/yunohost/data_migrations/0004_php5_to_php7_pools.py b/src/yunohost/data_migrations/0004_php5_to_php7_pools.py deleted file mode 100644 index 46a5eb91d..000000000 --- a/src/yunohost/data_migrations/0004_php5_to_php7_pools.py +++ /dev/null @@ -1,98 +0,0 @@ -import os -import glob -from shutil import copy2 - -from moulinette.utils.log import getActionLogger - -from yunohost.tools import Migration -from yunohost.service import _run_service_command - -logger = getActionLogger('yunohost.migration') - -PHP5_POOLS = "/etc/php5/fpm/pool.d" -PHP7_POOLS = "/etc/php/7.0/fpm/pool.d" - -PHP5_SOCKETS_PREFIX = "/var/run/php5-fpm" -PHP7_SOCKETS_PREFIX = "/run/php/php7.0-fpm" - -MIGRATION_COMMENT = "; YunoHost note : this file was automatically moved from {}".format(PHP5_POOLS) - - -class MyMigration(Migration): - - "Migrate php5-fpm 'pool' conf files to php7 stuff" - - def migrate(self): - - # Get list of php5 pool files - php5_pool_files = glob.glob("{}/*.conf".format(PHP5_POOLS)) - - # Keep only basenames - php5_pool_files = [os.path.basename(f) for f in php5_pool_files] - - # Ignore the "www.conf" (default stuff, probably don't want to touch it ?) - php5_pool_files = [f for f in php5_pool_files if f != "www.conf"] - - for f in php5_pool_files: - - # Copy the files to the php7 pool - src = "{}/{}".format(PHP5_POOLS, f) - dest = "{}/{}".format(PHP7_POOLS, f) - copy2(src, dest) - - # Replace the socket prefix if it's found - c = "sed -i -e 's@{}@{}@g' {}".format(PHP5_SOCKETS_PREFIX, PHP7_SOCKETS_PREFIX, dest) - os.system(c) - - # Also add a comment that it was automatically moved from php5 - # (for human traceability and backward migration) - c = "sed -i '1i {}' {}".format(MIGRATION_COMMENT, dest) - os.system(c) - - # Some old comments starting with '#' instead of ';' are not - # compatible in php7 - c = "sed -i 's/^#/;#/g' {}".format(dest) - os.system(c) - - # Reload/restart the php pools - _run_service_command("restart", "php7.0-fpm") - _run_service_command("enable", "php7.0-fpm") - os.system("systemctl stop php5-fpm") - os.system("systemctl disable php5-fpm") - os.system("rm /etc/logrotate.d/php5-fpm") # We remove this otherwise the logrotate cron will be unhappy - - # Get list of nginx conf file - nginx_conf_files = glob.glob("/etc/nginx/conf.d/*.d/*.conf") - for f in nginx_conf_files: - # Replace the socket prefix if it's found - c = "sed -i -e 's@{}@{}@g' {}".format(PHP5_SOCKETS_PREFIX, PHP7_SOCKETS_PREFIX, f) - os.system(c) - - # Reload nginx - _run_service_command("reload", "nginx") - - def backward(self): - - # Get list of php7 pool files - php7_pool_files = glob.glob("{}/*.conf".format(PHP7_POOLS)) - - # Keep only files which have the migration comment - php7_pool_files = [f for f in php7_pool_files if open(f).readline().strip() == MIGRATION_COMMENT] - - # Delete those files - for f in php7_pool_files: - os.remove(f) - - # Reload/restart the php pools - _run_service_command("stop", "php7.0-fpm") - os.system("systemctl start php5-fpm") - - # Get list of nginx conf file - nginx_conf_files = glob.glob("/etc/nginx/conf.d/*.d/*.conf") - for f in nginx_conf_files: - # Replace the socket prefix if it's found - c = "sed -i -e 's@{}@{}@g' {}".format(PHP7_SOCKETS_PREFIX, PHP5_SOCKETS_PREFIX, f) - os.system(c) - - # Reload nginx - _run_service_command("reload", "nginx") diff --git a/src/yunohost/data_migrations/0005_postgresql_9p4_to_9p6.py b/src/yunohost/data_migrations/0005_postgresql_9p4_to_9p6.py deleted file mode 100644 index 5ae729b60..000000000 --- a/src/yunohost/data_migrations/0005_postgresql_9p4_to_9p6.py +++ /dev/null @@ -1,43 +0,0 @@ -import subprocess - -from moulinette import m18n -from yunohost.utils.error import YunohostError -from moulinette.utils.log import getActionLogger - -from yunohost.tools import Migration -from yunohost.utils.filesystem import free_space_in_directory, space_used_by_directory - -logger = getActionLogger('yunohost.migration') - - -class MyMigration(Migration): - - "Migrate DBs from Postgresql 9.4 to 9.6 after migrating to Stretch" - - def migrate(self): - - if not self.package_is_installed("postgresql-9.4"): - logger.warning(m18n.n("migration_0005_postgresql_94_not_installed")) - return - - if not self.package_is_installed("postgresql-9.6"): - raise YunohostError("migration_0005_postgresql_96_not_installed") - - if not space_used_by_directory("/var/lib/postgresql/9.4") > free_space_in_directory("/var/lib/postgresql"): - raise YunohostError("migration_0005_not_enough_space", path="/var/lib/postgresql/") - - subprocess.check_call("service postgresql stop", shell=True) - subprocess.check_call("pg_dropcluster --stop 9.6 main", shell=True) - subprocess.check_call("pg_upgradecluster -m upgrade 9.4 main", shell=True) - subprocess.check_call("pg_dropcluster --stop 9.4 main", shell=True) - subprocess.check_call("service postgresql start", shell=True) - - def backward(self): - - pass - - def package_is_installed(self, package_name): - - p = subprocess.Popen("dpkg --list | grep '^ii ' | grep -q -w {}".format(package_name), shell=True) - p.communicate() - return p.returncode == 0 diff --git a/src/yunohost/data_migrations/0006_sync_admin_and_root_passwords.py b/src/yunohost/data_migrations/0006_sync_admin_and_root_passwords.py deleted file mode 100644 index cd13d680d..000000000 --- a/src/yunohost/data_migrations/0006_sync_admin_and_root_passwords.py +++ /dev/null @@ -1,81 +0,0 @@ -import spwd -import crypt -import random -import string -import subprocess - -from moulinette import m18n -from yunohost.utils.error import YunohostError -from moulinette.utils.log import getActionLogger -from moulinette.utils.process import run_commands, check_output -from moulinette.utils.filesystem import append_to_file -from moulinette.authenticators.ldap import Authenticator -from yunohost.tools import Migration - -logger = getActionLogger('yunohost.migration') -SMALL_PWD_LIST = ["yunohost", "olinuxino", "olinux", "raspberry", "admin", "root", "test", "rpi"] - - -class MyMigration(Migration): - - "Synchronize admin and root passwords" - - def migrate(self): - - new_hash = self._get_admin_hash() - self._replace_root_hash(new_hash) - - logger.info(m18n.n("root_password_replaced_by_admin_password")) - - def backward(self): - pass - - @property - def mode(self): - - # If the root password is still a "default" value, - # then this is an emergency and migration shall - # be applied automatically - # - # Otherwise, as playing with root password is touchy, - # we set this as a manual migration. - return "auto" if self._is_root_pwd_listed(SMALL_PWD_LIST) else "manual" - - @property - def disclaimer(self): - if self._is_root_pwd_listed(SMALL_PWD_LIST): - return None - - return m18n.n("migration_0006_disclaimer") - - def _get_admin_hash(self): - """ - Fetch the admin hash from the LDAP db using slapcat - """ - admin_hash = check_output("slapcat \ - | grep 'dn: cn=admin,dc=yunohost,dc=org' -A20 \ - | grep userPassword -A2 \ - | tr -d '\n ' \ - | tr ':' ' ' \ - | awk '{print $2}' \ - | base64 -d \ - | sed 's/{CRYPT}//g'") - return admin_hash - - def _replace_root_hash(self, new_hash): - hash_root = spwd.getspnam("root").sp_pwd - - with open('/etc/shadow', 'r') as before_file: - before = before_file.read() - - with open('/etc/shadow', 'w') as after_file: - after_file.write(before.replace("root:" + hash_root, - "root:" + new_hash)) - - def _is_root_pwd_listed(self, pwd_list): - hash_root = spwd.getspnam("root").sp_pwd - - for password in pwd_list: - if hash_root == crypt.crypt(password, hash_root): - return True - return False 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 deleted file mode 100644 index feffdc27c..000000000 --- a/src/yunohost/data_migrations/0007_ssh_conf_managed_by_yunohost_step1.py +++ /dev/null @@ -1,73 +0,0 @@ -import os -import re - -from shutil import copyfile - -from moulinette.utils.log import getActionLogger -from moulinette.utils.filesystem import mkdir, rm - -from yunohost.tools import Migration -from yunohost.service import _run_service_command -from yunohost.regenconf import regen_conf -from yunohost.settings import settings_set -from yunohost.utils.error import YunohostError - -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) - - # 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') - 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 YunohostError("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 YunohostError("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 deleted file mode 100644 index 8984440bd..000000000 --- a/src/yunohost/data_migrations/0008_ssh_conf_managed_by_yunohost_step2.py +++ /dev/null @@ -1,107 +0,0 @@ -import os -import re - -from moulinette import m18n -from moulinette.utils.log import getActionLogger -from moulinette.utils.filesystem import chown - -from yunohost.tools import Migration -from yunohost.regenconf import _get_conf_hashes, _calculate_hash -from yunohost.regenconf import regen_conf -from yunohost.settings import settings_set, settings_get -from yunohost.utils.error import YunohostError -from yunohost.backup import ARCHIVES_PATH - - -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) - regen_conf(names=['ssh'], force=True) - - # Update local archives folder permissions, so that - # admin can scp archives out of the server - if os.path.isdir(ARCHIVES_PATH): - chown(ARCHIVES_PATH, uid="admin", gid="root") - - def backward(self): - - raise YunohostError("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/data_migrations/0009_decouple_regenconf_from_services.py b/src/yunohost/data_migrations/0009_decouple_regenconf_from_services.py deleted file mode 100644 index d552d7c9c..000000000 --- a/src/yunohost/data_migrations/0009_decouple_regenconf_from_services.py +++ /dev/null @@ -1,42 +0,0 @@ -import os - -from moulinette import m18n -from moulinette.utils.log import getActionLogger - -from moulinette.utils.filesystem import read_file -from yunohost.service import _get_services, _save_services -from yunohost.regenconf import _update_conf_hashes, REGEN_CONF_FILE - -from yunohost.tools import Migration - -logger = getActionLogger('yunohost.migration') - - -class MyMigration(Migration): - """ - Decouple the regen conf mechanism from the concept of services - """ - - def migrate(self): - - if "conffiles" not in read_file("/etc/yunohost/services.yml") \ - or os.path.exists(REGEN_CONF_FILE): - logger.warning(m18n.n("migration_0009_not_needed")) - return - - # For all services - services = _get_services() - for service, infos in services.items(): - # If there are some conffiles (file hashes) - if "conffiles" in infos.keys(): - # Save them using the new regen conf thingy - _update_conf_hashes(service, infos["conffiles"]) - # And delete the old conffile key from the service infos - del services[service]["conffiles"] - - # (Actually save the modification of services) - _save_services(services) - - def backward(self): - - pass diff --git a/src/yunohost/data_migrations/0010_migrate_to_apps_json.py b/src/yunohost/data_migrations/0010_migrate_to_apps_json.py deleted file mode 100644 index 43ae9a86f..000000000 --- a/src/yunohost/data_migrations/0010_migrate_to_apps_json.py +++ /dev/null @@ -1,44 +0,0 @@ -import os - -from moulinette.utils.log import getActionLogger -from yunohost.app import app_fetchlist, app_removelist, _read_appslist_list, APPSLISTS_JSON -from yunohost.tools import Migration - -logger = getActionLogger('yunohost.migration') - -BASE_CONF_PATH = '/home/yunohost.conf' -BACKUP_CONF_DIR = os.path.join(BASE_CONF_PATH, 'backup') -APPSLISTS_BACKUP = os.path.join(BACKUP_CONF_DIR, "appslist_before_migration_to_unified_list.json") - - -class MyMigration(Migration): - - "Migrate from official.json to apps.json" - - def migrate(self): - - # Backup current app list json - os.system("cp %s %s" % (APPSLISTS_JSON, APPSLISTS_BACKUP)) - - # Remove all the deprecated lists - lists_to_remove = [ - "app.yunohost.org/list.json", # Old list on old installs, alias to official.json - "app.yunohost.org/official.json", - "app.yunohost.org/community.json", - "labriqueinter.net/apps/labriqueinternet.json", - "labriqueinter.net/internetcube.json" - ] - - appslists = _read_appslist_list() - for appslist, infos in appslists.items(): - if infos["url"].split("//")[-1] in lists_to_remove: - app_removelist(name=appslist) - - # Replace by apps.json list - app_fetchlist(name="yunohost", - url="https://app.yunohost.org/apps.json") - - def backward(self): - - if os.path.exists(APPSLISTS_BACKUP): - os.system("cp %s %s" % (APPSLISTS_BACKUP, APPSLISTS_JSON)) diff --git a/src/yunohost/data_migrations/0011_setup_group_permission.py b/src/yunohost/data_migrations/0011_setup_group_permission.py deleted file mode 100644 index 05c426936..000000000 --- a/src/yunohost/data_migrations/0011_setup_group_permission.py +++ /dev/null @@ -1,142 +0,0 @@ -import yaml -import time -import os - -from moulinette import m18n -from yunohost.utils.error import YunohostError -from moulinette.utils.log import getActionLogger - -from yunohost.tools import Migration -from yunohost.user import user_group_add, user_group_update -from yunohost.app import app_setting, app_list -from yunohost.regenconf import regen_conf -from yunohost.permission import permission_add, permission_sync_to_user -from yunohost.user import user_permission_add - -logger = getActionLogger('yunohost.migration') - -################################################### -# Tools used also for restoration -################################################### - -class MyMigration(Migration): - """ - Update the LDAP DB to be able to store the permission - Create a group for each yunohost user - Migrate app permission from apps setting to LDAP - """ - - required = True - - def migrate_LDAP_db(self): - - logger.info(m18n.n("migration_0011_update_LDAP_database")) - - from yunohost.utils.ldap import _get_ldap_interface - ldap = _get_ldap_interface() - - try: - ldap.remove('cn=sftpusers,ou=groups') - except: - logger.warn(m18n.n("error_when_removing_sftpuser_group")) - - with open('/usr/share/yunohost/yunohost-config/moulinette/ldap_scheme.yml') as f: - ldap_map = yaml.load(f) - - try: - attr_dict = ldap_map['parents']['ou=permission'] - ldap.add('ou=permission', attr_dict) - - attr_dict = ldap_map['children']['cn=all_users,ou=groups'] - ldap.add('cn=all_users,ou=groups', attr_dict) - - for rdn, attr_dict in ldap_map['depends_children'].items(): - ldap.add(rdn, attr_dict) - except Exception as e: - raise YunohostError("migration_0011_LDAP_update_failed", error=e) - - logger.info(m18n.n("migration_0011_create_group")) - - # Create a group for each yunohost user - user_list = ldap.search('ou=users,dc=yunohost,dc=org', - '(&(objectclass=person)(!(uid=root))(!(uid=nobody)))', - ['uid', 'uidNumber']) - for user_info in user_list: - username = user_info['uid'][0] - ldap.update('uid=%s,ou=users' % username, - {'objectClass': ['mailAccount', 'inetOrgPerson', 'posixAccount', 'userPermissionYnh']}) - user_group_add(username, gid=user_info['uidNumber'][0], sync_perm=False) - user_group_update(groupname=username, add_user=username, force=True, sync_perm=False) - user_group_update(groupname='all_users', add_user=username, force=True, sync_perm=False) - - - def migrate_app_permission(self, app=None): - logger.info(m18n.n("migration_0011_migrate_permission")) - - if app: - apps = app_list(installed=True, filter=app)['apps'] - else: - apps = app_list(installed=True)['apps'] - - for app_info in apps: - app = app_info['id'] - permission = app_setting(app, 'allowed_users') - path = app_setting(app, 'path') - domain = app_setting(app, 'domain') - - urls = [domain + path] if domain and path else None - permission_add(app, permission='main', urls=urls, default_allow=True, sync_perm=False) - if permission: - allowed_group = permission.split(',') - user_permission_add([app], permission='main', group=allowed_group, sync_perm=False) - app_setting(app, 'allowed_users', delete=True) - - - def migrate(self): - # Check if the migration can be processed - ldap_regen_conf_status = regen_conf(names=['slapd'], dry_run=True) - # By this we check if the have been customized - if ldap_regen_conf_status and ldap_regen_conf_status['slapd']['pending']: - raise YunohostError("migration_0011_LDAP_config_dirty") - - # Backup LDAP and the apps settings before to do the migration - logger.info(m18n.n("migration_0011_backup_before_migration")) - try: - backup_folder = "/home/yunohost.backup/premigration/" + time.strftime('%Y%m%d-%H%M%S', time.gmtime()) - os.makedirs(backup_folder, 0o750) - os.system("systemctl stop slapd") - os.system("cp -r --preserve /etc/ldap %s/ldap_config" % backup_folder) - os.system("cp -r --preserve /var/lib/ldap %s/ldap_db" % backup_folder) - os.system("cp -r --preserve /etc/yunohost/apps %s/apps_settings" % backup_folder) - except Exception as e: - raise YunohostError("migration_0011_can_not_backup_before_migration", error=e) - finally: - os.system("systemctl start slapd") - - try: - # Update LDAP schema restart slapd - logger.info(m18n.n("migration_0011_update_LDAP_schema")) - regen_conf(names=['slapd'], force=True) - - # Update LDAP database - self.migrate_LDAP_db() - - # Migrate permission - self.migrate_app_permission() - - permission_sync_to_user() - except Exception as e: - logger.warn(m18n.n("migration_0011_migration_failed_trying_to_rollback")) - os.system("systemctl stop slapd") - os.system("rm -r /etc/ldap/slapd.d") # To be sure that we don't keep some part of the old config - os.system("cp -r --preserve %s/ldap_config/. /etc/ldap/" % backup_folder) - os.system("cp -r --preserve %s/ldap_db/. /var/lib/ldap/" % backup_folder) - os.system("cp -r --preserve %s/apps_settings/. /etc/yunohost/apps/" % backup_folder) - os.system("systemctl start slapd") - os.system("rm -r " + backup_folder) - logger.info(m18n.n("migration_0011_rollback_success")) - raise - else: - os.system("rm -r " + backup_folder) - - logger.info(m18n.n("migration_0011_done")) diff --git a/src/yunohost/data_migrations/0015_migrate_to_buster.py b/src/yunohost/data_migrations/0015_migrate_to_buster.py new file mode 100644 index 000000000..4f2d4caf8 --- /dev/null +++ b/src/yunohost/data_migrations/0015_migrate_to_buster.py @@ -0,0 +1,291 @@ +import glob +import os + +from moulinette import m18n +from yunohost.utils.error import YunohostError +from moulinette.utils.log import getActionLogger +from moulinette.utils.process import check_output, call_async_output +from moulinette.utils.filesystem import read_file + +from yunohost.tools import Migration, tools_update, tools_upgrade +from yunohost.app import unstable_apps +from yunohost.regenconf import manually_modified_files +from yunohost.utils.filesystem import free_space_in_directory +from yunohost.utils.packages import ( + get_ynh_package_version, + _list_upgradable_apt_packages, +) + +logger = getActionLogger("yunohost.migration") + + +class MyMigration(Migration): + + "Upgrade the system to Debian Buster and Yunohost 4.x" + + mode = "manual" + + def run(self): + + self.check_assertions() + + logger.info(m18n.n("migration_0015_start")) + + # + # Make sure certificates do not use weak signature hash algorithms (md5, sha1) + # otherwise nginx will later refuse to start which result in + # catastrophic situation + # + self.validate_and_upgrade_cert_if_necessary() + + # + # Patch sources.list + # + logger.info(m18n.n("migration_0015_patching_sources_list")) + self.patch_apt_sources_list() + tools_update(target="system") + + # Tell libc6 it's okay to restart system stuff during the upgrade + os.system( + "echo 'libc6 libraries/restart-without-asking boolean true' | debconf-set-selections" + ) + + # Don't send an email to root about the postgresql migration. It should be handled automatically after. + os.system( + "echo 'postgresql-common postgresql-common/obsolete-major seen true' | debconf-set-selections" + ) + + # + # Specific packages upgrades + # + logger.info(m18n.n("migration_0015_specific_upgrade")) + + # Update unscd independently, was 0.53-1+yunohost on stretch (custom build of ours) but now it's 0.53-1+b1 on vanilla buster, + # which for apt appears as a lower version (hence the --allow-downgrades and the hardcoded version number) + unscd_version = check_output( + 'dpkg -s unscd | grep "^Version: " | cut -d " " -f 2' + ) + if "yunohost" in unscd_version: + new_version = check_output( + "LC_ALL=C apt policy unscd 2>/dev/null | grep -v '\\*\\*\\*' | grep http -B1 | head -n 1 | awk '{print $1}'" + ).strip() + if new_version: + self.apt_install("unscd=%s --allow-downgrades" % new_version) + else: + logger.warning("Could not identify which version of unscd to install") + + # Upgrade libpam-modules independently, small issue related to willing to overwrite a file previously provided by Yunohost + libpammodules_version = check_output( + 'dpkg -s libpam-modules | grep "^Version: " | cut -d " " -f 2' + ) + if not libpammodules_version.startswith("1.3"): + self.apt_install('libpam-modules -o Dpkg::Options::="--force-overwrite"') + + # + # Main upgrade + # + logger.info(m18n.n("migration_0015_main_upgrade")) + + apps_packages = self.get_apps_equivs_packages() + self.hold(apps_packages) + tools_upgrade(target="system", allow_yunohost_upgrade=False) + + if self.debian_major_version() == 9: + raise YunohostError("migration_0015_still_on_stretch_after_main_upgrade") + + # Clean the mess + logger.info(m18n.n("migration_0015_cleaning_up")) + os.system("apt autoremove --assume-yes") + os.system("apt clean --assume-yes") + + # + # Yunohost upgrade + # + logger.info(m18n.n("migration_0015_yunohost_upgrade")) + self.unhold(apps_packages) + tools_upgrade(target="system") + + def debian_major_version(self): + # The python module "platform" and lsb_release are not reliable because + # on some setup, they may still return Release=9 even after upgrading to + # buster ... (Apparently this is related to OVH overriding some stuff + # with /etc/lsb-release for instance -_-) + # Instead, we rely on /etc/os-release which should be the raw info from + # the distribution... + return int( + check_output( + "grep VERSION_ID /etc/os-release | head -n 1 | tr '\"' ' ' | cut -d ' ' -f2" + ) + ) + + def yunohost_major_version(self): + return int(get_ynh_package_version("yunohost")["version"].split(".")[0]) + + def check_assertions(self): + + # Be on stretch (9.x) and yunohost 3.x + # NB : we do both check to cover situations where the upgrade crashed + # in the middle and debian version could be > 9.x but yunohost package + # would still be in 3.x... + if ( + not self.debian_major_version() == 9 + and not self.yunohost_major_version() == 3 + ): + raise YunohostError("migration_0015_not_stretch") + + # Have > 1 Go free space on /var/ ? + if free_space_in_directory("/var/") / (1024 ** 3) < 1.0: + raise YunohostError("migration_0015_not_enough_free_space") + + # Check system is up to date + # (but we don't if 'stretch' is already in the sources.list ... + # which means maybe a previous upgrade crashed and we're re-running it) + if " buster " not in read_file("/etc/apt/sources.list"): + tools_update(target="system") + upgradable_system_packages = list(_list_upgradable_apt_packages()) + if upgradable_system_packages: + raise YunohostError("migration_0015_system_not_fully_up_to_date") + + @property + def disclaimer(self): + + # Avoid having a super long disclaimer + uncessary check if we ain't + # on stretch / yunohost 3.x anymore + # NB : we do both check to cover situations where the upgrade crashed + # in the middle and debian version could be >= 10.x but yunohost package + # would still be in 3.x... + if ( + not self.debian_major_version() == 9 + and not self.yunohost_major_version() == 3 + ): + return None + + # Get list of problematic apps ? I.e. not official or community+working + problematic_apps = unstable_apps() + problematic_apps = "".join(["\n - " + app for app in problematic_apps]) + + # Manually modified files ? (c.f. yunohost service regen-conf) + modified_files = manually_modified_files() + modified_files = "".join(["\n - " + f for f in modified_files]) + + message = m18n.n("migration_0015_general_warning") + + message = ( + "N.B.: This migration has been tested by the community over the last few months but has only been declared stable recently. If your server hosts critical services and if you are not too confident with debugging possible issues, we recommend you to wait a little bit more while we gather more feedback and polish things up. If on the other hand you are relatively confident with debugging small issues that may arise, you are encouraged to run this migration ;)! You can read about remaining known issues and feedback from the community here: https://forum.yunohost.org/t/12195\n\n" + + message + ) + + if problematic_apps: + message += "\n\n" + m18n.n( + "migration_0015_problematic_apps_warning", + problematic_apps=problematic_apps, + ) + + if modified_files: + message += "\n\n" + m18n.n( + "migration_0015_modified_files", manually_modified_files=modified_files + ) + + return message + + def patch_apt_sources_list(self): + + sources_list = glob.glob("/etc/apt/sources.list.d/*.list") + sources_list.append("/etc/apt/sources.list") + + # This : + # - replace single 'stretch' occurence by 'buster' + # - comments lines containing "backports" + # - replace 'stretch/updates' by 'strech/updates' (or same with -) + for f in sources_list: + command = ( + "sed -i -e 's@ stretch @ buster @g' " + "-e '/backports/ s@^#*@#@' " + "-e 's@ stretch/updates @ buster/updates @g' " + "-e 's@ stretch-@ buster-@g' " + "{}".format(f) + ) + os.system(command) + + def get_apps_equivs_packages(self): + + command = ( + "dpkg --get-selections" + " | grep -v deinstall" + " | awk '{print $1}'" + " | { grep 'ynh-deps$' || true; }" + ) + + output = check_output(command) + + return output.split("\n") if output else [] + + def hold(self, packages): + for package in packages: + os.system("apt-mark hold {}".format(package)) + + def unhold(self, packages): + for package in packages: + os.system("apt-mark unhold {}".format(package)) + + def apt_install(self, cmd): + def is_relevant(line): + return "Reading database ..." not in line.rstrip() + + callbacks = ( + lambda l: logger.info("+ " + l.rstrip() + "\r") + if is_relevant(l) + else logger.debug(l.rstrip() + "\r"), + lambda l: logger.warning(l.rstrip()), + ) + + cmd = ( + "LC_ALL=C DEBIAN_FRONTEND=noninteractive APT_LISTCHANGES_FRONTEND=none apt install --quiet -o=Dpkg::Use-Pty=0 --fix-broken --assume-yes " + + cmd + ) + + logger.debug("Running: %s" % cmd) + + call_async_output(cmd, callbacks, shell=True) + + def validate_and_upgrade_cert_if_necessary(self): + + active_certs = set( + check_output("grep -roh '/.*crt.pem' /etc/nginx/").split("\n") + ) + + cmd = "LC_ALL=C openssl x509 -in %s -text -noout | grep -i 'Signature Algorithm:' | awk '{print $3}' | uniq" + + default_crt = "/etc/yunohost/certs/yunohost.org/crt.pem" + default_key = "/etc/yunohost/certs/yunohost.org/key.pem" + default_signature = ( + check_output(cmd % default_crt) if default_crt in active_certs else None + ) + if default_signature is not None and ( + default_signature.startswith("md5") or default_signature.startswith("sha1") + ): + logger.warning( + "%s is using a pretty old certificate incompatible with newer versions of nginx ... attempting to regenerate a fresh one" + % default_crt + ) + + os.system("mv %s %s.old" % (default_crt, default_crt)) + os.system("mv %s %s.old" % (default_key, default_key)) + ret = os.system("/usr/share/yunohost/hooks/conf_regen/02-ssl init") + + if ret != 0 or not os.path.exists(default_crt): + logger.error("Upgrading the certificate failed ... reverting") + os.system("mv %s.old %s" % (default_crt, default_crt)) + os.system("mv %s.old %s" % (default_key, default_key)) + + signatures = {cert: check_output(cmd % cert) for cert in active_certs} + + def cert_is_weak(cert): + sig = signatures[cert] + return sig.startswith("md5") or sig.startswith("sha1") + + weak_certs = [cert for cert in signatures.keys() if cert_is_weak(cert)] + if weak_certs: + raise YunohostError( + "migration_0015_weak_certs", certs=", ".join(weak_certs) + ) diff --git a/src/yunohost/data_migrations/0016_php70_to_php73_pools.py b/src/yunohost/data_migrations/0016_php70_to_php73_pools.py new file mode 100644 index 000000000..6b424f211 --- /dev/null +++ b/src/yunohost/data_migrations/0016_php70_to_php73_pools.py @@ -0,0 +1,83 @@ +import os +import glob +from shutil import copy2 + +from moulinette.utils.log import getActionLogger + +from yunohost.app import _is_installed, _patch_legacy_php_versions_in_settings +from yunohost.tools import Migration +from yunohost.service import _run_service_command + +logger = getActionLogger("yunohost.migration") + +PHP70_POOLS = "/etc/php/7.0/fpm/pool.d" +PHP73_POOLS = "/etc/php/7.3/fpm/pool.d" + +PHP70_SOCKETS_PREFIX = "/run/php/php7.0-fpm" +PHP73_SOCKETS_PREFIX = "/run/php/php7.3-fpm" + +MIGRATION_COMMENT = ( + "; YunoHost note : this file was automatically moved from {}".format(PHP70_POOLS) +) + + +class MyMigration(Migration): + + "Migrate php7.0-fpm 'pool' conf files to php7.3" + + dependencies = ["migrate_to_buster"] + + def run(self): + # Get list of php7.0 pool files + php70_pool_files = glob.glob("{}/*.conf".format(PHP70_POOLS)) + + # Keep only basenames + php70_pool_files = [os.path.basename(f) for f in php70_pool_files] + + # Ignore the "www.conf" (default stuff, probably don't want to touch it ?) + php70_pool_files = [f for f in php70_pool_files if f != "www.conf"] + + for f in php70_pool_files: + + # Copy the files to the php7.3 pool + src = "{}/{}".format(PHP70_POOLS, f) + dest = "{}/{}".format(PHP73_POOLS, f) + copy2(src, dest) + + # Replace the socket prefix if it's found + c = "sed -i -e 's@{}@{}@g' {}".format( + PHP70_SOCKETS_PREFIX, PHP73_SOCKETS_PREFIX, dest + ) + os.system(c) + + # Also add a comment that it was automatically moved from php7.0 + # (for human traceability and backward migration) + c = "sed -i '1i {}' {}".format(MIGRATION_COMMENT, dest) + os.system(c) + + app_id = os.path.basename(f)[: -len(".conf")] + if _is_installed(app_id): + _patch_legacy_php_versions_in_settings( + "/etc/yunohost/apps/%s/" % app_id + ) + + nginx_conf_files = glob.glob("/etc/nginx/conf.d/*.d/%s.conf" % app_id) + for f in nginx_conf_files: + # Replace the socket prefix if it's found + c = "sed -i -e 's@{}@{}@g' {}".format( + PHP70_SOCKETS_PREFIX, PHP73_SOCKETS_PREFIX, f + ) + os.system(c) + + os.system( + "rm /etc/logrotate.d/php7.0-fpm" + ) # We remove this otherwise the logrotate cron will be unhappy + + # Reload/restart the php pools + _run_service_command("restart", "php7.3-fpm") + _run_service_command("enable", "php7.3-fpm") + os.system("systemctl stop php7.0-fpm") + os.system("systemctl disable php7.0-fpm") + + # Reload nginx + _run_service_command("reload", "nginx") diff --git a/src/yunohost/data_migrations/0017_postgresql_9p6_to_11.py b/src/yunohost/data_migrations/0017_postgresql_9p6_to_11.py new file mode 100644 index 000000000..1ccf5ccc9 --- /dev/null +++ b/src/yunohost/data_migrations/0017_postgresql_9p6_to_11.py @@ -0,0 +1,82 @@ +import subprocess + +from moulinette import m18n +from yunohost.utils.error import YunohostError, YunohostValidationError +from moulinette.utils.log import getActionLogger + +from yunohost.tools import Migration +from yunohost.utils.filesystem import free_space_in_directory, space_used_by_directory + +logger = getActionLogger("yunohost.migration") + + +class MyMigration(Migration): + + "Migrate DBs from Postgresql 9.6 to 11 after migrating to Buster" + + dependencies = ["migrate_to_buster"] + + def run(self): + + if not self.package_is_installed("postgresql-9.6"): + logger.warning(m18n.n("migration_0017_postgresql_96_not_installed")) + return + + if not self.package_is_installed("postgresql-11"): + raise YunohostValidationError("migration_0017_postgresql_11_not_installed") + + # Make sure there's a 9.6 cluster + try: + self.runcmd("pg_lsclusters | grep -q '^9.6 '") + except Exception: + logger.warning( + "It looks like there's not active 9.6 cluster, so probably don't need to run this migration" + ) + return + + if not space_used_by_directory( + "/var/lib/postgresql/9.6" + ) > free_space_in_directory("/var/lib/postgresql"): + raise YunohostValidationError( + "migration_0017_not_enough_space", path="/var/lib/postgresql/" + ) + + self.runcmd("systemctl stop postgresql") + self.runcmd( + "LC_ALL=C pg_dropcluster --stop 11 main || true" + ) # We do not trigger an exception if the command fails because that probably means cluster 11 doesn't exists, which is fine because it's created during the pg_upgradecluster) + self.runcmd("LC_ALL=C pg_upgradecluster -m upgrade 9.6 main") + self.runcmd("LC_ALL=C pg_dropcluster --stop 9.6 main") + self.runcmd("systemctl start postgresql") + + def package_is_installed(self, package_name): + + (returncode, out, err) = self.runcmd( + "dpkg --list | grep '^ii ' | grep -q -w {}".format(package_name), + raise_on_errors=False, + ) + return returncode == 0 + + def runcmd(self, cmd, raise_on_errors=True): + + logger.debug("Running command: " + cmd) + + p = subprocess.Popen( + cmd, + shell=True, + executable="/bin/bash", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + out, err = p.communicate() + returncode = p.returncode + if raise_on_errors and returncode != 0: + raise YunohostError( + "Failed to run command '{}'.\nreturncode: {}\nstdout:\n{}\nstderr:\n{}\n".format( + cmd, returncode, out, err + ) + ) + + out = out.strip().split(b"\n") + return (returncode, out, err) diff --git a/src/yunohost/data_migrations/0018_xtable_to_nftable.py b/src/yunohost/data_migrations/0018_xtable_to_nftable.py new file mode 100644 index 000000000..94b47d944 --- /dev/null +++ b/src/yunohost/data_migrations/0018_xtable_to_nftable.py @@ -0,0 +1,126 @@ +import os +import subprocess + +from moulinette import m18n +from yunohost.utils.error import YunohostError +from moulinette.utils.log import getActionLogger + +from yunohost.firewall import firewall_reload +from yunohost.service import service_restart +from yunohost.tools import Migration + +logger = getActionLogger("yunohost.migration") + + +class MyMigration(Migration): + + "Migrate legacy iptables rules from stretch that relied on xtable and should now rely on nftable" + + dependencies = ["migrate_to_buster"] + + def run(self): + + self.do_ipv4 = os.system("iptables -w -L >/dev/null") == 0 + self.do_ipv6 = os.system("ip6tables -w -L >/dev/null") == 0 + + if not self.do_ipv4: + logger.warning(m18n.n("iptables_unavailable")) + if not self.do_ipv6: + logger.warning(m18n.n("ip6tables_unavailable")) + + backup_folder = "/home/yunohost.backup/premigration/xtable_to_nftable/" + if not os.path.exists(backup_folder): + os.makedirs(backup_folder, 0o750) + self.backup_rules_ipv4 = os.path.join(backup_folder, "legacy_rules_ipv4") + self.backup_rules_ipv6 = os.path.join(backup_folder, "legacy_rules_ipv6") + + # Backup existing legacy rules to be able to rollback + if self.do_ipv4 and not os.path.exists(self.backup_rules_ipv4): + self.runcmd( + "iptables-legacy -L >/dev/null" + ) # For some reason if we don't do this, iptables-legacy-save is empty ? + self.runcmd("iptables-legacy-save > %s" % self.backup_rules_ipv4) + assert ( + open(self.backup_rules_ipv4).read().strip() + ), "Uhoh backup of legacy ipv4 rules is empty !?" + if self.do_ipv6 and not os.path.exists(self.backup_rules_ipv6): + self.runcmd( + "ip6tables-legacy -L >/dev/null" + ) # For some reason if we don't do this, iptables-legacy-save is empty ? + self.runcmd("ip6tables-legacy-save > %s" % self.backup_rules_ipv6) + assert ( + open(self.backup_rules_ipv6).read().strip() + ), "Uhoh backup of legacy ipv6 rules is empty !?" + + # We inject the legacy rules (iptables-legacy) into the new iptable (just "iptables") + try: + if self.do_ipv4: + self.runcmd("iptables-legacy-save | iptables-restore") + if self.do_ipv6: + self.runcmd("ip6tables-legacy-save | ip6tables-restore") + except Exception as e: + self.rollback() + raise YunohostError( + "migration_0018_failed_to_migrate_iptables_rules", error=e + ) + + # Reset everything in iptables-legacy + # Stolen from https://serverfault.com/a/200642 + try: + if self.do_ipv4: + self.runcmd( + "iptables-legacy-save | awk '/^[*]/ { print $1 }" # Keep lines like *raw, *filter and *nat + ' /^:[A-Z]+ [^-]/ { print $1 " ACCEPT" ; }' # Turn all policies to accept + " /COMMIT/ { print $0; }'" # Keep the line COMMIT + " | iptables-legacy-restore" + ) + if self.do_ipv6: + self.runcmd( + "ip6tables-legacy-save | awk '/^[*]/ { print $1 }" # Keep lines like *raw, *filter and *nat + ' /^:[A-Z]+ [^-]/ { print $1 " ACCEPT" ; }' # Turn all policies to accept + " /COMMIT/ { print $0; }'" # Keep the line COMMIT + " | ip6tables-legacy-restore" + ) + except Exception as e: + self.rollback() + raise YunohostError("migration_0018_failed_to_reset_legacy_rules", error=e) + + # You might be wondering "uh but is it really useful to + # iptables-legacy-save | iptables-restore considering firewall_reload() + # flush/resets everything anyway ?" + # But the answer is : firewall_reload() only resets the *filter table. + # On more complex setups (e.g. internet cube or docker) you will also + # have rules in the *nat (or maybe *raw?) sections of iptables. + firewall_reload() + service_restart("fail2ban") + + def rollback(self): + + if self.do_ipv4: + self.runcmd("iptables-legacy-restore < %s" % self.backup_rules_ipv4) + if self.do_ipv6: + self.runcmd("iptables-legacy-restore < %s" % self.backup_rules_ipv6) + + def runcmd(self, cmd, raise_on_errors=True): + + logger.debug("Running command: " + cmd) + + p = subprocess.Popen( + cmd, + shell=True, + executable="/bin/bash", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + out, err = p.communicate() + returncode = p.returncode + if raise_on_errors and returncode != 0: + raise YunohostError( + "Failed to run command '{}'.\nreturncode: {}\nstdout:\n{}\nstderr:\n{}\n".format( + cmd, returncode, out, err + ) + ) + + out = out.strip().split(b"\n") + return (returncode, out, err) diff --git a/src/yunohost/data_migrations/0019_extend_permissions_features.py b/src/yunohost/data_migrations/0019_extend_permissions_features.py new file mode 100644 index 000000000..5d4343deb --- /dev/null +++ b/src/yunohost/data_migrations/0019_extend_permissions_features.py @@ -0,0 +1,107 @@ +from moulinette import m18n +from moulinette.utils.log import getActionLogger + +from yunohost.tools import Migration +from yunohost.permission import user_permission_list +from yunohost.utils.legacy import migrate_legacy_permission_settings + +logger = getActionLogger("yunohost.migration") + + +class MyMigration(Migration): + """ + Add protected attribute in LDAP permission + """ + + @Migration.ldap_migration + def run(self, backup_folder): + + # Update LDAP database + self.add_new_ldap_attributes() + + # Migrate old settings + migrate_legacy_permission_settings() + + def add_new_ldap_attributes(self): + + from yunohost.utils.ldap import _get_ldap_interface + from yunohost.regenconf import regen_conf, BACKUP_CONF_DIR + + # Check if the migration can be processed + ldap_regen_conf_status = regen_conf(names=["slapd"], dry_run=True) + # By this we check if the have been customized + if ldap_regen_conf_status and ldap_regen_conf_status["slapd"]["pending"]: + logger.warning( + m18n.n( + "migration_0019_slapd_config_will_be_overwritten", + conf_backup_folder=BACKUP_CONF_DIR, + ) + ) + + # Update LDAP schema restart slapd + logger.info(m18n.n("migration_update_LDAP_schema")) + regen_conf(names=["slapd"], force=True) + + logger.info(m18n.n("migration_0019_add_new_attributes_in_ldap")) + ldap = _get_ldap_interface() + permission_list = user_permission_list(full=True)["permissions"] + + for permission in permission_list: + system_perms = { + "mail": "E-mail", + "xmpp": "XMPP", + "ssh": "SSH", + "sftp": "STFP", + } + if permission.split(".")[0] in system_perms: + update = { + "authHeader": ["FALSE"], + "label": [system_perms[permission.split(".")[0]]], + "showTile": ["FALSE"], + "isProtected": ["TRUE"], + } + else: + app, subperm_name = permission.split(".") + if permission.endswith(".main"): + update = { + "authHeader": ["TRUE"], + "label": [ + app + ], # Note that this is later re-changed during the call to migrate_legacy_permission_settings() if a 'label' setting exists + "showTile": ["TRUE"], + "isProtected": ["FALSE"], + } + else: + update = { + "authHeader": ["TRUE"], + "label": [subperm_name.title()], + "showTile": ["FALSE"], + "isProtected": ["TRUE"], + } + + ldap.update("cn=%s,ou=permission" % permission, update) + + introduced_in_version = "4.1" + + def run_after_system_restore(self): + # Update LDAP database + self.add_new_ldap_attributes() + + def run_before_app_restore(self, app_id): + from yunohost.app import app_setting + from yunohost.utils.legacy import migrate_legacy_permission_settings + + # Migrate old settings + legacy_permission_settings = [ + "skipped_uris", + "unprotected_uris", + "protected_uris", + "skipped_regex", + "unprotected_regex", + "protected_regex", + ] + if any( + app_setting(app_id, setting) is not None + for setting in legacy_permission_settings + ): + migrate_legacy_permission_settings(app=app_id) diff --git a/src/yunohost/data_migrations/0020_ssh_sftp_permissions.py b/src/yunohost/data_migrations/0020_ssh_sftp_permissions.py new file mode 100644 index 000000000..f1dbcd1e7 --- /dev/null +++ b/src/yunohost/data_migrations/0020_ssh_sftp_permissions.py @@ -0,0 +1,100 @@ +import subprocess +import os + +from moulinette import m18n +from moulinette.utils.log import getActionLogger + +from yunohost.tools import Migration +from yunohost.permission import user_permission_update, permission_sync_to_user +from yunohost.regenconf import manually_modified_files + +logger = getActionLogger("yunohost.migration") + +################################################### +# Tools used also for restoration +################################################### + + +class MyMigration(Migration): + """ + Add new permissions around SSH/SFTP features + """ + + introduced_in_version = "4.2.2" + dependencies = ["extend_permissions_features"] + + @Migration.ldap_migration + def run(self, *args): + + from yunohost.utils.ldap import _get_ldap_interface + + ldap = _get_ldap_interface() + + existing_perms_raw = ldap.search( + "ou=permission,dc=yunohost,dc=org", "(objectclass=permissionYnh)", ["cn"] + ) + existing_perms = [perm["cn"][0] for perm in existing_perms_raw] + + # Add SSH and SFTP permissions + if "sftp.main" not in existing_perms: + ldap.add( + "cn=sftp.main,ou=permission", + { + "cn": "sftp.main", + "gidNumber": "5004", + "objectClass": ["posixGroup", "permissionYnh"], + "groupPermission": [], + "authHeader": "FALSE", + "label": "SFTP", + "showTile": "FALSE", + "isProtected": "TRUE", + }, + ) + + if "ssh.main" not in existing_perms: + ldap.add( + "cn=ssh.main,ou=permission", + { + "cn": "ssh.main", + "gidNumber": "5003", + "objectClass": ["posixGroup", "permissionYnh"], + "groupPermission": [], + "authHeader": "FALSE", + "label": "SSH", + "showTile": "FALSE", + "isProtected": "TRUE", + }, + ) + + # Add a bash terminal to each users + users = ldap.search( + "ou=users,dc=yunohost,dc=org", + filter="(loginShell=*)", + attrs=["dn", "uid", "loginShell"], + ) + for user in users: + if user["loginShell"][0] == "/bin/false": + dn = user["dn"][0].replace(",dc=yunohost,dc=org", "") + ldap.update(dn, {"loginShell": ["/bin/bash"]}) + else: + user_permission_update( + "ssh.main", add=user["uid"][0], sync_perm=False + ) + + permission_sync_to_user() + + # Somehow this is needed otherwise the PAM thing doesn't forget about the + # old loginShell value ? + subprocess.call(["nscd", "-i", "passwd"]) + + if ( + "/etc/ssh/sshd_config" in manually_modified_files() + and os.system( + "grep -q '^ *AllowGroups\\|^ *AllowUsers' /etc/ssh/sshd_config" + ) + != 0 + ): + logger.error(m18n.n("diagnosis_sshd_config_insecure")) + + def run_after_system_restore(self): + self.run() diff --git a/src/yunohost/diagnosis.py b/src/yunohost/diagnosis.py new file mode 100644 index 000000000..4ac5e2731 --- /dev/null +++ b/src/yunohost/diagnosis.py @@ -0,0 +1,716 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2018 YunoHost + + 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 + +""" + +""" diagnosis.py + + Look for possible issues on the server +""" + +import re +import os +import time + +from moulinette import m18n, Moulinette +from moulinette.utils import log +from moulinette.utils.filesystem import ( + read_json, + write_to_json, + read_yaml, + write_to_yaml, +) + +from yunohost.utils.error import YunohostError, YunohostValidationError +from yunohost.hook import hook_list, hook_exec + +logger = log.getActionLogger("yunohost.diagnosis") + +DIAGNOSIS_CACHE = "/var/cache/yunohost/diagnosis/" +DIAGNOSIS_CONFIG_FILE = "/etc/yunohost/diagnosis.yml" +DIAGNOSIS_SERVER = "diagnosis.yunohost.org" + + +def diagnosis_list(): + all_categories_names = [h for h, _ in _list_diagnosis_categories()] + return {"categories": all_categories_names} + + +def diagnosis_get(category, item): + + # Get all the categories + all_categories = _list_diagnosis_categories() + all_categories_names = [c for c, _ in all_categories] + + if category not in all_categories_names: + raise YunohostValidationError( + "diagnosis_unknown_categories", categories=category + ) + + if isinstance(item, list): + if any("=" not in criteria for criteria in item): + raise YunohostValidationError( + "Criterias should be of the form key=value (e.g. domain=yolo.test)" + ) + + # Convert the provided criteria into a nice dict + item = {c.split("=")[0]: c.split("=")[1] for c in item} + + return Diagnoser.get_cached_report(category, item=item) + + +def diagnosis_show( + categories=[], issues=False, full=False, share=False, human_readable=False +): + + if not os.path.exists(DIAGNOSIS_CACHE): + logger.warning(m18n.n("diagnosis_never_ran_yet")) + return + + # Get all the categories + all_categories = _list_diagnosis_categories() + all_categories_names = [category for category, _ in all_categories] + + # Check the requested category makes sense + if categories == []: + categories = all_categories_names + else: + unknown_categories = [c for c in categories if c not in all_categories_names] + if unknown_categories: + raise YunohostValidationError( + "diagnosis_unknown_categories", categories=", ".join(unknown_categories) + ) + + # Fetch all reports + all_reports = [] + for category in categories: + + try: + report = Diagnoser.get_cached_report(category) + except Exception as e: + logger.error(m18n.n("diagnosis_failed", category=category, error=str(e))) + continue + + Diagnoser.i18n(report, force_remove_html_tags=share or human_readable) + + add_ignore_flag_to_issues(report) + if not full: + del report["timestamp"] + del report["cached_for"] + report["items"] = [item for item in report["items"] if not item["ignored"]] + for item in report["items"]: + del item["meta"] + del item["ignored"] + if "data" in item: + del item["data"] + if issues: + report["items"] = [ + item + for item in report["items"] + if item["status"] in ["WARNING", "ERROR"] + ] + # Ignore this category if no issue was found + if not report["items"]: + continue + + all_reports.append(report) + + if share: + from yunohost.utils.yunopaste import yunopaste + + content = _dump_human_readable_reports(all_reports) + url = yunopaste(content) + + logger.info(m18n.n("log_available_on_yunopaste", url=url)) + if Moulinette.interface.type == "api": + return {"url": url} + else: + return + elif human_readable: + print(_dump_human_readable_reports(all_reports)) + else: + return {"reports": all_reports} + + +def _dump_human_readable_reports(reports): + + output = "" + + for report in reports: + output += "=================================\n" + output += "{description} ({id})\n".format(**report) + output += "=================================\n\n" + for item in report["items"]: + output += "[{status}] {summary}\n".format(**item) + for detail in item.get("details", []): + output += " - " + detail.replace("\n", "\n ") + "\n" + output += "\n" + output += "\n\n" + + return output + + +def diagnosis_run( + categories=[], force=False, except_if_never_ran_yet=False, email=False +): + + if (email or except_if_never_ran_yet) and not os.path.exists(DIAGNOSIS_CACHE): + return + + # Get all the categories + all_categories = _list_diagnosis_categories() + all_categories_names = [category for category, _ in all_categories] + + # Check the requested category makes sense + if categories == []: + categories = all_categories_names + else: + unknown_categories = [c for c in categories if c not in all_categories_names] + if unknown_categories: + raise YunohostValidationError( + "diagnosis_unknown_categories", categories=", ".join(unknown_categories) + ) + + issues = [] + # Call the hook ... + diagnosed_categories = [] + for category in categories: + logger.debug("Running diagnosis for %s ..." % category) + path = [p for n, p in all_categories if n == category][0] + + try: + code, report = hook_exec(path, args={"force": force}, env=None) + except Exception: + import traceback + + logger.error( + m18n.n( + "diagnosis_failed_for_category", + category=category, + error="\n" + traceback.format_exc(), + ) + ) + else: + diagnosed_categories.append(category) + if report != {}: + issues.extend( + [ + item + for item in report["items"] + if item["status"] in ["WARNING", "ERROR"] + ] + ) + + if email: + _email_diagnosis_issues() + if issues and Moulinette.interface.type == "cli": + logger.warning(m18n.n("diagnosis_display_tip")) + + +def diagnosis_ignore(filter, list=False): + return _diagnosis_ignore(add_filter=filter, list=list) + + +def diagnosis_unignore(filter): + return _diagnosis_ignore(remove_filter=filter) + + +def _diagnosis_ignore(add_filter=None, remove_filter=None, list=False): + """ + This action is meant for the admin to ignore issues reported by the + diagnosis system if they are known and understood by the admin. For + example, the lack of ipv6 on an instance, or badly configured XMPP dns + records if the admin doesn't care so much about XMPP. The point being that + the diagnosis shouldn't keep complaining about those known and "expected" + issues, and instead focus on new unexpected issues that could arise. + + For example, to ignore badly XMPP dnsrecords for domain yolo.test: + + yunohost diagnosis ignore --add-filter dnsrecords domain=yolo.test category=xmpp + ^ ^ ^ + the general additional other + diagnosis criterias criteria + category to to target to target + act on specific specific + reports reports + Or to ignore all dnsrecords issues: + + yunohost diagnosis ignore --add-filter dnsrecords + + The filters are stored in the diagnosis configuration in a data structure like: + + ignore_filters: { + "ip": [ + {"version": 6} # Ignore all issues related to ipv6 + ], + "dnsrecords": [ + {"domain": "yolo.test", "category": "xmpp"}, # Ignore all issues related to DNS xmpp records for yolo.test + {} # Ignore all issues about dnsrecords + ] + } + """ + + # Ignore filters are stored in + configuration = _diagnosis_read_configuration() + + if list: + return {"ignore_filters": configuration.get("ignore_filters", {})} + + def validate_filter_criterias(filter_): + + # Get all the categories + all_categories = _list_diagnosis_categories() + all_categories_names = [category for category, _ in all_categories] + + # Sanity checks for the provided arguments + if len(filter_) == 0: + raise YunohostValidationError( + "You should provide at least one criteria being the diagnosis category to ignore" + ) + category = filter_[0] + if category not in all_categories_names: + raise YunohostValidationError("%s is not a diagnosis category" % category) + if any("=" not in criteria for criteria in filter_[1:]): + raise YunohostValidationError( + "Criterias should be of the form key=value (e.g. domain=yolo.test)" + ) + + # Convert the provided criteria into a nice dict + criterias = {c.split("=")[0]: c.split("=")[1] for c in filter_[1:]} + + return category, criterias + + if add_filter: + + category, criterias = validate_filter_criterias(add_filter) + + # Fetch current issues for the requested category + current_issues_for_this_category = diagnosis_show( + categories=[category], issues=True, full=True + ) + current_issues_for_this_category = current_issues_for_this_category["reports"][ + 0 + ].get("items", {}) + + # Accept the given filter only if the criteria effectively match an existing issue + if not any( + issue_matches_criterias(i, criterias) + for i in current_issues_for_this_category + ): + raise YunohostError("No issues was found matching the given criteria.") + + # Make sure the subdicts/lists exists + if "ignore_filters" not in configuration: + configuration["ignore_filters"] = {} + if category not in configuration["ignore_filters"]: + configuration["ignore_filters"][category] = [] + + if criterias in configuration["ignore_filters"][category]: + logger.warning("This filter already exists.") + return + + configuration["ignore_filters"][category].append(criterias) + _diagnosis_write_configuration(configuration) + logger.success("Filter added") + return + + if remove_filter: + + category, criterias = validate_filter_criterias(remove_filter) + + # Make sure the subdicts/lists exists + if "ignore_filters" not in configuration: + configuration["ignore_filters"] = {} + if category not in configuration["ignore_filters"]: + configuration["ignore_filters"][category] = [] + + if criterias not in configuration["ignore_filters"][category]: + raise YunohostValidationError("This filter does not exists.") + + configuration["ignore_filters"][category].remove(criterias) + _diagnosis_write_configuration(configuration) + logger.success("Filter removed") + return + + +def _diagnosis_read_configuration(): + if not os.path.exists(DIAGNOSIS_CONFIG_FILE): + return {} + + return read_yaml(DIAGNOSIS_CONFIG_FILE) + + +def _diagnosis_write_configuration(conf): + write_to_yaml(DIAGNOSIS_CONFIG_FILE, conf) + + +def issue_matches_criterias(issue, criterias): + """ + e.g. an issue with: + meta: + domain: yolo.test + category: xmpp + + matches the criterias {"domain": "yolo.test"} + """ + for key, value in criterias.items(): + if key not in issue["meta"]: + return False + if str(issue["meta"][key]) != value: + return False + return True + + +def add_ignore_flag_to_issues(report): + """ + Iterate over issues in a report, and flag them as ignored if they match an + ignored filter from the configuration + + N.B. : for convenience. we want to make sure the "ignored" key is set for + every item in the report + """ + + ignore_filters = ( + _diagnosis_read_configuration().get("ignore_filters", {}).get(report["id"], []) + ) + + for report_item in report["items"]: + report_item["ignored"] = False + if report_item["status"] not in ["WARNING", "ERROR"]: + continue + for criterias in ignore_filters: + if issue_matches_criterias(report_item, criterias): + report_item["ignored"] = True + break + + +############################################################ + + +class Diagnoser: + def __init__(self, args, env, loggers): + + # FIXME ? That stuff with custom loggers is weird ... (mainly inherited from the bash hooks, idk) + self.logger_debug, self.logger_warning, self.logger_info = loggers + self.env = env + self.args = args or {} + self.cache_file = Diagnoser.cache_file(self.id_) + self.description = Diagnoser.get_description(self.id_) + + def cached_time_ago(self): + + if not os.path.exists(self.cache_file): + return 99999999 + return time.time() - os.path.getmtime(self.cache_file) + + def write_cache(self, report): + if not os.path.exists(DIAGNOSIS_CACHE): + os.makedirs(DIAGNOSIS_CACHE) + return write_to_json(self.cache_file, report) + + def diagnose(self): + + if ( + not self.args.get("force", False) + and self.cached_time_ago() < self.cache_duration + ): + self.logger_debug("Cache still valid : %s" % self.cache_file) + logger.info( + m18n.n("diagnosis_cache_still_valid", category=self.description) + ) + return 0, {} + + for dependency in self.dependencies: + dep_report = Diagnoser.get_cached_report(dependency) + + if dep_report["timestamp"] == -1: # No cache yet for this dep + dep_errors = True + else: + dep_errors = [ + item for item in dep_report["items"] if item["status"] == "ERROR" + ] + + if dep_errors: + logger.error( + m18n.n( + "diagnosis_cant_run_because_of_dep", + category=self.description, + dep=Diagnoser.get_description(dependency), + ) + ) + return 1, {} + + items = list(self.run()) + + for item in items: + if "details" in item and not item["details"]: + del item["details"] + + new_report = {"id": self.id_, "cached_for": self.cache_duration, "items": items} + + self.logger_debug("Updating cache %s" % self.cache_file) + self.write_cache(new_report) + Diagnoser.i18n(new_report) + add_ignore_flag_to_issues(new_report) + + errors = [ + item + for item in new_report["items"] + if item["status"] == "ERROR" and not item["ignored"] + ] + warnings = [ + item + for item in new_report["items"] + if item["status"] == "WARNING" and not item["ignored"] + ] + errors_ignored = [ + item + for item in new_report["items"] + if item["status"] == "ERROR" and item["ignored"] + ] + warning_ignored = [ + item + for item in new_report["items"] + if item["status"] == "WARNING" and item["ignored"] + ] + ignored_msg = ( + " " + + m18n.n( + "diagnosis_ignored_issues", + nb_ignored=len(errors_ignored + warning_ignored), + ) + if errors_ignored or warning_ignored + else "" + ) + + if errors and warnings: + logger.error( + m18n.n( + "diagnosis_found_errors_and_warnings", + errors=len(errors), + warnings=len(warnings), + category=new_report["description"], + ) + + ignored_msg + ) + elif errors: + logger.error( + m18n.n( + "diagnosis_found_errors", + errors=len(errors), + category=new_report["description"], + ) + + ignored_msg + ) + elif warnings: + logger.warning( + m18n.n( + "diagnosis_found_warnings", + warnings=len(warnings), + category=new_report["description"], + ) + + ignored_msg + ) + else: + logger.success( + m18n.n("diagnosis_everything_ok", category=new_report["description"]) + + ignored_msg + ) + + return 0, new_report + + @staticmethod + def cache_file(id_): + return os.path.join(DIAGNOSIS_CACHE, "%s.json" % id_) + + @staticmethod + def get_cached_report(id_, item=None, warn_if_no_cache=True): + cache_file = Diagnoser.cache_file(id_) + if not os.path.exists(cache_file): + if warn_if_no_cache: + logger.warning(m18n.n("diagnosis_no_cache", category=id_)) + report = {"id": id_, "cached_for": -1, "timestamp": -1, "items": []} + else: + report = read_json(cache_file) + report["timestamp"] = int(os.path.getmtime(cache_file)) + + if item: + for report_item in report["items"]: + if report_item.get("meta") == item: + return report_item + return {} + else: + return report + + @staticmethod + def get_description(id_): + key = "diagnosis_description_" + id_ + # If no description available, fallback to id + return m18n.n(key) if m18n.key_exists(key) else id_ + + @staticmethod + def i18n(report, force_remove_html_tags=False): + + # "Render" the strings with m18n.n + # N.B. : we do those m18n.n right now instead of saving the already-translated report + # because we can't be sure we'll redisplay the infos with the same locale as it + # was generated ... e.g. if the diagnosing happened inside a cron job with locale EN + # instead of FR used by the actual admin... + + report["description"] = Diagnoser.get_description(report["id"]) + + for item in report["items"]: + + # For the summary and each details, we want to call + # m18n() on the string, with the appropriate data for string + # formatting which can come from : + # - infos super-specific to the summary/details (if it's a tuple(key,dict_with_info) and not just a string) + # - 'meta' info = parameters of the test (e.g. which domain/category for DNS conf record) + # - actual 'data' retrieved from the test (e.g. actual global IP, ...) + + meta_data = item.get("meta", {}).copy() + meta_data.update(item.get("data", {})) + + html_tags = re.compile(r"<[^>]+>") + + def m18n_(info): + if not isinstance(info, tuple) and not isinstance(info, list): + info = (info, {}) + info[1].update(meta_data) + s = m18n.n(info[0], **(info[1])) + # In cli, we remove the html tags + if Moulinette.interface.type != "api" or force_remove_html_tags: + s = s.replace("", "'").replace("", "'") + s = html_tags.sub("", s.replace("
", "\n")) + else: + s = s.replace("", "").replace( + "", "" + ) + # Make it so that links open in new tabs + s = s.replace( + "URL: %s
Status code: %s" + % (url, r.status_code) + ) + if r.status_code == 400: + raise Exception("Diagnosis request was refused: %s" % r.content) + + try: + r = r.json() + except Exception as e: + raise Exception( + "Failed to parse json from diagnosis server response.\nError: %s\nOriginal content: %s" + % (e, r.content) + ) + + return r + + +def _list_diagnosis_categories(): + hooks_raw = hook_list("diagnosis", list_by="priority", show_info=True)["hooks"] + hooks = [] + for _, some_hooks in sorted(hooks_raw.items(), key=lambda h: int(h[0])): + for name, info in some_hooks.items(): + hooks.append((name, info["path"])) + + return hooks + + +def _email_diagnosis_issues(): + from yunohost.domain import _get_maindomain + + maindomain = _get_maindomain() + from_ = "diagnosis@%s (Automatic diagnosis on %s)" % (maindomain, maindomain) + to_ = "root" + subject_ = "Issues found by automatic diagnosis on %s" % maindomain + + disclaimer = "The automatic diagnosis on your YunoHost server identified some issues on your server. You will find a description of the issues below. You can manage those issues in the 'Diagnosis' section in your webadmin." + + issues = diagnosis_show(issues=True)["reports"] + if not issues: + return + + content = _dump_human_readable_reports(issues) + + message = """\ +From: %s +To: %s +Subject: %s + +%s + +--- + +%s +""" % ( + from_, + to_, + subject_, + disclaimer, + content, + ) + + import smtplib + + smtp = smtplib.SMTP("localhost") + smtp.sendmail(from_, [to_], message.encode("utf-8")) + smtp.quit() diff --git a/src/yunohost/dns.py b/src/yunohost/dns.py new file mode 100644 index 000000000..0581fa82c --- /dev/null +++ b/src/yunohost/dns.py @@ -0,0 +1,1002 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2013 YunoHost + + 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 + +""" + +""" yunohost_domain.py + + Manage domains +""" +import os +import re +import time + +from difflib import SequenceMatcher +from collections import OrderedDict + +from moulinette import m18n, Moulinette +from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import read_file, write_to_file, read_toml + +from yunohost.domain import ( + domain_list, + _assert_domain_exists, + domain_config_get, + _get_domain_settings, + _set_domain_settings, +) +from yunohost.utils.dns import dig, YNH_DYNDNS_DOMAINS +from yunohost.utils.error import YunohostValidationError, YunohostError +from yunohost.utils.network import get_public_ip +from yunohost.log import is_unit_operation +from yunohost.hook import hook_callback + +logger = getActionLogger("yunohost.domain") + +DOMAIN_REGISTRAR_LIST_PATH = "/usr/share/yunohost/other/registrar_list.toml" + + +def domain_dns_suggest(domain): + """ + Generate DNS configuration for a domain + + Keyword argument: + domain -- Domain name + + """ + + _assert_domain_exists(domain) + + dns_conf = _build_dns_conf(domain) + + result = "" + + if dns_conf["basic"]: + result += "; Basic ipv4/ipv6 records" + for record in dns_conf["basic"]: + result += "\n{name} {ttl} IN {type} {value}".format(**record) + + if dns_conf["mail"]: + result += "\n\n" + result += "; Mail" + for record in dns_conf["mail"]: + result += "\n{name} {ttl} IN {type} {value}".format(**record) + result += "\n\n" + + if dns_conf["xmpp"]: + result += "\n\n" + result += "; XMPP" + for record in dns_conf["xmpp"]: + result += "\n{name} {ttl} IN {type} {value}".format(**record) + + if dns_conf["extra"]: + result += "; Extra" + for record in dns_conf["extra"]: + result += "\n{name} {ttl} IN {type} {value}".format(**record) + + for name, record_list in dns_conf.items(): + if name not in ("basic", "xmpp", "mail", "extra") and record_list: + result += "\n\n" + result += "; " + name + for record in record_list: + result += "\n{name} {ttl} IN {type} {value}".format(**record) + + if Moulinette.interface.type == "cli": + # FIXME Update this to point to our "dns push" doc + logger.info(m18n.n("domain_dns_conf_is_just_a_recommendation")) + + return result + + +def _list_subdomains_of(parent_domain): + + _assert_domain_exists(parent_domain) + + out = [] + for domain in domain_list()["domains"]: + if domain.endswith(f".{parent_domain}"): + out.append(domain) + + return out + + +def _build_dns_conf(base_domain, include_empty_AAAA_if_no_ipv6=False): + """ + Internal function that will returns a data structure containing the needed + information to generate/adapt the dns configuration + + Arguments: + domains -- List of a domain and its subdomains + + The returned datastructure will have the following form: + { + "basic": [ + # if ipv4 available + {"type": "A", "name": "@", "value": "123.123.123.123", "ttl": 3600}, + # if ipv6 available + {"type": "AAAA", "name": "@", "value": "valid-ipv6", "ttl": 3600}, + ], + "xmpp": [ + {"type": "SRV", "name": "_xmpp-client._tcp", "value": "0 5 5222 domain.tld.", "ttl": 3600}, + {"type": "SRV", "name": "_xmpp-server._tcp", "value": "0 5 5269 domain.tld.", "ttl": 3600}, + {"type": "CNAME", "name": "muc", "value": "@", "ttl": 3600}, + {"type": "CNAME", "name": "pubsub", "value": "@", "ttl": 3600}, + {"type": "CNAME", "name": "vjud", "value": "@", "ttl": 3600} + {"type": "CNAME", "name": "xmpp-upload", "value": "@", "ttl": 3600} + ], + "mail": [ + {"type": "MX", "name": "@", "value": "10 domain.tld.", "ttl": 3600}, + {"type": "TXT", "name": "@", "value": "\"v=spf1 a mx ip4:123.123.123.123 ipv6:valid-ipv6 -all\"", "ttl": 3600 }, + {"type": "TXT", "name": "mail._domainkey", "value": "\"v=DKIM1; k=rsa; p=some-super-long-key\"", "ttl": 3600}, + {"type": "TXT", "name": "_dmarc", "value": "\"v=DMARC1; p=none\"", "ttl": 3600} + ], + "extra": [ + # if ipv4 available + {"type": "A", "name": "*", "value": "123.123.123.123", "ttl": 3600}, + # if ipv6 available + {"type": "AAAA", "name": "*", "value": "valid-ipv6", "ttl": 3600}, + {"type": "CAA", "name": "@", "value": "128 issue \"letsencrypt.org\"", "ttl": 3600}, + ], + "example_of_a_custom_rule": [ + {"type": "SRV", "name": "_matrix", "value": "domain.tld.", "ttl": 3600} + ], + } + """ + + basic = [] + mail = [] + xmpp = [] + extra = [] + ipv4 = get_public_ip() + ipv6 = get_public_ip(6) + + # If this is a ynh_dyndns_domain, we're not gonna include all the subdomains in the conf + # Because dynette only accept a specific list of name/type + # And the wildcard */A already covers the bulk of use cases + if any( + base_domain.endswith("." + ynh_dyndns_domain) + for ynh_dyndns_domain in YNH_DYNDNS_DOMAINS + ): + subdomains = [] + else: + subdomains = _list_subdomains_of(base_domain) + + domains_settings = { + domain: domain_config_get(domain, export=True) + for domain in [base_domain] + subdomains + } + + base_dns_zone = _get_dns_zone_for_domain(base_domain) + + for domain, settings in domains_settings.items(): + + # Domain # Base DNS zone # Basename # Suffix # + # ------------------ # ----------------- # --------- # -------- # + # domain.tld # domain.tld # @ # # + # sub.domain.tld # domain.tld # sub # .sub # + # foo.sub.domain.tld # domain.tld # foo.sub # .foo.sub # + # sub.domain.tld # sub.domain.tld # @ # # + # foo.sub.domain.tld # sub.domain.tld # foo # .foo # + + basename = domain.replace(base_dns_zone, "").rstrip(".") or "@" + suffix = f".{basename}" if basename != "@" else "" + + # ttl = settings["ttl"] + ttl = 3600 + + ########################### + # Basic ipv4/ipv6 records # + ########################### + if ipv4: + basic.append([basename, ttl, "A", ipv4]) + + if ipv6: + basic.append([basename, ttl, "AAAA", ipv6]) + elif include_empty_AAAA_if_no_ipv6: + basic.append([basename, ttl, "AAAA", None]) + + ######### + # Email # + ######### + if settings["mail_in"]: + mail.append([basename, ttl, "MX", f"10 {domain}."]) + + if settings["mail_out"]: + mail.append([basename, ttl, "TXT", '"v=spf1 a mx -all"']) + + # DKIM/DMARC record + dkim_host, dkim_publickey = _get_DKIM(domain) + + if dkim_host: + mail += [ + [f"{dkim_host}{suffix}", ttl, "TXT", dkim_publickey], + [f"_dmarc{suffix}", ttl, "TXT", '"v=DMARC1; p=none"'], + ] + + ######## + # XMPP # + ######## + if settings["xmpp"]: + xmpp += [ + [ + f"_xmpp-client._tcp{suffix}", + ttl, + "SRV", + f"0 5 5222 {domain}.", + ], + [ + f"_xmpp-server._tcp{suffix}", + ttl, + "SRV", + f"0 5 5269 {domain}.", + ], + [f"muc{suffix}", ttl, "CNAME", basename], + [f"pubsub{suffix}", ttl, "CNAME", basename], + [f"vjud{suffix}", ttl, "CNAME", basename], + [f"xmpp-upload{suffix}", ttl, "CNAME", basename], + ] + + ######### + # Extra # + ######### + + # Only recommend wildcard and CAA for the top level + if domain == base_domain: + if ipv4: + extra.append([f"*{suffix}", ttl, "A", ipv4]) + + if ipv6: + extra.append([f"*{suffix}", ttl, "AAAA", ipv6]) + elif include_empty_AAAA_if_no_ipv6: + extra.append([f"*{suffix}", ttl, "AAAA", None]) + + extra.append([basename, ttl, "CAA", '128 issue "letsencrypt.org"']) + + #################### + # Standard records # + #################### + + records = { + "basic": [ + {"name": name, "ttl": ttl_, "type": type_, "value": value} + for name, ttl_, type_, value in basic + ], + "xmpp": [ + {"name": name, "ttl": ttl_, "type": type_, "value": value} + for name, ttl_, type_, value in xmpp + ], + "mail": [ + {"name": name, "ttl": ttl_, "type": type_, "value": value} + for name, ttl_, type_, value in mail + ], + "extra": [ + {"name": name, "ttl": ttl_, "type": type_, "value": value} + for name, ttl_, type_, value in extra + ], + } + + ################## + # Custom records # + ################## + + # Defined by custom hooks ships in apps for example ... + + hook_results = hook_callback("custom_dns_rules", args=[base_domain]) + for hook_name, results in hook_results.items(): + # + # There can be multiple results per hook name, so results look like + # {'/some/path/to/hook1': + # { 'state': 'succeed', + # 'stdreturn': [{'type': 'SRV', + # 'name': 'stuff.foo.bar.', + # 'value': 'yoloswag', + # 'ttl': 3600}] + # }, + # '/some/path/to/hook2': + # { ... }, + # [...] + # + # Loop over the sub-results + custom_records = [ + v["stdreturn"] for v in results.values() if v and v["stdreturn"] + ] + + records[hook_name] = [] + for record_list in custom_records: + # Check that record_list is indeed a list of dict + # with the required keys + if ( + not isinstance(record_list, list) + or any(not isinstance(record, dict) for record in record_list) + or any( + key not in record + for record in record_list + for key in ["name", "ttl", "type", "value"] + ) + ): + # Display an error, mainly for app packagers trying to implement a hook + logger.warning( + "Ignored custom record from hook '%s' because the data is not a *list* of dict with keys name, ttl, type and value. Raw data : %s" + % (hook_name, record_list) + ) + continue + + records[hook_name].extend(record_list) + + return records + + +def _get_DKIM(domain): + DKIM_file = "/etc/dkim/{domain}.mail.txt".format(domain=domain) + + if not os.path.isfile(DKIM_file): + return (None, None) + + with open(DKIM_file) as f: + dkim_content = f.read() + + # Gotta manage two formats : + # + # Legacy + # ----- + # + # mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " + # "p=" ) + # + # New + # ------ + # + # mail._domainkey IN TXT ( "v=DKIM1; h=sha256; k=rsa; " + # "p=" ) + + is_legacy_format = " h=sha256; " not in dkim_content + + # Legacy DKIM format + if is_legacy_format: + dkim = re.match( + ( + r"^(?P[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+" + r'[^"]*"v=(?P[^";]+);' + r'[\s"]*k=(?P[^";]+);' + r'[\s"]*p=(?P

[^";]+)' + ), + dkim_content, + re.M | re.S, + ) + else: + dkim = re.match( + ( + r"^(?P[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+" + r'[^"]*"v=(?P[^";]+);' + r'[\s"]*h=(?P[^";]+);' + r'[\s"]*k=(?P[^";]+);' + r'[\s"]*p=(?P

[^";]+)' + ), + dkim_content, + re.M | re.S, + ) + + if not dkim: + return (None, None) + + if is_legacy_format: + return ( + dkim.group("host"), + '"v={v}; k={k}; p={p}"'.format( + v=dkim.group("v"), k=dkim.group("k"), p=dkim.group("p") + ), + ) + else: + return ( + dkim.group("host"), + '"v={v}; h={h}; k={k}; p={p}"'.format( + v=dkim.group("v"), + h=dkim.group("h"), + k=dkim.group("k"), + p=dkim.group("p"), + ), + ) + + +def _get_dns_zone_for_domain(domain): + """ + Get the DNS zone of a domain + + Keyword arguments: + domain -- The domain name + + """ + + # First, check if domain is a nohost.me / noho.st / ynh.fr + # This is mainly meant to speed up things for "dyndns update" + # ... otherwise we end up constantly doing a bunch of dig requests + for ynh_dyndns_domain in YNH_DYNDNS_DOMAINS: + if domain.endswith("." + ynh_dyndns_domain): + return ynh_dyndns_domain + + # Check cache + cache_folder = "/var/cache/yunohost/dns_zones" + cache_file = f"{cache_folder}/{domain}" + cache_duration = 3600 # one hour + if ( + os.path.exists(cache_file) + and abs(os.path.getctime(cache_file) - time.time()) < cache_duration + ): + dns_zone = read_file(cache_file).strip() + if dns_zone: + return dns_zone + + # Check cache for parent domain + # This is another strick to try to prevent this function from being + # a bottleneck on system with 1 main domain + 10ish subdomains + # when building the dns conf for the main domain (which will call domain_config_get, etc...) + parent_domain = domain.split(".", 1)[1] + if parent_domain in domain_list()["domains"]: + parent_cache_file = f"{cache_folder}/{parent_domain}" + if ( + os.path.exists(parent_cache_file) + and abs(os.path.getctime(parent_cache_file) - time.time()) < cache_duration + ): + dns_zone = read_file(parent_cache_file).strip() + if dns_zone: + return dns_zone + + # For foo.bar.baz.gni we want to scan all the parent domains + # (including the domain itself) + # foo.bar.baz.gni + # bar.baz.gni + # baz.gni + # gni + # Until we find the first one that has a NS record + parent_list = [domain.split(".", i)[-1] for i, _ in enumerate(domain.split("."))] + + for parent in parent_list: + + # Check if there's a NS record for that domain + answer = dig(parent, rdtype="NS", full_answers=True, resolvers="force_external") + if answer[0] == "ok": + os.system(f"mkdir -p {cache_folder}") + write_to_file(cache_file, parent) + return parent + + if len(parent_list) >= 2: + zone = parent_list[-2] + else: + zone = parent_list[-1] + + logger.warning( + f"Could not identify the dns zone for domain {domain}, returning {zone}" + ) + return zone + + +def _get_registrar_config_section(domain): + + from lexicon.providers.auto import _relevant_provider_for_domain + + registrar_infos = {} + + dns_zone = _get_dns_zone_for_domain(domain) + + # If parent domain exists in yunohost + parent_domain = domain.split(".", 1)[1] + if parent_domain in domain_list()["domains"]: + + # Dirty hack to have a link on the webadmin + if Moulinette.interface.type == "api": + parent_domain_link = f"[{parent_domain}](#/domains/{parent_domain}/config)" + else: + parent_domain_link = parent_domain + + registrar_infos["registrar"] = OrderedDict( + { + "type": "alert", + "style": "info", + "ask": m18n.n( + "domain_dns_registrar_managed_in_parent_domain", + parent_domain=domain, + parent_domain_link=parent_domain_link, + ), + "value": "parent_domain", + } + ) + return OrderedDict(registrar_infos) + + # TODO big project, integrate yunohost's dynette as a registrar-like provider + # TODO big project, integrate other dyndns providers such as netlib.re, or cf the list of dyndns providers supported by cloudron... + if dns_zone in YNH_DYNDNS_DOMAINS: + registrar_infos["registrar"] = OrderedDict( + { + "type": "alert", + "style": "success", + "ask": m18n.n("domain_dns_registrar_yunohost"), + "value": "yunohost", + } + ) + return OrderedDict(registrar_infos) + + try: + registrar = _relevant_provider_for_domain(dns_zone)[0] + except ValueError: + registrar_infos["registrar"] = OrderedDict( + { + "type": "alert", + "style": "warning", + "ask": m18n.n("domain_dns_registrar_not_supported"), + "value": None, + } + ) + else: + + registrar_infos["registrar"] = OrderedDict( + { + "type": "alert", + "style": "info", + "ask": m18n.n("domain_dns_registrar_supported", registrar=registrar), + "value": registrar, + } + ) + + TESTED_REGISTRARS = ["ovh", "gandi"] + if registrar not in TESTED_REGISTRARS: + registrar_infos["experimental_disclaimer"] = OrderedDict( + { + "type": "alert", + "style": "danger", + "ask": m18n.n( + "domain_dns_registrar_experimental", registrar=registrar + ), + } + ) + + # TODO : add a help tip with the link to the registar's API doc (c.f. Lexicon's README) + registrar_list = read_toml(DOMAIN_REGISTRAR_LIST_PATH) + registrar_credentials = registrar_list[registrar] + for credential, infos in registrar_credentials.items(): + infos["default"] = infos.get("default", "") + infos["optional"] = infos.get("optional", "False") + registrar_infos.update(registrar_credentials) + + return OrderedDict(registrar_infos) + + +def _get_registar_settings(domain): + + _assert_domain_exists(domain) + + settings = domain_config_get(domain, key="dns.registrar", export=True) + + registrar = settings.pop("registrar") + + if "experimental_disclaimer" in settings: + settings.pop("experimental_disclaimer") + + return registrar, settings + + +@is_unit_operation() +def domain_dns_push(operation_logger, domain, dry_run=False, force=False, purge=False): + """ + Send DNS records to the previously-configured registrar of the domain. + """ + + from lexicon.client import Client as LexiconClient + from lexicon.config import ConfigResolver as LexiconConfigResolver + + registrar, registrar_credentials = _get_registar_settings(domain) + + _assert_domain_exists(domain) + + if not registrar or registrar == "None": # yes it's None as a string + raise YunohostValidationError("domain_dns_push_not_applicable", domain=domain) + + # FIXME: in the future, properly unify this with yunohost dyndns update + if registrar == "yunohost": + logger.info(m18n.n("domain_dns_registrar_yunohost")) + return {} + + if registrar == "parent_domain": + parent_domain = domain.split(".", 1)[1] + registar, registrar_credentials = _get_registar_settings(parent_domain) + if any(registrar_credentials.values()): + raise YunohostValidationError( + "domain_dns_push_managed_in_parent_domain", + domain=domain, + parent_domain=parent_domain, + ) + else: + raise YunohostValidationError( + "domain_registrar_is_not_configured", domain=parent_domain + ) + + if not all(registrar_credentials.values()): + raise YunohostValidationError( + "domain_registrar_is_not_configured", domain=domain + ) + + base_dns_zone = _get_dns_zone_for_domain(domain) + + # Convert the generated conf into a format that matches what we'll fetch using the API + # Makes it easier to compare "wanted records" with "current records on remote" + wanted_records = [] + for records in _build_dns_conf(domain).values(): + for record in records: + + # Make sure the name is a FQDN + name = ( + f"{record['name']}.{base_dns_zone}" + if record["name"] != "@" + else base_dns_zone + ) + type_ = record["type"] + content = record["value"] + + # Make sure the content is also a FQDN (with trailing . ?) + if content == "@" and record["type"] == "CNAME": + content = base_dns_zone + "." + + wanted_records.append( + {"name": name, "type": type_, "ttl": record["ttl"], "content": content} + ) + + # FIXME Lexicon does not support CAA records + # See https://github.com/AnalogJ/lexicon/issues/282 and https://github.com/AnalogJ/lexicon/pull/371 + # They say it's trivial to implement it! + # And yet, it is still not done/merged + # Update by Aleks: it works - at least with Gandi ?! + # wanted_records = [record for record in wanted_records if record["type"] != "CAA"] + + if purge: + wanted_records = [] + force = True + + # Construct the base data structure to use lexicon's API. + + base_config = { + "provider_name": registrar, + "domain": base_dns_zone, + registrar: registrar_credentials, + } + + # Ugly hack to be able to fetch all record types at once: + # we initialize a LexiconClient with a dummy type "all" + # (which lexicon doesnt actually understands) + # then trigger ourselves the authentication + list_records + # instead of calling .execute() + query = ( + LexiconConfigResolver() + .with_dict(dict_object=base_config) + .with_dict(dict_object={"action": "list", "type": "all"}) + ) + client = LexiconClient(query) + try: + client.provider.authenticate() + except Exception as e: + raise YunohostValidationError( + "domain_dns_push_failed_to_authenticate", domain=domain, error=str(e) + ) + + try: + current_records = client.provider.list_records() + except Exception as e: + raise YunohostError("domain_dns_push_failed_to_list", error=str(e)) + + managed_dns_records_hashes = _get_managed_dns_records_hashes(domain) + + # Keep only records for relevant types: A, AAAA, MX, TXT, CNAME, SRV + relevant_types = ["A", "AAAA", "MX", "TXT", "CNAME", "SRV", "CAA"] + current_records = [r for r in current_records if r["type"] in relevant_types] + + # Ignore records which are for a higher-level domain + # i.e. we don't care about the records for domain.tld when pushing yuno.domain.tld + current_records = [ + r + for r in current_records + if r["name"].endswith(f".{domain}") or r["name"] == domain + ] + + for record in current_records: + + # Try to get rid of weird stuff like ".domain.tld" or "@.domain.tld" + record["name"] = record["name"].strip("@").strip(".") + + # Some API return '@' in content and we shall convert it to absolute/fqdn + record["content"] = ( + record["content"] + .replace("@.", base_dns_zone + ".") + .replace("@", base_dns_zone + ".") + ) + + if record["type"] == "TXT": + if not record["content"].startswith('"'): + record["content"] = '"' + record["content"] + if not record["content"].endswith('"'): + record["content"] = record["content"] + '"' + + # Check if this record was previously set by YunoHost + record["managed_by_yunohost"] = ( + _hash_dns_record(record) in managed_dns_records_hashes + ) + + # Step 0 : Get the list of unique (type, name) + # And compare the current and wanted records + # + # i.e. we want this kind of stuff: + # wanted current + # (A, .domain.tld) 1.2.3.4 1.2.3.4 + # (A, www.domain.tld) 1.2.3.4 5.6.7.8 + # (A, foobar.domain.tld) 1.2.3.4 + # (AAAA, .domain.tld) 2001::abcd + # (MX, .domain.tld) 10 domain.tld [10 mx1.ovh.net, 20 mx2.ovh.net] + # (TXT, .domain.tld) "v=spf1 ..." ["v=spf1", "foobar"] + # (SRV, .domain.tld) 0 5 5269 domain.tld + changes = {"delete": [], "update": [], "create": [], "unchanged": []} + + type_and_names = sorted( + set([(r["type"], r["name"]) for r in current_records + wanted_records]) + ) + comparison = { + type_and_name: {"current": [], "wanted": []} for type_and_name in type_and_names + } + + for record in current_records: + comparison[(record["type"], record["name"])]["current"].append(record) + + for record in wanted_records: + comparison[(record["type"], record["name"])]["wanted"].append(record) + + for type_and_name, records in comparison.items(): + + # + # Step 1 : compute a first "diff" where we remove records which are the same on both sides + # + wanted_contents = [r["content"] for r in records["wanted"]] + current_contents = [r["content"] for r in records["current"]] + + current = [r for r in records["current"] if r["content"] not in wanted_contents] + wanted = [r for r in records["wanted"] if r["content"] not in current_contents] + + # + # Step 2 : simple case: 0 record on one side, 0 on the other + # -> either nothing do (0/0) or creations (0/N) or deletions (N/0) + # + if len(current) == 0 and len(wanted) == 0: + # No diff, nothing to do + changes["unchanged"].extend(records["current"]) + continue + + elif len(wanted) == 0: + changes["delete"].extend(current) + continue + + elif len(current) == 0: + changes["create"].extend(wanted) + continue + + # + # Step 3 : N record on one side, M on the other + # + # Fuzzy matching strategy: + # For each wanted record, try to find a current record which looks like the wanted one + # -> if found, trigger an update + # -> if no match found, trigger a create + # + for record in wanted: + + def likeliness(r): + # We compute this only on the first 100 chars, to have a high value even for completely different DKIM keys + return SequenceMatcher( + None, r["content"][:100], record["content"][:100] + ).ratio() + + matches = sorted(current, key=lambda r: likeliness(r), reverse=True) + if matches and likeliness(matches[0]) > 0.50: + match = matches[0] + # Remove the match from 'current' so that it's not added to the removed stuff later + current.remove(match) + match["old_content"] = match["content"] + match["content"] = record["content"] + changes["update"].append(match) + else: + changes["create"].append(record) + + # + # For all other remaining current records: + # -> trigger deletions + # + for record in current: + changes["delete"].append(record) + + def relative_name(name): + name = name.strip(".") + name = name.replace("." + base_dns_zone, "") + name = name.replace(base_dns_zone, "@") + return name + + def human_readable_record(action, record): + name = relative_name(record["name"]) + name = name[:20] + t = record["type"] + + if not force and action in ["update", "delete"]: + ignored = ( + "" + if record["managed_by_yunohost"] + else "(ignored, won't be changed by Yunohost unless forced)" + ) + else: + ignored = "" + + if action == "create": + old_content = record.get("old_content", "(None)")[:30] + new_content = record.get("content", "(None)")[:30] + return f"{name:>20} [{t:^5}] {new_content:^30} {ignored}" + elif action == "update": + old_content = record.get("old_content", "(None)")[:30] + new_content = record.get("content", "(None)")[:30] + return ( + f"{name:>20} [{t:^5}] {old_content:^30} -> {new_content:^30} {ignored}" + ) + elif action == "unchanged": + old_content = new_content = record.get("content", "(None)")[:30] + return f"{name:>20} [{t:^5}] {old_content:^30}" + else: + old_content = record.get("content", "(None)")[:30] + return f"{name:>20} [{t:^5}] {old_content:^30} {ignored}" + + if dry_run: + if Moulinette.interface.type == "api": + for records in changes.values(): + for record in records: + record["name"] = relative_name(record["name"]) + return changes + else: + out = {"delete": [], "create": [], "update": [], "unchanged": []} + for action in ["delete", "create", "update", "unchanged"]: + for record in changes[action]: + out[action].append(human_readable_record(action, record)) + + return out + + # If --force ain't used, we won't delete/update records not managed by yunohost + if not force: + for action in ["delete", "update"]: + changes[action] = [r for r in changes[action] if r["managed_by_yunohost"]] + + def progress(info=""): + progress.nb += 1 + width = 20 + bar = int(progress.nb * width / progress.total) + bar = "[" + "#" * bar + "." * (width - bar) + "]" + if info: + bar += " > " + info + if progress.old == bar: + return + progress.old = bar + logger.info(bar) + + progress.nb = 0 + progress.old = "" + progress.total = len(changes["delete"] + changes["create"] + changes["update"]) + + if progress.total == 0: + logger.success(m18n.n("domain_dns_push_already_up_to_date")) + return {} + + # + # Actually push the records + # + + operation_logger.start() + logger.info(m18n.n("domain_dns_pushing")) + + new_managed_dns_records_hashes = [_hash_dns_record(r) for r in changes["unchanged"]] + results = {"warnings": [], "errors": []} + + for action in ["delete", "create", "update"]: + + for record in changes[action]: + + relative_name = record["name"].replace(base_dns_zone, "").rstrip(".") or "@" + progress( + f"{action} {record['type']:^5} / {relative_name}" + ) # FIXME: i18n but meh + + # Apparently Lexicon yields us some 'id' during fetch + # But wants 'identifier' during push ... + if "id" in record: + record["identifier"] = record["id"] + del record["id"] + + if registrar == "godaddy": + if record["name"] == base_dns_zone: + record["name"] = "@." + record["name"] + if record["type"] in ["MX", "SRV", "CAA"]: + logger.warning( + f"Pushing {record['type']} records is not properly supported by Lexicon/Godaddy." + ) + results["warnings"].append( + f"Pushing {record['type']} records is not properly supported by Lexicon/Godaddy." + ) + continue + + record["action"] = action + query = ( + LexiconConfigResolver() + .with_dict(dict_object=base_config) + .with_dict(dict_object=record) + ) + + try: + result = LexiconClient(query).execute() + except Exception as e: + msg = m18n.n( + "domain_dns_push_record_failed", + action=action, + type=record["type"], + name=record["name"], + error=str(e), + ) + logger.error(msg) + results["errors"].append(msg) + else: + if result: + new_managed_dns_records_hashes.append(_hash_dns_record(record)) + else: + msg = m18n.n( + "domain_dns_push_record_failed", + action=action, + type=record["type"], + name=record["name"], + error="unkonwn error?", + ) + logger.error(msg) + results["errors"].append(msg) + + _set_managed_dns_records_hashes(domain, new_managed_dns_records_hashes) + + # Everything succeeded + if len(results["errors"]) + len(results["warnings"]) == 0: + logger.success(m18n.n("domain_dns_push_success")) + return {} + # Everything failed + elif len(results["errors"]) + len(results["warnings"]) == progress.total: + logger.error(m18n.n("domain_dns_push_failed")) + else: + logger.warning(m18n.n("domain_dns_push_partial_failure")) + + return results + + +def _get_managed_dns_records_hashes(domain: str) -> list: + return _get_domain_settings(domain).get("managed_dns_records_hashes", []) + + +def _set_managed_dns_records_hashes(domain: str, hashes: list) -> None: + settings = _get_domain_settings(domain) + settings["managed_dns_records_hashes"] = hashes or [] + _set_domain_settings(domain, settings) + + +def _hash_dns_record(record: dict) -> int: + + fields = ["name", "type", "content"] + record_ = {f: record.get(f) for f in fields} + + return hash(frozenset(record_.items())) diff --git a/src/yunohost/domain.py b/src/yunohost/domain.py index 42a4881ba..1f96ced8a 100644 --- a/src/yunohost/domain.py +++ b/src/yunohost/domain.py @@ -24,44 +24,85 @@ Manage domains """ import os -import re -import yaml +from typing import Dict, Any -from moulinette import m18n, msettings +from moulinette import m18n, Moulinette from moulinette.core import MoulinetteError -from yunohost.utils.error import YunohostError from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import write_to_file, read_yaml, write_to_yaml -import yunohost.certificate - -from yunohost.regenconf import regen_conf -from yunohost.utils.network import get_public_ip +from yunohost.app import ( + app_ssowatconf, + _installed_apps, + _get_app_settings, + _get_conflicting_apps, +) +from yunohost.regenconf import regen_conf, _force_clear_hashes, _process_regen_conf +from yunohost.utils.config import ConfigPanel, Question +from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.log import is_unit_operation -from yunohost.hook import hook_callback -logger = getActionLogger('yunohost.domain') +logger = getActionLogger("yunohost.domain") + +DOMAIN_CONFIG_PATH = "/usr/share/yunohost/other/config_domain.toml" +DOMAIN_SETTINGS_DIR = "/etc/yunohost/domains" + +# Lazy dev caching to avoid re-query ldap every time we need the domain list +domain_list_cache: Dict[str, Any] = {} -def domain_list(): +def domain_list(exclude_subdomains=False): """ List domains Keyword argument: - filter -- LDAP filter used to search - offset -- Starting number for domain fetching - limit -- Maximum number of domain fetched + exclude_subdomains -- Filter out domains that are subdomains of other declared domains """ + global domain_list_cache + if not exclude_subdomains and domain_list_cache: + return domain_list_cache + from yunohost.utils.ldap import _get_ldap_interface ldap = _get_ldap_interface() - result = ldap.search('ou=domains,dc=yunohost,dc=org', 'virtualdomain=*', ['virtualdomain']) + result = [ + entry["virtualdomain"][0] + for entry in ldap.search( + "ou=domains,dc=yunohost,dc=org", "virtualdomain=*", ["virtualdomain"] + ) + ] result_list = [] for domain in result: - result_list.append(domain['virtualdomain'][0]) + if exclude_subdomains: + parent_domain = domain.split(".", 1)[1] + if parent_domain in result: + continue - return {'domains': result_list} + result_list.append(domain) + + def cmp_domain(domain): + # Keep the main part of the domain and the extension together + # eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this'] + domain = domain.split(".") + domain[-1] = domain[-2] + domain.pop() + domain = list(reversed(domain)) + return domain + + result_list = sorted(result_list, key=cmp_domain) + + # Don't cache answer if using exclude_subdomains + if exclude_subdomains: + return {"domains": result_list, "main": _get_maindomain()} + + domain_list_cache = {"domains": result_list, "main": _get_maindomain()} + return domain_list_cache + + +def _assert_domain_exists(domain): + if domain not in domain_list()["domains"]: + raise YunohostValidationError("domain_name_unknown", domain=domain) @is_unit_operation() @@ -77,208 +118,259 @@ def domain_add(operation_logger, domain, dyndns=False): from yunohost.hook import hook_callback from yunohost.app import app_ssowatconf from yunohost.utils.ldap import _get_ldap_interface + from yunohost.certificate import _certificate_install_selfsigned + + if domain.startswith("xmpp-upload."): + raise YunohostValidationError("domain_cannot_add_xmpp_upload") ldap = _get_ldap_interface() try: - ldap.validate_uniqueness({'virtualdomain': domain}) + ldap.validate_uniqueness({"virtualdomain": domain}) except MoulinetteError: - raise YunohostError('domain_exists') + raise YunohostValidationError("domain_exists") - operation_logger.start() + # Lower domain to avoid some edge cases issues + # See: https://forum.yunohost.org/t/invalid-domain-causes-diagnosis-web-to-fail-fr-on-demand/11765 + domain = domain.lower() + + # Non-latin characters (e.g. café.com => xn--caf-dma.com) + domain = domain.encode("idna").decode("utf-8") # DynDNS domain if dyndns: - # Do not allow to subscribe to multiple dyndns domains... - if os.path.exists('/etc/cron.d/yunohost-dyndns'): - raise YunohostError('domain_dyndns_already_subscribed') + from yunohost.dyndns import _dyndns_provides, _guess_current_dyndns_domain - from yunohost.dyndns import dyndns_subscribe, _dyndns_provides + # Do not allow to subscribe to multiple dyndns domains... + if _guess_current_dyndns_domain("dyndns.yunohost.org") != (None, None): + raise YunohostValidationError("domain_dyndns_already_subscribed") # Check that this domain can effectively be provided by # dyndns.yunohost.org. (i.e. is it a nohost.me / noho.st) if not _dyndns_provides("dyndns.yunohost.org", domain): - raise YunohostError('domain_dyndns_root_unknown') + raise YunohostValidationError("domain_dyndns_root_unknown") + + operation_logger.start() + + if dyndns: + from yunohost.dyndns import dyndns_subscribe # Actually subscribe dyndns_subscribe(domain=domain) - try: - yunohost.certificate._certificate_install_selfsigned([domain], False) + _certificate_install_selfsigned([domain], False) + try: attr_dict = { - 'objectClass': ['mailDomain', 'top'], - 'virtualdomain': domain, + "objectClass": ["mailDomain", "top"], + "virtualdomain": domain, } - if not ldap.add('virtualdomain=%s,ou=domains' % domain, attr_dict): - raise YunohostError('domain_creation_failed') + try: + ldap.add("virtualdomain=%s,ou=domains" % domain, attr_dict) + except Exception as e: + raise YunohostError("domain_creation_failed", domain=domain, error=e) + finally: + global domain_list_cache + domain_list_cache = {} # Don't regen these conf if we're still in postinstall - if os.path.exists('/etc/yunohost/installed'): - regen_conf(names=['nginx', 'metronome', 'dnsmasq', 'postfix', 'rspamd']) + if os.path.exists("/etc/yunohost/installed"): + # Sometime we have weird issues with the regenconf where some files + # appears as manually modified even though they weren't touched ... + # There are a few ideas why this happens (like backup/restore nginx + # conf ... which we shouldnt do ...). This in turns creates funky + # situation where the regenconf may refuse to re-create the conf + # (when re-creating a domain..) + # So here we force-clear the has out of the regenconf if it exists. + # This is a pretty ad hoc solution and only applied to nginx + # because it's one of the major service, but in the long term we + # should identify the root of this bug... + _force_clear_hashes(["/etc/nginx/conf.d/%s.conf" % domain]) + regen_conf( + names=["nginx", "metronome", "dnsmasq", "postfix", "rspamd", "mdns"] + ) app_ssowatconf() - except Exception: + except Exception as e: # Force domain removal silently try: - domain_remove(domain, True) - except: + domain_remove(domain, force=True) + except Exception: pass - raise + raise e - hook_callback('post_domain_add', args=[domain]) + hook_callback("post_domain_add", args=[domain]) - logger.success(m18n.n('domain_created')) + logger.success(m18n.n("domain_created")) @is_unit_operation() -def domain_remove(operation_logger, domain, force=False): +def domain_remove(operation_logger, domain, remove_apps=False, force=False): """ Delete domains Keyword argument: domain -- Domain to delete - force -- Force the domain removal + remove_apps -- Remove applications installed on the domain + force -- Force the domain removal and don't not ask confirmation to + remove apps if remove_apps is specified """ from yunohost.hook import hook_callback - from yunohost.app import app_ssowatconf + from yunohost.app import app_ssowatconf, app_info, app_remove from yunohost.utils.ldap import _get_ldap_interface - if not force and domain not in domain_list()['domains']: - raise YunohostError('domain_unknown') + # the 'force' here is related to the exception happening in domain_add ... + # we don't want to check the domain exists because the ldap add may have + # failed + if not force: + _assert_domain_exists(domain) # Check domain is not the main domain if domain == _get_maindomain(): - raise YunohostError('domain_cannot_remove_main') + other_domains = domain_list()["domains"] + other_domains.remove(domain) + + if other_domains: + raise YunohostValidationError( + "domain_cannot_remove_main", + domain=domain, + other_domains="\n * " + ("\n * ".join(other_domains)), + ) + else: + raise YunohostValidationError( + "domain_cannot_remove_main_add_new_one", domain=domain + ) # Check if apps are installed on the domain - for app in os.listdir('/etc/yunohost/apps/'): - with open('/etc/yunohost/apps/' + app + '/settings.yml') as f: - try: - app_domain = yaml.load(f)['domain'] - except: - continue - else: - if app_domain == domain: - raise YunohostError('domain_uninstall_app_first') + apps_on_that_domain = [] + + for app in _installed_apps(): + settings = _get_app_settings(app) + label = app_info(app)["name"] + if settings.get("domain") == domain: + apps_on_that_domain.append( + ( + app, + ' - %s "%s" on https://%s%s' + % (app, label, domain, settings["path"]) + if "path" in settings + else app, + ) + ) + + if apps_on_that_domain: + if remove_apps: + if Moulinette.interface.type == "cli" and not force: + answer = Moulinette.prompt( + m18n.n( + "domain_remove_confirm_apps_removal", + apps="\n".join([x[1] for x in apps_on_that_domain]), + answers="y/N", + ), + color="yellow", + ) + if answer.upper() != "Y": + raise YunohostError("aborting") + + for app, _ in apps_on_that_domain: + app_remove(app) + else: + raise YunohostValidationError( + "domain_uninstall_app_first", + apps="\n".join([x[1] for x in apps_on_that_domain]), + ) operation_logger.start() - ldap = _get_ldap_interface() - if ldap.remove('virtualdomain=' + domain + ',ou=domains') or force: - os.system('rm -rf /etc/yunohost/certs/%s' % domain) - else: - raise YunohostError('domain_deletion_failed') - regen_conf(names=['nginx', 'metronome', 'dnsmasq', 'postfix']) + ldap = _get_ldap_interface() + try: + ldap.remove("virtualdomain=" + domain + ",ou=domains") + except Exception as e: + raise YunohostError("domain_deletion_failed", domain=domain, error=e) + finally: + global domain_list_cache + domain_list_cache = {} + + stuff_to_delete = [ + f"/etc/yunohost/certs/{domain}", + f"/etc/yunohost/dyndns/K{domain}.+*", + f"{DOMAIN_SETTINGS_DIR}/{domain}.yml", + ] + + for stuff in stuff_to_delete: + os.system("rm -rf {stuff}") + + # Sometime we have weird issues with the regenconf where some files + # appears as manually modified even though they weren't touched ... + # There are a few ideas why this happens (like backup/restore nginx + # conf ... which we shouldnt do ...). This in turns creates funky + # situation where the regenconf may refuse to re-create the conf + # (when re-creating a domain..) + # + # So here we force-clear the has out of the regenconf if it exists. + # This is a pretty ad hoc solution and only applied to nginx + # because it's one of the major service, but in the long term we + # should identify the root of this bug... + _force_clear_hashes(["/etc/nginx/conf.d/%s.conf" % domain]) + # And in addition we even force-delete the file Otherwise, if the file was + # manually modified, it may not get removed by the regenconf which leads to + # catastrophic consequences of nginx breaking because it can't load the + # cert file which disappeared etc.. + if os.path.exists("/etc/nginx/conf.d/%s.conf" % domain): + _process_regen_conf( + "/etc/nginx/conf.d/%s.conf" % domain, new_conf=None, save=True + ) + + regen_conf(names=["nginx", "metronome", "dnsmasq", "postfix", "rspamd", "mdns"]) app_ssowatconf() - hook_callback('post_domain_remove', args=[domain]) + hook_callback("post_domain_remove", args=[domain]) - logger.success(m18n.n('domain_deleted')) + logger.success(m18n.n("domain_deleted")) -def domain_dns_conf(domain, ttl=None): +@is_unit_operation() +def domain_main_domain(operation_logger, new_main_domain=None): """ - Generate DNS configuration for a domain + Check the current main domain, or change it Keyword argument: - domain -- Domain name - ttl -- Time to live + new_main_domain -- The new domain to be set as the main domain """ + from yunohost.tools import _set_hostname - ttl = 3600 if ttl is None else ttl + # If no new domain specified, we return the current main domain + if not new_main_domain: + return {"current_main_domain": _get_maindomain()} - dns_conf = _build_dns_conf(domain, ttl) + # Check domain exists + _assert_domain_exists(new_main_domain) - result = "" + operation_logger.related_to.append(("domain", new_main_domain)) + operation_logger.start() - result += "; Basic ipv4/ipv6 records" - for record in dns_conf["basic"]: - result += "\n{name} {ttl} IN {type} {value}".format(**record) + # Apply changes to ssl certs + try: + write_to_file("/etc/yunohost/current_host", new_main_domain) + global domain_list_cache + domain_list_cache = {} + _set_hostname(new_main_domain) + except Exception as e: + logger.warning("%s" % e, exc_info=1) + raise YunohostError("main_domain_change_failed") - result += "\n\n" - result += "; XMPP" - for record in dns_conf["xmpp"]: - result += "\n{name} {ttl} IN {type} {value}".format(**record) + # Generate SSOwat configuration file + app_ssowatconf() - result += "\n\n" - result += "; Mail" - for record in dns_conf["mail"]: - result += "\n{name} {ttl} IN {type} {value}".format(**record) - result += "\n\n" + # Regen configurations + if os.path.exists("/etc/yunohost/installed"): + regen_conf() - result += "; Extra" - for record in dns_conf["extra"]: - result += "\n{name} {ttl} IN {type} {value}".format(**record) - - for name, record_list in dns_conf.items(): - if name not in ("basic", "xmpp", "mail", "extra") and record_list: - result += "\n\n" - result += "; " + name - for record in record_list: - result += "\n{name} {ttl} IN {type} {value}".format(**record) - - is_cli = True if msettings.get('interface') == 'cli' else False - if is_cli: - logger.info(m18n.n("domain_dns_conf_is_just_a_recommendation")) - - return result - - -def domain_cert_status(domain_list, full=False): - return yunohost.certificate.certificate_status(domain_list, full) - - -def domain_cert_install(domain_list, force=False, no_checks=False, self_signed=False, staging=False): - return yunohost.certificate.certificate_install(domain_list, force, no_checks, self_signed, staging) - - -def domain_cert_renew(domain_list, force=False, no_checks=False, email=False, staging=False): - return yunohost.certificate.certificate_renew(domain_list, force, no_checks, email, staging) - - -def _get_conflicting_apps(domain, path, ignore_app=None): - """ - Return a list of all conflicting apps with a domain/path (it can be empty) - - Keyword argument: - domain -- The domain for the web path (e.g. your.domain.tld) - path -- The path to check (e.g. /coffee) - ignore_app -- An optional app id to ignore (c.f. the change_url usecase) - """ - - domain, path = _normalize_domain_path(domain, path) - - # Abort if domain is unknown - if domain not in domain_list()['domains']: - raise YunohostError('domain_unknown') - - # This import cannot be put on top of file because it would create a - # recursive import... - from yunohost.app import app_map - - # Fetch apps map - apps_map = app_map(raw=True) - - # Loop through all apps to check if path is taken by one of them - conflicts = [] - if domain in apps_map: - # Loop through apps - for p, a in apps_map[domain].items(): - if a["id"] == ignore_app: - continue - if path == p: - conflicts.append((p, a["id"], a["label"])) - # We also don't want conflicts with other apps starting with - # same name - elif path.startswith(p) or p.startswith(path): - conflicts.append((p, a["id"], a["label"])) - - return conflicts + logger.success(m18n.n("main_domain_changed")) def domain_url_available(domain, path): @@ -294,227 +386,140 @@ def domain_url_available(domain, path): def _get_maindomain(): - with open('/etc/yunohost/current_host', 'r') as f: + with open("/etc/yunohost/current_host", "r") as f: maindomain = f.readline().rstrip() return maindomain -def _set_maindomain(domain): - with open('/etc/yunohost/current_host', 'w') as f: - f.write(domain) - - -def _normalize_domain_path(domain, path): - - # We want url to be of the format : - # some.domain.tld/foo - - # Remove http/https prefix if it's there - if domain.startswith("https://"): - domain = domain[len("https://"):] - elif domain.startswith("http://"): - domain = domain[len("http://"):] - - # Remove trailing slashes - domain = domain.rstrip("/").lower() - path = "/" + path.strip("/") - - return domain, path - - -def _build_dns_conf(domain, ttl=3600): +def domain_config_get(domain, key="", full=False, export=False): """ - Internal function that will returns a data structure containing the needed - information to generate/adapt the dns configuration - - The returned datastructure will have the following form: - { - "basic": [ - # if ipv4 available - {"type": "A", "name": "@", "value": "123.123.123.123", "ttl": 3600}, - {"type": "A", "name": "*", "value": "123.123.123.123", "ttl": 3600}, - # if ipv6 available - {"type": "AAAA", "name": "@", "value": "valid-ipv6", "ttl": 3600}, - {"type": "AAAA", "name": "*", "value": "valid-ipv6", "ttl": 3600}, - ], - "xmpp": [ - {"type": "SRV", "name": "_xmpp-client._tcp", "value": "0 5 5222 domain.tld.", "ttl": 3600}, - {"type": "SRV", "name": "_xmpp-server._tcp", "value": "0 5 5269 domain.tld.", "ttl": 3600}, - {"type": "CNAME", "name": "muc", "value": "@", "ttl": 3600}, - {"type": "CNAME", "name": "pubsub", "value": "@", "ttl": 3600}, - {"type": "CNAME", "name": "vjud", "value": "@", "ttl": 3600} - ], - "mail": [ - {"type": "MX", "name": "@", "value": "10 domain.tld.", "ttl": 3600}, - {"type": "TXT", "name": "@", "value": "\"v=spf1 a mx ip4:123.123.123.123 ipv6:valid-ipv6 -all\"", "ttl": 3600 }, - {"type": "TXT", "name": "mail._domainkey", "value": "\"v=DKIM1; k=rsa; p=some-super-long-key\"", "ttl": 3600}, - {"type": "TXT", "name": "_dmarc", "value": "\"v=DMARC1; p=none\"", "ttl": 3600} - ], - "extra": [ - {"type": "CAA", "name": "@", "value": "128 issue \"letsencrypt.org\"", "ttl": 3600}, - ], - "example_of_a_custom_rule": [ - {"type": "SRV", "name": "_matrix", "value": "domain.tld.", "ttl": 3600} - ], - } + Display a domain configuration """ - ipv4 = get_public_ip() - ipv6 = get_public_ip(6) - - basic = [] - - # Basic ipv4/ipv6 records - if ipv4: - basic += [ - ["@", ttl, "A", ipv4], - ["*", ttl, "A", ipv4], - ] - - if ipv6: - basic += [ - ["@", ttl, "AAAA", ipv6], - ["*", ttl, "AAAA", ipv6], - ] - - # XMPP - xmpp = [ - ["_xmpp-client._tcp", ttl, "SRV", "0 5 5222 %s." % domain], - ["_xmpp-server._tcp", ttl, "SRV", "0 5 5269 %s." % domain], - ["muc", ttl, "CNAME", "@"], - ["pubsub", ttl, "CNAME", "@"], - ["vjud", ttl, "CNAME", "@"], - ] - - # SPF record - spf_record = '"v=spf1 a mx' - if ipv4: - spf_record += ' ip4:{ip4}'.format(ip4=ipv4) - if ipv6: - spf_record += ' ip6:{ip6}'.format(ip6=ipv6) - spf_record += ' -all"' - - # Email - mail = [ - ["@", ttl, "MX", "10 %s." % domain], - ["@", ttl, "TXT", spf_record], - ] - - # DKIM/DMARC record - dkim_host, dkim_publickey = _get_DKIM(domain) - - if dkim_host: - mail += [ - [dkim_host, ttl, "TXT", dkim_publickey], - ["_dmarc", ttl, "TXT", '"v=DMARC1; p=none"'], - ] - - # Extra - extra = [ - ["@", ttl, "CAA", '128 issue "letsencrypt.org"'] - ] - - # Official record - records = { - "basic": [{"name": name, "ttl": ttl, "type": type_, "value": value} for name, ttl, type_, value in basic], - "xmpp": [{"name": name, "ttl": ttl, "type": type_, "value": value} for name, ttl, type_, value in xmpp], - "mail": [{"name": name, "ttl": ttl, "type": type_, "value": value} for name, ttl, type_, value in mail], - "extra": [{"name": name, "ttl": ttl, "type": type_, "value": value} for name, ttl, type_, value in extra], - } - - # Custom records - hook_results = hook_callback('custom_dns_rules', args=[domain]) - for hook_name, results in hook_results.items(): - # - # There can be multiple results per hook name, so results look like - # {'/some/path/to/hook1': - # { 'state': 'succeed', - # 'stdreturn': [{'type': 'SRV', - # 'name': 'stuff.foo.bar.', - # 'value': 'yoloswag', - # 'ttl': 3600}] - # }, - # '/some/path/to/hook2': - # { ... }, - # [...] - # - # Loop over the sub-results - custom_records = [v['stdreturn'] for v in results.values() - if v and v['stdreturn']] - - records[hook_name] = [] - for record_list in custom_records: - # Check that record_list is indeed a list of dict - # with the required keys - if not isinstance(record_list, list) \ - or any(not isinstance(record, dict) for record in record_list) \ - or any(key not in record for record in record_list for key in ["name", "ttl", "type", "value"]): - # Display an error, mainly for app packagers trying to implement a hook - logger.warning("Ignored custom record from hook '%s' because the data is not a *list* of dict with keys name, ttl, type and value. Raw data : %s" % (hook_name, record_list)) - continue - - records[hook_name].extend(record_list) - - return records - - -def _get_DKIM(domain): - DKIM_file = '/etc/dkim/{domain}.mail.txt'.format(domain=domain) - - if not os.path.isfile(DKIM_file): - return (None, None) - - with open(DKIM_file) as f: - dkim_content = f.read() - - # Gotta manage two formats : - # - # Legacy - # ----- - # - # mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " - # "p=" ) - # - # New - # ------ - # - # mail._domainkey IN TXT ( "v=DKIM1; h=sha256; k=rsa; " - # "p=" ) - - is_legacy_format = " h=sha256; " not in dkim_content - - # Legacy DKIM format - if is_legacy_format: - dkim = re.match(( - r'^(?P[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+' - '[^"]*"v=(?P[^";]+);' - '[\s"]*k=(?P[^";]+);' - '[\s"]*p=(?P

[^";]+)'), dkim_content, re.M | re.S + if full and export: + raise YunohostValidationError( + "You can't use --full and --export together.", raw_msg=True ) + + if full: + mode = "full" + elif export: + mode = "export" else: - dkim = re.match(( - r'^(?P[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+' - '[^"]*"v=(?P[^";]+);' - '[\s"]*h=(?P[^";]+);' - '[\s"]*k=(?P[^";]+);' - '[\s"]*p=(?P

[^";]+)'), dkim_content, re.M | re.S + mode = "classic" + + config = DomainConfigPanel(domain) + return config.get(key, mode) + + +@is_unit_operation() +def domain_config_set( + operation_logger, domain, key=None, value=None, args=None, args_file=None +): + """ + Apply a new domain configuration + """ + Question.operation_logger = operation_logger + config = DomainConfigPanel(domain) + return config.set(key, value, args, args_file, operation_logger=operation_logger) + + +class DomainConfigPanel(ConfigPanel): + def __init__(self, domain): + _assert_domain_exists(domain) + self.domain = domain + self.save_mode = "diff" + super().__init__( + config_path=DOMAIN_CONFIG_PATH, + save_path=f"{DOMAIN_SETTINGS_DIR}/{domain}.yml", ) - if not dkim: - return (None, None) + def _get_toml(self): + from yunohost.dns import _get_registrar_config_section - if is_legacy_format: - return ( - dkim.group('host'), - '"v={v}; k={k}; p={p}"'.format(v=dkim.group('v'), - k=dkim.group('k'), - p=dkim.group('p')) + toml = super()._get_toml() + + toml["feature"]["xmpp"]["xmpp"]["default"] = ( + 1 if self.domain == _get_maindomain() else 0 ) + toml["dns"]["registrar"] = _get_registrar_config_section(self.domain) + + # FIXME: Ugly hack to save the registar id/value and reinject it in _load_current_values ... + self.registar_id = toml["dns"]["registrar"]["registrar"]["value"] + del toml["dns"]["registrar"]["registrar"]["value"] + + return toml + + def _load_current_values(self): + + # TODO add mechanism to share some settings with other domains on the same zone + super()._load_current_values() + + # FIXME: Ugly hack to save the registar id/value and reinject it in _load_current_values ... + self.values["registrar"] = self.registar_id + + +def _get_domain_settings(domain: str) -> dict: + + _assert_domain_exists(domain) + + if os.path.exists(f"{DOMAIN_SETTINGS_DIR}/{domain}.yml"): + return read_yaml(f"{DOMAIN_SETTINGS_DIR}/{domain}.yml") or {} else: - return ( - dkim.group('host'), - '"v={v}; h={h}; k={k}; p={p}"'.format(v=dkim.group('v'), - h=dkim.group('h'), - k=dkim.group('k'), - p=dkim.group('p')) - ) + return {} + + +def _set_domain_settings(domain: str, settings: dict) -> None: + + _assert_domain_exists(domain) + + write_to_yaml(f"{DOMAIN_SETTINGS_DIR}/{domain}.yml", settings) + + +# +# +# Stuff managed in other files +# +# + + +def domain_cert_status(domain_list, full=False): + import yunohost.certificate + + return yunohost.certificate.certificate_status(domain_list, full) + + +def domain_cert_install( + domain_list, force=False, no_checks=False, self_signed=False, staging=False +): + import yunohost.certificate + + return yunohost.certificate.certificate_install( + domain_list, force, no_checks, self_signed, staging + ) + + +def domain_cert_renew( + domain_list, force=False, no_checks=False, email=False, staging=False +): + import yunohost.certificate + + return yunohost.certificate.certificate_renew( + domain_list, force, no_checks, email, staging + ) + + +def domain_dns_conf(domain): + return domain_dns_suggest(domain) + + +def domain_dns_suggest(domain): + import yunohost.dns + + return yunohost.dns.domain_dns_suggest(domain) + + +def domain_dns_push(domain, dry_run, force, purge): + import yunohost.dns + + return yunohost.dns.domain_dns_push(domain, dry_run, force, purge) diff --git a/src/yunohost/dyndns.py b/src/yunohost/dyndns.py index 2dadcef52..519fbc8f0 100644 --- a/src/yunohost/dyndns.py +++ b/src/yunohost/dyndns.py @@ -33,25 +33,24 @@ import subprocess from moulinette import m18n from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger -from moulinette.utils.filesystem import write_to_file +from moulinette.utils.filesystem import write_to_file, read_file from moulinette.utils.network import download_json -from moulinette.utils.process import check_output -from yunohost.utils.error import YunohostError -from yunohost.domain import _get_maindomain, _build_dns_conf +from yunohost.utils.error import YunohostError, YunohostValidationError +from yunohost.domain import _get_maindomain from yunohost.utils.network import get_public_ip +from yunohost.utils.dns import dig from yunohost.log import is_unit_operation +from yunohost.regenconf import regen_conf -logger = getActionLogger('yunohost.dyndns') +logger = getActionLogger("yunohost.dyndns") -DYNDNS_ZONE = '/etc/yunohost/dyndns/zone' +DYNDNS_ZONE = "/etc/yunohost/dyndns/zone" -RE_DYNDNS_PRIVATE_KEY_MD5 = re.compile( - r'.*/K(?P[^\s\+]+)\.\+157.+\.private$' -) +RE_DYNDNS_PRIVATE_KEY_MD5 = re.compile(r".*/K(?P[^\s\+]+)\.\+157.+\.private$") RE_DYNDNS_PRIVATE_KEY_SHA512 = re.compile( - r'.*/K(?P[^\s\+]+)\.\+165.+\.private$' + r".*/K(?P[^\s\+]+)\.\+165.+\.private$" ) @@ -72,13 +71,15 @@ def _dyndns_provides(provider, domain): try: # Dyndomains will be a list of domains supported by the provider # e.g. [ "nohost.me", "noho.st" ] - dyndomains = download_json('https://%s/domains' % provider, timeout=30) + dyndomains = download_json("https://%s/domains" % provider, timeout=30) except MoulinetteError as e: logger.error(str(e)) - raise YunohostError('dyndns_could_not_check_provide', domain=domain, provider=provider) + raise YunohostError( + "dyndns_could_not_check_provide", domain=domain, provider=provider + ) # Extract 'dyndomain' from 'domain', e.g. 'nohost.me' from 'foo.nohost.me' - dyndomain = '.'.join(domain.split('.')[1:]) + dyndomain = ".".join(domain.split(".")[1:]) return dyndomain in dyndomains @@ -94,22 +95,25 @@ def _dyndns_available(provider, domain): Returns: True if the domain is available, False otherwise. """ - logger.debug("Checking if domain %s is available on %s ..." - % (domain, provider)) + logger.debug("Checking if domain %s is available on %s ..." % (domain, provider)) try: - r = download_json('https://%s/test/%s' % (provider, domain), - expected_status_code=None) + r = download_json( + "https://%s/test/%s" % (provider, domain), expected_status_code=None + ) except MoulinetteError as e: logger.error(str(e)) - raise YunohostError('dyndns_could_not_check_available', - domain=domain, provider=provider) + raise YunohostError( + "dyndns_could_not_check_available", domain=domain, provider=provider + ) - return r == u"Domain %s is available" % domain + return r == "Domain %s is available" % domain @is_unit_operation() -def dyndns_subscribe(operation_logger, subscribe_host="dyndns.yunohost.org", domain=None, key=None): +def dyndns_subscribe( + operation_logger, subscribe_host="dyndns.yunohost.org", domain=None, key=None +): """ Subscribe to a DynDNS service @@ -119,64 +123,97 @@ def dyndns_subscribe(operation_logger, subscribe_host="dyndns.yunohost.org", dom subscribe_host -- Dynette HTTP API to subscribe to """ - if len(glob.glob('/etc/yunohost/dyndns/*.key')) != 0 or os.path.exists('/etc/cron.d/yunohost-dyndns'): - raise YunohostError('domain_dyndns_already_subscribed') + + if _guess_current_dyndns_domain(subscribe_host) != (None, None): + raise YunohostValidationError("domain_dyndns_already_subscribed") if domain is None: domain = _get_maindomain() - operation_logger.related_to.append(('domain', domain)) + operation_logger.related_to.append(("domain", domain)) # Verify if domain is provided by subscribe_host if not _dyndns_provides(subscribe_host, domain): - raise YunohostError('dyndns_domain_not_provided', domain=domain, provider=subscribe_host) + raise YunohostValidationError( + "dyndns_domain_not_provided", domain=domain, provider=subscribe_host + ) # Verify if domain is available if not _dyndns_available(subscribe_host, domain): - raise YunohostError('dyndns_unavailable', domain=domain) + raise YunohostValidationError("dyndns_unavailable", domain=domain) operation_logger.start() if key is None: - if len(glob.glob('/etc/yunohost/dyndns/*.key')) == 0: - if not os.path.exists('/etc/yunohost/dyndns'): - os.makedirs('/etc/yunohost/dyndns') + if len(glob.glob("/etc/yunohost/dyndns/*.key")) == 0: + if not os.path.exists("/etc/yunohost/dyndns"): + os.makedirs("/etc/yunohost/dyndns") - logger.debug(m18n.n('dyndns_key_generating')) + logger.debug(m18n.n("dyndns_key_generating")) - os.system('cd /etc/yunohost/dyndns && ' - 'dnssec-keygen -a hmac-sha512 -b 512 -r /dev/urandom -n USER %s' % domain) - os.system('chmod 600 /etc/yunohost/dyndns/*.key /etc/yunohost/dyndns/*.private') + os.system( + "cd /etc/yunohost/dyndns && " + "dnssec-keygen -a hmac-sha512 -b 512 -r /dev/urandom -n USER %s" + % domain + ) + os.system( + "chmod 600 /etc/yunohost/dyndns/*.key /etc/yunohost/dyndns/*.private" + ) - private_file = glob.glob('/etc/yunohost/dyndns/*%s*.private' % domain)[0] - key_file = glob.glob('/etc/yunohost/dyndns/*%s*.key' % domain)[0] + private_file = glob.glob("/etc/yunohost/dyndns/*%s*.private" % domain)[0] + key_file = glob.glob("/etc/yunohost/dyndns/*%s*.key" % domain)[0] with open(key_file) as f: - key = f.readline().strip().split(' ', 6)[-1] + key = f.readline().strip().split(" ", 6)[-1] import requests # lazy loading this module for performance reasons + # Send subscription try: - r = requests.post('https://%s/key/%s?key_algo=hmac-sha512' % (subscribe_host, base64.b64encode(key)), data={'subdomain': domain}, timeout=30) + r = requests.post( + "https://%s/key/%s?key_algo=hmac-sha512" + % (subscribe_host, base64.b64encode(key.encode()).decode()), + data={"subdomain": domain}, + timeout=30, + ) except Exception as e: os.system("rm -f %s" % private_file) os.system("rm -f %s" % key_file) - raise YunohostError('dyndns_registration_failed', error=str(e)) + raise YunohostError("dyndns_registration_failed", error=str(e)) if r.status_code != 201: os.system("rm -f %s" % private_file) os.system("rm -f %s" % key_file) try: - error = json.loads(r.text)['error'] - except: - error = "Server error, code: %s. (Message: \"%s\")" % (r.status_code, r.text) - raise YunohostError('dyndns_registration_failed', error=error) + error = json.loads(r.text)["error"] + except Exception: + error = 'Server error, code: %s. (Message: "%s")' % (r.status_code, r.text) + raise YunohostError("dyndns_registration_failed", error=error) - logger.success(m18n.n('dyndns_registered')) + # Yunohost regen conf will add the dyndns cron job if a private key exists + # in /etc/yunohost/dyndns + regen_conf(["yunohost"]) - dyndns_installcron() + # Add some dyndns update in 2 and 4 minutes from now such that user should + # not have to wait 10ish minutes for the conf to propagate + cmd = ( + "at -M now + {t} >/dev/null 2>&1 <<< \"/bin/bash -c 'yunohost dyndns update'\"" + ) + # For some reason subprocess doesn't like the redirections so we have to use bash -c explicity... + subprocess.check_call(["bash", "-c", cmd.format(t="2 min")]) + subprocess.check_call(["bash", "-c", cmd.format(t="4 min")]) + + logger.success(m18n.n("dyndns_registered")) @is_unit_operation() -def dyndns_update(operation_logger, dyn_host="dyndns.yunohost.org", domain=None, key=None, - ipv4=None, ipv6=None): +def dyndns_update( + operation_logger, + dyn_host="dyndns.yunohost.org", + domain=None, + key=None, + ipv4=None, + ipv6=None, + force=False, + dry_run=False, +): """ Update IP on DynDNS platform @@ -188,52 +225,74 @@ def dyndns_update(operation_logger, dyn_host="dyndns.yunohost.org", domain=None, ipv6 -- IPv6 address to send """ - # Get old ipv4/v6 - old_ipv4, old_ipv6 = (None, None) # (default values) + from yunohost.dns import _build_dns_conf # If domain is not given, try to guess it from keys available... if domain is None: (domain, key) = _guess_current_dyndns_domain(dyn_host) + + if domain is None: + raise YunohostValidationError("dyndns_no_domain_registered") + # If key is not given, pick the first file we find with the domain given else: if key is None: - keys = glob.glob('/etc/yunohost/dyndns/K{0}.+*.private'.format(domain)) + keys = glob.glob("/etc/yunohost/dyndns/K{0}.+*.private".format(domain)) if not keys: - raise YunohostError('dyndns_key_not_found') + raise YunohostValidationError("dyndns_key_not_found") key = keys[0] - # This mean that hmac-md5 is used - # (Re?)Trigger the migration to sha256 and return immediately. - # The actual update will be done in next run. - if "+157" in key: - from yunohost.tools import _get_migration_by_name - migration = _get_migration_by_name("migrate_to_tsig_sha256") - try: - migration.migrate(dyn_host, domain, key) - except Exception as e: - logger.error(m18n.n('migrations_migration_has_failed', - exception=e, - number=migration.number, - name=migration.name), - exc_info=1) - return - # Extract 'host', e.g. 'nohost.me' from 'foo.nohost.me' - host = domain.split('.')[1:] - host = '.'.join(host) + host = domain.split(".")[1:] + host = ".".join(host) logger.debug("Building zone update file ...") lines = [ - 'server %s' % dyn_host, - 'zone %s' % host, + "server %s" % dyn_host, + "zone %s" % host, ] - old_ipv4 = check_output("dig @%s +short %s" % (dyn_host, domain)).strip() or None - old_ipv6 = check_output("dig @%s +short aaaa %s" % (dyn_host, domain)).strip() or None + def resolve_domain(domain, rdtype): + + # FIXME make this work for IPv6-only hosts too.. + ok, result = dig(dyn_host, "A") + dyn_host_ip = result[0] if ok == "ok" and len(result) else None + if not dyn_host_ip: + raise YunohostError("Failed to resolve %s" % dyn_host, raw_msg=True) + + ok, result = dig(domain, rdtype, resolvers=[dyn_host_ip]) + if ok == "ok": + return result[0] if len(result) else None + elif result[0] == "Timeout": + logger.debug( + "Timed-out while trying to resolve %s record for %s using %s" + % (rdtype, domain, dyn_host) + ) + else: + return None + + logger.debug("Falling back to external resolvers") + ok, result = dig(domain, rdtype, resolvers="force_external") + if ok == "ok": + return result[0] if len(result) else None + elif result[0] == "Timeout": + logger.debug( + "Timed-out while trying to resolve %s record for %s using external resolvers : %s" + % (rdtype, domain, result) + ) + else: + return None + + raise YunohostError( + "Failed to resolve %s for %s" % (rdtype, domain), raw_msg=True + ) + + old_ipv4 = resolve_domain(domain, "A") + old_ipv6 = resolve_domain(domain, "AAAA") # Get current IPv4 and IPv6 ipv4_ = get_public_ip() @@ -248,17 +307,28 @@ def dyndns_update(operation_logger, dyn_host="dyndns.yunohost.org", domain=None, logger.debug("Old IPv4/v6 are (%s, %s)" % (old_ipv4, old_ipv6)) logger.debug("Requested IPv4/v6 are (%s, %s)" % (ipv4, ipv6)) + if ipv4 is None and ipv6 is None: + logger.debug( + "No ipv4 nor ipv6 ?! Sounds like the server is not connected to the internet, or the ip.yunohost.org infrastructure is down somehow" + ) + return + # no need to update - if old_ipv4 == ipv4 and old_ipv6 == ipv6: + if (not force and not dry_run) and (old_ipv4 == ipv4 and old_ipv6 == ipv6): logger.info("No updated needed.") return else: - operation_logger.related_to.append(('domain', domain)) + operation_logger.related_to.append(("domain", domain)) operation_logger.start() logger.info("Updated needed, going on...") dns_conf = _build_dns_conf(domain) - del dns_conf["extra"] # Ignore records from the 'extra' category + + # Delete custom DNS records, we don't support them (have to explicitly + # authorize them on dynette) + for category in dns_conf.keys(): + if category not in ["basic", "mail", "xmpp", "extra"]: + del dns_conf[category] # Delete the old records for all domain/subdomains @@ -279,56 +349,48 @@ def dyndns_update(operation_logger, dyn_host="dyndns.yunohost.org", domain=None, # should be muc.the.domain.tld. or the.domain.tld if record["value"] == "@": record["value"] = domain - record["value"] = record["value"].replace(";", "\;") + record["value"] = record["value"].replace(";", r"\;") - action = "update add {name}.{domain}. {ttl} {type} {value}".format(domain=domain, **record) + action = "update add {name}.{domain}. {ttl} {type} {value}".format( + domain=domain, **record + ) action = action.replace(" @.", " ") lines.append(action) - lines += [ - 'show', - 'send' - ] + lines += ["show", "send"] # Write the actions to do to update to a file, to be able to pass it # to nsupdate as argument - write_to_file(DYNDNS_ZONE, '\n'.join(lines)) + write_to_file(DYNDNS_ZONE, "\n".join(lines)) logger.debug("Now pushing new conf to DynDNS host...") - try: - command = ["/usr/bin/nsupdate", "-k", key, DYNDNS_ZONE] - subprocess.check_call(command) - except subprocess.CalledProcessError: - raise YunohostError('dyndns_ip_update_failed') + if not dry_run: + try: + command = ["/usr/bin/nsupdate", "-k", key, DYNDNS_ZONE] + subprocess.check_call(command) + except subprocess.CalledProcessError: + raise YunohostError("dyndns_ip_update_failed") - logger.success(m18n.n('dyndns_ip_updated')) + logger.success(m18n.n("dyndns_ip_updated")) + else: + print(read_file(DYNDNS_ZONE)) + print("") + print( + "Warning: dry run, this is only the generated config, it won't be applied" + ) def dyndns_installcron(): - """ - Install IP update cron - - - """ - with open('/etc/cron.d/yunohost-dyndns', 'w+') as f: - f.write('*/2 * * * * root yunohost dyndns update >> /dev/null\n') - - logger.success(m18n.n('dyndns_cron_installed')) + logger.warning( + "This command is deprecated. The dyndns cron job should automatically be added/removed by the regenconf depending if there's a private key in /etc/yunohost/dyndns. You can run the regenconf yourself with 'yunohost tools regen-conf yunohost'." + ) def dyndns_removecron(): - """ - Remove IP update cron - - - """ - try: - os.remove("/etc/cron.d/yunohost-dyndns") - except: - raise YunohostError('dyndns_cron_remove_failed') - - logger.success(m18n.n('dyndns_cron_removed')) + logger.warning( + "This command is deprecated. The dyndns cron job should automatically be added/removed by the regenconf depending if there's a private key in /etc/yunohost/dyndns. You can run the regenconf yourself with 'yunohost tools regen-conf yunohost'." + ) def _guess_current_dyndns_domain(dyn_host): @@ -341,14 +403,14 @@ def _guess_current_dyndns_domain(dyn_host): """ # Retrieve the first registered domain - paths = list(glob.iglob('/etc/yunohost/dyndns/K*.private')) + paths = list(glob.iglob("/etc/yunohost/dyndns/K*.private")) for path in paths: match = RE_DYNDNS_PRIVATE_KEY_MD5.match(path) if not match: match = RE_DYNDNS_PRIVATE_KEY_SHA512.match(path) if not match: continue - _domain = match.group('domain') + _domain = match.group("domain") # Verify if domain is registered (i.e., if it's available, skip # current domain beause that's not the one we want to update..) @@ -359,4 +421,4 @@ def _guess_current_dyndns_domain(dyn_host): else: return (_domain, path) - raise YunohostError('dyndns_no_domain_registered') + return (None, None) diff --git a/src/yunohost/firewall.py b/src/yunohost/firewall.py index 9d209dbb8..4be6810ec 100644 --- a/src/yunohost/firewall.py +++ b/src/yunohost/firewall.py @@ -24,28 +24,24 @@ Manage firewall rules """ import os -import sys import yaml -try: - import miniupnpc -except ImportError: - sys.stderr.write('Error: Yunohost CLI Require miniupnpc lib\n') - sys.exit(1) +import miniupnpc from moulinette import m18n -from yunohost.utils.error import YunohostError +from yunohost.utils.error import YunohostError, YunohostValidationError from moulinette.utils import process from moulinette.utils.log import getActionLogger from moulinette.utils.text import prependlines -FIREWALL_FILE = '/etc/yunohost/firewall.yml' -UPNP_CRON_JOB = '/etc/cron.d/yunohost-firewall-upnp' +FIREWALL_FILE = "/etc/yunohost/firewall.yml" +UPNP_CRON_JOB = "/etc/cron.d/yunohost-firewall-upnp" -logger = getActionLogger('yunohost.firewall') +logger = getActionLogger("yunohost.firewall") -def firewall_allow(protocol, port, ipv4_only=False, ipv6_only=False, - no_upnp=False, no_reload=False): +def firewall_allow( + protocol, port, ipv4_only=False, ipv6_only=False, no_upnp=False, no_reload=False +): """ Allow connections on a port @@ -61,20 +57,26 @@ def firewall_allow(protocol, port, ipv4_only=False, ipv6_only=False, firewall = firewall_list(raw=True) # Validate port - if not isinstance(port, int) and ':' not in port: + if not isinstance(port, int) and ":" not in port: port = int(port) # Validate protocols - protocols = ['TCP', 'UDP'] - if protocol != 'Both' and protocol in protocols: - protocols = [protocol, ] + protocols = ["TCP", "UDP"] + if protocol != "Both" and protocol in protocols: + protocols = [ + protocol, + ] # Validate IP versions - ipvs = ['ipv4', 'ipv6'] + ipvs = ["ipv4", "ipv6"] if ipv4_only and not ipv6_only: - ipvs = ['ipv4', ] + ipvs = [ + "ipv4", + ] elif ipv6_only and not ipv4_only: - ipvs = ['ipv6', ] + ipvs = [ + "ipv6", + ] for p in protocols: # Iterate over IP versions to add port @@ -83,10 +85,15 @@ def firewall_allow(protocol, port, ipv4_only=False, ipv6_only=False, firewall[i][p].append(port) else: ipv = "IPv%s" % i[3] - logger.warning(m18n.n('port_already_opened', port=port, ip_version=ipv)) + logger.warning(m18n.n("port_already_opened", port=port, ip_version=ipv)) # Add port forwarding with UPnP - if not no_upnp and port not in firewall['uPnP'][p]: - firewall['uPnP'][p].append(port) + if not no_upnp and port not in firewall["uPnP"][p]: + firewall["uPnP"][p].append(port) + if ( + p + "_TO_CLOSE" in firewall["uPnP"] + and port in firewall["uPnP"][p + "_TO_CLOSE"] + ): + firewall["uPnP"][p + "_TO_CLOSE"].remove(port) # Update and reload firewall _update_firewall_file(firewall) @@ -94,8 +101,9 @@ def firewall_allow(protocol, port, ipv4_only=False, ipv6_only=False, return firewall_reload() -def firewall_disallow(protocol, port, ipv4_only=False, ipv6_only=False, - upnp_only=False, no_reload=False): +def firewall_disallow( + protocol, port, ipv4_only=False, ipv6_only=False, upnp_only=False, no_reload=False +): """ Disallow connections on a port @@ -111,24 +119,30 @@ def firewall_disallow(protocol, port, ipv4_only=False, ipv6_only=False, firewall = firewall_list(raw=True) # Validate port - if not isinstance(port, int) and ':' not in port: + if not isinstance(port, int) and ":" not in port: port = int(port) # Validate protocols - protocols = ['TCP', 'UDP'] - if protocol != 'Both' and protocol in protocols: - protocols = [protocol, ] + protocols = ["TCP", "UDP"] + if protocol != "Both" and protocol in protocols: + protocols = [ + protocol, + ] # Validate IP versions and UPnP - ipvs = ['ipv4', 'ipv6'] + ipvs = ["ipv4", "ipv6"] upnp = True if ipv4_only and ipv6_only: upnp = True # automatically disallow UPnP elif ipv4_only: - ipvs = ['ipv4', ] + ipvs = [ + "ipv4", + ] upnp = upnp_only elif ipv6_only: - ipvs = ['ipv6', ] + ipvs = [ + "ipv6", + ] upnp = upnp_only elif upnp_only: ipvs = [] @@ -140,10 +154,13 @@ def firewall_disallow(protocol, port, ipv4_only=False, ipv6_only=False, firewall[i][p].remove(port) else: ipv = "IPv%s" % i[3] - logger.warning(m18n.n('port_already_closed', port=port, ip_version=ipv)) + logger.warning(m18n.n("port_already_closed", port=port, ip_version=ipv)) # Remove port forwarding with UPnP - if upnp and port in firewall['uPnP'][p]: - firewall['uPnP'][p].remove(port) + if upnp and port in firewall["uPnP"][p]: + firewall["uPnP"][p].remove(port) + if p + "_TO_CLOSE" not in firewall["uPnP"]: + firewall["uPnP"][p + "_TO_CLOSE"] = [] + firewall["uPnP"][p + "_TO_CLOSE"].append(port) # Update and reload firewall _update_firewall_file(firewall) @@ -162,27 +179,35 @@ def firewall_list(raw=False, by_ip_version=False, list_forwarded=False): """ with open(FIREWALL_FILE) as f: - firewall = yaml.load(f) + firewall = yaml.safe_load(f) if raw: return firewall # Retrieve all ports for IPv4 and IPv6 ports = {} - for i in ['ipv4', 'ipv6']: + for i in ["ipv4", "ipv6"]: f = firewall[i] # Combine TCP and UDP ports - ports[i] = sorted(set(f['TCP']) | set(f['UDP'])) + ports[i] = sorted( + set(f["TCP"]) | set(f["UDP"]), + key=lambda p: int(p.split(":")[0]) if isinstance(p, str) else p, + ) if not by_ip_version: # Combine IPv4 and IPv6 ports - ports = sorted(set(ports['ipv4']) | set(ports['ipv6'])) + ports = sorted( + set(ports["ipv4"]) | set(ports["ipv6"]), + key=lambda p: int(p.split(":")[0]) if isinstance(p, str) else p, + ) # Format returned dict ret = {"opened_ports": ports} if list_forwarded: # Combine TCP and UDP forwarded ports - ret['forwarded_ports'] = sorted( - set(firewall['uPnP']['TCP']) | set(firewall['uPnP']['UDP'])) + ret["forwarded_ports"] = sorted( + set(firewall["uPnP"]["TCP"]) | set(firewall["uPnP"]["UDP"]), + key=lambda p: int(p.split(":")[0]) if isinstance(p, str) else p, + ) return ret @@ -202,20 +227,22 @@ def firewall_reload(skip_upnp=False): # Check if SSH port is allowed ssh_port = _get_ssh_port() - if ssh_port not in firewall_list()['opened_ports']: - firewall_allow('TCP', ssh_port, no_reload=True) + if ssh_port not in firewall_list()["opened_ports"]: + firewall_allow("TCP", ssh_port, no_reload=True) # Retrieve firewall rules and UPnP status firewall = firewall_list(raw=True) - upnp = firewall_upnp()['enabled'] if not skip_upnp else False + upnp = firewall_upnp()["enabled"] if not skip_upnp else False # IPv4 try: process.check_output("iptables -w -L") except process.CalledProcessError as e: - logger.debug('iptables seems to be not available, it outputs:\n%s', - prependlines(e.output.rstrip(), '> ')) - logger.warning(m18n.n('iptables_unavailable')) + logger.debug( + "iptables seems to be not available, it outputs:\n%s", + prependlines(e.output.rstrip(), "> "), + ) + logger.warning(m18n.n("iptables_unavailable")) else: rules = [ "iptables -w -F", @@ -223,10 +250,12 @@ def firewall_reload(skip_upnp=False): "iptables -w -A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT", ] # Iterate over ports and add rule - for protocol in ['TCP', 'UDP']: - for port in firewall['ipv4'][protocol]: - rules.append("iptables -w -A INPUT -p %s --dport %s -j ACCEPT" - % (protocol, process.quote(str(port)))) + for protocol in ["TCP", "UDP"]: + for port in firewall["ipv4"][protocol]: + rules.append( + "iptables -w -A INPUT -p %s --dport %s -j ACCEPT" + % (protocol, process.quote(str(port))) + ) rules += [ "iptables -w -A INPUT -i lo -j ACCEPT", "iptables -w -A INPUT -p icmp -j ACCEPT", @@ -242,9 +271,11 @@ def firewall_reload(skip_upnp=False): try: process.check_output("ip6tables -L") except process.CalledProcessError as e: - logger.debug('ip6tables seems to be not available, it outputs:\n%s', - prependlines(e.output.rstrip(), '> ')) - logger.warning(m18n.n('ip6tables_unavailable')) + logger.debug( + "ip6tables seems to be not available, it outputs:\n%s", + prependlines(e.output.rstrip(), "> "), + ) + logger.warning(m18n.n("ip6tables_unavailable")) else: rules = [ "ip6tables -w -F", @@ -252,10 +283,12 @@ def firewall_reload(skip_upnp=False): "ip6tables -w -A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT", ] # Iterate over ports and add rule - for protocol in ['TCP', 'UDP']: - for port in firewall['ipv6'][protocol]: - rules.append("ip6tables -w -A INPUT -p %s --dport %s -j ACCEPT" - % (protocol, process.quote(str(port)))) + for protocol in ["TCP", "UDP"]: + for port in firewall["ipv6"][protocol]: + rules.append( + "ip6tables -w -A INPUT -p %s --dport %s -j ACCEPT" + % (protocol, process.quote(str(port))) + ) rules += [ "ip6tables -w -A INPUT -i lo -j ACCEPT", "ip6tables -w -A INPUT -p icmpv6 -j ACCEPT", @@ -268,10 +301,11 @@ def firewall_reload(skip_upnp=False): reloaded = True if not reloaded: - raise YunohostError('firewall_reload_failed') + raise YunohostError("firewall_reload_failed") - hook_callback('post_iptable_rules', - args=[upnp, os.path.exists("/proc/net/if_inet6")]) + hook_callback( + "post_iptable_rules", args=[upnp, os.path.exists("/proc/net/if_inet6")] + ) if upnp: # Refresh port forwarding with UPnP @@ -280,13 +314,13 @@ def firewall_reload(skip_upnp=False): _run_service_command("reload", "fail2ban") if errors: - logger.warning(m18n.n('firewall_rules_cmd_failed')) + logger.warning(m18n.n("firewall_rules_cmd_failed")) else: - logger.success(m18n.n('firewall_reloaded')) + logger.success(m18n.n("firewall_reloaded")) return firewall_list() -def firewall_upnp(action='status', no_refresh=False): +def firewall_upnp(action="status", no_refresh=False): """ Manage port forwarding using UPnP @@ -300,113 +334,143 @@ def firewall_upnp(action='status', no_refresh=False): """ firewall = firewall_list(raw=True) - enabled = firewall['uPnP']['enabled'] + enabled = firewall["uPnP"]["enabled"] # Compatibility with previous version - if action == 'reload': + if action == "reload": logger.debug("'reload' action is deprecated and will be removed") try: # Remove old cron job - os.remove('/etc/cron.d/yunohost-firewall') - except: + os.remove("/etc/cron.d/yunohost-firewall") + except Exception: pass - action = 'status' + action = "status" no_refresh = False - if action == 'status' and no_refresh: + if action == "status" and no_refresh: # Only return current state - return {'enabled': enabled} - elif action == 'enable' or (enabled and action == 'status'): + return {"enabled": enabled} + elif action == "enable" or (enabled and action == "status"): # Add cron job - with open(UPNP_CRON_JOB, 'w+') as f: - f.write('*/50 * * * * root ' - '/usr/bin/yunohost firewall upnp status >>/dev/null\n') + with open(UPNP_CRON_JOB, "w+") as f: + f.write( + "*/50 * * * * root " + "/usr/bin/yunohost firewall upnp status >>/dev/null\n" + ) # Open port 1900 to receive discovery message - if 1900 not in firewall['ipv4']['UDP']: - firewall_allow('UDP', 1900, no_upnp=True, no_reload=True) + if 1900 not in firewall["ipv4"]["UDP"]: + firewall_allow("UDP", 1900, no_upnp=True, no_reload=True) if not enabled: firewall_reload(skip_upnp=True) enabled = True - elif action == 'disable' or (not enabled and action == 'status'): + elif action == "disable" or (not enabled and action == "status"): try: # Remove cron job os.remove(UPNP_CRON_JOB) - except: + except Exception: pass enabled = False - if action == 'status': + if action == "status": no_refresh = True else: - raise YunohostError('action_invalid', action=action) + raise YunohostValidationError("action_invalid", action=action) # Refresh port mapping using UPnP if not no_refresh: - upnpc = miniupnpc.UPnP() + upnpc = miniupnpc.UPnP(localport=1) upnpc.discoverdelay = 3000 # Discover UPnP device(s) - logger.debug('discovering UPnP devices...') + logger.debug("discovering UPnP devices...") nb_dev = upnpc.discover() - logger.debug('found %d UPnP device(s)', int(nb_dev)) + logger.debug("found %d UPnP device(s)", int(nb_dev)) if nb_dev < 1: - logger.error(m18n.n('upnp_dev_not_found')) + logger.error(m18n.n("upnp_dev_not_found")) enabled = False else: try: # Select UPnP device upnpc.selectigd() - except: - logger.debug('unable to select UPnP device', exc_info=1) + except Exception: + logger.debug("unable to select UPnP device", exc_info=1) enabled = False else: # Iterate over ports - for protocol in ['TCP', 'UDP']: - for port in firewall['uPnP'][protocol]: + for protocol in ["TCP", "UDP"]: + if protocol + "_TO_CLOSE" in firewall["uPnP"]: + for port in firewall["uPnP"][protocol + "_TO_CLOSE"]: + + if not isinstance(port, int): + # FIXME : how should we handle port ranges ? + logger.warning("Can't use UPnP to close '%s'" % port) + continue + + # Clean the mapping of this port + if upnpc.getspecificportmapping(port, protocol): + try: + upnpc.deleteportmapping(port, protocol) + except Exception: + pass + firewall["uPnP"][protocol + "_TO_CLOSE"] = [] + + for port in firewall["uPnP"][protocol]: + + if not isinstance(port, int): + # FIXME : how should we handle port ranges ? + logger.warning("Can't use UPnP to open '%s'" % port) + continue + # Clean the mapping of this port if upnpc.getspecificportmapping(port, protocol): try: upnpc.deleteportmapping(port, protocol) - except: + except Exception: pass if not enabled: continue try: # Add new port mapping - upnpc.addportmapping(port, protocol, upnpc.lanaddr, - port, 'yunohost firewall: port %d' % port, '') - except: - logger.debug('unable to add port %d using UPnP', - port, exc_info=1) + upnpc.addportmapping( + port, + protocol, + upnpc.lanaddr, + port, + "yunohost firewall: port %d" % port, + "", + ) + except Exception: + logger.debug( + "unable to add port %d using UPnP", port, exc_info=1 + ) enabled = False - if enabled != firewall['uPnP']['enabled']: - firewall = firewall_list(raw=True) - firewall['uPnP']['enabled'] = enabled + _update_firewall_file(firewall) - # Make a backup and update firewall file - os.system("cp {0} {0}.old".format(FIREWALL_FILE)) - with open(FIREWALL_FILE, 'w') as f: - yaml.safe_dump(firewall, f, default_flow_style=False) + if enabled != firewall["uPnP"]["enabled"]: + firewall = firewall_list(raw=True) + firewall["uPnP"]["enabled"] = enabled + + _update_firewall_file(firewall) if not no_refresh: # Display success message if needed - if action == 'enable' and enabled: - logger.success(m18n.n('upnp_enabled')) - elif action == 'disable' and not enabled: - logger.success(m18n.n('upnp_disabled')) + if action == "enable" and enabled: + logger.success(m18n.n("upnp_enabled")) + elif action == "disable" and not enabled: + logger.success(m18n.n("upnp_disabled")) # Make sure to disable UPnP - elif action != 'disable' and not enabled: - firewall_upnp('disable', no_refresh=True) + elif action != "disable" and not enabled: + firewall_upnp("disable", no_refresh=True) - if not enabled and (action == 'enable' or 1900 in firewall['ipv4']['UDP']): + if not enabled and (action == "enable" or 1900 in firewall["ipv4"]["UDP"]): # Close unused port 1900 - firewall_disallow('UDP', 1900, no_reload=True) + firewall_disallow("UDP", 1900, no_reload=True) if not no_refresh: firewall_reload(skip_upnp=True) - if action == 'enable' and not enabled: - raise YunohostError('upnp_port_open_failed') - return {'enabled': enabled} + if action == "enable" and not enabled: + raise YunohostError("upnp_port_open_failed") + return {"enabled": enabled} def firewall_stop(): @@ -417,7 +481,7 @@ def firewall_stop(): """ if os.system("iptables -w -P INPUT ACCEPT") != 0: - raise YunohostError('iptables_unavailable') + raise YunohostError("iptables_unavailable") os.system("iptables -w -F") os.system("iptables -w -X") @@ -428,7 +492,7 @@ def firewall_stop(): os.system("ip6tables -X") if os.path.exists(UPNP_CRON_JOB): - firewall_upnp('disable') + firewall_upnp("disable") def _get_ssh_port(default=22): @@ -438,12 +502,12 @@ def _get_ssh_port(default=22): one if it's not defined. """ from moulinette.utils.text import searchf + try: - m = searchf(r'^Port[ \t]+([0-9]+)$', - '/etc/ssh/sshd_config', count=-1) + m = searchf(r"^Port[ \t]+([0-9]+)$", "/etc/ssh/sshd_config", count=-1) if m: return int(m) - except: + except Exception: pass return default @@ -451,13 +515,17 @@ def _get_ssh_port(default=22): def _update_firewall_file(rules): """Make a backup and write new rules to firewall file""" os.system("cp {0} {0}.old".format(FIREWALL_FILE)) - with open(FIREWALL_FILE, 'w') as f: + with open(FIREWALL_FILE, "w") as f: yaml.safe_dump(rules, f, default_flow_style=False) def _on_rule_command_error(returncode, cmd, output): """Callback for rules commands error""" # Log error and continue commands execution - logger.debug('"%s" returned non-zero exit status %d:\n%s', - cmd, returncode, prependlines(output.rstrip(), '> ')) + logger.debug( + '"%s" returned non-zero exit status %d:\n%s', + cmd, + returncode, + prependlines(output.rstrip(), "> "), + ) return True diff --git a/src/yunohost/hook.py b/src/yunohost/hook.py index 67e77f033..c55809fce 100644 --- a/src/yunohost/hook.py +++ b/src/yunohost/hook.py @@ -25,18 +25,21 @@ """ import os import re +import sys import tempfile +import mimetypes from glob import iglob +from importlib import import_module -from moulinette import m18n, msettings -from yunohost.utils.error import YunohostError +from moulinette import m18n, Moulinette +from yunohost.utils.error import YunohostError, YunohostValidationError from moulinette.utils import log -from moulinette.utils.filesystem import read_json +from moulinette.utils.filesystem import read_yaml -HOOK_FOLDER = '/usr/share/yunohost/hooks/' -CUSTOM_HOOK_FOLDER = '/etc/yunohost/hooks.d/' +HOOK_FOLDER = "/usr/share/yunohost/hooks/" +CUSTOM_HOOK_FOLDER = "/etc/yunohost/hooks.d/" -logger = log.getActionLogger('yunohost.hook') +logger = log.getActionLogger("yunohost.hook") def hook_add(app, file): @@ -56,11 +59,11 @@ def hook_add(app, file): except OSError: os.makedirs(CUSTOM_HOOK_FOLDER + action) - finalpath = CUSTOM_HOOK_FOLDER + action + '/' + priority + '-' + app - os.system('cp %s %s' % (file, finalpath)) - os.system('chown -hR admin: %s' % HOOK_FOLDER) + finalpath = CUSTOM_HOOK_FOLDER + action + "/" + priority + "-" + app + os.system("cp %s %s" % (file, finalpath)) + os.system("chown -hR admin: %s" % HOOK_FOLDER) - return {'hook': finalpath} + return {"hook": finalpath} def hook_remove(app): @@ -75,7 +78,7 @@ def hook_remove(app): for action in os.listdir(CUSTOM_HOOK_FOLDER): for script in os.listdir(CUSTOM_HOOK_FOLDER + action): if script.endswith(app): - os.remove(CUSTOM_HOOK_FOLDER + action + '/' + script) + os.remove(CUSTOM_HOOK_FOLDER + action + "/" + script) except OSError: pass @@ -93,34 +96,36 @@ def hook_info(action, name): priorities = set() # Search in custom folder first - for h in iglob('{:s}{:s}/*-{:s}'.format( - CUSTOM_HOOK_FOLDER, action, name)): + for h in iglob("{:s}{:s}/*-{:s}".format(CUSTOM_HOOK_FOLDER, action, name)): priority, _ = _extract_filename_parts(os.path.basename(h)) priorities.add(priority) - hooks.append({ - 'priority': priority, - 'path': h, - }) + hooks.append( + { + "priority": priority, + "path": h, + } + ) # Append non-overwritten system hooks - for h in iglob('{:s}{:s}/*-{:s}'.format( - HOOK_FOLDER, action, name)): + for h in iglob("{:s}{:s}/*-{:s}".format(HOOK_FOLDER, action, name)): priority, _ = _extract_filename_parts(os.path.basename(h)) if priority not in priorities: - hooks.append({ - 'priority': priority, - 'path': h, - }) + hooks.append( + { + "priority": priority, + "path": h, + } + ) if not hooks: - raise YunohostError('hook_name_unknown', name=name) + raise YunohostValidationError("hook_name_unknown", name=name) return { - 'action': action, - 'name': name, - 'hooks': hooks, + "action": action, + "name": name, + "hooks": hooks, } -def hook_list(action, list_by='name', show_info=False): +def hook_list(action, list_by="name", show_info=False): """ List available hooks for an action @@ -133,85 +138,102 @@ def hook_list(action, list_by='name', show_info=False): result = {} # Process the property to list hook by - if list_by == 'priority': + if list_by == "priority": if show_info: + def _append_hook(d, priority, name, path): # Use the priority as key and a dict of hooks names # with their info as value - value = {'path': path} + value = {"path": path} try: d[priority][name] = value except KeyError: d[priority] = {name: value} + else: + def _append_hook(d, priority, name, path): # Use the priority as key and the name as value try: d[priority].add(name) except KeyError: d[priority] = set([name]) - elif list_by == 'name' or list_by == 'folder': + + elif list_by == "name" or list_by == "folder": if show_info: + def _append_hook(d, priority, name, path): # Use the name as key and a list of hooks info - the # executed ones with this name - as value - l = d.get(name, list()) - for h in l: + name_list = d.get(name, list()) + for h in name_list: # Only one priority for the hook is accepted - if h['priority'] == priority: + if h["priority"] == priority: # Custom hooks overwrite system ones and they # are appended at the end - so overwite it - if h['path'] != path: - h['path'] = path + if h["path"] != path: + h["path"] = path return - l.append({'priority': priority, 'path': path}) - d[name] = l + name_list.append({"priority": priority, "path": path}) + d[name] = name_list + else: - if list_by == 'name': + if list_by == "name": result = set() def _append_hook(d, priority, name, path): # Add only the name d.add(name) + else: - raise YunohostError('hook_list_by_invalid') + raise YunohostValidationError("hook_list_by_invalid") def _append_folder(d, folder): # Iterate over and add hook from a folder for f in os.listdir(folder + action): - if f[0] == '.' or f[-1] == '~': + if ( + f[0] == "." + or f[-1] == "~" + or f.endswith(".pyc") + or (f.startswith("__") and f.endswith("__")) + ): continue - path = '%s%s/%s' % (folder, action, f) + path = "%s%s/%s" % (folder, action, f) priority, name = _extract_filename_parts(f) _append_hook(d, priority, name, path) try: # Append system hooks first - if list_by == 'folder': - result['system'] = dict() if show_info else set() - _append_folder(result['system'], HOOK_FOLDER) + if list_by == "folder": + result["system"] = dict() if show_info else set() + _append_folder(result["system"], HOOK_FOLDER) else: _append_folder(result, HOOK_FOLDER) except OSError: - logger.debug("system hook folder not found for action '%s' in %s", - action, HOOK_FOLDER) + pass try: # Append custom hooks - if list_by == 'folder': - result['custom'] = dict() if show_info else set() - _append_folder(result['custom'], CUSTOM_HOOK_FOLDER) + if list_by == "folder": + result["custom"] = dict() if show_info else set() + _append_folder(result["custom"], CUSTOM_HOOK_FOLDER) else: _append_folder(result, CUSTOM_HOOK_FOLDER) except OSError: - logger.debug("custom hook folder not found for action '%s' in %s", - action, CUSTOM_HOOK_FOLDER) + pass - return {'hooks': result} + return {"hooks": result} -def hook_callback(action, hooks=[], args=None, no_trace=False, chdir=None, - env=None, pre_callback=None, post_callback=None): +def hook_callback( + action, + hooks=[], + args=None, + chdir=None, + env=None, + pre_callback=None, + post_callback=None, +): """ Execute all scripts binded to an action @@ -219,7 +241,6 @@ def hook_callback(action, hooks=[], args=None, no_trace=False, chdir=None, action -- Action name hooks -- List of hooks names to execute args -- Ordered list of arguments to pass to the scripts - no_trace -- Do not print each command that will be executed chdir -- The directory from where the scripts will be executed env -- Dictionnary of environment variables to export pre_callback -- An object to call before each script execution with @@ -234,11 +255,9 @@ def hook_callback(action, hooks=[], args=None, no_trace=False, chdir=None, # Retrieve hooks if not hooks: - hooks_dict = hook_list(action, list_by='priority', - show_info=True)['hooks'] + hooks_dict = hook_list(action, list_by="priority", show_info=True)["hooks"] else: - hooks_names = hook_list(action, list_by='name', - show_info=True)['hooks'] + hooks_names = hook_list(action, list_by="name", show_info=True)["hooks"] # Add similar hooks to the list # For example: Having a 16-postfix hook in the list will execute a @@ -246,8 +265,7 @@ def hook_callback(action, hooks=[], args=None, no_trace=False, chdir=None, all_hooks = [] for n in hooks: for key in hooks_names.keys(): - if key == n or key.startswith("%s_" % n) \ - and key not in all_hooks: + if key == n or key.startswith("%s_" % n) and key not in all_hooks: all_hooks.append(key) # Iterate over given hooks names list @@ -255,49 +273,61 @@ def hook_callback(action, hooks=[], args=None, no_trace=False, chdir=None, try: hl = hooks_names[n] except KeyError: - raise YunohostError('hook_name_unknown', n) + raise YunohostValidationError("hook_name_unknown", n) # Iterate over hooks with this name for h in hl: # Update hooks dict - d = hooks_dict.get(h['priority'], dict()) - d.update({n: {'path': h['path']}}) - hooks_dict[h['priority']] = d + d = hooks_dict.get(h["priority"], dict()) + d.update({n: {"path": h["path"]}}) + hooks_dict[h["priority"]] = d if not hooks_dict: return result # Validate callbacks if not callable(pre_callback): - pre_callback = lambda name, priority, path, args: args + + def pre_callback(name, priority, path, args): + return args + if not callable(post_callback): - post_callback = lambda name, priority, path, succeed: None + + def post_callback(name, priority, path, succeed): + return None # Iterate over hooks and execute them for priority in sorted(hooks_dict): for name, info in iter(hooks_dict[priority].items()): - state = 'succeed' - path = info['path'] + state = "succeed" + path = info["path"] try: - hook_args = pre_callback(name=name, priority=priority, - path=path, args=args) - hook_return = hook_exec(path, args=hook_args, chdir=chdir, env=env, - no_trace=no_trace, raise_on_error=True)[1] + hook_args = pre_callback( + name=name, priority=priority, path=path, args=args + ) + hook_return = hook_exec( + path, args=hook_args, chdir=chdir, env=env, raise_on_error=True + )[1] except YunohostError as e: - state = 'failed' + state = "failed" hook_return = {} logger.error(e.strerror, exc_info=1) - post_callback(name=name, priority=priority, path=path, - succeed=False) + post_callback(name=name, priority=priority, path=path, succeed=False) else: - post_callback(name=name, priority=priority, path=path, - succeed=True) - if not name in result: + post_callback(name=name, priority=priority, path=path, succeed=True) + if name not in result: result[name] = {} - result[name][path] = {'state' : state, 'stdreturn' : hook_return } + result[name][path] = {"state": state, "stdreturn": hook_return} return result -def hook_exec(path, args=None, raise_on_error=False, no_trace=False, - chdir=None, env=None, user="root", return_format="json"): +def hook_exec( + path, + args=None, + raise_on_error=False, + chdir=None, + env=None, + user="root", + return_format="yaml", +): """ Execute hook from a file with arguments @@ -305,111 +335,129 @@ def hook_exec(path, args=None, raise_on_error=False, no_trace=False, path -- Path of the script to execute args -- Ordered list of arguments to pass to the script raise_on_error -- Raise if the script returns a non-zero exit code - no_trace -- Do not print each command that will be executed chdir -- The directory from where the script will be executed env -- Dictionnary of environment variables to export user -- User with which to run the command - """ - from moulinette.utils.process import call_async_output # Validate hook path - if path[0] != '/': + if path[0] != "/": path = os.path.realpath(path) if not os.path.isfile(path): - raise YunohostError('file_does_not_exist', path=path) + raise YunohostError("file_does_not_exist", path=path) + + def is_relevant_warning(msg): + + # Ignore empty warning messages... + if not msg: + return False + + # Some of these are shit sent from apt and we don't give a shit about + # them because they ain't actual warnings >_> + irrelevant_warnings = [ + r"invalid value for trace file descriptor", + r"Creating config file .* with new version", + r"Created symlink /etc/systemd", + r"dpkg: warning: while removing .* not empty so not removed", + r"apt-key output should not be parsed", + ] + return all(not re.search(w, msg) for w in irrelevant_warnings) + + # Define output loggers and call command + loggers = ( + lambda l: logger.debug(l.rstrip() + "\r"), + lambda l: logger.warning(l.rstrip()) + if is_relevant_warning(l.rstrip()) + else logger.debug(l.rstrip()), + lambda l: logger.info(l.rstrip()), + ) + + # Check the type of the hook (bash by default) + # For now we support only python and bash hooks. + hook_type = mimetypes.MimeTypes().guess_type(path)[0] + if hook_type == "text/x-python": + returncode, returndata = _hook_exec_python(path, args, env, loggers) + else: + returncode, returndata = _hook_exec_bash( + path, args, chdir, env, user, return_format, loggers + ) + + # Check and return process' return code + if returncode is None: + if raise_on_error: + raise YunohostError("hook_exec_not_terminated", path=path) + else: + logger.error(m18n.n("hook_exec_not_terminated", path=path)) + return 1, {} + elif raise_on_error and returncode != 0: + raise YunohostError("hook_exec_failed", path=path) + + return returncode, returndata + + +def _hook_exec_bash(path, args, chdir, env, user, return_format, loggers): + + from moulinette.utils.process import call_async_output # Construct command variables - cmd_args = '' + cmd_args = "" if args and isinstance(args, list): # Concatenate escaped arguments - cmd_args = ' '.join(shell_quote(s) for s in args) + cmd_args = " ".join(shell_quote(s) for s in args) if not chdir: # use the script directory as current one chdir, cmd_script = os.path.split(path) - cmd_script = './{0}'.format(cmd_script) + cmd_script = "./{0}".format(cmd_script) else: cmd_script = path # Add Execution dir to environment var if env is None: env = {} - env['YNH_CWD'] = chdir + env["YNH_CWD"] = chdir - env['YNH_INTERFACE'] = msettings.get('interface') - - stdinfo = os.path.join(tempfile.mkdtemp(), "stdinfo") - env['YNH_STDINFO'] = stdinfo + env["YNH_INTERFACE"] = Moulinette.interface.type stdreturn = os.path.join(tempfile.mkdtemp(), "stdreturn") - with open(stdreturn, 'w') as f: - f.write('') - env['YNH_STDRETURN'] = stdreturn + with open(stdreturn, "w") as f: + f.write("") + env["YNH_STDRETURN"] = stdreturn # Construct command to execute if user == "root": - command = ['sh', '-c'] + command = ["sh", "-c"] else: - command = ['sudo', '-n', '-u', user, '-H', 'sh', '-c'] + command = ["sudo", "-n", "-u", user, "-H", "sh", "-c"] - if no_trace: - cmd = '/bin/bash "{script}" {args}' - else: - # use xtrace on fd 7 which is redirected to stdout - cmd = 'BASH_XTRACEFD=7 /bin/bash -x "{script}" {args} 7>&1' - - # prepend environment variables - cmd = '{0} {1}'.format( - ' '.join(['{0}={1}'.format(k, shell_quote(v)) - for k, v in env.items()]), cmd) + # use xtrace on fd 7 which is redirected to stdout + env["BASH_XTRACEFD"] = "7" + cmd = '/bin/bash -x "{script}" {args} 7>&1' command.append(cmd.format(script=cmd_script, args=cmd_args)) - if logger.isEnabledFor(log.DEBUG): - logger.debug(m18n.n('executing_command', command=' '.join(command))) - else: - logger.debug(m18n.n('executing_script', script=path)) + logger.debug("Executing command '%s'" % command) - # Define output callbacks and call command - callbacks = ( - lambda l: logger.debug(l.rstrip()+"\r"), - lambda l: logger.warning(l.rstrip()), - ) + _env = os.environ.copy() + _env.update(env) - if stdinfo: - callbacks = (callbacks[0], callbacks[1], - lambda l: logger.info(l.rstrip())) - - logger.debug("About to run the command '%s'" % command) - - returncode = call_async_output( - command, callbacks, shell=False, cwd=chdir, - stdinfo=stdinfo - ) - - # Check and return process' return code - if returncode is None: - if raise_on_error: - raise YunohostError('hook_exec_not_terminated', path=path) - else: - logger.error(m18n.n('hook_exec_not_terminated', path=path)) - return 1, {} - elif raise_on_error and returncode != 0: - raise YunohostError('hook_exec_failed', path=path) + returncode = call_async_output(command, loggers, shell=False, cwd=chdir, env=_env) raw_content = None try: - with open(stdreturn, 'r') as f: + with open(stdreturn, "r") as f: raw_content = f.read() returncontent = {} - if return_format == "json": - if raw_content != '': + if return_format == "yaml": + if raw_content != "": try: - returncontent = read_json(stdreturn) + returncontent = read_yaml(stdreturn) except Exception as e: - raise YunohostError('hook_json_return_error', - path=path, msg=str(e), - raw_content=raw_content) + raise YunohostError( + "hook_json_return_error", + path=path, + msg=str(e), + raw_content=raw_content, + ) elif return_format == "plain_dict": for line in raw_content.split("\n"): @@ -418,7 +466,10 @@ def hook_exec(path, args=None, raise_on_error=False, no_trace=False, returncontent[key] = value else: - raise YunohostError("Excepted value for return_format is either 'json' or 'plain_dict', got '%s'" % return_format) + raise YunohostError( + "Expected value for return_format is either 'json' or 'plain_dict', got '%s'" + % return_format + ) finally: stdreturndir = os.path.split(stdreturn)[0] os.remove(stdreturn) @@ -427,19 +478,76 @@ def hook_exec(path, args=None, raise_on_error=False, no_trace=False, return returncode, returncontent +def _hook_exec_python(path, args, env, loggers): + + dir_ = os.path.dirname(path) + name = os.path.splitext(os.path.basename(path))[0] + + if dir_ not in sys.path: + sys.path = [dir_] + sys.path + module = import_module(name) + + ret = module.main(args, env, loggers) + # # Assert that the return is a (int, dict) tuple + assert ( + isinstance(ret, tuple) + and len(ret) == 2 + and isinstance(ret[0], int) + and isinstance(ret[1], dict) + ), ("Module %s did not return a (int, dict) tuple !" % module) + return ret + + +def hook_exec_with_script_debug_if_failure(*args, **kwargs): + + operation_logger = kwargs.pop("operation_logger") + error_message_if_failed = kwargs.pop("error_message_if_failed") + error_message_if_script_failed = kwargs.pop("error_message_if_script_failed") + + failed = True + failure_message_with_debug_instructions = None + try: + retcode, retpayload = hook_exec(*args, **kwargs) + failed = True if retcode != 0 else False + if failed: + error = error_message_if_script_failed + logger.error(error_message_if_failed(error)) + failure_message_with_debug_instructions = operation_logger.error(error) + if Moulinette.interface.type != "api": + operation_logger.dump_script_log_extract_for_debugging() + # Script got manually interrupted ... + # N.B. : KeyboardInterrupt does not inherit from Exception + except (KeyboardInterrupt, EOFError): + error = m18n.n("operation_interrupted") + logger.error(error_message_if_failed(error)) + failure_message_with_debug_instructions = operation_logger.error(error) + # Something wrong happened in Yunohost's code (most probably hook_exec) + except Exception: + import traceback + + error = m18n.n("unexpected_error", error="\n" + traceback.format_exc()) + logger.error(error_message_if_failed(error)) + failure_message_with_debug_instructions = operation_logger.error(error) + + return failed, failure_message_with_debug_instructions + + def _extract_filename_parts(filename): """Extract hook parts from filename""" - if '-' in filename: - priority, action = filename.split('-', 1) + if "-" in filename: + priority, action = filename.split("-", 1) else: - priority = '50' + priority = "50" action = filename + + # Remove extension if there's one + action = os.path.splitext(action)[0] return priority, action # Taken from Python 3 shlex module -------------------------------------------- -_find_unsafe = re.compile(r'[^\w@%+=:,./-]', re.UNICODE).search +_find_unsafe = re.compile(r"[^\w@%+=:,./-]", re.UNICODE).search def shell_quote(s): diff --git a/src/yunohost/log.py b/src/yunohost/log.py index bf3535375..c99c1bbc9 100644 --- a/src/yunohost/log.py +++ b/src/yunohost/log.py @@ -27,102 +27,124 @@ import os import re import yaml -import collections +import glob +import psutil +from typing import List -from datetime import datetime +from datetime import datetime, timedelta from logging import FileHandler, getLogger, Formatter +from io import IOBase -from moulinette import m18n, msettings +from moulinette import m18n, Moulinette from moulinette.core import MoulinetteError -from yunohost.utils.error import YunohostError +from yunohost.utils.error import YunohostError, YunohostValidationError +from yunohost.utils.packages import get_ynh_package_version from moulinette.utils.log import getActionLogger from moulinette.utils.filesystem import read_file, read_yaml -CATEGORIES_PATH = '/var/log/yunohost/categories/' -OPERATIONS_PATH = '/var/log/yunohost/categories/operation/' -CATEGORIES = ['operation', 'history', 'package', 'system', 'access', 'service', - 'app'] -METADATA_FILE_EXT = '.yml' -LOG_FILE_EXT = '.log' -RELATED_CATEGORIES = ['app', 'domain', 'service', 'user'] +CATEGORIES_PATH = "/var/log/yunohost/categories/" +OPERATIONS_PATH = "/var/log/yunohost/categories/operation/" +METADATA_FILE_EXT = ".yml" +LOG_FILE_EXT = ".log" -logger = getActionLogger('yunohost.log') +logger = getActionLogger("yunohost.log") -def log_list(category=[], limit=None, with_details=False): +def log_list(limit=None, with_details=False, with_suboperations=False): """ List available logs Keyword argument: limit -- Maximum number of logs - with_details -- Include details (e.g. if the operation was a success). Likely to increase the command time as it needs to open and parse the metadata file for each log... So try to use this in combination with --limit. + with_details -- Include details (e.g. if the operation was a success). + Likely to increase the command time as it needs to open and parse the + metadata file for each log... + with_suboperations -- Include operations that are not the "main" + operation but are sub-operations triggered by another ongoing operation + ... (e.g. initializing groups/permissions when installing an app) """ - categories = category - is_api = msettings.get('interface') == 'api' + operations = {} - # In cli we just display `operation` logs by default - if not categories: - categories = ["operation"] if not is_api else CATEGORIES + logs = [x for x in os.listdir(OPERATIONS_PATH) if x.endswith(METADATA_FILE_EXT)] + logs = list(reversed(sorted(logs))) - result = collections.OrderedDict() - for category in categories: - result[category] = [] + if limit is not None: + if with_suboperations: + logs = logs[:limit] + else: + # If we displaying only parent, we are still gonna load up to limit * 5 logs + # because many of them are suboperations which are not gonna be kept + # Yet we still want to obtain ~limit number of logs + logs = logs[: limit * 5] - category_path = os.path.join(CATEGORIES_PATH, category) - if not os.path.exists(category_path): - logger.debug(m18n.n('log_category_404', category=category)) + for log in logs: + + base_filename = log[: -len(METADATA_FILE_EXT)] + md_path = os.path.join(OPERATIONS_PATH, log) + + entry = { + "name": base_filename, + "path": md_path, + "description": _get_description_from_name(base_filename), + } + + try: + entry["started_at"] = _get_datetime_from_name(base_filename) + except ValueError: + pass + + try: + metadata = ( + read_yaml(md_path) or {} + ) # Making sure this is a dict and not None..? + except Exception as e: + # If we can't read the yaml for some reason, report an error and ignore this entry... + logger.error(m18n.n("log_corrupted_md_file", md_file=md_path, error=e)) continue - logs = filter(lambda x: x.endswith(METADATA_FILE_EXT), - os.listdir(category_path)) - logs = list(reversed(sorted(logs))) + if with_details: + entry["success"] = metadata.get("success", "?") + entry["parent"] = metadata.get("parent") - if limit is not None: - logs = logs[:limit] + if with_suboperations: + entry["parent"] = metadata.get("parent") + entry["suboperations"] = [] + elif metadata.get("parent") is not None: + continue - for log in logs: + operations[base_filename] = entry - base_filename = log[:-len(METADATA_FILE_EXT)] - md_filename = log - md_path = os.path.join(category_path, md_filename) + # When displaying suboperations, we build a tree-like structure where + # "suboperations" is a list of suboperations (each of them may also have a list of + # "suboperations" suboperations etc... + if with_suboperations: + suboperations = [o for o in operations.values() if o["parent"] is not None] + for suboperation in suboperations: + parent = operations.get(suboperation["parent"]) + if not parent: + continue + parent["suboperations"].append(suboperation) + operations = [o for o in operations.values() if o["parent"] is None] + else: + operations = [o for o in operations.values()] - log = base_filename.split("-") - - entry = { - "name": base_filename, - "path": md_path, - } - entry["description"] = _get_description_from_name(base_filename) - try: - log_datetime = datetime.strptime(" ".join(log[:2]), - "%Y%m%d %H%M%S") - except ValueError: - pass - else: - entry["started_at"] = log_datetime - - if with_details: - try: - metadata = read_yaml(md_path) - except Exception as e: - # If we can't read the yaml for some reason, report an error and ignore this entry... - logger.error(m18n.n('log_corrupted_md_file', md_file=md_path, error=e)) - continue - entry["success"] = metadata.get("success", "?") if metadata else "?" - - result[category].append(entry) + if limit: + operations = operations[:limit] + operations = list(reversed(sorted(operations, key=lambda o: o["name"]))) # Reverse the order of log when in cli, more comfortable to read (avoid # unecessary scrolling) + is_api = Moulinette.interface.type == "api" if not is_api: - for category in result: - result[category] = list(reversed(result[category])) + operations = list(reversed(operations)) - return result + return {"operation": operations} -def log_display(path, number=None, share=False): +def log_show( + path, number=None, share=False, filter_irrelevant=False, with_suboperations=False +): """ Display a log file enriched with metadata if any. @@ -135,20 +157,58 @@ def log_display(path, number=None, share=False): share """ + if share: + filter_irrelevant = True + + if filter_irrelevant: + + def _filter(lines): + filters = [ + r"set [+-]x$", + r"set [+-]o xtrace$", + r"set [+-]o errexit$", + r"set [+-]o nounset$", + r"trap '' EXIT", + r"local \w+$", + r"local exit_code=(1|0)$", + r"local legacy_args=.*$", + r"local -A args_array$", + r"args_array=.*$", + r"ret_code=1", + r".*Helper used in legacy mode.*", + r"ynh_handle_getopts_args", + r"ynh_script_progression", + r"sleep 0.5", + r"'\[' (1|0) -eq (1|0) '\]'$", + r"\[?\['? -n '' '?\]\]?$", + r"rm -rf /var/cache/yunohost/download/$", + r"type -t ynh_clean_setup$", + r"DEBUG - \+ echo '", + r"DEBUG - \+ exit (1|0)$", + ] + filters = [re.compile(f) for f in filters] + return [ + line + for line in lines + if not any(f.search(line.strip()) for f in filters) + ] + + else: + + def _filter(lines): + return lines + # Normalize log/metadata paths and filenames abs_path = path log_path = None - if not path.startswith('/'): - for category in CATEGORIES: - abs_path = os.path.join(CATEGORIES_PATH, category, path) - if os.path.exists(abs_path) or os.path.exists(abs_path + METADATA_FILE_EXT): - break + if not path.startswith("/"): + abs_path = os.path.join(OPERATIONS_PATH, path) if os.path.exists(abs_path) and not path.endswith(METADATA_FILE_EXT): log_path = abs_path if abs_path.endswith(METADATA_FILE_EXT) or abs_path.endswith(LOG_FILE_EXT): - base_path = ''.join(os.path.splitext(abs_path)[:-1]) + base_path = "".join(os.path.splitext(abs_path)[:-1]) else: base_path = abs_path base_filename = os.path.basename(base_path) @@ -157,28 +217,30 @@ def log_display(path, number=None, share=False): log_path = base_path + LOG_FILE_EXT if not os.path.exists(md_path) and not os.path.exists(log_path): - raise YunohostError('log_does_exists', log=path) + raise YunohostValidationError("log_does_exists", log=path) infos = {} # If it's a unit operation, display the name and the description if base_path.startswith(CATEGORIES_PATH): infos["description"] = _get_description_from_name(base_filename) - infos['name'] = base_filename + infos["name"] = base_filename if share: from yunohost.utils.yunopaste import yunopaste + content = "" if os.path.exists(md_path): content += read_file(md_path) content += "\n============\n\n" if os.path.exists(log_path): - content += read_file(log_path) + actual_log = read_file(log_path) + content += "\n".join(_filter(actual_log.split("\n"))) url = yunopaste(content) logger.info(m18n.n("log_available_on_yunopaste", url=url)) - if msettings.get('interface') == 'api': + if Moulinette.interface.type == "api": return {"url": url} else: return @@ -186,35 +248,90 @@ def log_display(path, number=None, share=False): # Display metadata if exist if os.path.exists(md_path): try: - metadata = read_yaml(md_path) + metadata = read_yaml(md_path) or {} except MoulinetteError as e: - error = m18n.n('log_corrupted_md_file', md_file=md_path, error=e) + error = m18n.n("log_corrupted_md_file", md_file=md_path, error=e) if os.path.exists(log_path): logger.warning(error) else: raise YunohostError(error) else: - infos['metadata_path'] = md_path - infos['metadata'] = metadata + infos["metadata_path"] = md_path + infos["metadata"] = metadata - if 'log_path' in metadata: - log_path = metadata['log_path'] + if "log_path" in metadata: + log_path = metadata["log_path"] + + if with_suboperations: + + def suboperations(): + try: + log_start = _get_datetime_from_name(base_filename) + except ValueError: + return + + for filename in os.listdir(OPERATIONS_PATH): + + if not filename.endswith(METADATA_FILE_EXT): + continue + + # We first retrict search to a ~48h time window to limit the number + # of .yml we look into + try: + date = _get_datetime_from_name(base_filename) + except ValueError: + continue + if (date < log_start) or ( + date > log_start + timedelta(hours=48) + ): + continue + + try: + submetadata = read_yaml( + os.path.join(OPERATIONS_PATH, filename) + ) + except Exception: + continue + + if submetadata and submetadata.get("parent") == base_filename: + yield { + "name": filename[: -len(METADATA_FILE_EXT)], + "description": _get_description_from_name( + filename[: -len(METADATA_FILE_EXT)] + ), + "success": submetadata.get("success", "?"), + } + + metadata["suboperations"] = list(suboperations()) # Display logs if exist if os.path.exists(log_path): from yunohost.service import _tail - if number: + + if number and filter_irrelevant: + logs = _tail(log_path, int(number * 4)) + elif number: logs = _tail(log_path, int(number)) else: logs = read_file(log_path) - infos['log_path'] = log_path - infos['logs'] = logs + logs = list(_filter(logs)) + if number: + logs = logs[-number:] + infos["log_path"] = log_path + infos["logs"] = logs return infos -def is_unit_operation(entities=['app', 'domain', 'service', 'user'], - exclude=['password'], operation_key=None): +def log_share(path): + return log_show(path, share=True) + + +def is_unit_operation( + entities=["app", "domain", "group", "service", "user"], + exclude=["password"], + operation_key=None, +): """ Configure quickly a unit operation @@ -236,6 +353,7 @@ def is_unit_operation(entities=['app', 'domain', 'service', 'user'], 'log_' is present in locales/en.json otherwise it won't be translatable. """ + def decorate(func): def func_wrapper(*args, **kwargs): op_key = operation_key @@ -248,10 +366,11 @@ def is_unit_operation(entities=['app', 'domain', 'service', 'user'], # Indeed, we use convention naming in this decorator and we need to # know name of each args (so we need to use kwargs instead of args) if len(args) > 0: - from inspect import getargspec - keys = getargspec(func).args - if 'operation_logger' in keys: - keys.remove('operation_logger') + from inspect import signature + + keys = list(signature(func).parameters.keys()) + if "operation_logger" in keys: + keys.remove("operation_logger") for k, arg in enumerate(args): kwargs[keys[k]] = arg args = () @@ -266,7 +385,7 @@ def is_unit_operation(entities=['app', 'domain', 'service', 'user'], entity_type = entity if entity in kwargs and kwargs[entity] is not None: - if isinstance(kwargs[entity], basestring): + if isinstance(kwargs[entity], str): related_to.append((entity_type, kwargs[entity])) else: for x in kwargs[entity]: @@ -278,6 +397,18 @@ def is_unit_operation(entities=['app', 'domain', 'service', 'user'], for field in exclude: if field in context: context.pop(field, None) + + # Context is made from args given to main function by argparse + # This context will be added in extra parameters in yml file, so this context should + # be serializable and short enough (it will be displayed in webadmin) + # Argparse can provide some File or Stream, so here we display the filename or + # the IOBase, if we have no name. + for field, value in context.items(): + if isinstance(value, IOBase): + try: + context[field] = value.name + except: + context[field] = "IOBase" operation_logger = OperationLogger(op_key, related_to, args=context) try: @@ -291,12 +422,13 @@ def is_unit_operation(entities=['app', 'domain', 'service', 'user'], else: operation_logger.success() return result + return func_wrapper + return decorate class RedactingFormatter(Formatter): - def __init__(self, format_string, data_to_redact): super(RedactingFormatter, self).__init__(format_string) self.data_to_redact = data_to_redact @@ -305,7 +437,11 @@ class RedactingFormatter(Formatter): msg = super(RedactingFormatter, self).format(record) self.identify_data_to_redact(msg) for data in self.data_to_redact: - msg = msg.replace(data, "**********") + # we check that data is not empty string, + # otherwise this may lead to super epic stuff + # (try to run "foo".replace("", "bar")) + if data: + msg = msg.replace(data, "**********") return msg def identify_data_to_redact(self, record): @@ -315,11 +451,21 @@ class RedactingFormatter(Formatter): try: # This matches stuff like db_pwd=the_secret or admin_password=other_secret # (the secret part being at least 3 chars to avoid catching some lines like just "db_pwd=") - match = re.search(r'(pwd|pass|password|secret|key)=(\S{3,})$', record.strip()) - if match and match.group(2) not in self.data_to_redact: + # Some names like "key" or "manifest_key" are ignored, used in helpers like ynh_app_setting_set or ynh_read_manifest + match = re.search( + r"(pwd|pass|passwd|password|passphrase|secret\w*|\w+key|token|PASSPHRASE)=(\S{3,})$", + record.strip(), + ) + if ( + match + and match.group(2) not in self.data_to_redact + and match.group(1) not in ["key", "manifest_key"] + ): self.data_to_redact.append(match.group(2)) except Exception as e: - logger.warning("Failed to parse line to try to identify data to redact ... : %s" % e) + logger.warning( + "Failed to parse line to try to identify data to redact ... : %s" % e + ) class OperationLogger(object): @@ -333,6 +479,8 @@ class OperationLogger(object): This class record logs and metadata like context or start time/end time. """ + _instances: List[object] = [] + def __init__(self, operation, related_to=None, **kwargs): # TODO add a way to not save password on app installation self.operation = operation @@ -343,6 +491,8 @@ class OperationLogger(object): self.logger = None self._name = None self.data_to_redact = [] + self.parent = self.parent_logger() + self._instances.append(self) for filename in ["/etc/yunohost/mysql", "/etc/yunohost/psql"]: if os.path.exists(filename): @@ -353,6 +503,60 @@ class OperationLogger(object): if not os.path.exists(self.path): os.makedirs(self.path) + def parent_logger(self): + + # If there are other operation logger instances + for instance in reversed(self._instances): + # Is one of these operation logger started but not yet done ? + if instance.started_at is not None and instance.ended_at is None: + # We are a child of the first one we found + return instance.name + + # If no lock exists, we are probably in tests or yunohost is used as a + # lib ... let's not really care about that case and assume we're the + # root logger then. + if not os.path.exists("/var/run/moulinette_yunohost.lock"): + return None + + locks = read_file("/var/run/moulinette_yunohost.lock").strip().split("\n") + # If we're the process with the lock, we're the root logger + if locks == [] or str(os.getpid()) in locks: + return None + + # If we get here, we are in a yunohost command called by a yunohost + # (maybe indirectly from an app script for example...) + # + # The strategy is : + # 1. list 20 most recent log files + # 2. iterate over the PID of parent processes + # 3. see if parent process has some log file open (being actively + # written in) + # 4. if among those file, there's an operation log file, we use the id + # of the most recent file + + recent_operation_logs = sorted( + glob.iglob(OPERATIONS_PATH + "*.log"), key=os.path.getctime, reverse=True + )[:20] + + proc = psutil.Process().parent() + while proc is not None: + # We use proc.open_files() to list files opened / actively used by this proc + # We only keep files matching a recent yunohost operation log + active_logs = sorted( + [f.path for f in proc.open_files() if f.path in recent_operation_logs], + key=os.path.getctime, + reverse=True, + ) + if active_logs != []: + # extra the log if from the full path + return os.path.basename(active_logs[0])[:-4] + else: + proc = proc.parent() + continue + + # If nothing found, assume we're the root operation logger + return None + def start(self): """ Start to record logs that change the system @@ -388,10 +592,12 @@ class OperationLogger(object): # N.B. : the subtle thing here is that the class will remember a pointer to the list, # so we can directly append stuff to self.data_to_redact and that'll be automatically # propagated to the RedactingFormatter - self.file_handler.formatter = RedactingFormatter('%(asctime)s: %(levelname)s - %(message)s', self.data_to_redact) + self.file_handler.formatter = RedactingFormatter( + "%(asctime)s: %(levelname)s - %(message)s", self.data_to_redact + ) # Listen to the root logger - self.logger = getLogger('yunohost') + self.logger = getLogger("yunohost") self.logger.addHandler(self.file_handler) def flush(self): @@ -403,7 +609,7 @@ class OperationLogger(object): for data in self.data_to_redact: # N.B. : we need quotes here, otherwise yaml isn't happy about loading the yml later dump = dump.replace(data, "'**********'") - with open(self.md_path, 'w') as outfile: + with open(self.md_path, "w") as outfile: outfile.write(dump) @property @@ -427,7 +633,7 @@ class OperationLogger(object): # We use the name of the first related thing name.append(self.related_to[0][1]) - self._name = '-'.join(name) + self._name = "-".join(name) return self._name @property @@ -437,16 +643,19 @@ class OperationLogger(object): """ data = { - 'started_at': self.started_at, - 'operation': self.operation, + "started_at": self.started_at, + "operation": self.operation, + "parent": self.parent, + "yunohost_version": get_ynh_package_version("yunohost")["version"], + "interface": Moulinette.interface.type, } if self.related_to is not None: - data['related_to'] = self.related_to + data["related_to"] = self.related_to if self.ended_at is not None: - data['ended_at'] = self.ended_at - data['success'] = self._success + data["ended_at"] = self.ended_at + data["success"] = self._success if self.error is not None: - data['error'] = self._error + data["error"] = self._error # TODO: detect if 'extra' erase some key of 'data' data.update(self.extra) return data @@ -467,31 +676,48 @@ class OperationLogger(object): """ Close properly the unit operation """ + + # When the error happen's in the is_unit_operation try/except, + # we want to inject the log ref in the exception, such that it may be + # transmitted to the webadmin which can then redirect to the appropriate + # log page + if ( + self.started_at + and isinstance(error, Exception) + and not isinstance(error, YunohostValidationError) + ): + error.log_ref = self.name + if self.ended_at is not None or self.started_at is None: return - if error is not None and not isinstance(error, basestring): + if error is not None and not isinstance(error, str): error = str(error) + self.ended_at = datetime.utcnow() self._error = error self._success = error is None + if self.logger is not None: self.logger.removeHandler(self.file_handler) + self.file_handler.close() - is_api = msettings.get('interface') == 'api' + is_api = Moulinette.interface.type == "api" desc = _get_description_from_name(self.name) if error is None: if is_api: - msg = m18n.n('log_link_to_log', name=self.name, desc=desc) + msg = m18n.n("log_link_to_log", name=self.name, desc=desc) else: - msg = m18n.n('log_help_to_get_log', name=self.name, desc=desc) + msg = m18n.n("log_help_to_get_log", name=self.name, desc=desc) logger.debug(msg) else: if is_api: - msg = "" + m18n.n('log_link_to_failed_log', - name=self.name, desc=desc) + "" + msg = ( + "" + + m18n.n("log_link_to_failed_log", name=self.name, desc=desc) + + "" + ) else: - msg = m18n.n('log_help_to_get_failed_log', name=self.name, - desc=desc) + msg = m18n.n("log_help_to_get_failed_log", name=self.name, desc=desc) logger.info(msg) self.flush() return msg @@ -502,7 +728,65 @@ class OperationLogger(object): The missing of the message below could help to see an electrical shortage. """ - self.error(m18n.n('log_operation_unit_unclosed_properly')) + if self.ended_at is not None or self.started_at is None: + return + else: + self.error(m18n.n("log_operation_unit_unclosed_properly")) + + def dump_script_log_extract_for_debugging(self): + + with open(self.log_path, "r") as f: + lines = f.readlines() + + filters = [ + r"set [+-]x$", + r"set [+-]o xtrace$", + r"local \w+$", + r"local legacy_args=.*$", + r".*Helper used in legacy mode.*", + r"args_array=.*$", + r"local -A args_array$", + r"ynh_handle_getopts_args", + r"ynh_script_progression", + ] + + filters = [re.compile(f_) for f_ in filters] + + lines_to_display = [] + for line in lines: + + if ": " not in line.strip(): + continue + + # A line typically looks like + # 2019-10-19 16:10:27,611: DEBUG - + mysql -u piwigo --password=********** -B piwigo + # And we just want the part starting by "DEBUG - " + line = line.strip().split(": ", 1)[1] + + if any(filter_.search(line) for filter_ in filters): + continue + + lines_to_display.append(line) + + if line.endswith("+ ynh_exit_properly") or " + ynh_die " in line: + break + elif len(lines_to_display) > 20: + lines_to_display.pop(0) + + logger.warning( + "Here's an extract of the logs before the crash. It might help debugging the error:" + ) + for line in lines_to_display: + logger.info(line) + + +def _get_datetime_from_name(name): + + # Filenames are expected to follow the format: + # 20200831-170740-short_description-and-stuff + + raw_datetime = " ".join(name.split("-")[:2]) + return datetime.strptime(raw_datetime, "%Y%m%d %H%M%S") def _get_description_from_name(name): diff --git a/src/yunohost/monitor.py b/src/yunohost/monitor.py deleted file mode 100644 index 7af55f287..000000000 --- a/src/yunohost/monitor.py +++ /dev/null @@ -1,740 +0,0 @@ -# -*- coding: utf-8 -*- - -""" License - - Copyright (C) 2013 YunoHost - - 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 - -""" - -""" yunohost_monitor.py - - Monitoring functions -""" -import re -import json -import time -import psutil -import calendar -import subprocess -import xmlrpclib -import os.path -import os -import dns.resolver -import cPickle as pickle -from datetime import datetime - -from moulinette import m18n -from yunohost.utils.error import YunohostError -from moulinette.utils.log import getActionLogger - -from yunohost.utils.network import get_public_ip -from yunohost.domain import _get_maindomain - -logger = getActionLogger('yunohost.monitor') - -GLANCES_URI = 'http://127.0.0.1:61209' -STATS_PATH = '/var/lib/yunohost/stats' -CRONTAB_PATH = '/etc/cron.d/yunohost-monitor' - - -def monitor_disk(units=None, mountpoint=None, human_readable=False): - """ - Monitor disk space and usage - - Keyword argument: - units -- Unit(s) to monitor - mountpoint -- Device mountpoint - human_readable -- Print sizes in human readable format - - """ - glances = _get_glances_api() - result_dname = None - result = {} - - if units is None: - units = ['io', 'filesystem'] - - _format_dname = lambda d: (os.path.realpath(d)).replace('/dev/', '') - - # Get mounted devices - devices = {} - for p in psutil.disk_partitions(all=True): - if not p.device.startswith('/dev/') or not p.mountpoint: - continue - if mountpoint is None: - devices[_format_dname(p.device)] = p.mountpoint - elif mountpoint == p.mountpoint: - dn = _format_dname(p.device) - devices[dn] = p.mountpoint - result_dname = dn - if len(devices) == 0: - if mountpoint is not None: - raise YunohostError('mountpoint_unknown') - return result - - # Retrieve monitoring for unit(s) - for u in units: - if u == 'io': - # Define setter - if len(units) > 1: - def _set(dn, dvalue): - try: - result[dn][u] = dvalue - except KeyError: - result[dn] = {u: dvalue} - else: - def _set(dn, dvalue): - result[dn] = dvalue - - # Iterate over values - devices_names = devices.keys() - for d in json.loads(glances.getDiskIO()): - dname = d.pop('disk_name') - try: - devices_names.remove(dname) - except: - continue - else: - _set(dname, d) - for dname in devices_names: - _set(dname, 'not-available') - elif u == 'filesystem': - # Define setter - if len(units) > 1: - def _set(dn, dvalue): - try: - result[dn][u] = dvalue - except KeyError: - result[dn] = {u: dvalue} - else: - def _set(dn, dvalue): - result[dn] = dvalue - - # Iterate over values - devices_names = devices.keys() - for d in json.loads(glances.getFs()): - dname = _format_dname(d.pop('device_name')) - try: - devices_names.remove(dname) - except: - continue - else: - d['avail'] = d['size'] - d['used'] - if human_readable: - for i in ['used', 'avail', 'size']: - d[i] = binary_to_human(d[i]) + 'B' - _set(dname, d) - for dname in devices_names: - _set(dname, 'not-available') - else: - raise YunohostError('unit_unknown', unit=u) - - if result_dname is not None: - return result[result_dname] - return result - - -def monitor_network(units=None, human_readable=False): - """ - Monitor network interfaces - - Keyword argument: - units -- Unit(s) to monitor - human_readable -- Print sizes in human readable format - - """ - glances = _get_glances_api() - result = {} - - if units is None: - units = ['check', 'usage', 'infos'] - - # Get network devices and their addresses - # TODO / FIXME : use functions in utils/network.py to manage this - devices = {} - output = subprocess.check_output('ip addr show'.split()) - for d in re.split('^(?:[0-9]+: )', output, flags=re.MULTILINE): - # Extract device name (1) and its addresses (2) - m = re.match('([^\s@]+)(?:@[\S]+)?: (.*)', d, flags=re.DOTALL) - if m: - devices[m.group(1)] = m.group(2) - - # Retrieve monitoring for unit(s) - for u in units: - if u == 'check': - result[u] = {} - domain = _get_maindomain() - cmd_check_smtp = os.system('/bin/nc -z -w1 yunohost.org 25') - if cmd_check_smtp == 0: - smtp_check = m18n.n('network_check_smtp_ok') - else: - smtp_check = m18n.n('network_check_smtp_ko') - - try: - answers = dns.resolver.query(domain, 'MX') - mx_check = {} - i = 0 - for server in answers: - mx_id = 'mx%s' % i - mx_check[mx_id] = server - i = i + 1 - except: - mx_check = m18n.n('network_check_mx_ko') - result[u] = { - 'smtp_check': smtp_check, - 'mx_check': mx_check - } - elif u == 'usage': - result[u] = {} - for i in json.loads(glances.getNetwork()): - iname = i['interface_name'] - if iname in devices.keys(): - del i['interface_name'] - if human_readable: - for k in i.keys(): - if k != 'time_since_update': - i[k] = binary_to_human(i[k]) + 'B' - result[u][iname] = i - else: - logger.debug('interface name %s was not found', iname) - elif u == 'infos': - p_ipv4 = get_public_ip() or 'unknown' - - # TODO / FIXME : use functions in utils/network.py to manage this - l_ip = 'unknown' - for name, addrs in devices.items(): - if name == 'lo': - continue - if not isinstance(l_ip, dict): - l_ip = {} - l_ip[name] = _extract_inet(addrs) - - gateway = 'unknown' - output = subprocess.check_output('ip route show'.split()) - m = re.search('default via (.*) dev ([a-z]+[0-9]?)', output) - if m: - addr = _extract_inet(m.group(1), True) - if len(addr) == 1: - proto, gateway = addr.popitem() - - result[u] = { - 'public_ip': p_ipv4, - 'local_ip': l_ip, - 'gateway': gateway, - } - else: - raise YunohostError('unit_unknown', unit=u) - - if len(units) == 1: - return result[units[0]] - return result - - -def monitor_system(units=None, human_readable=False): - """ - Monitor system informations and usage - - Keyword argument: - units -- Unit(s) to monitor - human_readable -- Print sizes in human readable format - - """ - glances = _get_glances_api() - result = {} - - if units is None: - units = ['memory', 'cpu', 'process', 'uptime', 'infos'] - - # Retrieve monitoring for unit(s) - for u in units: - if u == 'memory': - ram = json.loads(glances.getMem()) - swap = json.loads(glances.getMemSwap()) - if human_readable: - for i in ram.keys(): - if i != 'percent': - ram[i] = binary_to_human(ram[i]) + 'B' - for i in swap.keys(): - if i != 'percent': - swap[i] = binary_to_human(swap[i]) + 'B' - result[u] = { - 'ram': ram, - 'swap': swap - } - elif u == 'cpu': - result[u] = { - 'load': json.loads(glances.getLoad()), - 'usage': json.loads(glances.getCpu()) - } - elif u == 'process': - result[u] = json.loads(glances.getProcessCount()) - elif u == 'uptime': - result[u] = (str(datetime.now() - datetime.fromtimestamp(psutil.boot_time())).split('.')[0]) - elif u == 'infos': - result[u] = json.loads(glances.getSystem()) - else: - raise YunohostError('unit_unknown', unit=u) - - if len(units) == 1 and not isinstance(result[units[0]], str): - return result[units[0]] - return result - - -def monitor_update_stats(period): - """ - Update monitoring statistics - - Keyword argument: - period -- Time period to update (day, week, month) - - """ - if period not in ['day', 'week', 'month']: - raise YunohostError('monitor_period_invalid') - - stats = _retrieve_stats(period) - if not stats: - stats = {'disk': {}, 'network': {}, 'system': {}, 'timestamp': []} - - monitor = None - # Get monitoring stats - if period == 'day': - monitor = _monitor_all('day') - else: - t = stats['timestamp'] - p = 'day' if period == 'week' else 'week' - if len(t) > 0: - monitor = _monitor_all(p, t[len(t) - 1]) - else: - monitor = _monitor_all(p, 0) - if not monitor: - raise YunohostError('monitor_stats_no_update') - - stats['timestamp'].append(time.time()) - - # Append disk stats - for dname, units in monitor['disk'].items(): - disk = {} - # Retrieve current stats for disk name - if dname in stats['disk'].keys(): - disk = stats['disk'][dname] - - for unit, values in units.items(): - # Continue if unit doesn't contain stats - if not isinstance(values, dict): - continue - - # Retrieve current stats for unit and append new ones - curr = disk[unit] if unit in disk.keys() else {} - if unit == 'io': - disk[unit] = _append_to_stats(curr, values, 'time_since_update') - elif unit == 'filesystem': - disk[unit] = _append_to_stats(curr, values, ['fs_type', 'mnt_point']) - stats['disk'][dname] = disk - - # Append network stats - net_usage = {} - for iname, values in monitor['network']['usage'].items(): - # Continue if units doesn't contain stats - if not isinstance(values, dict): - continue - - # Retrieve current stats and append new ones - curr = {} - if 'usage' in stats['network'] and iname in stats['network']['usage']: - curr = stats['network']['usage'][iname] - net_usage[iname] = _append_to_stats(curr, values, 'time_since_update') - stats['network'] = {'usage': net_usage, 'infos': monitor['network']['infos']} - - # Append system stats - for unit, values in monitor['system'].items(): - # Continue if units doesn't contain stats - if not isinstance(values, dict): - continue - - # Set static infos unit - if unit == 'infos': - stats['system'][unit] = values - continue - - # Retrieve current stats and append new ones - curr = stats['system'][unit] if unit in stats['system'].keys() else {} - stats['system'][unit] = _append_to_stats(curr, values) - - _save_stats(stats, period) - - -def monitor_show_stats(period, date=None): - """ - Show monitoring statistics - - Keyword argument: - period -- Time period to show (day, week, month) - - """ - if period not in ['day', 'week', 'month']: - raise YunohostError('monitor_period_invalid') - - result = _retrieve_stats(period, date) - if result is False: - raise YunohostError('monitor_stats_file_not_found') - elif result is None: - raise YunohostError('monitor_stats_period_unavailable') - return result - - -def monitor_enable(with_stats=False): - """ - Enable server monitoring - - Keyword argument: - with_stats -- Enable monitoring statistics - - """ - from yunohost.service import (service_status, service_enable, - service_start) - - glances = service_status('glances') - if glances['status'] != 'running': - service_start('glances') - if glances['loaded'] != 'enabled': - service_enable('glances') - - # Install crontab - if with_stats: - # day: every 5 min # week: every 1 h # month: every 4 h # - rules = ('*/5 * * * * root {cmd} day >> /dev/null\n' - '3 * * * * root {cmd} week >> /dev/null\n' - '6 */4 * * * root {cmd} month >> /dev/null').format( - cmd='/usr/bin/yunohost --quiet monitor update-stats') - with open(CRONTAB_PATH, 'w') as f: - f.write(rules) - - logger.success(m18n.n('monitor_enabled')) - - -def monitor_disable(): - """ - Disable server monitoring - - """ - from yunohost.service import (service_status, service_disable, - service_stop) - - glances = service_status('glances') - if glances['status'] != 'inactive': - service_stop('glances') - if glances['loaded'] != 'disabled': - try: - service_disable('glances') - except YunohostError as e: - logger.warning(e.strerror) - - # Remove crontab - try: - os.remove(CRONTAB_PATH) - except: - pass - - logger.success(m18n.n('monitor_disabled')) - - -def _get_glances_api(): - """ - Retrieve Glances API running on the local server - - """ - try: - p = xmlrpclib.ServerProxy(GLANCES_URI) - p.system.methodHelp('getAll') - except (xmlrpclib.ProtocolError, IOError): - pass - else: - return p - - from yunohost.service import service_status - - if service_status('glances')['status'] != 'running': - raise YunohostError('monitor_not_enabled') - raise YunohostError('monitor_glances_con_failed') - - -def _extract_inet(string, skip_netmask=False, skip_loopback=True): - """ - Extract IP addresses (v4 and/or v6) from a string limited to one - address by protocol - - Keyword argument: - string -- String to search in - skip_netmask -- True to skip subnet mask extraction - skip_loopback -- False to include addresses reserved for the - loopback interface - - Returns: - A dict of {protocol: address} with protocol one of 'ipv4' or 'ipv6' - - """ - ip4_pattern = '((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}' - ip6_pattern = '(((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)' - ip4_pattern += '/[0-9]{1,2})' if not skip_netmask else ')' - ip6_pattern += '/[0-9]{1,3})' if not skip_netmask else ')' - result = {} - - for m in re.finditer(ip4_pattern, string): - addr = m.group(1) - if skip_loopback and addr.startswith('127.'): - continue - - # Limit to only one result - result['ipv4'] = addr - break - - for m in re.finditer(ip6_pattern, string): - addr = m.group(1) - if skip_loopback and addr == '::1': - continue - - # Limit to only one result - result['ipv6'] = addr - break - - return result - - -def binary_to_human(n, customary=False): - """ - Convert bytes or bits into human readable format with binary prefix - - Keyword argument: - n -- Number to convert - customary -- Use customary symbol instead of IEC standard - - """ - symbols = ('Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi') - if customary: - symbols = ('K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') - prefix = {} - for i, s in enumerate(symbols): - prefix[s] = 1 << (i + 1) * 10 - for s in reversed(symbols): - if n >= prefix[s]: - value = float(n) / prefix[s] - return '%.1f%s' % (value, s) - return "%s" % n - - -def _retrieve_stats(period, date=None): - """ - Retrieve statistics from pickle file - - Keyword argument: - period -- Time period to retrieve (day, week, month) - date -- Date of stats to retrieve - - """ - pkl_file = None - - # Retrieve pickle file - if date is not None: - timestamp = calendar.timegm(date) - pkl_file = '%s/%d_%s.pkl' % (STATS_PATH, timestamp, period) - else: - pkl_file = '%s/%s.pkl' % (STATS_PATH, period) - if not os.path.isfile(pkl_file): - return False - - # Read file and process its content - with open(pkl_file, 'r') as f: - result = pickle.load(f) - if not isinstance(result, dict): - return None - return result - - -def _save_stats(stats, period, date=None): - """ - Save statistics to pickle file - - Keyword argument: - stats -- Stats dict to save - period -- Time period of stats (day, week, month) - date -- Date of stats - - """ - pkl_file = None - - # Set pickle file name - if date is not None: - timestamp = calendar.timegm(date) - pkl_file = '%s/%d_%s.pkl' % (STATS_PATH, timestamp, period) - else: - pkl_file = '%s/%s.pkl' % (STATS_PATH, period) - if not os.path.isdir(STATS_PATH): - os.makedirs(STATS_PATH) - - # Limit stats - if date is None: - t = stats['timestamp'] - limit = {'day': 86400, 'week': 604800, 'month': 2419200} - if (t[len(t) - 1] - t[0]) > limit[period]: - begin = t[len(t) - 1] - limit[period] - stats = _filter_stats(stats, begin) - - # Write file content - with open(pkl_file, 'w') as f: - pickle.dump(stats, f) - return True - - -def _monitor_all(period=None, since=None): - """ - Monitor all units (disk, network and system) for the given period - If since is None, real-time monitoring is returned. Otherwise, the - mean of stats since this timestamp is calculated and returned. - - Keyword argument: - period -- Time period to monitor (day, week, month) - since -- Timestamp of the stats beginning - - """ - result = {'disk': {}, 'network': {}, 'system': {}} - - # Real-time stats - if period == 'day' and since is None: - result['disk'] = monitor_disk() - result['network'] = monitor_network() - result['system'] = monitor_system() - return result - - # Retrieve stats and calculate mean - stats = _retrieve_stats(period) - if not stats: - return None - stats = _filter_stats(stats, since) - if not stats: - return None - result = _calculate_stats_mean(stats) - - return result - - -def _filter_stats(stats, t_begin=None, t_end=None): - """ - Filter statistics by beginning and/or ending timestamp - - Keyword argument: - stats -- Dict stats to filter - t_begin -- Beginning timestamp - t_end -- Ending timestamp - - """ - if t_begin is None and t_end is None: - return stats - - i_begin = i_end = None - # Look for indexes of timestamp interval - for i, t in enumerate(stats['timestamp']): - if t_begin and i_begin is None and t >= t_begin: - i_begin = i - if t_end and i != 0 and i_end is None and t > t_end: - i_end = i - # Check indexes - if i_begin is None: - if t_begin and t_begin > stats['timestamp'][0]: - return None - i_begin = 0 - if i_end is None: - if t_end and t_end < stats['timestamp'][0]: - return None - i_end = len(stats['timestamp']) - if i_begin == 0 and i_end == len(stats['timestamp']): - return stats - - # Filter function - def _filter(s, i, j): - for k, v in s.items(): - if isinstance(v, dict): - s[k] = _filter(v, i, j) - elif isinstance(v, list): - s[k] = v[i:j] - return s - - stats = _filter(stats, i_begin, i_end) - return stats - - -def _calculate_stats_mean(stats): - """ - Calculate the weighted mean for each statistic - - Keyword argument: - stats -- Stats dict to process - - """ - timestamp = stats['timestamp'] - t_sum = sum(timestamp) - del stats['timestamp'] - - # Weighted mean function - def _mean(s, t, ts): - for k, v in s.items(): - if isinstance(v, dict): - s[k] = _mean(v, t, ts) - elif isinstance(v, list): - try: - nums = [float(x * t[i]) for i, x in enumerate(v)] - except: - pass - else: - s[k] = sum(nums) / float(ts) - return s - - stats = _mean(stats, timestamp, t_sum) - return stats - - -def _append_to_stats(stats, monitor, statics=[]): - """ - Append monitoring statistics to current statistics - - Keyword argument: - stats -- Current stats dict - monitor -- Monitoring statistics - statics -- List of stats static keys - - """ - if isinstance(statics, str): - statics = [statics] - - # Appending function - def _append(s, m, st): - for k, v in m.items(): - if k in st: - s[k] = v - elif isinstance(v, dict): - if k not in s: - s[k] = {} - s[k] = _append(s[k], v, st) - else: - if k not in s: - s[k] = [] - if isinstance(v, list): - s[k].extend(v) - else: - s[k].append(v) - return s - - stats = _append(stats, monitor, statics) - return stats diff --git a/src/yunohost/permission.py b/src/yunohost/permission.py index 3beb3ac2b..80d3b8602 100644 --- a/src/yunohost/permission.py +++ b/src/yunohost/permission.py @@ -24,320 +24,392 @@ Manage permissions """ +import re +import copy import grp import random from moulinette import m18n from moulinette.utils.log import getActionLogger -from yunohost.utils.error import YunohostError -from yunohost.user import user_list +from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.log import is_unit_operation -logger = getActionLogger('yunohost.user') +logger = getActionLogger("yunohost.user") + +SYSTEM_PERMS = ["mail", "xmpp", "sftp", "ssh"] + +# +# +# The followings are the methods exposed through the "yunohost user permission" interface +# +# -def user_permission_list(app=None, permission=None, username=None, group=None): +def user_permission_list( + short=False, full=False, ignore_system_perms=False, absolute_urls=False, apps=[] +): """ - List permission for specific application - - Keyword argument: - app -- an application OR sftp, xmpp (metronome), mail - permission -- name of the permission ("main" by default) - username -- Username to get informations - group -- Groupname to get informations - + List permissions and corresponding accesses """ - from yunohost.utils.ldap import _get_ldap_interface + # Fetch relevant informations + from yunohost.app import app_setting, _installed_apps + from yunohost.utils.ldap import _get_ldap_interface, _ldap_path_extract + ldap = _get_ldap_interface() + permissions_infos = ldap.search( + "ou=permission,dc=yunohost,dc=org", + "(objectclass=permissionYnh)", + [ + "cn", + "groupPermission", + "inheritPermission", + "URL", + "additionalUrls", + "authHeader", + "label", + "showTile", + "isProtected", + ], + ) - permission_attrs = [ - 'cn', - 'groupPermission', - 'inheritPermission', - 'URL', - ] - - # Normally app is alway defined but it should be possible to set it - if app and not isinstance(app, list): - app = [app] - if permission and not isinstance(permission, list): - permission = [permission] - if not isinstance(username, list): - username = [username] - if not isinstance(group, list): - group = [group] + # Parse / organize information to be outputed + installed_apps = sorted(_installed_apps()) + filter_ = apps + apps = filter_ if filter_ else installed_apps + apps_base_path = { + app: app_setting(app, "domain") + app_setting(app, "path") + for app in apps + if app in installed_apps + and app_setting(app, "domain") + and app_setting(app, "path") + } permissions = {} + for infos in permissions_infos: - result = ldap.search('ou=permission,dc=yunohost,dc=org', - '(objectclass=permissionYnh)', permission_attrs) + name = infos["cn"][0] + app = name.split(".")[0] - for res in result: - try: - permission_name, app_name = res['cn'][0].split('.') - except: - logger.warning(m18n.n('permission_name_not_valid', permission=res['cn'][0])) - group_name = [] - if 'groupPermission' in res: - for g in res['groupPermission']: - group_name.append(g.split("=")[1].split(",")[0]) - user_name = [] - if 'inheritPermission' in res: - for u in res['inheritPermission']: - user_name.append(u.split("=")[1].split(",")[0]) - - # Don't show the result if the user defined a specific permission, user or group - if app and app_name not in app: + if ignore_system_perms and app in SYSTEM_PERMS: continue - if permission and permission_name not in permission: - continue - if username[0] and not set(username) & set(user_name): - continue - if group[0] and not set(group) & set(group_name): + if filter_ and app not in apps: continue - if app_name not in permissions: - permissions[app_name] = {} + perm = {} + perm["allowed"] = [ + _ldap_path_extract(p, "cn") for p in infos.get("groupPermission", []) + ] - permissions[app_name][permission_name] = {'allowed_users': [], 'allowed_groups': []} - for g in group_name: - permissions[app_name][permission_name]['allowed_groups'].append(g) - for u in user_name: - permissions[app_name][permission_name]['allowed_users'].append(u) - if 'URL' in res: - permissions[app_name][permission_name]['URL'] = [] - for u in res['URL']: - permissions[app_name][permission_name]['URL'].append(u) + if full: + perm["corresponding_users"] = [ + _ldap_path_extract(p, "uid") for p in infos.get("inheritPermission", []) + ] + perm["auth_header"] = infos.get("authHeader", [False])[0] == "TRUE" + perm["label"] = infos.get("label", [None])[0] + perm["show_tile"] = infos.get("showTile", [False])[0] == "TRUE" + perm["protected"] = infos.get("isProtected", [False])[0] == "TRUE" + perm["url"] = infos.get("URL", [None])[0] + perm["additional_urls"] = infos.get("additionalUrls", []) - return {'permissions': permissions} + if absolute_urls: + app_base_path = ( + apps_base_path[app] if app in apps_base_path else "" + ) # Meh in some situation where the app is currently installed/removed, this function may be called and we still need to act as if the corresponding permission indeed exists ... dunno if that's really the right way to proceed but okay. + perm["url"] = _get_absolute_url(perm["url"], app_base_path) + perm["additional_urls"] = [ + _get_absolute_url(url, app_base_path) + for url in perm["additional_urls"] + ] + + permissions[name] = perm + + # Make sure labels for sub-permissions are the form " Applabel (Sublabel) " + if full: + subpermissions = { + k: v for k, v in permissions.items() if not k.endswith(".main") + } + for name, infos in subpermissions.items(): + main_perm_name = name.split(".")[0] + ".main" + if main_perm_name not in permissions: + logger.debug( + "Uhoh, unknown permission %s ? (Maybe we're in the process or deleting the perm for this app...)" + % main_perm_name + ) + continue + main_perm_label = permissions[main_perm_name]["label"] + infos["sublabel"] = infos["label"] + infos["label"] = "%s (%s)" % (main_perm_label, infos["label"]) + + if short: + permissions = list(permissions.keys()) + + return {"permissions": permissions} -def user_permission_update(operation_logger, app=[], permission=None, add_username=None, add_group=None, del_username=None, del_group=None, sync_perm=True): +@is_unit_operation() +def user_permission_update( + operation_logger, + permission, + add=None, + remove=None, + label=None, + show_tile=None, + protected=None, + force=False, + sync_perm=True, +): """ Allow or Disallow a user or group to a permission for a specific application Keyword argument: - app -- an application OR sftp, xmpp (metronome), mail - permission -- name of the permission ("main" by default) - add_username -- Username to allow - add_group -- Groupname to allow - del_username -- Username to disallow - del_group -- Groupname to disallow - + permission -- Name of the permission (e.g. mail or or wordpress or wordpress.editors) + add -- (optional) List of groups or usernames to add to this permission + remove -- (optional) List of groups or usernames to remove from to this permission + label -- (optional) Define a name for the permission. This label will be shown on the SSO and in the admin + show_tile -- (optional) Define if a tile will be shown in the SSO + protected -- (optional) Define if the permission can be added/removed to the visitor group + force -- (optional) Give the possibility to add/remove access from the visitor group to a protected permission """ - from yunohost.hook import hook_callback from yunohost.user import user_group_list - from yunohost.utils.ldap import _get_ldap_interface - ldap = _get_ldap_interface() - if permission: - if not isinstance(permission, list): - permission = [permission] - else: - permission = ["main"] + # By default, manipulate main permission + if "." not in permission: + permission = permission + ".main" - if add_group: - if not isinstance(add_group, list): - add_group = [add_group] - else: - add_group = [] + existing_permission = user_permission_info(permission) - if add_username: - if not isinstance(add_username, list): - add_username = [add_username] - else: - add_username = [] + # Refuse to add "visitors" to mail, xmpp ... they require an account to make sense. + if add and "visitors" in add and permission.split(".")[0] in SYSTEM_PERMS: + raise YunohostValidationError( + "permission_require_account", permission=permission + ) - if del_group: - if not isinstance(del_group, list): - del_group = [del_group] - else: - del_group = [] + # Refuse to add "visitors" to protected permission + if ( + (add and "visitors" in add and existing_permission["protected"]) + or (remove and "visitors" in remove and existing_permission["protected"]) + ) and not force: + raise YunohostValidationError("permission_protected", permission=permission) - if del_username: - if not isinstance(del_username, list): - del_username = [del_username] - else: - del_username = [] + # Refuse to add "all_users" to ssh/sftp permissions + if ( + permission.split(".")[0] in ["ssh", "sftp"] + and (add and "all_users" in add) + and not force + ): + raise YunohostValidationError( + "permission_cant_add_to_all_users", permission=permission + ) - # Validate that the group exist - for g in add_group: - if g not in user_group_list(['cn'])['groups']: - raise YunohostError('group_unknown', group=g) - for u in add_username: - if u not in user_list(['uid'])['users']: - raise YunohostError('user_unknown', user=u) - for g in del_group: - if g not in user_group_list(['cn'])['groups']: - raise YunohostError('group_unknown', group=g) - for u in del_username: - if u not in user_list(['uid'])['users']: - raise YunohostError('user_unknown', user=u) + # Fetch currently allowed groups for this permission - # Merge user and group (note that we consider all user as a group) - add_group.extend(add_username) - del_group.extend(del_username) + current_allowed_groups = existing_permission["allowed"] + operation_logger.related_to.append(("app", permission.split(".")[0])) - if 'all_users' in add_group or 'all_users' in del_group: - raise YunohostError('edit_permission_with_group_all_users_not_allowed') + # Compute new allowed group list (and make sure what we're doing make sense) - # Populate permission informations - permission_attrs = [ - 'cn', - 'groupPermission', - ] - result = ldap.search('ou=permission,dc=yunohost,dc=org', - '(objectclass=permissionYnh)', permission_attrs) - result = {p['cn'][0]: p for p in result} + new_allowed_groups = copy.copy(current_allowed_groups) + all_existing_groups = user_group_list()["groups"].keys() - new_per_dict = {} + if add: + groups_to_add = [add] if not isinstance(add, list) else add + for group in groups_to_add: + if group not in all_existing_groups: + raise YunohostValidationError("group_unknown", group=group) + if group in current_allowed_groups: + logger.warning( + m18n.n( + "permission_already_allowed", permission=permission, group=group + ) + ) + else: + operation_logger.related_to.append(("group", group)) + new_allowed_groups += [group] - for a in app: - for per in permission: - permission_name = per + '.' + a - if permission_name not in result: - raise YunohostError('permission_not_found', permission=per, app=a) - new_per_dict[permission_name] = set() - if 'groupPermission' in result[permission_name]: - new_per_dict[permission_name] = set(result[permission_name]['groupPermission']) + if remove: + groups_to_remove = [remove] if not isinstance(remove, list) else remove + for group in groups_to_remove: + if group not in current_allowed_groups: + logger.warning( + m18n.n( + "permission_already_disallowed", + permission=permission, + group=group, + ) + ) + else: + operation_logger.related_to.append(("group", group)) - for g in del_group: - if 'cn=all_users,ou=groups,dc=yunohost,dc=org' in new_per_dict[permission_name]: - raise YunohostError('need_define_permission_before') - group_name = 'cn=' + g + ',ou=groups,dc=yunohost,dc=org' - if group_name not in new_per_dict[permission_name]: - logger.warning(m18n.n('group_already_disallowed', permission=per, app=a, group=g)) - else: - new_per_dict[permission_name].remove(group_name) + new_allowed_groups = [ + g for g in new_allowed_groups if g not in groups_to_remove + ] - if 'cn=all_users,ou=groups,dc=yunohost,dc=org' in new_per_dict[permission_name]: - new_per_dict[permission_name].remove('cn=all_users,ou=groups,dc=yunohost,dc=org') - for g in add_group: - group_name = 'cn=' + g + ',ou=groups,dc=yunohost,dc=org' - if group_name in new_per_dict[permission_name]: - logger.warning(m18n.n('group_already_allowed', permission=per, app=a, group=g)) - else: - new_per_dict[permission_name].add(group_name) + # If we end up with something like allowed groups is ["all_users", "volunteers"] + # we shall warn the users that they should probably choose between one or + # the other, because the current situation is probably not what they expect + # / is temporary ? Note that it's fine to have ["all_users", "visitors"] + # though, but it's not fine to have ["all_users", "visitors", "volunteers"] + if "all_users" in new_allowed_groups and len(new_allowed_groups) >= 2: + if "visitors" not in new_allowed_groups or len(new_allowed_groups) >= 3: + logger.warning(m18n.n("permission_currently_allowed_for_all_users")) + # Note that we can get this argument as string if we it come from the CLI + if isinstance(show_tile, str): + if show_tile.lower() == "true": + show_tile = True + else: + show_tile = False + + if ( + existing_permission["url"] + and existing_permission["url"].startswith("re:") + and show_tile + ): + logger.warning( + m18n.n( + "regex_incompatible_with_tile", + regex=existing_permission["url"], + permission=permission, + ) + ) + + # Commit the new allowed group list operation_logger.start() - for per, val in new_per_dict.items(): - # Don't update LDAP if we update exactly the same values - if val == set(result[per]['groupPermission'] if 'groupPermission' in result[per] else []): - continue - if ldap.update('cn=%s,ou=permission' % per, {'groupPermission': val}): - p = per.split('.') - logger.debug(m18n.n('permission_updated', permission=p[0], app=p[1])) - else: - raise YunohostError('permission_update_failed') + new_permission = _update_ldap_group_permission( + permission=permission, + allowed=new_allowed_groups, + label=label, + show_tile=show_tile, + protected=protected, + sync_perm=sync_perm, + ) - if sync_perm: - permission_sync_to_user() + logger.debug(m18n.n("permission_updated", permission=permission)) - for a in app: - allowed_users = set() - disallowed_users = set() - group_list = user_group_list(['member'])['groups'] - - for g in add_group: - if 'members' in group_list[g]: - allowed_users.union(group_list[g]['members']) - for g in del_group: - if 'members' in group_list[g]: - disallowed_users.union(group_list[g]['members']) - - allowed_users = ','.join(allowed_users) - disallowed_users = ','.join(disallowed_users) - if add_group: - hook_callback('post_app_addaccess', args=[app, allowed_users]) - if del_group: - hook_callback('post_app_removeaccess', args=[app, disallowed_users]) - - return user_permission_list(app, permission) + return new_permission -def user_permission_clear(operation_logger, app=[], permission=None, sync_perm=True): +@is_unit_operation() +def user_permission_reset(operation_logger, permission, sync_perm=True): """ - Reset the permission for a specific application + Reset a given permission to just 'all_users' Keyword argument: - app -- an application OR sftp, xmpp (metronome), mail - permission -- name of the permission ("main" by default) - username -- Username to get informations (all by default) - group -- Groupname to get informations (all by default) - + permission -- Name of the permission (e.g. mail or nextcloud or wordpress.editors) """ - from yunohost.hook import hook_callback - from yunohost.utils.ldap import _get_ldap_interface - ldap = _get_ldap_interface() - if permission: - if not isinstance(permission, list): - permission = [permission] - else: - permission = ["main"] + # By default, manipulate main permission + if "." not in permission: + permission = permission + ".main" - default_permission = {'groupPermission': ['cn=all_users,ou=groups,dc=yunohost,dc=org']} + # Fetch existing permission - # Populate permission informations - permission_attrs = [ - 'cn', - 'groupPermission', - ] - result = ldap.search('ou=permission,dc=yunohost,dc=org', - '(objectclass=permissionYnh)', permission_attrs) - result = {p['cn'][0]: p for p in result} + existing_permission = user_permission_info(permission) - for a in app: - for per in permission: - permission_name = per + '.' + a - if permission_name not in result: - raise YunohostError('permission_not_found', permission=per, app=a) - if 'groupPermission' in result[permission_name] and 'cn=all_users,ou=groups,dc=yunohost,dc=org' in result[permission_name]['groupPermission']: - logger.warning(m18n.n('permission_already_clear', permission=per, app=a)) - continue - if ldap.update('cn=%s,ou=permission' % permission_name, default_permission): - logger.debug(m18n.n('permission_updated', permission=per, app=a)) - else: - raise YunohostError('permission_update_failed') + if existing_permission["allowed"] == ["all_users"]: + logger.warning(m18n.n("permission_already_up_to_date")) + return - permission_sync_to_user() + # Update permission with default (all_users) - for a in app: - permission_name = 'main.' + a - result = ldap.search('ou=permission,dc=yunohost,dc=org', - filter='cn=' + permission_name, attrs=['inheritPermission']) - if result: - allowed_users = result[0]['inheritPermission'] - new_user_list = ','.join(allowed_users) - hook_callback('post_app_removeaccess', args=[app, new_user_list]) + operation_logger.related_to.append(("app", permission.split(".")[0])) + operation_logger.start() - return user_permission_list(app, permission) + new_permission = _update_ldap_group_permission( + permission=permission, allowed="all_users", sync_perm=sync_perm + ) + + logger.debug(m18n.n("permission_updated", permission=permission)) + + return new_permission -@is_unit_operation(['permission', 'app']) -def permission_add(operation_logger, app, permission, urls=None, default_allow=True, sync_perm=True): +def user_permission_info(permission): + """ + Return informations about a specific permission + + Keyword argument: + permission -- Name of the permission (e.g. mail or nextcloud or wordpress.editors) + """ + + # By default, manipulate main permission + if "." not in permission: + permission = permission + ".main" + + # Fetch existing permission + + existing_permission = user_permission_list(full=True)["permissions"].get( + permission, None + ) + if existing_permission is None: + raise YunohostValidationError("permission_not_found", permission=permission) + + return existing_permission + + +# +# +# The followings methods are *not* directly exposed. +# They are used to create/delete the permissions (e.g. during app install/remove) +# and by some app helpers to possibly add additional permissions +# +# + + +@is_unit_operation() +def permission_create( + operation_logger, + permission, + allowed=None, + url=None, + additional_urls=None, + auth_header=True, + label=None, + show_tile=False, + protected=False, + sync_perm=True, +): """ Create a new permission for a specific application Keyword argument: - app -- an application OR sftp, xmpp (metronome), mail - permission -- name of the permission ("main" by default) - urls -- list of urls to specify for the permission + permission -- Name of the permission (e.g. mail or nextcloud or wordpress.editors) + allowed -- (optional) List of group/user to allow for the permission + url -- (optional) URL for which access will be allowed/forbidden + additional_urls -- (optional) List of additional URL for which access will be allowed/forbidden + auth_header -- (optional) Define for the URL of this permission, if SSOwat pass the authentication header to the application + label -- (optional) Define a name for the permission. This label will be shown on the SSO and in the admin. Default is "permission name" + show_tile -- (optional) Define if a tile will be shown in the SSO + protected -- (optional) Define if the permission can be added/removed to the visitor group + If provided, 'url' is assumed to be relative to the app domain/path if they + start with '/'. For example: + / -> domain.tld/app + /admin -> domain.tld/app/admin + domain.tld/app/api -> domain.tld/app/api + + 'url' can be later treated as a regex if it starts with "re:". + For example: + re:/api/[A-Z]*$ -> domain.tld/app/api/[A-Z]*$ + re:domain.tld/app/api/[A-Z]*$ -> domain.tld/app/api/[A-Z]*$ """ - from yunohost.domain import _normalize_domain_path + from yunohost.utils.ldap import _get_ldap_interface + from yunohost.user import user_group_list + ldap = _get_ldap_interface() + # By default, manipulate main permission + if "." not in permission: + permission = permission + ".main" + # Validate uniqueness of permission in LDAP - permission_name = str(permission + '.' + app) # str(...) Fix encoding issue - conflict = ldap.get_conflict({ - 'cn': permission_name - }, base_dn='ou=permission,dc=yunohost,dc=org') - if conflict: - raise YunohostError('permission_already_exist', permission=permission, app=app) + if ldap.get_conflict( + {"cn": permission}, base_dn="ou=permission,dc=yunohost,dc=org" + ): + raise YunohostValidationError("permission_already_exist", permission=permission) # Get random GID all_gid = {x.gr_gid for x in grp.getgrall()} @@ -347,181 +419,522 @@ def permission_add(operation_logger, app, permission, urls=None, default_allow=T gid = str(random.randint(200, 99999)) uid_guid_found = gid not in all_gid + app, subperm = permission.split(".") + attr_dict = { - 'objectClass': ['top', 'permissionYnh', 'posixGroup'], - 'cn': permission_name, - 'gidNumber': gid, + "objectClass": ["top", "permissionYnh", "posixGroup"], + "cn": str(permission), + "gidNumber": gid, + "authHeader": ["TRUE"], + "label": [ + str(label) if label else (subperm if subperm != "main" else app.title()) + ], + "showTile": [ + "FALSE" + ], # Dummy value, it will be fixed when we call '_update_ldap_group_permission' + "isProtected": [ + "FALSE" + ], # Dummy value, it will be fixed when we call '_update_ldap_group_permission' } - if default_allow: - attr_dict['groupPermission'] = 'cn=all_users,ou=groups,dc=yunohost,dc=org' - if urls: - attr_dict['URL'] = [] - for url in urls: - domain = url[:url.index('/')] - path = url[url.index('/'):] - domain, path = _normalize_domain_path(domain, path) - attr_dict['URL'].append(domain + path) + if allowed is not None: + if not isinstance(allowed, list): + allowed = [allowed] + # Validate that the groups to add actually exist + all_existing_groups = user_group_list()["groups"].keys() + for group in allowed or []: + if group not in all_existing_groups: + raise YunohostValidationError("group_unknown", group=group) + + operation_logger.related_to.append(("app", permission.split(".")[0])) operation_logger.start() - if ldap.add('cn=%s,ou=permission' % permission_name, attr_dict): - if sync_perm: - permission_sync_to_user() - logger.debug(m18n.n('permission_created', permission=permission, app=app)) - return user_permission_list(app, permission) - raise YunohostError('permission_creation_failed') + try: + ldap.add("cn=%s,ou=permission" % permission, attr_dict) + except Exception as e: + raise YunohostError( + "permission_creation_failed", permission=permission, error=e + ) + + try: + permission_url( + permission, + url=url, + add_url=additional_urls, + auth_header=auth_header, + sync_perm=False, + ) + + new_permission = _update_ldap_group_permission( + permission=permission, + allowed=allowed, + label=label, + show_tile=show_tile, + protected=protected, + sync_perm=sync_perm, + ) + except: + permission_delete(permission, force=True) + raise + + logger.debug(m18n.n("permission_created", permission=permission)) + return new_permission -@is_unit_operation(['permission', 'app']) -def permission_update(operation_logger, app, permission, add_url=None, remove_url=None, sync_perm=True): +@is_unit_operation() +def permission_url( + operation_logger, + permission, + url=None, + add_url=None, + remove_url=None, + auth_header=None, + clear_urls=False, + sync_perm=True, +): """ - Update a permission for a specific application + Update urls related to a permission for a specific application Keyword argument: - app -- an application OR sftp, xmpp (metronome), mail - permission -- name of the permission ("main" by default) - add_url -- Add a new url for a permission - remove_url -- Remove a url for a permission - + permission -- Name of the permission (e.g. mail or nextcloud or wordpress.editors) + url -- (optional) URL for which access will be allowed/forbidden. + add_url -- (optional) List of additional url to add for which access will be allowed/forbidden + remove_url -- (optional) List of additional url to remove for which access will be allowed/forbidden + auth_header -- (optional) Define for the URL of this permission, if SSOwat pass the authentication header to the application + clear_urls -- (optional) Clean all urls (url and additional_urls) """ - from yunohost.domain import _normalize_domain_path + from yunohost.app import app_setting from yunohost.utils.ldap import _get_ldap_interface + ldap = _get_ldap_interface() - permission_name = str(permission + '.' + app) # str(...) Fix encoding issue + # By default, manipulate main permission + if "." not in permission: + permission = permission + ".main" - # Populate permission informations - result = ldap.search(base='ou=permission,dc=yunohost,dc=org', - filter='cn=' + permission_name, attrs=['URL']) - if not result: - raise YunohostError('permission_not_found', permission=permission, app=app) - permission_obj = result[0] + app = permission.split(".")[0] - if 'URL' not in permission_obj: - permission_obj['URL'] = [] + if url or add_url: + domain = app_setting(app, "domain") + path = app_setting(app, "path") + if domain is None or path is None: + raise YunohostError("unknown_main_domain_path", app=app) + else: + app_main_path = domain + path - url = set(permission_obj['URL']) + # Fetch existing permission + + existing_permission = user_permission_info(permission) + + show_tile = existing_permission["show_tile"] + + if url is None: + url = existing_permission["url"] + else: + url = _validate_and_sanitize_permission_url(url, app_main_path, app) + + if url.startswith("re:") and existing_permission["show_tile"]: + logger.warning( + m18n.n("regex_incompatible_with_tile", regex=url, permission=permission) + ) + show_tile = False + + current_additional_urls = existing_permission["additional_urls"] + new_additional_urls = copy.copy(current_additional_urls) if add_url: - for u in add_url: - domain = u[:u.index('/')] - path = u[u.index('/'):] - domain, path = _normalize_domain_path(domain, path) - url.add(domain + path) + for ur in add_url: + if ur in current_additional_urls: + logger.warning( + m18n.n( + "additional_urls_already_added", permission=permission, url=ur + ) + ) + else: + ur = _validate_and_sanitize_permission_url(ur, app_main_path, app) + new_additional_urls += [ur] + if remove_url: - for u in remove_url: - domain = u[:u.index('/')] - path = u[u.index('/'):] - domain, path = _normalize_domain_path(domain, path) - url.discard(domain + path) + for ur in remove_url: + if ur not in current_additional_urls: + logger.warning( + m18n.n( + "additional_urls_already_removed", permission=permission, url=ur + ) + ) - if url == set(permission_obj['URL']): - logger.warning(m18n.n('permission_update_nothing_to_do')) - return user_permission_list(app, permission) + new_additional_urls = [u for u in new_additional_urls if u not in remove_url] + if auth_header is None: + auth_header = existing_permission["auth_header"] + + if clear_urls: + url = None + new_additional_urls = [] + show_tile = False + + # Guarantee uniqueness of all values, which would otherwise make ldap.update angry. + new_additional_urls = set(new_additional_urls) + + # Actually commit the change + + operation_logger.related_to.append(("app", permission.split(".")[0])) operation_logger.start() - if ldap.update('cn=%s,ou=permission' % permission_name, {'cn': permission_name, 'URL': url}): - if sync_perm: - permission_sync_to_user() - logger.debug(m18n.n('permission_updated', permission=permission, app=app)) - return user_permission_list(app, permission) - raise YunohostError('premission_update_failed') + try: + ldap.update( + "cn=%s,ou=permission" % permission, + { + "URL": [url] if url is not None else [], + "additionalUrls": new_additional_urls, + "authHeader": [str(auth_header).upper()], + "showTile": [str(show_tile).upper()], + }, + ) + except Exception as e: + raise YunohostError("permission_update_failed", permission=permission, error=e) - -@is_unit_operation(['permission', 'app']) -def permission_remove(operation_logger, app, permission, force=False, sync_perm=True): - """ - Remove a permission for a specific application - - Keyword argument: - app -- an application OR sftp, xmpp (metronome), mail - permission -- name of the permission ("main" by default) - - """ - - if permission == "main" and not force: - raise YunohostError('remove_main_permission_not_allowed') - - from yunohost.utils.ldap import _get_ldap_interface - ldap = _get_ldap_interface() - - operation_logger.start() - if not ldap.remove('cn=%s,ou=permission' % str(permission + '.' + app)): - raise YunohostError('permission_deletion_failed', permission=permission, app=app) if sync_perm: permission_sync_to_user() - logger.debug(m18n.n('permission_deleted', permission=permission, app=app)) + + logger.debug(m18n.n("permission_updated", permission=permission)) + return user_permission_info(permission) -def permission_sync_to_user(force=False): +@is_unit_operation() +def permission_delete(operation_logger, permission, force=False, sync_perm=True): + """ + Delete a permission + + Keyword argument: + permission -- Name of the permission (e.g. mail or nextcloud or wordpress.editors) + """ + + # By default, manipulate main permission + if "." not in permission: + permission = permission + ".main" + + if permission.endswith(".main") and not force: + raise YunohostValidationError("permission_cannot_remove_main") + + from yunohost.utils.ldap import _get_ldap_interface + + ldap = _get_ldap_interface() + + # Make sure this permission exists + + _ = user_permission_info(permission) + + # Actually delete the permission + + operation_logger.related_to.append(("app", permission.split(".")[0])) + operation_logger.start() + + try: + ldap.remove("cn=%s,ou=permission" % permission) + except Exception as e: + raise YunohostError( + "permission_deletion_failed", permission=permission, error=e + ) + + if sync_perm: + permission_sync_to_user() + logger.debug(m18n.n("permission_deleted", permission=permission)) + + +def permission_sync_to_user(): """ Sychronise the inheritPermission attribut in the permission object from the user<->group link and the group<->permission link - - Keyword argument: - force -- Force to recreate all attributes. Used generally with the - backup which uses "slapadd" which doesnt' use the memberOf overlay. - Note that by removing all value and adding a new time, we force the - overlay to update all attributes """ - # Note that a LDAP operation with the same value that is in LDAP crash SLAP. - # So we need to check before each ldap operation that we really change something in LDAP import os from yunohost.app import app_ssowatconf + from yunohost.user import user_group_list from yunohost.utils.ldap import _get_ldap_interface + ldap = _get_ldap_interface() - permission_attrs = [ - 'cn', - 'member', - ] - group_info = ldap.search('ou=groups,dc=yunohost,dc=org', - '(objectclass=groupOfNamesYnh)', permission_attrs) - group_info = {g['cn'][0]: g for g in group_info} + groups = user_group_list(full=True)["groups"] + permissions = user_permission_list(full=True)["permissions"] - for per in ldap.search('ou=permission,dc=yunohost,dc=org', - '(objectclass=permissionYnh)', - ['cn', 'inheritPermission', 'groupPermission', 'memberUid']): - if 'groupPermission' not in per: + for permission_name, permission_infos in permissions.items(): + + # These are the users currently allowed because there's an 'inheritPermission' object corresponding to it + currently_allowed_users = set(permission_infos["corresponding_users"]) + + # These are the users that should be allowed because they are member of a group that is allowed for this permission ... + should_be_allowed_users = set( + [ + user + for group in permission_infos["allowed"] + for user in groups[group]["members"] + ] + ) + + # Note that a LDAP operation with the same value that is in LDAP crash SLAP. + # So we need to check before each ldap operation that we really change something in LDAP + if currently_allowed_users == should_be_allowed_users: + # We're all good, this permission is already correctly synchronized ! continue - user_permission = set() - for group in per['groupPermission']: - group = group.split("=")[1].split(",")[0] - if 'member' not in group_info[group]: - continue - for user in group_info[group]['member']: - user_permission.add(user) - if 'inheritPermission' not in per: - per['inheritPermission'] = [] - if 'memberUid' not in per: - per['memberUid'] = [] + new_inherited_perms = { + "inheritPermission": [ + "uid=%s,ou=users,dc=yunohost,dc=org" % u + for u in should_be_allowed_users + ], + "memberUid": should_be_allowed_users, + } - uid_val = [v.split("=")[1].split(",")[0] for v in user_permission] - if user_permission == set(per['inheritPermission']) and set(uid_val) == set(per['memberUid']) and not force: - continue - inheritPermission = {'inheritPermission': user_permission, 'memberUid': uid_val} - if force: - if per['groupPermission']: - if not ldap.update('cn=%s,ou=permission' % per['cn'][0], {'groupPermission': []}): - raise YunohostError('permission_update_failed_clear') - if not ldap.update('cn=%s,ou=permission' % per['cn'][0], {'groupPermission': per['groupPermission']}): - raise YunohostError('permission_update_failed_populate') - if per['inheritPermission']: - if not ldap.update('cn=%s,ou=permission' % per['cn'][0], {'inheritPermission': []}): - raise YunohostError('permission_update_failed_clear') - if user_permission: - if not ldap.update('cn=%s,ou=permission' % per['cn'][0], inheritPermission): - raise YunohostError('permission_update_failed') - else: - if not ldap.update('cn=%s,ou=permission' % per['cn'][0], inheritPermission): - raise YunohostError('permission_update_failed') - logger.debug(m18n.n('permission_generated')) + # Commit the change with the new inherited stuff + try: + ldap.update("cn=%s,ou=permission" % permission_name, new_inherited_perms) + except Exception as e: + raise YunohostError( + "permission_update_failed", permission=permission_name, error=e + ) + + logger.debug("The permission database has been resynchronized") app_ssowatconf() # Reload unscd, otherwise the group ain't propagated to the LDAP database - os.system('nscd --invalidate=passwd') - os.system('nscd --invalidate=group') + os.system("nscd --invalidate=passwd") + os.system("nscd --invalidate=group") + + +def _update_ldap_group_permission( + permission, allowed, label=None, show_tile=None, protected=None, sync_perm=True +): + """ + Internal function that will rewrite user permission + + permission -- Name of the permission (e.g. mail or nextcloud or wordpress.editors) + allowed -- (optional) A list of group/user to allow for the permission + label -- (optional) Define a name for the permission. This label will be shown on the SSO and in the admin + show_tile -- (optional) Define if a tile will be shown in the SSO + protected -- (optional) Define if the permission can be added/removed to the visitor group + + + Assumptions made, that should be checked before calling this function: + - the permission does currently exists ... + - the 'allowed' list argument is *different* from the current + permission state ... otherwise ldap will miserably fail in such + case... + - the 'allowed' list contains *existing* groups. + """ + + from yunohost.hook import hook_callback + from yunohost.utils.ldap import _get_ldap_interface + + ldap = _get_ldap_interface() + + existing_permission = user_permission_info(permission) + + update = {} + + if allowed is not None: + allowed = [allowed] if not isinstance(allowed, list) else allowed + # Guarantee uniqueness of values in allowed, which would otherwise make ldap.update angry. + allowed = set(allowed) + update["groupPermission"] = [ + "cn=" + g + ",ou=groups,dc=yunohost,dc=org" for g in allowed + ] + + if label is not None: + update["label"] = [str(label)] + + if protected is not None: + update["isProtected"] = [str(protected).upper()] + + if show_tile is not None: + + if show_tile is True: + if not existing_permission["url"]: + logger.warning( + m18n.n( + "show_tile_cant_be_enabled_for_url_not_defined", + permission=permission, + ) + ) + show_tile = False + elif existing_permission["url"].startswith("re:"): + logger.warning( + m18n.n("show_tile_cant_be_enabled_for_regex", permission=permission) + ) + show_tile = False + update["showTile"] = [str(show_tile).upper()] + + try: + ldap.update("cn=%s,ou=permission" % permission, update) + except Exception as e: + raise YunohostError("permission_update_failed", permission=permission, error=e) + + # Trigger permission sync if asked + + if sync_perm: + permission_sync_to_user() + + new_permission = user_permission_info(permission) + + # Trigger app callbacks + + app = permission.split(".")[0] + sub_permission = permission.split(".")[1] + + old_corresponding_users = set(existing_permission["corresponding_users"]) + new_corresponding_users = set(new_permission["corresponding_users"]) + + old_allowed_users = set(existing_permission["allowed"]) + new_allowed_users = set(new_permission["allowed"]) + + effectively_added_users = new_corresponding_users - old_corresponding_users + effectively_removed_users = old_corresponding_users - new_corresponding_users + + effectively_added_group = ( + new_allowed_users - old_allowed_users - effectively_added_users + ) + effectively_removed_group = ( + old_allowed_users - new_allowed_users - effectively_removed_users + ) + + if effectively_added_users or effectively_added_group: + hook_callback( + "post_app_addaccess", + args=[ + app, + ",".join(effectively_added_users), + sub_permission, + ",".join(effectively_added_group), + ], + ) + if effectively_removed_users or effectively_removed_group: + hook_callback( + "post_app_removeaccess", + args=[ + app, + ",".join(effectively_removed_users), + sub_permission, + ",".join(effectively_removed_group), + ], + ) + + return new_permission + + +def _get_absolute_url(url, base_path): + # + # For example transform: + # (/api, domain.tld/nextcloud) into domain.tld/nextcloud/api + # (/api, domain.tld/nextcloud/) into domain.tld/nextcloud/api + # (re:/foo.*, domain.tld/app) into re:domain\.tld/app/foo.* + # (domain.tld/bar, domain.tld/app) into domain.tld/bar + # + base_path = base_path.rstrip("/") + if url is None: + return None + if url.startswith("/"): + return base_path + url.rstrip("/") + if url.startswith("re:/"): + return "re:" + base_path.replace(".", "\\.") + url[3:] + else: + return url + + +def _validate_and_sanitize_permission_url(url, app_base_path, app): + """ + Check and normalize the urls passed for all permissions + Also check that the Regex is valid + + As documented in the 'ynh_permission_create' helper: + + If provided, 'url' is assumed to be relative to the app domain/path if they + start with '/'. For example: + / -> domain.tld/app + /admin -> domain.tld/app/admin + domain.tld/app/api -> domain.tld/app/api + domain.tld -> domain.tld + + 'url' can be later treated as a regex if it starts with "re:". + For example: + re:/api/[A-Z]*$ -> domain.tld/app/api/[A-Z]*$ + re:domain.tld/app/api/[A-Z]*$ -> domain.tld/app/api/[A-Z]*$ + + We can also have less-trivial regexes like: + re:^/api/.*|/scripts/api.js$ + """ + + from yunohost.domain import _assert_domain_exists + from yunohost.app import _assert_no_conflicting_apps + + # + # Regexes + # + + def validate_regex(regex): + if "%" in regex: + logger.warning( + "/!\\ Packagers! You are probably using a lua regex. You should use a PCRE regex instead." + ) + return + + try: + re.compile(regex) + except Exception: + raise YunohostValidationError("invalid_regex", regex=regex) + + if url.startswith("re:"): + + # regex without domain + # we check for the first char after 're:' + if url[3] in ["/", "^", "\\"]: + validate_regex(url[3:]) + return url + + # regex with domain + + if "/" not in url: + raise YunohostValidationError("regex_with_only_domain") + domain, path = url[3:].split("/", 1) + path = "/" + path + + domain_with_no_regex = domain.replace("%", "").replace("\\", "") + _assert_domain_exists(domain_with_no_regex) + + validate_regex(path) + + return "re:" + domain + path + + # + # "Regular" URIs + # + + def split_domain_path(url): + url = url.strip("/") + (domain, path) = url.split("/", 1) if "/" in url else (url, "/") + if path != "/": + path = "/" + path + return (domain, path) + + # uris without domain + if url.startswith("/"): + # if url is for example /admin/ + # we want sanitized_url to be: /admin + # and (domain, path) to be : (domain.tld, /app/admin) + sanitized_url = "/" + url.strip("/") + domain, path = split_domain_path(app_base_path) + path = "/" + path.strip("/") + sanitized_url + + # uris with domain + else: + # if url is for example domain.tld/wat/ + # we want sanitized_url to be: domain.tld/wat + # and (domain, path) to be : (domain.tld, /wat) + domain, path = split_domain_path(url) + sanitized_url = domain + path + + _assert_domain_exists(domain) + + _assert_no_conflicting_apps(domain, path, ignore_app=app) + + return sanitized_url diff --git a/src/yunohost/regenconf.py b/src/yunohost/regenconf.py index 48129634a..1beef8a44 100644 --- a/src/yunohost/regenconf.py +++ b/src/yunohost/regenconf.py @@ -21,8 +21,6 @@ import os import yaml -import json -import subprocess import shutil import hashlib @@ -31,25 +29,31 @@ from datetime import datetime from moulinette import m18n from moulinette.utils import log, filesystem -from moulinette.utils.filesystem import read_file +from moulinette.utils.process import check_output from yunohost.utils.error import YunohostError 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') -PENDING_CONF_DIR = os.path.join(BASE_CONF_PATH, 'pending') -REGEN_CONF_FILE = '/etc/yunohost/regenconf.yml' +BASE_CONF_PATH = "/home/yunohost.conf" +BACKUP_CONF_DIR = os.path.join(BASE_CONF_PATH, "backup") +PENDING_CONF_DIR = os.path.join(BASE_CONF_PATH, "pending") +REGEN_CONF_FILE = "/etc/yunohost/regenconf.yml" -logger = log.getActionLogger('yunohost.regenconf') +logger = log.getActionLogger("yunohost.regenconf") # FIXME : those ain't just services anymore ... what are we supposed to do with this ... # FIXME : check for all reference of 'service' close to operation_logger stuff -@is_unit_operation([('names', 'configuration')]) -def regen_conf(operation_logger, names=[], with_diff=False, force=False, dry_run=False, - list_pending=False): +@is_unit_operation([("names", "configuration")]) +def regen_conf( + operation_logger, + names=[], + with_diff=False, + force=False, + dry_run=False, + list_pending=False, +): """ Regenerate the configuration file(s) @@ -62,16 +66,6 @@ def regen_conf(operation_logger, names=[], with_diff=False, force=False, dry_run """ - # Legacy code to automatically run the migration - # This is required because regen_conf is called before the migration call - # in debian's postinst script - if os.path.exists("/etc/yunohost/installed") \ - and ("conffiles" in read_file("/etc/yunohost/services.yml") \ - or not os.path.exists(REGEN_CONF_FILE)): - from yunohost.tools import _get_migration_by_name - migration = _get_migration_by_name("decouple_regenconf_from_services") - migration.migrate() - result = {} # Return the list of pending conf @@ -85,19 +79,20 @@ def regen_conf(operation_logger, names=[], with_diff=False, force=False, dry_run for system_path, pending_path in conf_files.items(): pending_conf[category][system_path] = { - 'pending_conf': pending_path, - 'diff': _get_files_diff( - system_path, pending_path, True), + "pending_conf": pending_path, + "diff": _get_files_diff(system_path, pending_path, True), } return pending_conf if not dry_run: - operation_logger.related_to = [('configuration', x) for x in names] + operation_logger.related_to = [("configuration", x) for x in names] if not names: - operation_logger.name_parameter_override = 'all' + operation_logger.name_parameter_override = "all" elif len(names) != 1: - operation_logger.name_parameter_override = str(len(operation_logger.related_to)) + '_categories' + operation_logger.name_parameter_override = ( + str(len(operation_logger.related_to)) + "_categories" + ) operation_logger.start() # Clean pending conf directory @@ -106,43 +101,73 @@ def regen_conf(operation_logger, names=[], with_diff=False, force=False, dry_run shutil.rmtree(PENDING_CONF_DIR, ignore_errors=True) else: for name in names: - shutil.rmtree(os.path.join(PENDING_CONF_DIR, name), - ignore_errors=True) + shutil.rmtree(os.path.join(PENDING_CONF_DIR, name), ignore_errors=True) else: filesystem.mkdir(PENDING_CONF_DIR, 0o755, True) - # Format common hooks arguments - common_args = [1 if force else 0, 1 if dry_run else 0] - # Execute hooks for pre-regen - pre_args = ['pre', ] + common_args + # element 2 and 3 with empty string is because of legacy... + pre_args = ["pre", "", ""] def _pre_call(name, priority, path, args): # create the pending conf directory for the category category_pending_path = os.path.join(PENDING_CONF_DIR, name) - filesystem.mkdir(category_pending_path, 0o755, True, uid='root') + filesystem.mkdir(category_pending_path, 0o755, True, uid="root") # return the arguments to pass to the script - return pre_args + [category_pending_path, ] + return pre_args + [ + category_pending_path, + ] - # Don't regen SSH if not specifically specified + ssh_explicitly_specified = isinstance(names, list) and "ssh" in names + + # By default, we regen everything if not names: - names = hook_list('conf_regen', list_by='name', - show_info=False)['hooks'] - names.remove('ssh') + names = hook_list("conf_regen", list_by="name", show_info=False)["hooks"] - pre_result = hook_callback('conf_regen', names, pre_callback=_pre_call) + # Dirty hack for legacy code : avoid attempting to regen the conf for + # glances because it got removed ... This is only needed *once* + # during the upgrade from 3.7 to 3.8 because Yunohost will attempt to + # regen glance's conf *before* it gets automatically removed from + # services.yml (which will happens only during the regen-conf of + # 'yunohost', so at the very end of the regen-conf cycle) Anyway, + # this can be safely removed once we're in >= 4.0 + if "glances" in names: + names.remove("glances") + + if "avahi-daemon" in names: + names.remove("avahi-daemon") + + # [Optimization] We compute and feed the domain list to the conf regen + # hooks to avoid having to call "yunohost domain list" so many times which + # ends up in wasted time (about 3~5 seconds per call on a RPi2) + from yunohost.domain import domain_list + + env = {} + # Well we can only do domain_list() if postinstall is done ... + # ... but hooks that effectively need the domain list are only + # called only after the 'installed' flag is set so that's all good, + # though kinda tight-coupled to the postinstall logic :s + if os.path.exists("/etc/yunohost/installed"): + env["YNH_DOMAINS"] = " ".join(domain_list()["domains"]) + + pre_result = hook_callback("conf_regen", names, pre_callback=_pre_call, env=env) # Keep only the hook names with at least one success - names = [hook for hook, infos in pre_result.items() - if any(result["state"] == "succeed" for result in infos.values())] + names = [ + hook + for hook, infos in pre_result.items() + if any(result["state"] == "succeed" for result in infos.values()) + ] # FIXME : what do in case of partial success/failure ... if not names: - ret_failed = [hook for hook, infos in pre_result.items() - if any(result["state"] == "failed" for result in infos.values())] - raise YunohostError('regenconf_failed', - categories=', '.join(ret_failed)) + ret_failed = [ + hook + for hook, infos in pre_result.items() + if any(result["state"] == "failed" for result in infos.values()) + ] + raise YunohostError("regenconf_failed", categories=", ".join(ret_failed)) # Set the processing method _regen = _process_regen_conf if not dry_run else lambda *a, **k: True @@ -152,88 +177,185 @@ def regen_conf(operation_logger, names=[], with_diff=False, force=False, dry_run # Iterate over categories and process pending conf for category, conf_files in _get_pending_conf(names).items(): if not dry_run: - operation_logger.related_to.append(('configuration', category)) + operation_logger.related_to.append(("configuration", category)) - logger.debug(m18n.n( - 'regenconf_pending_applying' if not dry_run else - 'regenconf_dry_pending_applying', - category=category)) + if dry_run: + logger.debug(m18n.n("regenconf_pending_applying", category=category)) + else: + logger.debug(m18n.n("regenconf_dry_pending_applying", category=category)) conf_hashes = _get_conf_hashes(category) succeed_regen = {} failed_regen = {} + # Here we are doing some weird legacy shit + # The thing is, on some very old or specific setup, the sshd_config file + # was absolutely not managed by the regenconf ... + # But we now want to make sure that this file is managed. + # However, we don't want to overwrite a specific custom sshd_config + # which may make the admin unhappy ... + # So : if the hash for this file does not exists, we set the hash as the + # hash of the pending configuration ... + # That way, the file will later appear as manually modified. + sshd_config = "/etc/ssh/sshd_config" + if ( + category == "ssh" + and sshd_config not in conf_hashes + and sshd_config in conf_files + ): + conf_hashes[sshd_config] = _calculate_hash(conf_files[sshd_config]) + _update_conf_hashes(category, conf_hashes) + + # Consider the following scenario: + # - you add a domain foo.bar + # - the regen-conf creates file /etc/dnsmasq.d/foo.bar + # - the admin manually *deletes* /etc/dnsmasq.d/foo.bar + # - the file is now understood as manually deleted because there's the old file hash in regenconf.yml + # + # ... so far so good, that's the expected behavior. + # + # But then: + # - the admin remove domain foo.bar entirely + # - but now the hash for /etc/dnsmasq.d/foo.bar is *still* in + # regenconf.yml and and the file is still flagged as manually + # modified/deleted... And the user cannot even do anything about it + # except removing the hash in regenconf.yml... + # + # Expected behavior: it should forget about that + # hash because dnsmasq's regen-conf doesn't say anything about what's + # the state of that file so it should assume that it should be deleted. + # + # - then the admin tries to *re-add* foo.bar ! + # - ... but because the file is still flagged as manually modified + # the regen-conf refuses to re-create the file. + # + # Excepted behavior : the regen-conf should have forgot about the hash + # from earlier and this wouldnt happen. + # ------ + # conf_files contain files explicitly set by the current regen conf run + # conf_hashes contain all files known from the past runs + # we compare these to get the list of stale hashes and flag the file as + # "should be removed" + stale_files = set(conf_hashes.keys()) - set(conf_files.keys()) + stale_files_with_non_empty_hash = [f for f in stale_files if conf_hashes.get(f)] + for f in stale_files_with_non_empty_hash: + conf_files[f] = None + # End discussion about stale file hashes + + force_update_hashes_for_this_category = False + for system_path, pending_path in conf_files.items(): - logger.debug("processing pending conf '%s' to system conf '%s'", - pending_path, system_path) + logger.debug( + "processing pending conf '%s' to system conf '%s'", + pending_path, + system_path, + ) conf_status = None regenerated = False # Get the diff between files - conf_diff = _get_files_diff( - system_path, pending_path, True) if with_diff else None + conf_diff = ( + _get_files_diff(system_path, pending_path, True) if with_diff else None + ) # Check if the conf must be removed - to_remove = True if os.path.getsize(pending_path) == 0 else False + to_remove = ( + True if pending_path and os.path.getsize(pending_path) == 0 else False + ) # Retrieve and calculate hashes system_hash = _calculate_hash(system_path) saved_hash = conf_hashes.get(system_path, None) new_hash = None if to_remove else _calculate_hash(pending_path) + # -> configuration was previously managed by yunohost but should now + # be removed / unmanaged + if system_path in stale_files_with_non_empty_hash: + # File is already deleted, so let's just silently forget about this hash entirely + if not system_hash: + logger.debug("> forgetting about stale file/hash") + conf_hashes[system_path] = None + conf_status = "forget-about-it" + regenerated = True + # Otherwise there's still a file on the system but it's not managed by + # Yunohost anymore... But if user requested --force we shall + # force-erase it + elif force: + logger.debug("> force-remove stale file") + regenerated = _regen(system_path) + conf_status = "force-removed" + # Otherwise, flag the file as manually modified + else: + logger.warning( + m18n.n("regenconf_file_manually_modified", conf=system_path) + ) + conf_status = "modified" + # -> system conf does not exists - if not system_hash: + elif not system_hash: if to_remove: logger.debug("> system conf is already removed") os.remove(pending_path) + conf_hashes[system_path] = None + conf_status = "forget-about-it" + force_update_hashes_for_this_category = True continue - if not saved_hash or force: + elif not saved_hash or force: if force: logger.debug("> system conf has been manually removed") - conf_status = 'force-created' + conf_status = "force-created" else: logger.debug("> system conf does not exist yet") - conf_status = 'created' - regenerated = _regen( - system_path, pending_path, save=False) + conf_status = "created" + regenerated = _regen(system_path, pending_path, save=False) else: - logger.info(m18n.n( - 'regenconf_file_manually_removed', - conf=system_path)) - conf_status = 'removed' + logger.info( + m18n.n("regenconf_file_manually_removed", conf=system_path) + ) + conf_status = "removed" # -> system conf is not managed yet elif not saved_hash: logger.debug("> system conf is not managed yet") if system_hash == new_hash: logger.debug("> no changes to system conf has been made") - conf_status = 'managed' + conf_status = "managed" regenerated = True elif not to_remove: # If the conf exist but is not managed yet, and is not to be removed, # we assume that it is safe to regen it, since the file is backuped # anyway (by default in _regen), as long as we warn the user # appropriately. - logger.info(m18n.n('regenconf_now_managed_by_yunohost', - conf=system_path, category=category)) + logger.info( + m18n.n( + "regenconf_now_managed_by_yunohost", + conf=system_path, + category=category, + ) + ) regenerated = _regen(system_path, pending_path) - conf_status = 'new' + conf_status = "new" elif force: regenerated = _regen(system_path) - conf_status = 'force-removed' + conf_status = "force-removed" else: - logger.info(m18n.n('regenconf_file_kept_back', - conf=system_path, category=category)) - conf_status = 'unmanaged' + logger.info( + m18n.n( + "regenconf_file_kept_back", + conf=system_path, + category=category, + ) + ) + conf_status = "unmanaged" # -> system conf has not been manually modified elif system_hash == saved_hash: if to_remove: regenerated = _regen(system_path) - conf_status = 'removed' + conf_status = "removed" elif system_hash != new_hash: regenerated = _regen(system_path, pending_path) - conf_status = 'updated' + conf_status = "updated" else: logger.debug("> system conf is already up-to-date") os.remove(pending_path) @@ -243,64 +365,71 @@ def regen_conf(operation_logger, names=[], with_diff=False, force=False, dry_run logger.debug("> system conf has been manually modified") if system_hash == new_hash: logger.debug("> new conf is as current system conf") - conf_status = 'managed' + conf_status = "managed" regenerated = True + elif ( + force + and system_path == sshd_config + and not ssh_explicitly_specified + ): + logger.warning(m18n.n("regenconf_need_to_explicitly_specify_ssh")) + conf_status = "modified" elif force: regenerated = _regen(system_path, pending_path) - conf_status = 'force-updated' + conf_status = "force-updated" else: - logger.warning(m18n.n( - 'regenconf_file_manually_modified', - conf=system_path)) - conf_status = 'modified' + logger.warning( + m18n.n("regenconf_file_manually_modified", conf=system_path) + ) + conf_status = "modified" # Store the result - conf_result = {'status': conf_status} + conf_result = {"status": conf_status} if conf_diff is not None: - conf_result['diff'] = conf_diff + conf_result["diff"] = conf_diff if regenerated: succeed_regen[system_path] = conf_result conf_hashes[system_path] = new_hash - if os.path.isfile(pending_path): + if pending_path and os.path.isfile(pending_path): os.remove(pending_path) else: failed_regen[system_path] = conf_result # Check for category conf changes if not succeed_regen and not failed_regen: - logger.debug(m18n.n('regenconf_up_to_date', category=category)) + logger.debug(m18n.n("regenconf_up_to_date", category=category)) continue elif not failed_regen: - logger.success(m18n.n( - 'regenconf_updated' if not dry_run else - 'regenconf_would_be_updated', - category=category)) + if not dry_run: + logger.success(m18n.n("regenconf_updated", category=category)) + else: + logger.success(m18n.n("regenconf_would_be_updated", category=category)) - if succeed_regen and not dry_run: + if (succeed_regen or force_update_hashes_for_this_category) and not dry_run: _update_conf_hashes(category, conf_hashes) # Append the category results - result[category] = { - 'applied': succeed_regen, - 'pending': failed_regen - } + result[category] = {"applied": succeed_regen, "pending": failed_regen} # Return in case of dry run if dry_run: return result # Execute hooks for post-regen - post_args = ['post', ] + common_args + # element 2 and 3 with empty string is because of legacy... + post_args = ["post", "", ""] def _pre_call(name, priority, path, args): # append coma-separated applied changes for the category - if name in result and result[name]['applied']: - regen_conf_files = ','.join(result[name]['applied'].keys()) + if name in result and result[name]["applied"]: + regen_conf_files = ",".join(result[name]["applied"].keys()) else: - regen_conf_files = '' - return post_args + [regen_conf_files, ] + regen_conf_files = "" + return post_args + [ + regen_conf_files, + ] - hook_callback('conf_regen', names, pre_callback=_pre_call) + hook_callback("conf_regen", names, pre_callback=_pre_call, env=env) operation_logger.success() @@ -312,9 +441,9 @@ def _get_regenconf_infos(): Get a dict of regen conf informations """ try: - with open(REGEN_CONF_FILE, 'r') as f: - return yaml.load(f) - except: + with open(REGEN_CONF_FILE, "r") as f: + return yaml.safe_load(f) + except Exception: return {} @@ -324,11 +453,22 @@ def _save_regenconf_infos(infos): Keyword argument: categories -- A dict containing the regenconf infos """ + + # Ugly hack to get rid of legacy glances stuff + if "glances" in infos: + del infos["glances"] + + # Ugly hack to get rid of legacy avahi stuff + if "avahi-daemon" in infos: + del infos["avahi-daemon"] + try: - with open(REGEN_CONF_FILE, 'w') as f: + with open(REGEN_CONF_FILE, "w") as f: yaml.safe_dump(infos, f, default_flow_style=False) except Exception as e: - logger.warning('Error while saving regenconf infos, exception: %s', e, exc_info=1) + logger.warning( + "Error while saving regenconf infos, exception: %s", e, exc_info=1 + ) raise @@ -341,14 +481,14 @@ def _get_files_diff(orig_file, new_file, as_string=False, skip_header=True): """ - if os.path.exists(orig_file): - with open(orig_file, 'r') as orig_file: + if orig_file and os.path.exists(orig_file): + with open(orig_file, "r") as orig_file: orig_file = orig_file.readlines() else: orig_file = [] - if os.path.exists(new_file): - with open(new_file, 'r') as new_file: + if new_file and os.path.exists(new_file): + with open(new_file, "r") as new_file: new_file = new_file.readlines() else: new_file = [] @@ -360,11 +500,11 @@ def _get_files_diff(orig_file, new_file, as_string=False, skip_header=True): try: next(diff) next(diff) - except: + except Exception: pass if as_string: - return ''.join(diff).rstrip() + return "".join(diff).rstrip() return diff @@ -372,18 +512,20 @@ def _get_files_diff(orig_file, new_file, as_string=False, skip_header=True): def _calculate_hash(path): """Calculate the MD5 hash of a file""" - if not os.path.exists(path): + if not path or not os.path.exists(path): return None hasher = hashlib.md5() try: - with open(path, 'rb') as f: + with open(path, "rb") as f: hasher.update(f.read()) return hasher.hexdigest() except IOError as e: - logger.warning("Error while calculating file '%s' hash: %s", path, e, exc_info=1) + logger.warning( + "Error while calculating file '%s' hash: %s", path, e, exc_info=1 + ) return None @@ -438,18 +580,17 @@ def _get_conf_hashes(category): logger.debug("category %s is not in categories.yml yet.", category) return {} - elif categories[category] is None or 'conffiles' not in categories[category]: + elif categories[category] is None or "conffiles" not in categories[category]: logger.debug("No configuration files for category %s.", category) return {} else: - return categories[category]['conffiles'] + return categories[category]["conffiles"] def _update_conf_hashes(category, hashes): """Update the registered conf hashes for a category""" - logger.debug("updating conf hashes for '%s' with: %s", - category, hashes) + logger.debug("updating conf hashes for '%s' with: %s", category, hashes) categories = _get_regenconf_infos() category_conf = categories.get(category, {}) @@ -458,11 +599,36 @@ def _update_conf_hashes(category, hashes): if category_conf is None: category_conf = {} - category_conf['conffiles'] = hashes + # If a file shall be removed and is indeed removed, forget entirely about + # that path. + # It avoid keeping weird old entries like + # /etc/nginx/conf.d/some.domain.that.got.removed.conf + hashes = { + path: hash_ + for path, hash_ in hashes.items() + if hash_ is not None or os.path.exists(path) + } + + category_conf["conffiles"] = hashes categories[category] = category_conf _save_regenconf_infos(categories) +def _force_clear_hashes(paths): + + categories = _get_regenconf_infos() + for path in paths: + for category in categories.keys(): + if path in categories[category]["conffiles"]: + logger.debug( + "force-clearing old conf hash for %s in category %s" + % (path, category) + ) + del categories[category]["conffiles"][path] + + _save_regenconf_infos(categories) + + def _process_regen_conf(system_conf, new_conf=None, save=True): """Regenerate a given system configuration file @@ -472,22 +638,26 @@ def _process_regen_conf(system_conf, new_conf=None, save=True): """ if save: - backup_path = os.path.join(BACKUP_CONF_DIR, '{0}-{1}'.format( - system_conf.lstrip('/'), datetime.utcnow().strftime("%Y%m%d.%H%M%S"))) + backup_path = os.path.join( + BACKUP_CONF_DIR, + "{0}-{1}".format( + system_conf.lstrip("/"), datetime.utcnow().strftime("%Y%m%d.%H%M%S") + ), + ) backup_dir = os.path.dirname(backup_path) if not os.path.isdir(backup_dir): filesystem.mkdir(backup_dir, 0o755, True) shutil.copy2(system_conf, backup_path) - logger.debug(m18n.n('regenconf_file_backed_up', - conf=system_conf, backup=backup_path)) + logger.debug( + m18n.n("regenconf_file_backed_up", conf=system_conf, backup=backup_path) + ) try: if not new_conf: os.remove(system_conf) - logger.debug(m18n.n('regenconf_file_removed', - conf=system_conf)) + logger.debug(m18n.n("regenconf_file_removed", conf=system_conf)) else: system_dir = os.path.dirname(system_conf) @@ -495,14 +665,18 @@ def _process_regen_conf(system_conf, new_conf=None, save=True): filesystem.mkdir(system_dir, 0o755, True) shutil.copyfile(new_conf, system_conf) - logger.debug(m18n.n('regenconf_file_updated', - conf=system_conf)) + logger.debug(m18n.n("regenconf_file_updated", conf=system_conf)) except Exception as e: - logger.warning("Exception while trying to regenerate conf '%s': %s", system_conf, e, exc_info=1) + logger.warning( + "Exception while trying to regenerate conf '%s': %s", + system_conf, + e, + exc_info=1, + ) if not new_conf and os.path.exists(system_conf): - logger.warning(m18n.n('regenconf_file_remove_failed', - conf=system_conf), - exc_info=1) + logger.warning( + m18n.n("regenconf_file_remove_failed", conf=system_conf), exc_info=1 + ) return False elif new_conf: @@ -511,13 +685,16 @@ def _process_regen_conf(system_conf, new_conf=None, save=True): # Raise an exception if an os.stat() call on either pathname fails. # (os.stats returns a series of information from a file like type, size...) copy_succeed = os.path.samefile(system_conf, new_conf) - except: + except Exception: copy_succeed = False finally: if not copy_succeed: - logger.warning(m18n.n('regenconf_file_copy_failed', - conf=system_conf, new=new_conf), - exc_info=1) + logger.warning( + m18n.n( + "regenconf_file_copy_failed", conf=system_conf, new=new_conf + ), + exc_info=1, + ) return False return True @@ -525,31 +702,36 @@ def _process_regen_conf(system_conf, new_conf=None, save=True): def manually_modified_files(): - # We do this to have --quiet, i.e. don't throw a whole bunch of logs - # just to fetch this... - # Might be able to optimize this by looking at what the regen conf does - # and only do the part that checks file hashes... - cmd = "yunohost tools regen-conf --dry-run --output-as json --quiet" - j = json.loads(subprocess.check_output(cmd.split())) - - # j is something like : - # {"postfix": {"applied": {}, "pending": {"/etc/postfix/main.cf": {"status": "modified"}}} - output = [] - for app, actions in j.items(): - for action, files in actions.items(): - for filename, infos in files.items(): - if infos["status"] == "modified": - output.append(filename) + regenconf_categories = _get_regenconf_infos() + for category, infos in regenconf_categories.items(): + conffiles = infos["conffiles"] + for path, hash_ in conffiles.items(): + if hash_ != _calculate_hash(path): + output.append(path) return output -def manually_modified_files_compared_to_debian_default(): +def manually_modified_files_compared_to_debian_default( + ignore_handled_by_regenconf=False, +): # from https://serverfault.com/a/90401 - r = subprocess.check_output("dpkg-query -W -f='${Conffiles}\n' '*' \ - | awk 'OFS=\" \"{print $2,$1}' \ - | md5sum -c 2>/dev/null \ - | awk -F': ' '$2 !~ /OK/{print $1}'", shell=True) - return r.strip().split("\n") + files = check_output( + "dpkg-query -W -f='${Conffiles}\n' '*' \ + | awk 'OFS=\" \"{print $2,$1}' \ + | md5sum -c 2>/dev/null \ + | awk -F': ' '$2 !~ /OK/{print $1}'" + ) + files = files.strip().split("\n") + + if ignore_handled_by_regenconf: + regenconf_categories = _get_regenconf_infos() + regenconf_files = [] + for infos in regenconf_categories.values(): + regenconf_files.extend(infos["conffiles"].keys()) + + files = [f for f in files if f not in regenconf_files] + + return files diff --git a/src/yunohost/service.py b/src/yunohost/service.py index 17a3cc83e..f200d08c0 100644 --- a/src/yunohost/service.py +++ b/src/yunohost/service.py @@ -23,6 +23,8 @@ Manage services """ + +import re import os import time import yaml @@ -32,68 +34,114 @@ from glob import glob from datetime import datetime from moulinette import m18n -from yunohost.utils.error import YunohostError -from moulinette.utils import log, filesystem +from yunohost.utils.error import YunohostError, YunohostValidationError +from moulinette.utils.process import check_output +from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import ( + read_file, + append_to_file, + write_to_file, + read_yaml, + write_to_yaml, +) MOULINETTE_LOCK = "/var/run/moulinette_yunohost.lock" -logger = log.getActionLogger('yunohost.service') +SERVICES_CONF = "/etc/yunohost/services.yml" +SERVICES_CONF_BASE = "/usr/share/yunohost/templates/yunohost/services.yml" + +logger = getActionLogger("yunohost.service") -def service_add(name, status=None, log=None, runlevel=None, need_lock=False, description=None, log_type="file"): +def service_add( + name, + description=None, + log=None, + log_type=None, + test_status=None, + test_conf=None, + needs_exposed_ports=None, + need_lock=False, + status=None, +): """ Add a custom service Keyword argument: name -- Service name to add - status -- Custom status command - log -- Absolute path to log file to display - runlevel -- Runlevel priority of the service - need_lock -- Use this option to prevent deadlocks if the service does invoke yunohost commands. description -- description of the service - log_type -- Precise if the corresponding log is a file or a systemd log + log -- Absolute path to log file to display + log_type -- (deprecated) Specify if the corresponding log is a file or a systemd log + test_status -- Specify a custom bash command to check the status of the service. N.B. : it only makes sense to specify this if the corresponding systemd service does not return the proper information. + test_conf -- Specify a custom bash command to check if the configuration of the service is valid or broken, similar to nginx -t. + needs_exposed_ports -- A list of ports that needs to be publicly exposed for the service to work as intended. + need_lock -- Use this option to prevent deadlocks if the service does invoke yunohost commands. + status -- Deprecated, doesn't do anything anymore. Use test_status instead. """ services = _get_services() - if not status: - services[name] = {'status': 'service'} - else: - services[name] = {'status': status} + services[name] = service = {} if log is not None: if not isinstance(log, list): log = [log] - services[name]['log'] = log + # Deprecated log_type stuff + if log_type is not None: + logger.warning( + "/!\\ Packagers! --log_type is deprecated. You do not need to specify --log_type systemd anymore ... Yunohost now automatically fetch the journalctl of the systemd service by default." + ) + # Usually when adding such a service, the service name will be provided so we remove it as it's not a log file path + if name in log: + log.remove(name) - if not isinstance(log_type, list): - log_type = [log_type] + service["log"] = log - if len(log_type) < len(log): - log_type.extend([log_type[-1]] * (len(log) - len(log_type))) # extend list to have the same size as log + if not description: + # Try to get the description from systemd service + unit, _ = _get_service_information_from_systemd(name) + description = str(unit.get("Description", "")) if unit is not None else "" + # If the service does not yet exists or if the description is empty, + # systemd will anyway return foo.service as default value, so we wanna + # make sure there's actually something here. + if description == name + ".service": + description = "" - if len(log_type) == len(log): - services[name]['log_type'] = log_type - else: - raise YunohostError('service_add_failed', service=name) - - - if runlevel is not None: - services[name]['runlevel'] = runlevel + if description: + service["description"] = description + else: + logger.warning( + "/!\\ Packagers! You added a custom service without specifying a description. Please add a proper Description in the systemd configuration, or use --description to explain what the service does in a similar fashion to existing services." + ) if need_lock: - services[name]['need_lock'] = True + service["need_lock"] = True - if description is not None: - services[name]['description'] = description + if test_status: + service["test_status"] = test_status + else: + # Try to get the description from systemd service + _, systemd_info = _get_service_information_from_systemd(name) + type_ = systemd_info.get("Type") if systemd_info is not None else "" + if type_ == "oneshot" and name != "postgresql": + logger.warning( + "/!\\ Packagers! Please provide a --test_status when adding oneshot-type services in Yunohost, such that it has a reliable way to check if the service is running or not." + ) + + if test_conf: + service["test_conf"] = test_conf + + if needs_exposed_ports: + service["needs_exposed_ports"] = needs_exposed_ports try: _save_services(services) - except: + except Exception as e: + logger.warning(e) # we'll get a logger.warning with more details in _save_services - raise YunohostError('service_add_failed', service=name) + raise YunohostError("service_add_failed", service=name) - logger.success(m18n.n('service_added', service=name)) + logger.success(m18n.n("service_added", service=name)) def service_remove(name): @@ -106,18 +154,17 @@ def service_remove(name): """ services = _get_services() - try: - del services[name] - except KeyError: - raise YunohostError('service_unknown', service=name) + if name not in services: + raise YunohostValidationError("service_unknown", service=name) + del services[name] try: _save_services(services) - except: + except Exception: # we'll get a logger.warning with more details in _save_services - raise YunohostError('service_remove_failed', service=name) + raise YunohostError("service_remove_failed", service=name) - logger.success(m18n.n('service_removed', service=name)) + logger.success(m18n.n("service_removed", service=name)) def service_start(names): @@ -132,12 +179,16 @@ def service_start(names): names = [names] for name in names: - if _run_service_command('start', name): - logger.success(m18n.n('service_started', service=name)) + if _run_service_command("start", name): + logger.success(m18n.n("service_started", service=name)) else: - if service_status(name)['status'] != 'running': - raise YunohostError('service_start_failed', service=name, logs=_get_journalctl_logs(name)) - logger.debug(m18n.n('service_already_started', service=name)) + if service_status(name)["status"] != "running": + raise YunohostError( + "service_start_failed", + service=name, + logs=_get_journalctl_logs(name), + ) + logger.debug(m18n.n("service_already_started", service=name)) def service_stop(names): @@ -151,12 +202,14 @@ def service_stop(names): if isinstance(names, str): names = [names] for name in names: - if _run_service_command('stop', name): - logger.success(m18n.n('service_stopped', service=name)) + if _run_service_command("stop", name): + logger.success(m18n.n("service_stopped", service=name)) else: - if service_status(name)['status'] != 'inactive': - raise YunohostError('service_stop_failed', service=name, logs=_get_journalctl_logs(name)) - logger.debug(m18n.n('service_already_stopped', service=name)) + if service_status(name)["status"] != "inactive": + raise YunohostError( + "service_stop_failed", service=name, logs=_get_journalctl_logs(name) + ) + logger.debug(m18n.n("service_already_stopped", service=name)) def service_reload(names): @@ -170,11 +223,15 @@ def service_reload(names): if isinstance(names, str): names = [names] for name in names: - if _run_service_command('reload', name): - logger.success(m18n.n('service_reloaded', service=name)) + if _run_service_command("reload", name): + logger.success(m18n.n("service_reloaded", service=name)) else: - if service_status(name)['status'] != 'inactive': - raise YunohostError('service_reload_failed', service=name, logs=_get_journalctl_logs(name)) + if service_status(name)["status"] != "inactive": + raise YunohostError( + "service_reload_failed", + service=name, + logs=_get_journalctl_logs(name), + ) def service_restart(names): @@ -188,14 +245,18 @@ def service_restart(names): if isinstance(names, str): names = [names] for name in names: - if _run_service_command('restart', name): - logger.success(m18n.n('service_restarted', service=name)) + if _run_service_command("restart", name): + logger.success(m18n.n("service_restarted", service=name)) else: - if service_status(name)['status'] != 'inactive': - raise YunohostError('service_restart_failed', service=name, logs=_get_journalctl_logs(name)) + if service_status(name)["status"] != "inactive": + raise YunohostError( + "service_restart_failed", + service=name, + logs=_get_journalctl_logs(name), + ) -def service_reload_or_restart(names): +def service_reload_or_restart(names, test_conf=True): """ Reload one or more services if they support it. If not, restart them instead. If the services are not running yet, they will be started. @@ -205,12 +266,45 @@ def service_reload_or_restart(names): """ if isinstance(names, str): names = [names] + + services = _get_services() + for name in names: - if _run_service_command('reload-or-restart', name): - logger.success(m18n.n('service_reloaded_or_restarted', service=name)) + + logger.debug(f"Reloading service {name}") + + test_conf_cmd = services.get(name, {}).get("test_conf") + if test_conf and test_conf_cmd: + + p = subprocess.Popen( + test_conf_cmd, + shell=True, + executable="/bin/bash", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + + out, _ = p.communicate() + if p.returncode != 0: + errors = out.decode().strip().split("\n") + logger.error( + m18n.n( + "service_not_reloading_because_conf_broken", + name=name, + errors=errors, + ) + ) + continue + + if _run_service_command("reload-or-restart", name): + logger.success(m18n.n("service_reloaded_or_restarted", service=name)) else: - if service_status(name)['status'] != 'inactive': - raise YunohostError('service_reload_or_restart_failed', service=name, logs=_get_journalctl_logs(name)) + if service_status(name)["status"] != "inactive": + raise YunohostError( + "service_reload_or_restart_failed", + service=name, + logs=_get_journalctl_logs(name), + ) def service_enable(names): @@ -224,10 +318,12 @@ def service_enable(names): if isinstance(names, str): names = [names] for name in names: - if _run_service_command('enable', name): - logger.success(m18n.n('service_enabled', service=name)) + if _run_service_command("enable", name): + logger.success(m18n.n("service_enabled", service=name)) else: - raise YunohostError('service_enable_failed', service=name, logs=_get_journalctl_logs(name)) + raise YunohostError( + "service_enable_failed", service=name, logs=_get_journalctl_logs(name) + ) def service_disable(names): @@ -241,10 +337,12 @@ def service_disable(names): if isinstance(names, str): names = [names] for name in names: - if _run_service_command('disable', name): - logger.success(m18n.n('service_disabled', service=name)) + if _run_service_command("disable", name): + logger.success(m18n.n("service_disabled", service=name)) else: - raise YunohostError('service_disable_failed', service=name, logs=_get_journalctl_logs(name)) + raise YunohostError( + "service_disable_failed", service=name, logs=_get_journalctl_logs(name) + ) def service_status(names=[]): @@ -256,83 +354,36 @@ def service_status(names=[]): """ services = _get_services() - check_names = True - result = {} - if isinstance(names, str): - names = [names] - elif len(names) == 0: - names = services.keys() - check_names = False + # If function was called with a specific list of service + if names != []: + # If user wanna check the status of a single service + if isinstance(names, str): + names = [names] - for name in names: - if check_names and name not in services.keys(): - raise YunohostError('service_unknown', service=name) + # Validate service names requested + for name in names: + if name not in services.keys(): + raise YunohostValidationError("service_unknown", service=name) - # this "service" isn't a service actually so we skip it - # - # the historical reason is because regenconf has been hacked into the - # service part of YunoHost will in some situation we need to regenconf - # for things that aren't services - # the hack was to add fake services... - # we need to extract regenconf from service at some point, also because - # some app would really like to use it - if "status" in services[name] and services[name]["status"] is None: - continue + # Filter only requested servivces + services = {k: v for k, v in services.items() if k in names} - status = _get_service_information_from_systemd(name) + # Remove services that aren't "real" services + # + # the historical reason is because regenconf has been hacked into the + # service part of YunoHost will in some situation we need to regenconf + # for things that aren't services + # the hack was to add fake services... + services = {k: v for k, v in services.items() if v.get("status", "") is not None} - # try to get status using alternative version if they exists - # this is for mariadb/mysql but is generic in case of - alternates = services[name].get("alternates", []) - while status is None and alternates: - status = _get_service_information_from_systemd(alternates.pop()) - - if status is None: - logger.error("Failed to get status information via dbus for service %s, systemctl didn't recognize this service ('NoSuchUnit')." % name) - result[name] = { - 'status': "unknown", - 'loaded': "unknown", - 'active': "unknown", - 'active_at': "unknown", - 'description': "Error: failed to get information for this service, it doesn't exists for systemd", - 'service_file_path': "unknown", - } - - else: - translation_key = "service_description_%s" % name - if "description" in services[name] is not None: - description = services[name].get("description") - else: - description = m18n.n(translation_key) - - # that mean that we don't have a translation for this string - # that's the only way to test for that for now - # if we don't have it, uses the one provided by systemd - if description == translation_key: - description = str(status.get("Description", "")) - - result[name] = { - 'status': str(status.get("SubState", "unknown")), - 'loaded': str(status.get("UnitFileState", "unknown")), - 'active': str(status.get("ActiveState", "unknown")), - 'description': description, - 'service_file_path': str(status.get("FragmentPath", "unknown")), - } - - # Fun stuff™ : to obtain the enabled/disabled status for sysv services, - # gotta do this ... cf code of /lib/systemd/systemd-sysv-install - if result[name]["loaded"] == "generated": - result[name]["loaded"] = "enabled" if glob("/etc/rc[S5].d/S??"+name) else "disabled" - - if "ActiveEnterTimestamp" in status: - result[name]['active_at'] = datetime.utcfromtimestamp(status["ActiveEnterTimestamp"] / 1000000) - else: - result[name]['active_at'] = "unknown" + output = { + s: _get_and_format_service_status(s, infos) for s, infos in services.items() + } if len(names) == 1: - return result[names[0]] - return result + return output[names[0]] + return output def _get_service_information_from_systemd(service): @@ -341,22 +392,123 @@ def _get_service_information_from_systemd(service): d = dbus.SystemBus() - systemd = d.get_object('org.freedesktop.systemd1', '/org/freedesktop/systemd1') - manager = dbus.Interface(systemd, 'org.freedesktop.systemd1.Manager') + systemd = d.get_object("org.freedesktop.systemd1", "/org/freedesktop/systemd1") + manager = dbus.Interface(systemd, "org.freedesktop.systemd1.Manager") # c.f. https://zignar.net/2014/09/08/getting-started-with-dbus-python-systemd/ # Very interface, much intuitive, wow - service_unit = manager.LoadUnit(service + '.service') - service_proxy = d.get_object('org.freedesktop.systemd1', str(service_unit)) - properties_interface = dbus.Interface(service_proxy, 'org.freedesktop.DBus.Properties') + service_unit = manager.LoadUnit(service + ".service") + service_proxy = d.get_object("org.freedesktop.systemd1", str(service_unit)) + properties_interface = dbus.Interface( + service_proxy, "org.freedesktop.DBus.Properties" + ) - properties = properties_interface.GetAll('org.freedesktop.systemd1.Unit') + unit = properties_interface.GetAll("org.freedesktop.systemd1.Unit") + service = properties_interface.GetAll("org.freedesktop.systemd1.Service") - if properties.get("LoadState", "not-found") == "not-found": + if unit.get("LoadState", "not-found") == "not-found": # Service doesn't really exist - return None + return (None, None) else: - return properties + return (unit, service) + + +def _get_and_format_service_status(service, infos): + + systemd_service = infos.get("actual_systemd_service", service) + raw_status, raw_service = _get_service_information_from_systemd(systemd_service) + + if raw_status is None: + logger.error( + "Failed to get status information via dbus for service %s, systemctl didn't recognize this service ('NoSuchUnit')." + % systemd_service + ) + return { + "status": "unknown", + "start_on_boot": "unknown", + "last_state_change": "unknown", + "description": "Error: failed to get information for this service, it doesn't exists for systemd", + "configuration": "unknown", + } + + # Try to get description directly from services.yml + description = infos.get("description") + + # If no description was there, try to get it from the .json locales + if not description: + + translation_key = "service_description_%s" % service + if m18n.key_exists(translation_key): + description = m18n.n(translation_key) + else: + description = str(raw_status.get("Description", "")) + + output = { + "status": str(raw_status.get("SubState", "unknown")), + "start_on_boot": str(raw_status.get("UnitFileState", "unknown")), + "last_state_change": "unknown", + "description": description, + "configuration": "unknown", + } + + # Fun stuff™ : to obtain the enabled/disabled status for sysv services, + # gotta do this ... cf code of /lib/systemd/systemd-sysv-install + if output["start_on_boot"] == "generated": + output["start_on_boot"] = ( + "enabled" if glob("/etc/rc[S5].d/S??" + service) else "disabled" + ) + elif os.path.exists( + "/etc/systemd/system/multi-user.target.wants/%s.service" % service + ): + output["start_on_boot"] = "enabled" + + if "StateChangeTimestamp" in raw_status: + output["last_state_change"] = datetime.utcfromtimestamp( + raw_status["StateChangeTimestamp"] / 1000000 + ) + + # 'test_status' is an optional field to test the status of the service using a custom command + if "test_status" in infos: + p = subprocess.Popen( + infos["test_status"], + shell=True, + executable="/bin/bash", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + + p.communicate() + + output["status"] = "running" if p.returncode == 0 else "failed" + elif ( + raw_service.get("Type", "").lower() == "oneshot" + and output["status"] == "exited" + ): + # These are services like yunohost-firewall, hotspot, vpnclient, + # ... they will be "exited" why doesn't provide any info about + # the real state of the service (unless they did provide a + # test_status, c.f. previous condition) + output["status"] = "unknown" + + # 'test_status' is an optional field to test the status of the service using a custom command + if "test_conf" in infos: + p = subprocess.Popen( + infos["test_conf"], + shell=True, + executable="/bin/bash", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + + out, _ = p.communicate() + if p.returncode == 0: + output["configuration"] = "valid" + else: + out = out.decode() + output["configuration"] = "broken" + output["configuration-details"] = out.strip().split("\n") + + return output def service_log(name, number=50): @@ -369,51 +521,62 @@ def service_log(name, number=50): """ services = _get_services() + number = int(number) if name not in services.keys(): - raise YunohostError('service_unknown', service=name) + raise YunohostValidationError("service_unknown", service=name) - if 'log' not in services[name]: - raise YunohostError('service_no_log', service=name) - - log_list = services[name]['log'] - log_type_list = services[name].get('log_type', []) + log_list = services[name].get("log", []) if not isinstance(log_list, list): log_list = [log_list] - if len(log_type_list) < len(log_list): - log_type_list.extend(["file"] * (len(log_list)-len(log_type_list))) + + # Legacy stuff related to --log_type where we'll typically have the service + # name in the log list but it's not an actual logfile. Nowadays journalctl + # is automatically fetch as well as regular log files. + if name in log_list: + log_list.remove(name) result = {} - for index, log_path in enumerate(log_list): - log_type = log_type_list[index] + # First we always add the logs from journalctl / systemd + result["journalctl"] = _get_journalctl_logs(name, number).splitlines() - if log_type == "file": - # log is a file, read it - if not os.path.isdir(log_path): - result[log_path] = _tail(log_path, int(number)) if os.path.exists(log_path) else [] + for log_path in log_list: + + if not os.path.exists(log_path): + continue + + # Make sure to resolve symlinks + log_path = os.path.realpath(log_path) + + # log is a file, read it + if os.path.isfile(log_path): + result[log_path] = _tail(log_path, number) + continue + elif not os.path.isdir(log_path): + result[log_path] = [] + continue + + for log_file in os.listdir(log_path): + log_file_path = os.path.join(log_path, log_file) + # not a file : skip + if not os.path.isfile(log_file_path): continue - for log_file in os.listdir(log_path): - log_file_path = os.path.join(log_path, log_file) - # not a file : skip - if not os.path.isfile(log_file_path): - continue + if not log_file.endswith(".log"): + continue - if not log_file.endswith(".log"): - continue - - result[log_file_path] = _tail(log_file_path, int(number)) if os.path.exists(log_file_path) else [] - else: - # get log with journalctl - result[log_path] = _get_journalctl_logs(log_path, int(number)).splitlines() + result[log_file_path] = ( + _tail(log_file_path, number) if os.path.exists(log_file_path) else [] + ) return result -def service_regen_conf(names=[], with_diff=False, force=False, dry_run=False, - list_pending=False): +def service_regen_conf( + names=[], with_diff=False, force=False, dry_run=False, list_pending=False +): services = _get_services() @@ -422,14 +585,15 @@ def service_regen_conf(names=[], with_diff=False, force=False, dry_run=False, for name in names: if name not in services.keys(): - raise YunohostError('service_unknown', service=name) + raise YunohostValidationError("service_unknown", service=name) if names is []: - names = services.keys() + names = list(services.keys()) logger.warning(m18n.n("service_regen_conf_is_deprecated")) from yunohost.regenconf import regen_conf + return regen_conf(names, with_diff, force, dry_run, list_pending) @@ -444,16 +608,35 @@ def _run_service_command(action, service): """ services = _get_services() if service not in services.keys(): - raise YunohostError('service_unknown', service=service) + raise YunohostValidationError("service_unknown", service=service) - possible_actions = ['start', 'stop', 'restart', 'reload', 'reload-or-restart', 'enable', 'disable'] + possible_actions = [ + "start", + "stop", + "restart", + "reload", + "reload-or-restart", + "enable", + "disable", + ] if action not in possible_actions: - raise ValueError("Unknown action '%s', available actions are: %s" % (action, ", ".join(possible_actions))) + raise ValueError( + "Unknown action '%s', available actions are: %s" + % (action, ", ".join(possible_actions)) + ) - cmd = 'systemctl %s %s' % (action, service) + cmd = "systemctl %s %s" % (action, service) - need_lock = services[service].get('need_lock', False) \ - and action in ['start', 'stop', 'restart', 'reload', 'reload-or-restart'] + need_lock = services[service].get("need_lock", False) and action in [ + "start", + "stop", + "restart", + "reload", + "reload-or-restart", + ] + + if action in ["enable", "disable"]: + cmd += " --quiet" try: # Launch the command @@ -467,7 +650,7 @@ def _run_service_command(action, service): p.communicate() if p.returncode != 0: - logger.warning(m18n.n('service_cmd_exec_failed', command=cmd)) + logger.warning(m18n.n("service_cmd_exec_failed", command=cmd)) return False except Exception as e: @@ -496,17 +679,17 @@ def _give_lock(action, service, p): while son_PID == 0 and p.poll() is None: # Call systemctl to get the PID # Output of the command is e.g. ControlPID=1234 - son_PID = subprocess.check_output(cmd_get_son_PID.split()) \ - .strip().split("=")[1] + son_PID = check_output(cmd_get_son_PID).split("=")[1] son_PID = int(son_PID) time.sleep(1) # If we found a PID if son_PID != 0: # Append the PID to the lock file - logger.debug("Giving a lock to PID %s for service %s !" - % (str(son_PID), service)) - filesystem.append_to_file(MOULINETTE_LOCK, "\n%s" % str(son_PID)) + logger.debug( + "Giving a lock to PID %s for service %s !" % (str(son_PID), service) + ) + append_to_file(MOULINETTE_LOCK, "\n%s" % str(son_PID)) return son_PID @@ -514,9 +697,9 @@ def _give_lock(action, service, p): def _remove_lock(PID_to_remove): # FIXME ironically not concurrency safe because it's not atomic... - PIDs = filesystem.read_file(MOULINETTE_LOCK).split("\n") + PIDs = read_file(MOULINETTE_LOCK).split("\n") PIDs_to_keep = [PID for PID in PIDs if int(PID) != PID_to_remove] - filesystem.write_to_file(MOULINETTE_LOCK, '\n'.join(PIDs_to_keep)) + write_to_file(MOULINETTE_LOCK, "\n".join(PIDs_to_keep)) def _get_services(): @@ -525,18 +708,52 @@ def _get_services(): """ try: - with open('/etc/yunohost/services.yml', 'r') as f: - services = yaml.load(f) - except: - return {} - else: - # some services are marked as None to remove them from YunoHost - # filter this - for key, value in services.items(): - if value is None: - del services[key] + services = read_yaml(SERVICES_CONF_BASE) or {} - return services + # These are keys flagged 'null' in the base conf + legacy_keys_to_delete = [k for k, v in services.items() if v is None] + + services.update(read_yaml(SERVICES_CONF) or {}) + + services = { + name: infos + for name, infos in services.items() + if name not in legacy_keys_to_delete + } + except Exception: + return {} + + # Dirty hack to automatically find custom SSH port ... + ssh_port_line = re.findall( + r"\bPort *([0-9]{2,5})\b", read_file("/etc/ssh/sshd_config") + ) + if len(ssh_port_line) == 1: + services["ssh"]["needs_exposed_ports"] = [int(ssh_port_line[0])] + + # Dirty hack to check the status of ynh-vpnclient + if "ynh-vpnclient" in services: + status_check = "systemctl is-active openvpn@client.service" + if "test_status" not in services["ynh-vpnclient"]: + services["ynh-vpnclient"]["test_status"] = status_check + if "log" not in services["ynh-vpnclient"]: + services["ynh-vpnclient"]["log"] = ["/var/log/ynh-vpnclient.log"] + + # Stupid hack for postgresql which ain't an official service ... Can't + # really inject that info otherwise. Real service we want to check for + # status and log is in fact postgresql@x.y-main (x.y being the version) + if "postgresql" in services: + if "description" in services["postgresql"]: + del services["postgresql"]["description"] + services["postgresql"]["actual_systemd_service"] = "postgresql@11-main" + + # Remove legacy /var/log/daemon.log and /var/log/syslog from log entries + # because they are too general. Instead, now the journalctl log is + # returned by default which is more relevant. + for infos in services.values(): + if infos.get("log") in ["/var/log/syslog", "/var/log/daemon.log"]: + del infos["log"] + + return services def _save_services(services): @@ -547,12 +764,26 @@ def _save_services(services): services -- A dict of managed services with their parameters """ - try: - with open('/etc/yunohost/services.yml', 'w') as f: - yaml.safe_dump(services, f, default_flow_style=False) - except Exception as e: - logger.warning('Error while saving services, exception: %s', e, exc_info=1) - raise + + # Compute the diff with the base file + # such that /etc/yunohost/services.yml contains the minimal + # changes with respect to the base conf + + conf_base = yaml.safe_load(open(SERVICES_CONF_BASE)) or {} + + diff = {} + + for service_name, service_infos in services.items(): + service_conf_base = conf_base.get(service_name, {}) + diff[service_name] = {} + + for key, value in service_infos.items(): + if service_conf_base.get(key) != value: + diff[service_name][key] = value + + diff = {name: infos for name, infos in diff.items() if infos} + + write_to_yaml(SERVICES_CONF, diff) def _tail(file, n): @@ -569,6 +800,7 @@ def _tail(file, n): try: if file.endswith(".gz"): import gzip + f = gzip.open(file) lines = f.read().splitlines() else: @@ -608,18 +840,16 @@ def _find_previous_log_file(file): """ Find the previous log file """ - import re - splitext = os.path.splitext(file) - if splitext[1] == '.gz': + if splitext[1] == ".gz": file = splitext[0] splitext = os.path.splitext(file) ext = splitext[1] - i = re.findall(r'\.(\d+)', ext) + i = re.findall(r"\.(\d+)", ext) i = int(i[0]) + 1 if len(i) > 0 else 1 previous_file = file if i == 1 else splitext[0] - previous_file = previous_file + '.%d' % (i) + previous_file = previous_file + ".%d" % (i) if os.path.exists(previous_file): return previous_file @@ -631,8 +861,18 @@ def _find_previous_log_file(file): def _get_journalctl_logs(service, number="all"): + services = _get_services() + systemd_service = services.get(service, {}).get("actual_systemd_service", service) try: - return subprocess.check_output("journalctl -xn -u {0} -n{1}".format(service, number), shell=True) - except: + return check_output( + "journalctl --no-hostname --no-pager -u {0} -n{1}".format( + systemd_service, number + ) + ) + except Exception: import traceback - return "error while get services logs from journalctl:\n%s" % traceback.format_exc() + + return ( + "error while get services logs from journalctl:\n%s" + % traceback.format_exc() + ) diff --git a/src/yunohost/settings.py b/src/yunohost/settings.py index a24ecb637..d59b41a58 100644 --- a/src/yunohost/settings.py +++ b/src/yunohost/settings.py @@ -1,19 +1,49 @@ import os import json +import subprocess from datetime import datetime from collections import OrderedDict from moulinette import m18n -from yunohost.utils.error import YunohostError +from yunohost.utils.error import YunohostError, YunohostValidationError from moulinette.utils.log import getActionLogger -from yunohost.service import service_regen_conf +from yunohost.regenconf import regen_conf +from yunohost.firewall import firewall_reload -logger = getActionLogger('yunohost.settings') +logger = getActionLogger("yunohost.settings") SETTINGS_PATH = "/etc/yunohost/settings.json" SETTINGS_PATH_OTHER_LOCATION = "/etc/yunohost/settings-%s.json" + +def is_boolean(value): + TRUE = ["true", "on", "yes", "y", "1"] + FALSE = ["false", "off", "no", "n", "0"] + + """ + Ensure a string value is intended as a boolean + + Keyword arguments: + arg -- The string to check + + Returns: + (is_boolean, boolean_value) + + """ + if isinstance(value, bool): + return True, value + if value in [0, 1]: + return True, bool(value) + elif isinstance(value, str): + if str(value).lower() in TRUE + FALSE: + return True, str(value).lower() in TRUE + else: + return False, None + else: + return False, None + + # a settings entry is in the form of: # namespace.subnamespace.name: {type, value, default, description, [choices]} # choices is only for enum @@ -27,26 +57,66 @@ SETTINGS_PATH_OTHER_LOCATION = "/etc/yunohost/settings-%s.json" # * bool # * int # * string -# * enum (in form a python list) +# * enum (in the form of a python list) -DEFAULTS = OrderedDict([ - ("example.bool", {"type": "bool", "default": True}), - ("example.int", {"type": "int", "default": 42}), - ("example.string", {"type": "string", "default": "yolo swag"}), - ("example.enum", {"type": "enum", "default": "a", "choices": ["a", "b", "c"]}), - - # Password Validation - # -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}), - ("security.ssh.compatibility", {"type": "enum", "default": "modern", - "choices": ["intermediate", "modern"]}), - ("security.nginx.compatibility", {"type": "enum", "default": "intermediate", - "choices": ["intermediate", "modern"]}), - ("security.postfix.compatibility", {"type": "enum", "default": "intermediate", - "choices": ["intermediate", "modern"]}), -]) +DEFAULTS = OrderedDict( + [ + # Password Validation + # -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}, + ), + ( + "security.ssh.compatibility", + { + "type": "enum", + "default": "modern", + "choices": ["intermediate", "modern"], + }, + ), + ( + "security.ssh.port", + {"type": "int", "default": 22}, + ), + ( + "security.nginx.redirect_to_https", + { + "type": "bool", + "default": True, + }, + ), + ( + "security.nginx.compatibility", + { + "type": "enum", + "default": "intermediate", + "choices": ["intermediate", "modern"], + }, + ), + ( + "security.postfix.compatibility", + { + "type": "enum", + "default": "intermediate", + "choices": ["intermediate", "modern"], + }, + ), + ("pop3.enabled", {"type": "bool", "default": False}), + ("smtp.allow_ipv6", {"type": "bool", "default": True}), + ("smtp.relay.host", {"type": "string", "default": ""}), + ("smtp.relay.port", {"type": "int", "default": 587}), + ("smtp.relay.user", {"type": "string", "default": ""}), + ("smtp.relay.password", {"type": "string", "default": ""}), + ("backup.compress_tar_archives", {"type": "bool", "default": False}), + ("ssowat.panel_overlay.enabled", {"type": "bool", "default": True}), + ("security.webadmin.allowlist.enabled", {"type": "bool", "default": False}), + ("security.webadmin.allowlist", {"type": "string", "default": ""}), + ("security.experimental.enabled", {"type": "bool", "default": False}), + ] +) def settings_get(key, full=False): @@ -60,12 +130,14 @@ def settings_get(key, full=False): settings = _get_settings() if key not in settings: - raise YunohostError('global_settings_key_doesnt_exists', settings_key=key) + raise YunohostValidationError( + "global_settings_key_doesnt_exists", settings_key=key + ) if full: return settings[key] - return settings[key]['value'] + return settings[key]["value"] def settings_list(): @@ -88,46 +160,67 @@ def settings_set(key, value): settings = _get_settings() if key not in settings: - raise YunohostError('global_settings_key_doesnt_exists', settings_key=key) + raise YunohostValidationError( + "global_settings_key_doesnt_exists", settings_key=key + ) key_type = settings[key]["type"] if key_type == "bool": - if not isinstance(value, bool): - raise YunohostError('global_settings_bad_type_for_setting', setting=key, - received_type=type(value).__name__, expected_type=key_type) + boolean_value = is_boolean(value) + if boolean_value[0]: + value = boolean_value[1] + else: + raise YunohostValidationError( + "global_settings_bad_type_for_setting", + setting=key, + received_type=type(value).__name__, + expected_type=key_type, + ) elif key_type == "int": if not isinstance(value, int) or isinstance(value, bool): if isinstance(value, str): try: value = int(value) - except: - raise YunohostError('global_settings_bad_type_for_setting', - setting=key, - received_type=type(value).__name__, - expected_type=key_type) + except Exception: + raise YunohostValidationError( + "global_settings_bad_type_for_setting", + setting=key, + received_type=type(value).__name__, + expected_type=key_type, + ) else: - raise YunohostError('global_settings_bad_type_for_setting', setting=key, - received_type=type(value).__name__, expected_type=key_type) + raise YunohostValidationError( + "global_settings_bad_type_for_setting", + setting=key, + received_type=type(value).__name__, + expected_type=key_type, + ) elif key_type == "string": - if not isinstance(value, basestring): - raise YunohostError('global_settings_bad_type_for_setting', setting=key, - received_type=type(value).__name__, expected_type=key_type) + if not isinstance(value, str): + raise YunohostValidationError( + "global_settings_bad_type_for_setting", + setting=key, + received_type=type(value).__name__, + expected_type=key_type, + ) elif key_type == "enum": if value not in settings[key]["choices"]: - raise YunohostError('global_settings_bad_choice_for_enum', setting=key, - choice=str(value), - available_choices=", ".join(settings[key]["choices"])) + raise YunohostValidationError( + "global_settings_bad_choice_for_enum", + setting=key, + choice=str(value), + available_choices=", ".join(settings[key]["choices"]), + ) else: - raise YunohostError('global_settings_unknown_type', setting=key, - unknown_type=key_type) + raise YunohostValidationError( + "global_settings_unknown_type", setting=key, unknown_type=key_type + ) old_value = settings[key].get("value") settings[key]["value"] = value _save_settings(settings) - # TODO : whatdo if the old value is the same as - # the new value... try: trigger_post_change_hook(key, old_value, value) except Exception as e: @@ -146,7 +239,9 @@ def settings_reset(key): settings = _get_settings() if key not in settings: - raise YunohostError('global_settings_key_doesnt_exists', settings_key=key) + raise YunohostValidationError( + "global_settings_key_doesnt_exists", settings_key=key + ) settings[key]["value"] = settings[key]["default"] _save_settings(settings) @@ -167,7 +262,9 @@ def settings_reset_all(): # addition but we'll see if this is a common need. # Another solution would be to use etckeeper and integrate those # modification inside of it and take advantage of its git history - old_settings_backup_path = SETTINGS_PATH_OTHER_LOCATION % datetime.utcnow().strftime("%F_%X") + old_settings_backup_path = ( + SETTINGS_PATH_OTHER_LOCATION % datetime.utcnow().strftime("%F_%X") + ) _save_settings(settings, location=old_settings_backup_path) for value in settings.values(): @@ -177,17 +274,24 @@ def settings_reset_all(): return { "old_settings_backup_path": old_settings_backup_path, - "message": m18n.n("global_settings_reset_success", path=old_settings_backup_path) + "message": m18n.n( + "global_settings_reset_success", path=old_settings_backup_path + ), } +def _get_setting_description(key): + return m18n.n("global_settings_setting_%s" % key.replace(".", "_")) + + def _get_settings(): + settings = {} for key, value in DEFAULTS.copy().items(): settings[key] = value settings[key]["value"] = value["default"] - settings[key]["description"] = m18n.n("global_settings_setting_%s" % key.replace(".", "_")) + settings[key]["description"] = _get_setting_description(key) if not os.path.exists(SETTINGS_PATH): return settings @@ -216,19 +320,26 @@ def _get_settings(): for key, value in local_settings.items(): if key in settings: settings[key] = value - settings[key]["description"] = m18n.n("global_settings_setting_%s" % key.replace(".", "_")) + settings[key]["description"] = _get_setting_description(key) else: - logger.warning(m18n.n('global_settings_unknown_setting_from_settings_file', - setting_key=key)) + logger.warning( + m18n.n( + "global_settings_unknown_setting_from_settings_file", + setting_key=key, + ) + ) unknown_settings[key] = value except Exception as e: - raise YunohostError('global_settings_cant_open_settings', reason=e) + raise YunohostValidationError("global_settings_cant_open_settings", reason=e) if unknown_settings: try: _save_settings(unknown_settings, location=unknown_settings_path) + _save_settings(settings) except Exception as e: - logger.warning("Failed to save unknown settings (because %s), aborting." % e) + logger.warning( + "Failed to save unknown settings (because %s), aborting." % e + ) return settings @@ -243,13 +354,13 @@ def _save_settings(settings, location=SETTINGS_PATH): try: result = json.dumps(settings_without_description, indent=4) except Exception as e: - raise YunohostError('global_settings_cant_serialize_settings', reason=e) + raise YunohostError("global_settings_cant_serialize_settings", reason=e) try: with open(location, "w") as settings_fd: settings_fd.write(result) except Exception as e: - raise YunohostError('global_settings_cant_write_settings', reason=e) + raise YunohostError("global_settings_cant_write_settings", reason=e) # Meant to be a dict of setting_name -> function to call @@ -258,10 +369,16 @@ post_change_hooks = {} def post_change_hook(setting_name): def decorator(func): - assert setting_name in DEFAULTS.keys(), "The setting %s does not exists" % setting_name - assert setting_name not in post_change_hooks, "You can only register one post change hook per setting (in particular for %s)" % setting_name + assert setting_name in DEFAULTS.keys(), ( + "The setting %s does not exists" % setting_name + ) + assert setting_name not in post_change_hooks, ( + "You can only register one post change hook per setting (in particular for %s)" + % setting_name + ) post_change_hooks[setting_name] = func return func + return decorator @@ -285,17 +402,69 @@ def trigger_post_change_hook(setting_name, old_value, new_value): # # =========================================== + +@post_change_hook("ssowat.panel_overlay.enabled") +@post_change_hook("security.nginx.redirect_to_https") @post_change_hook("security.nginx.compatibility") +@post_change_hook("security.webadmin.allowlist.enabled") +@post_change_hook("security.webadmin.allowlist") def reconfigure_nginx(setting_name, old_value, new_value): if old_value != new_value: - service_regen_conf(names=['nginx']) + regen_conf(names=["nginx"]) + + +@post_change_hook("security.experimental.enabled") +def reconfigure_nginx_and_yunohost(setting_name, old_value, new_value): + if old_value != new_value: + regen_conf(names=["nginx", "yunohost"]) + @post_change_hook("security.ssh.compatibility") def reconfigure_ssh(setting_name, old_value, new_value): if old_value != new_value: - service_regen_conf(names=['ssh']) + regen_conf(names=["ssh"]) -@post_change_hook("security.postfix.compatibility") -def reconfigure_ssh(setting_name, old_value, new_value): + +@post_change_hook("security.ssh.port") +def reconfigure_ssh_and_fail2ban(setting_name, old_value, new_value): if old_value != new_value: - service_regen_conf(names=['postfix']) + regen_conf(names=["ssh", "fail2ban"]) + firewall_reload() + + +@post_change_hook("smtp.allow_ipv6") +@post_change_hook("smtp.relay.host") +@post_change_hook("smtp.relay.port") +@post_change_hook("smtp.relay.user") +@post_change_hook("smtp.relay.password") +@post_change_hook("security.postfix.compatibility") +def reconfigure_postfix(setting_name, old_value, new_value): + if old_value != new_value: + regen_conf(names=["postfix"]) + + +@post_change_hook("pop3.enabled") +def reconfigure_dovecot(setting_name, old_value, new_value): + dovecot_package = "dovecot-pop3d" + + environment = os.environ.copy() + environment.update({"DEBIAN_FRONTEND": "noninteractive"}) + + if new_value == "True": + command = [ + "apt-get", + "-y", + "--no-remove", + "-o Dpkg::Options::=--force-confdef", + "-o Dpkg::Options::=--force-confold", + "install", + dovecot_package, + ] + subprocess.call(command, env=environment) + if old_value != new_value: + regen_conf(names=["dovecot"]) + else: + if old_value != new_value: + regen_conf(names=["dovecot"]) + command = ["apt-get", "-y", "remove", dovecot_package] + subprocess.call(command, env=environment) diff --git a/src/yunohost/ssh.py b/src/yunohost/ssh.py index f0110b34e..ecee39f4a 100644 --- a/src/yunohost/ssh.py +++ b/src/yunohost/ssh.py @@ -3,62 +3,21 @@ import re import os import pwd -import subprocess -from yunohost.utils.error import YunohostError +from yunohost.utils.error import YunohostValidationError from moulinette.utils.filesystem import read_file, write_to_file, chown, chmod, mkdir SSHD_CONFIG_PATH = "/etc/ssh/sshd_config" -def user_ssh_allow(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(username): - raise YunohostError('user_unknown', user=username) - - from yunohost.utils.ldap import _get_ldap_interface - ldap = _get_ldap_interface() - ldap.update('uid=%s,ou=users' % username, {'loginShell': '/bin/bash'}) - - # Somehow this is needed otherwise the PAM thing doesn't forget about the - # old loginShell value ? - subprocess.call(['nscd', '-i', 'passwd']) - - -def user_ssh_disallow(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(username): - raise YunohostError('user_unknown', user=username) - - from yunohost.utils.ldap import _get_ldap_interface - ldap = _get_ldap_interface() - ldap.update('uid=%s,ou=users' % username, {'loginShell': '/bin/false'}) - - # Somehow this is needed otherwise the PAM thing doesn't forget about the - # old loginShell value ? - subprocess.call(['nscd', '-i', 'passwd']) - - def user_ssh_list_keys(username): user = _get_user_for_ssh(username, ["homeDirectory"]) if not user: - raise Exception("User with username '%s' doesn't exists" % username) + raise YunohostValidationError("user_unknown", user=username) - authorized_keys_file = os.path.join(user["homeDirectory"][0], ".ssh", "authorized_keys") + authorized_keys_file = os.path.join( + user["homeDirectory"][0], ".ssh", "authorized_keys" + ) if not os.path.exists(authorized_keys_file): return {"keys": []} @@ -76,10 +35,12 @@ def user_ssh_list_keys(username): # assuming a key per non empty line key = line.strip() - keys.append({ - "key": key, - "name": last_comment, - }) + keys.append( + { + "key": key, + "name": last_comment, + } + ) last_comment = "" @@ -89,14 +50,21 @@ def user_ssh_list_keys(username): def user_ssh_add_key(username, key, comment): user = _get_user_for_ssh(username, ["homeDirectory", "uid"]) if not user: - raise Exception("User with username '%s' doesn't exists" % username) + raise YunohostValidationError("user_unknown", user=username) - authorized_keys_file = os.path.join(user["homeDirectory"][0], ".ssh", "authorized_keys") + 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]) + mkdir( + os.path.join(user["homeDirectory"][0], ".ssh"), + force=True, + parents=True, + uid=user["uid"][0], + ) + chmod(os.path.join(user["homeDirectory"][0], ".ssh"), 0o600) # create empty file to set good permissions write_to_file(authorized_keys_file, "") @@ -123,17 +91,24 @@ def user_ssh_add_key(username, key, comment): def user_ssh_remove_key(username, key): user = _get_user_for_ssh(username, ["homeDirectory", "uid"]) if not user: - raise Exception("User with username '%s' doesn't exists" % username) + raise YunohostValidationError("user_unknown", user=username) - authorized_keys_file = os.path.join(user["homeDirectory"][0], ".ssh", "authorized_keys") + 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)) + raise YunohostValidationError( + "this key doesn't exists ({} dosesn't exists)".format(authorized_keys_file), + raw_msg=True, + ) 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)) + raise YunohostValidationError( + "Key '{}' is not present in authorized_keys".format(key), raw_msg=True + ) # don't delete the previous comment because we can't verify if it's legit @@ -147,6 +122,7 @@ def user_ssh_remove_key(username, key): write_to_file(authorized_keys_file, authorized_keys_content) + # # Helpers # @@ -164,8 +140,11 @@ def _get_user_for_ssh(username, attrs=None): # default is “yes”. sshd_config_content = read_file(SSHD_CONFIG_PATH) - if re.search("^ *PermitRootLogin +(no|forced-commands-only) *$", - sshd_config_content, re.MULTILINE): + if re.search( + "^ *PermitRootLogin +(no|forced-commands-only) *$", + sshd_config_content, + re.MULTILINE, + ): return {"PermitRootLogin": False} return {"PermitRootLogin": True} @@ -173,31 +152,30 @@ def _get_user_for_ssh(username, attrs=None): if username == "root": root_unix = pwd.getpwnam("root") return { - 'username': 'root', - 'fullname': '', - 'mail': '', - 'ssh_allowed': ssh_root_login_status()["PermitRootLogin"], - 'shell': root_unix.pw_shell, - 'home_path': root_unix.pw_dir, + "username": "root", + "fullname": "", + "mail": "", + "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, + "username": "admin", + "fullname": "", + "mail": "", + "home_path": admin_unix.pw_dir, } # TODO escape input using https://www.python-ldap.org/doc/html/ldap-filter.html from yunohost.utils.ldap import _get_ldap_interface + ldap = _get_ldap_interface() - user = ldap.search('ou=users,dc=yunohost,dc=org', - '(&(objectclass=person)(uid=%s))' % username, - attrs) + user = ldap.search( + "ou=users,dc=yunohost,dc=org", + "(&(objectclass=person)(uid=%s))" % username, + attrs, + ) assert len(user) in (0, 1) diff --git a/src/yunohost/tests/conftest.py b/src/yunohost/tests/conftest.py index a2dc585bd..a07c44346 100644 --- a/src/yunohost/tests/conftest.py +++ b/src/yunohost/tests/conftest.py @@ -1,12 +1,47 @@ -import sys -import moulinette +import os +import pytest -sys.path.append("..") +import moulinette +from moulinette import m18n, Moulinette +from yunohost.utils.error import YunohostError +from contextlib import contextmanager + + +@pytest.fixture(scope="session", autouse=True) +def clone_test_app(request): + cwd = os.path.split(os.path.realpath(__file__))[0] + + if not os.path.exists(cwd + "/apps"): + os.system("git clone https://github.com/YunoHost/test_apps %s/apps" % cwd) + else: + os.system("cd %s/apps && git pull > /dev/null 2>&1" % cwd) + + +def get_test_apps_dir(): + cwd = os.path.split(os.path.realpath(__file__))[0] + return os.path.join(cwd, "apps") + + +@contextmanager +def message(mocker, key, **kwargs): + mocker.spy(m18n, "n") + yield + m18n.n.assert_any_call(key, **kwargs) + + +@contextmanager +def raiseYunohostError(mocker, key, **kwargs): + with pytest.raises(YunohostError) as e_info: + yield + assert e_info._excinfo[1].key == key + if kwargs: + assert e_info._excinfo[1].kwargs == kwargs def pytest_addoption(parser): parser.addoption("--yunodebug", action="store_true", default=False) + # # Tweak translator to raise exceptions if string keys are not defined # # @@ -21,12 +56,15 @@ def new_translate(self, key, *args, **kwargs): raise KeyError("Unable to retrieve key %s for default locale !" % key) return old_translate(self, key, *args, **kwargs) + + moulinette.core.Translator.translate = new_translate def new_m18nn(self, key, *args, **kwargs): return self._namespaces[self._current_namespace].translate(key, *args, **kwargs) + moulinette.core.Moulinette18n.n = new_m18nn # @@ -35,65 +73,22 @@ moulinette.core.Moulinette18n.n = new_m18nn def pytest_cmdline_main(config): - """Configure logging and initialize the moulinette""" - # Define loggers handlers - handlers = set(['tty']) - root_handlers = set(handlers) - # Define loggers level - level = 'INFO' - if config.option.yunodebug: - tty_level = 'DEBUG' - else: - tty_level = 'SUCCESS' + import sys - # Custom logging configuration - logging = { - 'version': 1, - 'disable_existing_loggers': True, - 'formatters': { - 'tty-debug': { - 'format': '%(relativeCreated)-4d %(fmessage)s' - }, - 'precise': { - 'format': '%(asctime)-15s %(levelname)-8s %(name)s %(funcName)s - %(fmessage)s' - }, - }, - 'filters': { - 'action': { - '()': 'moulinette.utils.log.ActionFilter', - }, - }, - 'handlers': { - 'tty': { - 'level': tty_level, - 'class': 'moulinette.interfaces.cli.TTYHandler', - 'formatter': '', - }, - }, - 'loggers': { - 'yunohost': { - 'level': level, - 'handlers': handlers, - 'propagate': False, - }, - 'moulinette': { - 'level': level, - 'handlers': [], - 'propagate': True, - }, - 'moulinette.interface': { - 'level': level, - 'handlers': handlers, - 'propagate': False, - }, - }, - 'root': { - 'level': level, - 'handlers': root_handlers, - }, - } + sys.path.insert(0, "/usr/lib/moulinette/") + import yunohost - # Initialize moulinette - moulinette.init(logging_config=logging, _from_source=False) - moulinette.m18n.load_namespace('yunohost') + yunohost.init(debug=config.option.yunodebug) + + class DummyInterface: + + type = "cli" + + def prompt(self, *args, **kwargs): + raise NotImplementedError + + def display(self, message, *args, **kwargs): + print(message) + + Moulinette._interface = DummyInterface() diff --git a/src/yunohost/tests/test_app_config.py b/src/yunohost/tests/test_app_config.py new file mode 100644 index 000000000..0eb813672 --- /dev/null +++ b/src/yunohost/tests/test_app_config.py @@ -0,0 +1,206 @@ +import glob +import os +import shutil +import pytest +from mock import patch + +from .conftest import get_test_apps_dir + +from moulinette import Moulinette +from moulinette.utils.filesystem import read_file + +from yunohost.domain import _get_maindomain +from yunohost.app import ( + app_setting, + app_install, + app_remove, + _is_installed, + app_config_get, + app_config_set, + app_ssowatconf, +) + +from yunohost.utils.error import YunohostError, YunohostValidationError + + +def setup_function(function): + + clean() + + +def teardown_function(function): + + clean() + + +def clean(): + + # Make sure we have a ssowat + os.system("mkdir -p /etc/ssowat/") + app_ssowatconf() + + test_apps = ["config_app", "legacy_app"] + + for test_app in test_apps: + + if _is_installed(test_app): + app_remove(test_app) + + for filepath in glob.glob("/etc/nginx/conf.d/*.d/*%s*" % test_app): + os.remove(filepath) + for folderpath in glob.glob("/etc/yunohost/apps/*%s*" % test_app): + shutil.rmtree(folderpath, ignore_errors=True) + for folderpath in glob.glob("/var/www/*%s*" % test_app): + shutil.rmtree(folderpath, ignore_errors=True) + + os.system("bash -c \"mysql -B 2>/dev/null <<< 'DROP DATABASE %s' \"" % test_app) + os.system( + "bash -c \"mysql -B 2>/dev/null <<< 'DROP USER %s@localhost'\"" % test_app + ) + + # Reset failed quota for service to avoid running into start-limit rate ? + os.system("systemctl reset-failed nginx") + os.system("systemctl start nginx") + + +@pytest.fixture() +def legacy_app(request): + + main_domain = _get_maindomain() + + app_install( + os.path.join(get_test_apps_dir(), "legacy_app_ynh"), + args="domain=%s&path=%s&is_public=%s" % (main_domain, "/", 1), + force=True, + ) + + def remove_app(): + app_remove("legacy_app") + + request.addfinalizer(remove_app) + + return "legacy_app" + + +@pytest.fixture() +def config_app(request): + + app_install( + os.path.join(get_test_apps_dir(), "config_app_ynh"), + args="", + force=True, + ) + + def remove_app(): + app_remove("config_app") + + request.addfinalizer(remove_app) + + return "config_app" + + +def test_app_config_get(config_app): + + assert isinstance(app_config_get(config_app), dict) + assert isinstance(app_config_get(config_app, full=True), dict) + assert isinstance(app_config_get(config_app, export=True), dict) + assert isinstance(app_config_get(config_app, "main"), dict) + assert isinstance(app_config_get(config_app, "main.components"), dict) + assert app_config_get(config_app, "main.components.boolean") == "0" + + +def test_app_config_nopanel(legacy_app): + + with pytest.raises(YunohostValidationError): + app_config_get(legacy_app) + + +def test_app_config_get_nonexistentstuff(config_app): + + with pytest.raises(YunohostValidationError): + app_config_get("nonexistent") + + with pytest.raises(YunohostValidationError): + app_config_get(config_app, "nonexistent") + + with pytest.raises(YunohostValidationError): + app_config_get(config_app, "main.nonexistent") + + with pytest.raises(YunohostValidationError): + app_config_get(config_app, "main.components.nonexistent") + + app_setting(config_app, "boolean", delete=True) + with pytest.raises(YunohostError): + app_config_get(config_app, "main.components.boolean") + + +def test_app_config_regular_setting(config_app): + + assert app_config_get(config_app, "main.components.boolean") == "0" + + app_config_set(config_app, "main.components.boolean", "no") + + assert app_config_get(config_app, "main.components.boolean") == "0" + assert app_setting(config_app, "boolean") == "0" + + app_config_set(config_app, "main.components.boolean", "yes") + + assert app_config_get(config_app, "main.components.boolean") == "1" + assert app_setting(config_app, "boolean") == "1" + + with pytest.raises(YunohostValidationError), patch.object( + os, "isatty", return_value=False + ), patch.object(Moulinette, "prompt", return_value="pwet"): + app_config_set(config_app, "main.components.boolean", "pwet") + + +def test_app_config_bind_on_file(config_app): + + # c.f. conf/test.php in the config app + assert '$arg5= "Arg5 value";' in read_file("/var/www/config_app/test.php") + assert app_config_get(config_app, "bind.variable.arg5") == "Arg5 value" + assert app_setting(config_app, "arg5") is None + + app_config_set(config_app, "bind.variable.arg5", "Foo Bar") + + assert '$arg5= "Foo Bar";' in read_file("/var/www/config_app/test.php") + assert app_config_get(config_app, "bind.variable.arg5") == "Foo Bar" + assert app_setting(config_app, "arg5") == "Foo Bar" + + +def test_app_config_custom_get(config_app): + + assert app_setting(config_app, "arg9") is None + assert ( + "Files in /var/www" + in app_config_get(config_app, "bind.function.arg9")["ask"]["en"] + ) + assert app_setting(config_app, "arg9") is None + + +def test_app_config_custom_validator(config_app): + + # c.f. the config script + # arg8 is a password that must be at least 8 chars + assert not os.path.exists("/var/www/config_app/password") + assert app_setting(config_app, "arg8") is None + + with pytest.raises(YunohostValidationError): + app_config_set(config_app, "bind.function.arg8", "pZo6i7u91h") + + assert not os.path.exists("/var/www/config_app/password") + assert app_setting(config_app, "arg8") is None + + +def test_app_config_custom_set(config_app): + + assert not os.path.exists("/var/www/config_app/password") + assert app_setting(config_app, "arg8") is None + + app_config_set(config_app, "bind.function.arg8", "OneSuperStrongPassword") + + assert os.path.exists("/var/www/config_app/password") + content = read_file("/var/www/config_app/password") + assert "OneSuperStrongPassword" not in content + assert content.startswith("$6$saltsalt$") + assert app_setting(config_app, "arg8") is None diff --git a/src/yunohost/tests/test_apps.py b/src/yunohost/tests/test_apps.py new file mode 100644 index 000000000..43125341b --- /dev/null +++ b/src/yunohost/tests/test_apps.py @@ -0,0 +1,397 @@ +import glob +import os +import pytest +import shutil +import requests + +from .conftest import message, raiseYunohostError, get_test_apps_dir + +from moulinette.utils.filesystem import mkdir + +from yunohost.app import ( + app_install, + app_remove, + app_ssowatconf, + _is_installed, + app_upgrade, + app_map, +) +from yunohost.domain import _get_maindomain, domain_add, domain_remove, domain_list +from yunohost.utils.error import YunohostError +from yunohost.tests.test_permission import ( + check_LDAP_db_integrity, + check_permission_for_apps, +) +from yunohost.permission import user_permission_list, permission_delete + + +def setup_function(function): + + clean() + + +def teardown_function(function): + + clean() + + +def clean(): + + # Make sure we have a ssowat + os.system("mkdir -p /etc/ssowat/") + app_ssowatconf() + + test_apps = ["break_yo_system", "legacy_app", "legacy_app__2", "full_domain_app"] + + for test_app in test_apps: + + if _is_installed(test_app): + app_remove(test_app) + + for filepath in glob.glob("/etc/nginx/conf.d/*.d/*%s*" % test_app): + os.remove(filepath) + for folderpath in glob.glob("/etc/yunohost/apps/*%s*" % test_app): + shutil.rmtree(folderpath, ignore_errors=True) + for folderpath in glob.glob("/var/www/*%s*" % test_app): + shutil.rmtree(folderpath, ignore_errors=True) + + os.system("bash -c \"mysql -B 2>/dev/null <<< 'DROP DATABASE %s' \"" % test_app) + os.system( + "bash -c \"mysql -B 2>/dev/null <<< 'DROP USER %s@localhost'\"" % test_app + ) + + # Reset failed quota for service to avoid running into start-limit rate ? + os.system("systemctl reset-failed nginx") + os.system("systemctl start nginx") + + # Clean permissions + for permission_name in user_permission_list(short=True)["permissions"]: + if any(test_app in permission_name for test_app in test_apps): + permission_delete(permission_name, force=True) + + +@pytest.fixture(autouse=True) +def check_LDAP_db_integrity_call(): + check_LDAP_db_integrity() + yield + check_LDAP_db_integrity() + + +@pytest.fixture(autouse=True) +def check_permission_for_apps_call(): + check_permission_for_apps() + yield + check_permission_for_apps() + + +@pytest.fixture(scope="module") +def secondary_domain(request): + + if "example.test" not in domain_list()["domains"]: + domain_add("example.test") + + def remove_example_domain(): + domain_remove("example.test") + + request.addfinalizer(remove_example_domain) + + return "example.test" + + +# +# Helpers # +# + + +def app_expected_files(domain, app): + + yield "/etc/nginx/conf.d/%s.d/%s.conf" % (domain, app) + if app.startswith("legacy_app"): + yield "/var/www/%s/index.html" % app + yield "/etc/yunohost/apps/%s/settings.yml" % app + yield "/etc/yunohost/apps/%s/manifest.json" % app + yield "/etc/yunohost/apps/%s/scripts/install" % app + yield "/etc/yunohost/apps/%s/scripts/remove" % app + + +def app_is_installed(domain, app): + + return _is_installed(app) and all( + os.path.exists(f) for f in app_expected_files(domain, app) + ) + + +def app_is_not_installed(domain, app): + + return not _is_installed(app) and not all( + os.path.exists(f) for f in app_expected_files(domain, app) + ) + + +def app_is_exposed_on_http(domain, path, message_in_page): + + try: + r = requests.get( + "https://127.0.0.1" + path + "/", + headers={"Host": domain}, + timeout=10, + verify=False, + ) + return r.status_code == 200 and message_in_page in r.text + except Exception: + return False + + +def install_legacy_app(domain, path, public=True): + + app_install( + os.path.join(get_test_apps_dir(), "legacy_app_ynh"), + args="domain=%s&path=%s&is_public=%s" % (domain, path, 1 if public else 0), + force=True, + ) + + +def install_full_domain_app(domain): + + app_install( + os.path.join(get_test_apps_dir(), "full_domain_app_ynh"), + args="domain=%s" % domain, + force=True, + ) + + +def install_break_yo_system(domain, breakwhat): + + app_install( + os.path.join(get_test_apps_dir(), "break_yo_system_ynh"), + args="domain=%s&breakwhat=%s" % (domain, breakwhat), + force=True, + ) + + +def test_legacy_app_install_main_domain(): + + main_domain = _get_maindomain() + + install_legacy_app(main_domain, "/legacy") + + app_map_ = app_map(raw=True) + assert main_domain in app_map_ + assert "/legacy" in app_map_[main_domain] + assert "id" in app_map_[main_domain]["/legacy"] + assert app_map_[main_domain]["/legacy"]["id"] == "legacy_app" + + assert app_is_installed(main_domain, "legacy_app") + assert app_is_exposed_on_http(main_domain, "/legacy", "This is a dummy app") + + app_remove("legacy_app") + + assert app_is_not_installed(main_domain, "legacy_app") + + +def test_legacy_app_install_secondary_domain(secondary_domain): + + install_legacy_app(secondary_domain, "/legacy") + + assert app_is_installed(secondary_domain, "legacy_app") + assert app_is_exposed_on_http(secondary_domain, "/legacy", "This is a dummy app") + + app_remove("legacy_app") + + assert app_is_not_installed(secondary_domain, "legacy_app") + + +def test_legacy_app_install_secondary_domain_on_root(secondary_domain): + + install_legacy_app(secondary_domain, "/") + + app_map_ = app_map(raw=True) + assert secondary_domain in app_map_ + assert "/" in app_map_[secondary_domain] + assert "id" in app_map_[secondary_domain]["/"] + assert app_map_[secondary_domain]["/"]["id"] == "legacy_app" + + assert app_is_installed(secondary_domain, "legacy_app") + assert app_is_exposed_on_http(secondary_domain, "/", "This is a dummy app") + + app_remove("legacy_app") + + assert app_is_not_installed(secondary_domain, "legacy_app") + + +def test_legacy_app_install_private(secondary_domain): + + install_legacy_app(secondary_domain, "/legacy", public=False) + + assert app_is_installed(secondary_domain, "legacy_app") + assert not app_is_exposed_on_http( + secondary_domain, "/legacy", "This is a dummy app" + ) + + app_remove("legacy_app") + + assert app_is_not_installed(secondary_domain, "legacy_app") + + +def test_legacy_app_install_unknown_domain(mocker): + + with pytest.raises(YunohostError): + with message(mocker, "app_argument_invalid"): + install_legacy_app("whatever.nope", "/legacy") + + assert app_is_not_installed("whatever.nope", "legacy_app") + + +def test_legacy_app_install_multiple_instances(secondary_domain): + + install_legacy_app(secondary_domain, "/foo") + install_legacy_app(secondary_domain, "/bar") + + assert app_is_installed(secondary_domain, "legacy_app") + assert app_is_exposed_on_http(secondary_domain, "/foo", "This is a dummy app") + + assert app_is_installed(secondary_domain, "legacy_app__2") + assert app_is_exposed_on_http(secondary_domain, "/bar", "This is a dummy app") + + app_remove("legacy_app") + + assert app_is_not_installed(secondary_domain, "legacy_app") + assert app_is_installed(secondary_domain, "legacy_app__2") + + app_remove("legacy_app__2") + + assert app_is_not_installed(secondary_domain, "legacy_app") + assert app_is_not_installed(secondary_domain, "legacy_app__2") + + +def test_legacy_app_install_path_unavailable(mocker, secondary_domain): + + # These will be removed in teardown + install_legacy_app(secondary_domain, "/legacy") + + with pytest.raises(YunohostError): + with message(mocker, "app_location_unavailable"): + install_legacy_app(secondary_domain, "/") + + assert app_is_installed(secondary_domain, "legacy_app") + assert app_is_not_installed(secondary_domain, "legacy_app__2") + + +def test_legacy_app_install_with_nginx_down(mocker, secondary_domain): + + os.system("systemctl stop nginx") + + with raiseYunohostError( + mocker, "app_action_cannot_be_ran_because_required_services_down" + ): + install_legacy_app(secondary_domain, "/legacy") + + +def test_legacy_app_failed_install(mocker, secondary_domain): + + # This will conflict with the folder that the app + # attempts to create, making the install fail + mkdir("/var/www/legacy_app/", 0o750) + + with pytest.raises(YunohostError): + with message(mocker, "app_install_script_failed"): + install_legacy_app(secondary_domain, "/legacy") + + assert app_is_not_installed(secondary_domain, "legacy_app") + + +def test_legacy_app_failed_remove(mocker, secondary_domain): + + install_legacy_app(secondary_domain, "/legacy") + + # The remove script runs with set -eu and attempt to remove this + # file without -f, so will fail if it's not there ;) + os.remove("/etc/nginx/conf.d/%s.d/%s.conf" % (secondary_domain, "legacy_app")) + + # TODO / FIXME : can't easily validate that 'app_not_properly_removed' + # is triggered for weird reasons ... + app_remove("legacy_app") + + # + # Well here, we hit the classical issue where if an app removal script + # fails, so far there's no obvious way to make sure that all files related + # to this app got removed ... + # + assert app_is_not_installed(secondary_domain, "legacy") + + +def test_full_domain_app(secondary_domain): + + install_full_domain_app(secondary_domain) + + assert app_is_exposed_on_http(secondary_domain, "/", "This is a dummy app") + + +def test_full_domain_app_with_conflicts(mocker, secondary_domain): + + install_legacy_app(secondary_domain, "/legacy") + + with raiseYunohostError(mocker, "app_full_domain_unavailable"): + install_full_domain_app(secondary_domain) + + +def test_systemfuckedup_during_app_install(mocker, secondary_domain): + + with pytest.raises(YunohostError): + with message(mocker, "app_install_failed"): + with message(mocker, "app_action_broke_system"): + install_break_yo_system(secondary_domain, breakwhat="install") + + assert app_is_not_installed(secondary_domain, "break_yo_system") + + +def test_systemfuckedup_during_app_remove(mocker, secondary_domain): + + install_break_yo_system(secondary_domain, breakwhat="remove") + + with pytest.raises(YunohostError): + with message(mocker, "app_action_broke_system"): + with message(mocker, "app_removed"): + app_remove("break_yo_system") + + assert app_is_not_installed(secondary_domain, "break_yo_system") + + +def test_systemfuckedup_during_app_install_and_remove(mocker, secondary_domain): + + with pytest.raises(YunohostError): + with message(mocker, "app_install_failed"): + with message(mocker, "app_action_broke_system"): + install_break_yo_system(secondary_domain, breakwhat="everything") + + assert app_is_not_installed(secondary_domain, "break_yo_system") + + +def test_systemfuckedup_during_app_upgrade(mocker, secondary_domain): + + install_break_yo_system(secondary_domain, breakwhat="upgrade") + + with pytest.raises(YunohostError): + with message(mocker, "app_action_broke_system"): + app_upgrade( + "break_yo_system", + file=os.path.join(get_test_apps_dir(), "break_yo_system_ynh"), + ) + + +def test_failed_multiple_app_upgrade(mocker, secondary_domain): + + install_legacy_app(secondary_domain, "/legacy") + install_break_yo_system(secondary_domain, breakwhat="upgrade") + + with pytest.raises(YunohostError): + with message(mocker, "app_not_upgraded"): + app_upgrade( + ["break_yo_system", "legacy_app"], + file={ + "break_yo_system": os.path.join( + get_test_apps_dir(), "break_yo_system_ynh" + ), + "legacy": os.path.join(get_test_apps_dir(), "legacy_app_ynh"), + }, + ) diff --git a/src/yunohost/tests/test_appscatalog.py b/src/yunohost/tests/test_appscatalog.py new file mode 100644 index 000000000..a2619a660 --- /dev/null +++ b/src/yunohost/tests/test_appscatalog.py @@ -0,0 +1,317 @@ +import os +import pytest +import requests +import requests_mock +import glob +import shutil + +from moulinette import m18n +from moulinette.utils.filesystem import read_json, write_to_json, write_to_yaml + +from yunohost.utils.error import YunohostError +from yunohost.app import ( + _initialize_apps_catalog_system, + _read_apps_catalog_list, + _update_apps_catalog, + _actual_apps_catalog_api_url, + _load_apps_catalog, + app_catalog, + logger, + APPS_CATALOG_CACHE, + APPS_CATALOG_CONF, + APPS_CATALOG_API_VERSION, + APPS_CATALOG_DEFAULT_URL, +) + +APPS_CATALOG_DEFAULT_URL_FULL = _actual_apps_catalog_api_url(APPS_CATALOG_DEFAULT_URL) + +DUMMY_APP_CATALOG = """{ + "apps": { + "foo": {"id": "foo", "level": 4, "category": "yolo", "manifest":{"description": "Foo"}}, + "bar": {"id": "bar", "level": 7, "category": "swag", "manifest":{"description": "Bar"}} + }, + "categories": [ + {"id": "yolo", "description": "YoLo", "title": {"en": "Yolo"}}, + {"id": "swag", "description": "sWaG", "title": {"en": "Swag"}} + ] +} +""" + + +class AnyStringWith(str): + def __eq__(self, other): + return self in other + + +def setup_function(function): + + # Clear apps catalog cache + shutil.rmtree(APPS_CATALOG_CACHE, ignore_errors=True) + + # Clear apps_catalog conf + if os.path.exists(APPS_CATALOG_CONF): + os.remove(APPS_CATALOG_CONF) + + +def teardown_function(function): + + # Clear apps catalog cache + # Otherwise when using apps stuff after running the test, + # we'll still have the dummy unusable list + shutil.rmtree(APPS_CATALOG_CACHE, ignore_errors=True) + + +# +# ################################################ +# + + +def test_apps_catalog_init(mocker): + + # Cache is empty + assert not glob.glob(APPS_CATALOG_CACHE + "/*") + # Conf doesn't exist yet + assert not os.path.exists(APPS_CATALOG_CONF) + + # Initialize ... + mocker.spy(m18n, "n") + _initialize_apps_catalog_system() + m18n.n.assert_any_call("apps_catalog_init_success") + + # And a conf with at least one list + assert os.path.exists(APPS_CATALOG_CONF) + apps_catalog_list = _read_apps_catalog_list() + assert len(apps_catalog_list) + + # Cache is expected to still be empty though + # (if we did update the apps_catalog during init, + # we couldn't differentiate easily exceptions + # related to lack of network connectivity) + assert not glob.glob(APPS_CATALOG_CACHE + "/*") + + +def test_apps_catalog_emptylist(): + + # Initialize ... + _initialize_apps_catalog_system() + + # Let's imagine somebody removed the default apps catalog because uh idk they dont want to use our default apps catalog + os.system("rm %s" % APPS_CATALOG_CONF) + os.system("touch %s" % APPS_CATALOG_CONF) + + apps_catalog_list = _read_apps_catalog_list() + assert not len(apps_catalog_list) + + +def test_apps_catalog_update_nominal(mocker): + + # Initialize ... + _initialize_apps_catalog_system() + + # Cache is empty + assert not glob.glob(APPS_CATALOG_CACHE + "/*") + + # Update + with requests_mock.Mocker() as m: + + _actual_apps_catalog_api_url, + # Mock the server response with a dummy apps catalog + m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG) + + mocker.spy(m18n, "n") + _update_apps_catalog() + m18n.n.assert_any_call("apps_catalog_updating") + m18n.n.assert_any_call("apps_catalog_update_success") + + # Cache shouldn't be empty anymore empty + assert glob.glob(APPS_CATALOG_CACHE + "/*") + + # And if we load the catalog, we sould find + # - foo and bar as apps (unordered), + # - yolo and swag as categories (ordered) + catalog = app_catalog(with_categories=True) + + assert "apps" in catalog + assert set(catalog["apps"].keys()) == set(["foo", "bar"]) + + assert "categories" in catalog + assert [c["id"] for c in catalog["categories"]] == ["yolo", "swag"] + + +def test_apps_catalog_update_404(mocker): + + # Initialize ... + _initialize_apps_catalog_system() + + with requests_mock.Mocker() as m: + + # 404 error + m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, status_code=404) + + with pytest.raises(YunohostError): + mocker.spy(m18n, "n") + _update_apps_catalog() + m18n.n.assert_any_call("apps_catalog_failed_to_download") + + +def test_apps_catalog_update_timeout(mocker): + + # Initialize ... + _initialize_apps_catalog_system() + + with requests_mock.Mocker() as m: + + # Timeout + m.register_uri( + "GET", APPS_CATALOG_DEFAULT_URL_FULL, exc=requests.exceptions.ConnectTimeout + ) + + with pytest.raises(YunohostError): + mocker.spy(m18n, "n") + _update_apps_catalog() + m18n.n.assert_any_call("apps_catalog_failed_to_download") + + +def test_apps_catalog_update_sslerror(mocker): + + # Initialize ... + _initialize_apps_catalog_system() + + with requests_mock.Mocker() as m: + + # SSL error + m.register_uri( + "GET", APPS_CATALOG_DEFAULT_URL_FULL, exc=requests.exceptions.SSLError + ) + + with pytest.raises(YunohostError): + mocker.spy(m18n, "n") + _update_apps_catalog() + m18n.n.assert_any_call("apps_catalog_failed_to_download") + + +def test_apps_catalog_update_corrupted(mocker): + + # Initialize ... + _initialize_apps_catalog_system() + + with requests_mock.Mocker() as m: + + # Corrupted json + m.register_uri( + "GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG[:-2] + ) + + with pytest.raises(YunohostError): + mocker.spy(m18n, "n") + _update_apps_catalog() + m18n.n.assert_any_call("apps_catalog_failed_to_download") + + +def test_apps_catalog_load_with_empty_cache(mocker): + + # Initialize ... + _initialize_apps_catalog_system() + + # Cache is empty + assert not glob.glob(APPS_CATALOG_CACHE + "/*") + + # Update + with requests_mock.Mocker() as m: + + # Mock the server response with a dummy apps catalog + m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG) + + # Try to load the apps catalog + # This should implicitly trigger an update in the background + mocker.spy(m18n, "n") + app_dict = _load_apps_catalog()["apps"] + m18n.n.assert_any_call("apps_catalog_obsolete_cache") + m18n.n.assert_any_call("apps_catalog_update_success") + + # Cache shouldn't be empty anymore empty + assert glob.glob(APPS_CATALOG_CACHE + "/*") + + assert "foo" in app_dict.keys() + assert "bar" in app_dict.keys() + + +def test_apps_catalog_load_with_conflicts_between_lists(mocker): + + # Initialize ... + _initialize_apps_catalog_system() + + conf = [ + {"id": "default", "url": APPS_CATALOG_DEFAULT_URL}, + { + "id": "default2", + "url": APPS_CATALOG_DEFAULT_URL.replace("yunohost.org", "yolohost.org"), + }, + ] + + write_to_yaml(APPS_CATALOG_CONF, conf) + + # Update + with requests_mock.Mocker() as m: + + # Mock the server response with a dummy apps catalog + # + the same apps catalog for the second list + m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG) + m.register_uri( + "GET", + APPS_CATALOG_DEFAULT_URL_FULL.replace("yunohost.org", "yolohost.org"), + text=DUMMY_APP_CATALOG, + ) + + # Try to load the apps catalog + # This should implicitly trigger an update in the background + mocker.spy(logger, "warning") + app_dict = _load_apps_catalog()["apps"] + logger.warning.assert_any_call(AnyStringWith("Duplicate")) + + # Cache shouldn't be empty anymore empty + assert glob.glob(APPS_CATALOG_CACHE + "/*") + + assert "foo" in app_dict.keys() + assert "bar" in app_dict.keys() + + +def test_apps_catalog_load_with_oudated_api_version(mocker): + + # Initialize ... + _initialize_apps_catalog_system() + + # Update + with requests_mock.Mocker() as m: + + mocker.spy(m18n, "n") + m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG) + _update_apps_catalog() + + # Cache shouldn't be empty anymore empty + assert glob.glob(APPS_CATALOG_CACHE + "/*") + + # Tweak the cache to replace the from_api_version with a different one + for cache_file in glob.glob(APPS_CATALOG_CACHE + "/*"): + cache_json = read_json(cache_file) + assert cache_json["from_api_version"] == APPS_CATALOG_API_VERSION + cache_json["from_api_version"] = 0 + write_to_json(cache_file, cache_json) + + # Update + with requests_mock.Mocker() as m: + + # Mock the server response with a dummy apps catalog + m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG) + + mocker.spy(m18n, "n") + app_dict = _load_apps_catalog()["apps"] + m18n.n.assert_any_call("apps_catalog_update_success") + + assert "foo" in app_dict.keys() + assert "bar" in app_dict.keys() + + # Check that we indeed have the new api number in cache + for cache_file in glob.glob(APPS_CATALOG_CACHE + "/*"): + cache_json = read_json(cache_file) + assert cache_json["from_api_version"] == APPS_CATALOG_API_VERSION diff --git a/src/yunohost/tests/test_appslist.py b/src/yunohost/tests/test_appslist.py deleted file mode 100644 index 817807ed9..000000000 --- a/src/yunohost/tests/test_appslist.py +++ /dev/null @@ -1,389 +0,0 @@ -import os -import pytest -import requests -import requests_mock -import glob -import time - -from yunohost.utils.error import YunohostError - -from yunohost.app import app_fetchlist, app_removelist, app_listlists, _using_legacy_appslist_system, _migrate_appslist_system, _register_new_appslist - -URL_OFFICIAL_APP_LIST = "https://app.yunohost.org/official.json" -REPO_PATH = '/var/cache/yunohost/repo' -APPSLISTS_JSON = '/etc/yunohost/appslists.json' - - -def setup_function(function): - - # Clear all appslist - files = glob.glob(REPO_PATH + "/*") - for f in files: - os.remove(f) - - # Clear appslist crons - files = glob.glob("/etc/cron.d/yunohost-applist-*") - for f in files: - os.remove(f) - - if os.path.exists("/etc/cron.daily/yunohost-fetch-appslists"): - os.remove("/etc/cron.daily/yunohost-fetch-appslists") - - if os.path.exists(APPSLISTS_JSON): - os.remove(APPSLISTS_JSON) - - -def teardown_function(function): - pass - - -def cron_job_is_there(): - r = os.system("run-parts -v --test /etc/cron.daily/ | grep yunohost-fetch-appslists") - return r == 0 - - -# -# Test listing of appslists and registering of appslists # -# - - -def test_appslist_list_empty(): - """ - Calling app_listlists() with no registered list should return empty dict - """ - - assert app_listlists() == {} - - -def test_appslist_list_register(): - """ - Register a new list - """ - - # Assume we're starting with an empty app list - assert app_listlists() == {} - - # Register a new dummy list - _register_new_appslist("https://lol.com/appslist.json", "dummy") - - appslist_dict = app_listlists() - assert "dummy" in appslist_dict.keys() - assert appslist_dict["dummy"]["url"] == "https://lol.com/appslist.json" - - assert cron_job_is_there() - - -def test_appslist_list_register_conflict_name(): - """ - Attempt to register a new list with conflicting name - """ - - _register_new_appslist("https://lol.com/appslist.json", "dummy") - with pytest.raises(YunohostError): - _register_new_appslist("https://lol.com/appslist2.json", "dummy") - - appslist_dict = app_listlists() - - assert "dummy" in appslist_dict.keys() - assert "dummy2" not in appslist_dict.keys() - - -def test_appslist_list_register_conflict_url(): - """ - Attempt to register a new list with conflicting url - """ - - _register_new_appslist("https://lol.com/appslist.json", "dummy") - with pytest.raises(YunohostError): - _register_new_appslist("https://lol.com/appslist.json", "plopette") - - appslist_dict = app_listlists() - - assert "dummy" in appslist_dict.keys() - assert "plopette" not in appslist_dict.keys() - - -# -# Test fetching of appslists # -# - - -def test_appslist_fetch(): - """ - Do a fetchlist and test the .json got updated. - """ - assert app_listlists() == {} - - _register_new_appslist(URL_OFFICIAL_APP_LIST, "yunohost") - - with requests_mock.Mocker() as m: - - # Mock the server response with a valid (well, empty, yep) json - m.register_uri("GET", URL_OFFICIAL_APP_LIST, text='{ }') - - official_lastUpdate = app_listlists()["yunohost"]["lastUpdate"] - app_fetchlist() - new_official_lastUpdate = app_listlists()["yunohost"]["lastUpdate"] - - assert new_official_lastUpdate > official_lastUpdate - - -def test_appslist_fetch_single_appslist(): - """ - Register several lists but only fetch one. Check only one got updated. - """ - - assert app_listlists() == {} - _register_new_appslist(URL_OFFICIAL_APP_LIST, "yunohost") - _register_new_appslist("https://lol.com/appslist.json", "dummy") - - time.sleep(1) - - with requests_mock.Mocker() as m: - - # Mock the server response with a valid (well, empty, yep) json - m.register_uri("GET", URL_OFFICIAL_APP_LIST, text='{ }') - - official_lastUpdate = app_listlists()["yunohost"]["lastUpdate"] - dummy_lastUpdate = app_listlists()["dummy"]["lastUpdate"] - app_fetchlist(name="yunohost") - new_official_lastUpdate = app_listlists()["yunohost"]["lastUpdate"] - new_dummy_lastUpdate = app_listlists()["dummy"]["lastUpdate"] - - assert new_official_lastUpdate > official_lastUpdate - assert new_dummy_lastUpdate == dummy_lastUpdate - - -def test_appslist_fetch_unknownlist(): - """ - Attempt to fetch an unknown list - """ - - assert app_listlists() == {} - - with pytest.raises(YunohostError): - app_fetchlist(name="swag") - - -def test_appslist_fetch_url_but_no_name(): - """ - Do a fetchlist with url given, but no name given - """ - - with pytest.raises(YunohostError): - app_fetchlist(url=URL_OFFICIAL_APP_LIST) - - -def test_appslist_fetch_badurl(): - """ - Do a fetchlist with a bad url - """ - - app_fetchlist(url="https://not.a.valid.url/plop.json", name="plop") - - -def test_appslist_fetch_badfile(): - """ - Do a fetchlist and mock a response with a bad json - """ - assert app_listlists() == {} - - _register_new_appslist(URL_OFFICIAL_APP_LIST, "yunohost") - - with requests_mock.Mocker() as m: - - m.register_uri("GET", URL_OFFICIAL_APP_LIST, text='{ not json lol }') - - app_fetchlist() - - -def test_appslist_fetch_404(): - """ - Do a fetchlist and mock a 404 response - """ - assert app_listlists() == {} - - _register_new_appslist(URL_OFFICIAL_APP_LIST, "yunohost") - - with requests_mock.Mocker() as m: - - m.register_uri("GET", URL_OFFICIAL_APP_LIST, status_code=404) - - app_fetchlist() - - -def test_appslist_fetch_sslerror(): - """ - Do a fetchlist and mock an SSL error - """ - assert app_listlists() == {} - - _register_new_appslist(URL_OFFICIAL_APP_LIST, "yunohost") - - with requests_mock.Mocker() as m: - - m.register_uri("GET", URL_OFFICIAL_APP_LIST, - exc=requests.exceptions.SSLError) - - app_fetchlist() - - -def test_appslist_fetch_timeout(): - """ - Do a fetchlist and mock a timeout - """ - assert app_listlists() == {} - - _register_new_appslist(URL_OFFICIAL_APP_LIST, "yunohost") - - with requests_mock.Mocker() as m: - - m.register_uri("GET", URL_OFFICIAL_APP_LIST, - exc=requests.exceptions.ConnectTimeout) - - app_fetchlist() - - -# -# Test remove of appslist # -# - - -def test_appslist_remove(): - """ - Register a new appslist, then remove it - """ - - # Assume we're starting with an empty app list - assert app_listlists() == {} - - # Register a new dummy list - _register_new_appslist("https://lol.com/appslist.json", "dummy") - app_removelist("dummy") - - # Should end up with no list registered - assert app_listlists() == {} - - -def test_appslist_remove_unknown(): - """ - Attempt to remove an unknown list - """ - - with pytest.raises(YunohostError): - app_removelist("dummy") - - -# -# Test migration from legacy appslist system # -# - - -def add_legacy_cron(name, url): - with open("/etc/cron.d/yunohost-applist-%s" % name, "w") as f: - f.write('00 00 * * * root yunohost app fetchlist -u %s -n %s > /dev/null 2>&1\n' % (url, name)) - - -def test_appslist_check_using_legacy_system_testFalse(): - """ - If no legacy cron job is there, the check should return False - """ - assert glob.glob("/etc/cron.d/yunohost-applist-*") == [] - assert _using_legacy_appslist_system() is False - - -def test_appslist_check_using_legacy_system_testTrue(): - """ - If there's a legacy cron job, the check should return True - """ - assert glob.glob("/etc/cron.d/yunohost-applist-*") == [] - add_legacy_cron("yunohost", "https://app.yunohost.org/official.json") - assert _using_legacy_appslist_system() is True - - -def test_appslist_system_migration(): - """ - Test that legacy cron jobs get migrated correctly when calling app_listlists - """ - - # Start with no legacy cron, no appslist registered - assert glob.glob("/etc/cron.d/yunohost-applist-*") == [] - assert app_listlists() == {} - assert not os.path.exists("/etc/cron.daily/yunohost-fetch-appslists") - - # Add a few legacy crons - add_legacy_cron("yunohost", "https://app.yunohost.org/official.json") - add_legacy_cron("dummy", "https://swiggitty.swaggy.lol/yolo.json") - - # Migrate - assert _using_legacy_appslist_system() is True - _migrate_appslist_system() - assert _using_legacy_appslist_system() is False - - # No legacy cron job should remain - assert glob.glob("/etc/cron.d/yunohost-applist-*") == [] - - # Check they are in app_listlists anyway - appslist_dict = app_listlists() - assert "yunohost" in appslist_dict.keys() - assert appslist_dict["yunohost"]["url"] == "https://app.yunohost.org/official.json" - assert "dummy" in appslist_dict.keys() - assert appslist_dict["dummy"]["url"] == "https://swiggitty.swaggy.lol/yolo.json" - - assert cron_job_is_there() - - -def test_appslist_system_migration_badcron(): - """ - Test the migration on a bad legacy cron (no url found inside cron job) - """ - - # Start with no legacy cron, no appslist registered - assert glob.glob("/etc/cron.d/yunohost-applist-*") == [] - assert app_listlists() == {} - assert not os.path.exists("/etc/cron.daily/yunohost-fetch-appslists") - - # Add a "bad" legacy cron - add_legacy_cron("wtflist", "ftp://the.fuck.is.this") - - # Migrate - assert _using_legacy_appslist_system() is True - _migrate_appslist_system() - assert _using_legacy_appslist_system() is False - - # No legacy cron should remain, but it should be backuped in /etc/yunohost - assert glob.glob("/etc/cron.d/yunohost-applist-*") == [] - assert os.path.exists("/etc/yunohost/wtflist.oldlist.bkp") - - # Appslist should still be empty - assert app_listlists() == {} - - -def test_appslist_system_migration_conflict(): - """ - Test migration of conflicting cron job (in terms of url) - """ - - # Start with no legacy cron, no appslist registered - assert glob.glob("/etc/cron.d/yunohost-applist-*") == [] - assert app_listlists() == {} - assert not os.path.exists("/etc/cron.daily/yunohost-fetch-appslists") - - # Add a few legacy crons - add_legacy_cron("yunohost", "https://app.yunohost.org/official.json") - add_legacy_cron("dummy", "https://app.yunohost.org/official.json") - - # Migrate - assert _using_legacy_appslist_system() is True - _migrate_appslist_system() - assert _using_legacy_appslist_system() is False - - # No legacy cron job should remain - assert glob.glob("/etc/cron.d/yunohost-applist-*") == [] - - # Only one among "dummy" and "yunohost" should be listed - appslist_dict = app_listlists() - assert (len(appslist_dict.keys()) == 1) - assert ("dummy" in appslist_dict.keys()) or ("yunohost" in appslist_dict.keys()) - - assert cron_job_is_there() diff --git a/src/yunohost/tests/test_appurl.py b/src/yunohost/tests/test_appurl.py index b0a47d89f..5954d239a 100644 --- a/src/yunohost/tests/test_appurl.py +++ b/src/yunohost/tests/test_appurl.py @@ -1,8 +1,12 @@ import pytest +import os + +from .conftest import get_test_apps_dir from yunohost.utils.error import YunohostError from yunohost.app import app_install, app_remove -from yunohost.domain import _get_maindomain, domain_url_available, _normalize_domain_path +from yunohost.domain import _get_maindomain, domain_url_available +from yunohost.permission import _validate_and_sanitize_permission_url # Get main domain maindomain = _get_maindomain() @@ -12,7 +16,7 @@ def setup_function(function): try: app_remove("register_url_app") - except: + except Exception: pass @@ -20,17 +24,10 @@ def teardown_function(function): try: app_remove("register_url_app") - except: + except Exception: pass -def test_normalize_domain_path(): - - assert _normalize_domain_path("https://yolo.swag/", "macnuggets") == ("yolo.swag", "/macnuggets") - assert _normalize_domain_path("http://yolo.swag", "/macnuggets/") == ("yolo.swag", "/macnuggets") - assert _normalize_domain_path("yolo.swag/", "macnuggets/") == ("yolo.swag", "/macnuggets") - - def test_urlavailable(): # Except the maindomain/macnuggets to be available @@ -43,19 +40,152 @@ def test_urlavailable(): def test_registerurl(): - app_install("./tests/apps/register_url_app_ynh", - args="domain=%s&path=%s" % (maindomain, "/urlregisterapp"), force=True) + app_install( + os.path.join(get_test_apps_dir(), "register_url_app_ynh"), + args="domain=%s&path=%s" % (maindomain, "/urlregisterapp"), + force=True, + ) assert not domain_url_available(maindomain, "/urlregisterapp") # Try installing at same location with pytest.raises(YunohostError): - app_install("./tests/apps/register_url_app_ynh", - args="domain=%s&path=%s" % (maindomain, "/urlregisterapp"), force=True) + app_install( + os.path.join(get_test_apps_dir(), "register_url_app_ynh"), + args="domain=%s&path=%s" % (maindomain, "/urlregisterapp"), + force=True, + ) def test_registerurl_baddomain(): with pytest.raises(YunohostError): - app_install("./tests/apps/register_url_app_ynh", - args="domain=%s&path=%s" % ("yolo.swag", "/urlregisterapp"), force=True) + app_install( + os.path.join(get_test_apps_dir(), "register_url_app_ynh"), + args="domain=%s&path=%s" % ("yolo.swag", "/urlregisterapp"), + force=True, + ) + + +def test_normalize_permission_path(): + # Relative path + assert ( + _validate_and_sanitize_permission_url( + "/wiki/", maindomain + "/path", "test_permission" + ) + == "/wiki" + ) + assert ( + _validate_and_sanitize_permission_url( + "/", maindomain + "/path", "test_permission" + ) + == "/" + ) + assert ( + _validate_and_sanitize_permission_url( + "//salut/", maindomain + "/path", "test_permission" + ) + == "/salut" + ) + + # Full path + assert ( + _validate_and_sanitize_permission_url( + maindomain + "/hey/", maindomain + "/path", "test_permission" + ) + == maindomain + "/hey" + ) + assert ( + _validate_and_sanitize_permission_url( + maindomain + "//", maindomain + "/path", "test_permission" + ) + == maindomain + "/" + ) + assert ( + _validate_and_sanitize_permission_url( + maindomain + "/", maindomain + "/path", "test_permission" + ) + == maindomain + "/" + ) + + # Relative Regex + assert ( + _validate_and_sanitize_permission_url( + "re:/yolo.*/", maindomain + "/path", "test_permission" + ) + == "re:/yolo.*/" + ) + assert ( + _validate_and_sanitize_permission_url( + "re:/y.*o(o+)[a-z]*/bo\1y", maindomain + "/path", "test_permission" + ) + == "re:/y.*o(o+)[a-z]*/bo\1y" + ) + + # Full Regex + assert ( + _validate_and_sanitize_permission_url( + "re:" + maindomain + "/yolo.*/", maindomain + "/path", "test_permission" + ) + == "re:" + maindomain + "/yolo.*/" + ) + assert ( + _validate_and_sanitize_permission_url( + "re:" + maindomain + "/y.*o(o+)[a-z]*/bo\1y", + maindomain + "/path", + "test_permission", + ) + == "re:" + maindomain + "/y.*o(o+)[a-z]*/bo\1y" + ) + + +def test_normalize_permission_path_with_bad_regex(): + # Relative Regex + with pytest.raises(YunohostError): + _validate_and_sanitize_permission_url( + "re:/yolo.*[1-7]^?/", maindomain + "/path", "test_permission" + ) + with pytest.raises(YunohostError): + _validate_and_sanitize_permission_url( + "re:/yolo.*[1-7](]/", maindomain + "/path", "test_permission" + ) + + # Full Regex + with pytest.raises(YunohostError): + _validate_and_sanitize_permission_url( + "re:" + maindomain + "/yolo?+/", maindomain + "/path", "test_permission" + ) + with pytest.raises(YunohostError): + _validate_and_sanitize_permission_url( + "re:" + maindomain + "/yolo[1-9]**/", + maindomain + "/path", + "test_permission", + ) + + +def test_normalize_permission_path_with_unknown_domain(): + with pytest.raises(YunohostError): + _validate_and_sanitize_permission_url( + "shouldntexist.tld/hey", maindomain + "/path", "test_permission" + ) + with pytest.raises(YunohostError): + _validate_and_sanitize_permission_url( + "re:shouldntexist.tld/hey.*", maindomain + "/path", "test_permission" + ) + + +def test_normalize_permission_path_conflicting_path(): + app_install( + os.path.join(get_test_apps_dir(), "register_url_app_ynh"), + args="domain=%s&path=%s" % (maindomain, "/url/registerapp"), + force=True, + ) + + with pytest.raises(YunohostError): + _validate_and_sanitize_permission_url( + "/registerapp", maindomain + "/url", "test_permission" + ) + with pytest.raises(YunohostError): + _validate_and_sanitize_permission_url( + maindomain + "/url/registerapp", maindomain + "/path", "test_permission" + ) diff --git a/src/yunohost/tests/test_backuprestore.py b/src/yunohost/tests/test_backuprestore.py index 7d384a46a..6e2c3b514 100644 --- a/src/yunohost/tests/test_backuprestore.py +++ b/src/yunohost/tests/test_backuprestore.py @@ -2,27 +2,38 @@ import pytest import os import shutil import subprocess -from mock import ANY +from mock import patch + +from .conftest import message, raiseYunohostError, get_test_apps_dir -from moulinette import m18n from yunohost.app import app_install, app_remove, app_ssowatconf from yunohost.app import _is_installed -from yunohost.backup import backup_create, backup_restore, backup_list, backup_info, backup_delete, _recursive_umount -from yunohost.domain import _get_maindomain -from yunohost.utils.error import YunohostError -from yunohost.user import user_permission_list -from yunohost.tests.test_permission import check_LDAP_db_integrity, check_permission_for_apps +from yunohost.backup import ( + backup_create, + backup_restore, + backup_list, + backup_info, + backup_delete, + _recursive_umount, +) +from yunohost.domain import _get_maindomain, domain_list, domain_add, domain_remove +from yunohost.user import user_create, user_list, user_delete +from yunohost.permission import user_permission_list +from yunohost.tests.test_permission import ( + check_LDAP_db_integrity, + check_permission_for_apps, +) +from yunohost.hook import CUSTOM_HOOK_FOLDER # Get main domain maindomain = "" + def setup_function(function): global maindomain maindomain = _get_maindomain() - print "" - assert backup_test_dependencies_are_met() clean_tmp_backup_directory() @@ -32,33 +43,50 @@ def setup_function(function): assert len(backup_list()["archives"]) == 0 - markers = [m.name for m in function.__dict__.get("pytestmark",[])] + markers = { + m.name: {"args": m.args, "kwargs": m.kwargs} + for m in function.__dict__.get("pytestmark", []) + } - if "with_wordpress_archive_from_2p4" in markers: - add_archive_wordpress_from_2p4() + if "with_wordpress_archive_from_3p8" in markers: + add_archive_wordpress_from_3p8() assert len(backup_list()["archives"]) == 1 - if "with_backup_legacy_app_installed" in markers: - assert not app_is_installed("backup_legacy_app") - install_app("backup_legacy_app_ynh", "/yolo") - assert app_is_installed("backup_legacy_app") + if "with_legacy_app_installed" in markers: + assert not app_is_installed("legacy_app") + install_app("legacy_app_ynh", "/yolo") + assert app_is_installed("legacy_app") if "with_backup_recommended_app_installed" in markers: assert not app_is_installed("backup_recommended_app") - install_app("backup_recommended_app_ynh", "/yolo", - "&helper_to_test=ynh_restore_file") + install_app( + "backup_recommended_app_ynh", "/yolo", "&helper_to_test=ynh_restore_file" + ) assert app_is_installed("backup_recommended_app") if "with_backup_recommended_app_installed_with_ynh_restore" in markers: assert not app_is_installed("backup_recommended_app") - install_app("backup_recommended_app_ynh", "/yolo", - "&helper_to_test=ynh_restore") + install_app( + "backup_recommended_app_ynh", "/yolo", "&helper_to_test=ynh_restore" + ) assert app_is_installed("backup_recommended_app") - if "with_system_archive_from_2p4" in markers: - add_archive_system_from_2p4() + if "with_system_archive_from_3p8" in markers: + add_archive_system_from_3p8() assert len(backup_list()["archives"]) == 1 + if "with_permission_app_installed" in markers: + assert not app_is_installed("permissions_app") + user_create("alice", "Alice", "White", maindomain, "test123Ynh") + with patch.object(os, "isatty", return_value=False): + install_app("permissions_app_ynh", "/urlpermissionapp" "&admin=alice") + assert app_is_installed("permissions_app") + + if "with_custom_domain" in markers: + domain = markers["with_custom_domain"]["args"][0] + if domain not in domain_list()["domains"]: + domain_add(domain) + def teardown_function(function): @@ -68,11 +96,22 @@ def teardown_function(function): delete_all_backups() uninstall_test_apps_if_needed() - markers = [m.name for m in function.__dict__.get("pytestmark",[])] + markers = { + m.name: {"args": m.args, "kwargs": m.kwargs} + for m in function.__dict__.get("pytestmark", []) + } if "clean_opt_dir" in markers: shutil.rmtree("/opt/test_backup_output_directory") + if "alice" in user_list()["users"]: + user_delete("alice") + + if "with_custom_domain" in markers: + domain = markers["with_custom_domain"]["args"][0] + if domain != maindomain: + domain_remove(domain) + @pytest.fixture(autouse=True) def check_LDAP_db_integrity_call(): @@ -80,18 +119,24 @@ def check_LDAP_db_integrity_call(): yield check_LDAP_db_integrity() + @pytest.fixture(autouse=True) def check_permission_for_apps_call(): check_permission_for_apps() yield check_permission_for_apps() + # # Helpers # # + def app_is_installed(app): + if app == "permissions_app": + return _is_installed(app) + # These are files we know should be installed by the app app_files = [] app_files.append("/etc/nginx/conf.d/%s.d/%s.conf" % (maindomain, app)) @@ -104,9 +149,13 @@ def app_is_installed(app): def backup_test_dependencies_are_met(): # Dummy test apps (or backup archives) - assert os.path.exists("./tests/apps/backup_wordpress_from_2p4") - assert os.path.exists("./tests/apps/backup_legacy_app_ynh") - assert os.path.exists("./tests/apps/backup_recommended_app_ynh") + assert os.path.exists( + os.path.join(get_test_apps_dir(), "backup_wordpress_from_3p8") + ) + assert os.path.exists(os.path.join(get_test_apps_dir(), "legacy_app_ynh")) + assert os.path.exists( + os.path.join(get_test_apps_dir(), "backup_recommended_app_ynh") + ) return True @@ -116,7 +165,7 @@ def tmp_backup_directory_is_empty(): if not os.path.exists("/home/yunohost.backup/tmp/"): return True else: - return len(os.listdir('/home/yunohost.backup/tmp/')) == 0 + return len(os.listdir("/home/yunohost.backup/tmp/")) == 0 def clean_tmp_backup_directory(): @@ -124,17 +173,18 @@ def clean_tmp_backup_directory(): if tmp_backup_directory_is_empty(): return - mount_lines = subprocess.check_output("mount").split("\n") + mount_lines = subprocess.check_output("mount").decode().split("\n") - points_to_umount = [line.split(" ")[2] - for line in mount_lines - if len(line) >= 3 - and line.split(" ")[2].startswith("/home/yunohost.backup/tmp")] + points_to_umount = [ + line.split(" ")[2] + for line in mount_lines + if len(line) >= 3 and line.split(" ")[2].startswith("/home/yunohost.backup/tmp") + ] for point in reversed(points_to_umount): os.system("umount %s" % point) - for f in os.listdir('/home/yunohost.backup/tmp/'): + for f in os.listdir("/home/yunohost.backup/tmp/"): shutil.rmtree("/home/yunohost.backup/tmp/%s" % f) shutil.rmtree("/home/yunohost.backup/tmp/") @@ -155,53 +205,52 @@ def delete_all_backups(): def uninstall_test_apps_if_needed(): - if _is_installed("backup_legacy_app"): - app_remove("backup_legacy_app") - - if _is_installed("backup_recommended_app"): - app_remove("backup_recommended_app") - - if _is_installed("wordpress"): - app_remove("wordpress") + for app in ["legacy_app", "backup_recommended_app", "wordpress", "permissions_app"]: + if _is_installed(app): + app_remove(app) def install_app(app, path, additionnal_args=""): - app_install("./tests/apps/%s" % app, - args="domain=%s&path=%s%s" % (maindomain, path, - additionnal_args), force=True) + app_install( + os.path.join(get_test_apps_dir(), app), + args="domain=%s&path=%s%s" % (maindomain, path, additionnal_args), + force=True, + ) -def add_archive_wordpress_from_2p4(): +def add_archive_wordpress_from_3p8(): os.system("mkdir -p /home/yunohost.backup/archives") - os.system("cp ./tests/apps/backup_wordpress_from_2p4/backup.info.json \ - /home/yunohost.backup/archives/backup_wordpress_from_2p4.info.json") - - os.system("cp ./tests/apps/backup_wordpress_from_2p4/backup.tar.gz \ - /home/yunohost.backup/archives/backup_wordpress_from_2p4.tar.gz") + os.system( + "cp " + + os.path.join(get_test_apps_dir(), "backup_wordpress_from_3p8/backup.tar.gz") + + " /home/yunohost.backup/archives/backup_wordpress_from_3p8.tar.gz" + ) -def add_archive_system_from_2p4(): +def add_archive_system_from_3p8(): os.system("mkdir -p /home/yunohost.backup/archives") - os.system("cp ./tests/apps/backup_system_from_2p4/backup.info.json \ - /home/yunohost.backup/archives/backup_system_from_2p4.info.json") + os.system( + "cp " + + os.path.join(get_test_apps_dir(), "backup_system_from_3p8/backup.tar.gz") + + " /home/yunohost.backup/archives/backup_system_from_3p8.tar.gz" + ) - os.system("cp ./tests/apps/backup_system_from_2p4/backup.tar.gz \ - /home/yunohost.backup/archives/backup_system_from_2p4.tar.gz") # # System backup # # -def test_backup_only_ldap(): +def test_backup_only_ldap(mocker): # Create the backup - backup_create(system=["conf_ldap"], apps=None) + with message(mocker, "backup_created"): + backup_create(system=["conf_ldap"], apps=None) archives = backup_list()["archives"] assert len(archives) == 1 @@ -214,32 +263,31 @@ def test_backup_only_ldap(): def test_backup_system_part_that_does_not_exists(mocker): - mocker.spy(m18n, "n") - # Create the backup - with pytest.raises(YunohostError): - backup_create(system=["yolol"], apps=None) + with message(mocker, "backup_hook_unknown", hook="doesnt_exist"): + with raiseYunohostError(mocker, "backup_nothings_done"): + backup_create(system=["doesnt_exist"], apps=None) - m18n.n.assert_any_call('backup_hook_unknown', hook="yolol") - m18n.n.assert_any_call('backup_nothings_done') # # System backup and restore # # -def test_backup_and_restore_all_sys(): +def test_backup_and_restore_all_sys(mocker): # Create the backup - backup_create(system=[], apps=None) + with message(mocker, "backup_created"): + backup_create(system=[], apps=None) archives = backup_list()["archives"] assert len(archives) == 1 archives_info = backup_info(archives[0], with_details=True) assert archives_info["apps"] == {} - assert (len(archives_info["system"].keys()) == - len(os.listdir("/usr/share/yunohost/hooks/backup/"))) + assert len(archives_info["system"].keys()) == len( + os.listdir("/usr/share/yunohost/hooks/backup/") + ) # Remove ssowat conf assert os.path.exists("/etc/ssowat/conf.json") @@ -247,37 +295,39 @@ def test_backup_and_restore_all_sys(): assert not os.path.exists("/etc/ssowat/conf.json") # Restore the backup - backup_restore(name=archives[0], force=True, - system=[], apps=None) + with message(mocker, "restore_complete"): + backup_restore(name=archives[0], force=True, system=[], apps=None) # Check ssowat conf is back assert os.path.exists("/etc/ssowat/conf.json") # -# System restore from 2.4 # +# System restore from 3.8 # # -@pytest.mark.with_system_archive_from_2p4 -def test_restore_system_from_Ynh2p4(monkeypatch, mocker): + +@pytest.mark.with_system_archive_from_3p8 +def test_restore_system_from_Ynh3p8(monkeypatch, mocker): # Backup current system - backup_create(system=[], apps=None) + with message(mocker, "backup_created"): + backup_create(system=[], apps=None) archives = backup_list()["archives"] assert len(archives) == 2 - # Restore system archive from 2.4 + # Restore system archive from 3.8 try: - backup_restore(name=backup_list()["archives"][1], - system=[], - apps=None, - force=True) + with message(mocker, "restore_complete"): + backup_restore( + name=backup_list()["archives"][1], system=[], apps=None, force=True + ) finally: # Restore system as it was - backup_restore(name=backup_list()["archives"][0], - system=[], - apps=None, - force=True) + backup_restore( + name=backup_list()["archives"][0], system=[], apps=None, force=True + ) + # # App backup # @@ -286,7 +336,6 @@ def test_restore_system_from_Ynh2p4(monkeypatch, mocker): @pytest.mark.with_backup_recommended_app_installed def test_backup_script_failure_handling(monkeypatch, mocker): - def custom_hook_exec(name, *args, **kwargs): if os.path.basename(name).startswith("backup_"): @@ -298,17 +347,14 @@ def test_backup_script_failure_handling(monkeypatch, mocker): # call with monkeypatch). We also patch m18n to check later it's been called # with the expected error message key monkeypatch.setattr("yunohost.backup.hook_exec", custom_hook_exec) - mocker.spy(m18n, "n") - with pytest.raises(YunohostError): - backup_create(system=None, apps=["backup_recommended_app"]) - - m18n.n.assert_any_call('backup_app_failed', app='backup_recommended_app') + with message(mocker, "backup_app_failed", app="backup_recommended_app"): + with raiseYunohostError(mocker, "backup_nothings_done"): + backup_create(system=None, apps=["backup_recommended_app"]) @pytest.mark.with_backup_recommended_app_installed def test_backup_not_enough_free_space(monkeypatch, mocker): - def custom_disk_usage(path): return 99999999999999999 @@ -316,28 +362,21 @@ def test_backup_not_enough_free_space(monkeypatch, mocker): return 0 monkeypatch.setattr("yunohost.backup.disk_usage", custom_disk_usage) - monkeypatch.setattr("yunohost.backup.free_space_in_directory", - custom_free_space_in_directory) + monkeypatch.setattr( + "yunohost.backup.free_space_in_directory", custom_free_space_in_directory + ) - mocker.spy(m18n, "n") - - with pytest.raises(YunohostError): + with raiseYunohostError(mocker, "not_enough_disk_space"): backup_create(system=None, apps=["backup_recommended_app"]) - m18n.n.assert_any_call('not_enough_disk_space', path=ANY) - def test_backup_app_not_installed(mocker): assert not _is_installed("wordpress") - mocker.spy(m18n, "n") - - with pytest.raises(YunohostError): - backup_create(system=None, apps=["wordpress"]) - - m18n.n.assert_any_call("unbackup_app", app="wordpress") - m18n.n.assert_any_call('backup_nothings_done') + with message(mocker, "unbackup_app", app="wordpress"): + with raiseYunohostError(mocker, "backup_nothings_done"): + backup_create(system=None, apps=["wordpress"]) @pytest.mark.with_backup_recommended_app_installed @@ -347,13 +386,11 @@ def test_backup_app_with_no_backup_script(mocker): os.system("rm %s" % backup_script) assert not os.path.exists(backup_script) - mocker.spy(m18n, "n") - - with pytest.raises(YunohostError): - backup_create(system=None, apps=["backup_recommended_app"]) - - m18n.n.assert_any_call("backup_with_no_backup_script_for_app", app="backup_recommended_app") - m18n.n.assert_any_call('backup_nothings_done') + with message( + mocker, "backup_with_no_backup_script_for_app", app="backup_recommended_app" + ): + with raiseYunohostError(mocker, "backup_nothings_done"): + backup_create(system=None, apps=["backup_recommended_app"]) @pytest.mark.with_backup_recommended_app_installed @@ -363,25 +400,28 @@ def test_backup_app_with_no_restore_script(mocker): os.system("rm %s" % restore_script) assert not os.path.exists(restore_script) - mocker.spy(m18n, "n") - # Backuping an app with no restore script will only display a warning to the # user... - backup_create(system=None, apps=["backup_recommended_app"]) - - m18n.n.assert_any_call("backup_with_no_restore_script_for_app", app="backup_recommended_app") + with message( + mocker, "backup_with_no_restore_script_for_app", app="backup_recommended_app" + ): + backup_create(system=None, apps=["backup_recommended_app"]) @pytest.mark.clean_opt_dir -def test_backup_with_different_output_directory(): +def test_backup_with_different_output_directory(mocker): # Create the backup - backup_create(system=["conf_ssh"], apps=None, - output_directory="/opt/test_backup_output_directory", - name="backup") + with message(mocker, "backup_created"): + backup_create( + system=["conf_ynh_settings"], + apps=None, + output_directory="/opt/test_backup_output_directory", + name="backup", + ) - assert os.path.exists("/opt/test_backup_output_directory/backup.tar.gz") + assert os.path.exists("/opt/test_backup_output_directory/backup.tar") archives = backup_list()["archives"] assert len(archives) == 1 @@ -389,16 +429,21 @@ def test_backup_with_different_output_directory(): archives_info = backup_info(archives[0], with_details=True) assert archives_info["apps"] == {} assert len(archives_info["system"].keys()) == 1 - assert "conf_ssh" in archives_info["system"].keys() + assert "conf_ynh_settings" in archives_info["system"].keys() @pytest.mark.clean_opt_dir -def test_backup_with_no_compress(): +def test_backup_using_copy_method(mocker): + # Create the backup - backup_create(system=["conf_nginx"], apps=None, - output_directory="/opt/test_backup_output_directory", - no_compress=True, - name="backup") + with message(mocker, "backup_created"): + backup_create( + system=["conf_ynh_settings"], + apps=None, + output_directory="/opt/test_backup_output_directory", + methods=["copy"], + name="backup", + ) assert os.path.exists("/opt/test_backup_output_directory/info.json") @@ -407,118 +452,149 @@ def test_backup_with_no_compress(): # App restore # # -@pytest.mark.with_wordpress_archive_from_2p4 -def test_restore_app_wordpress_from_Ynh2p4(): - backup_restore(system=None, name=backup_list()["archives"][0], - apps=["wordpress"]) +@pytest.mark.with_wordpress_archive_from_3p8 +@pytest.mark.with_custom_domain("yolo.test") +def test_restore_app_wordpress_from_Ynh3p8(mocker): + + with message(mocker, "restore_complete"): + backup_restore( + system=None, name=backup_list()["archives"][0], apps=["wordpress"] + ) -@pytest.mark.with_wordpress_archive_from_2p4 +@pytest.mark.with_wordpress_archive_from_3p8 +@pytest.mark.with_custom_domain("yolo.test") def test_restore_app_script_failure_handling(monkeypatch, mocker): - def custom_hook_exec(name, *args, **kwargs): if os.path.basename(name).startswith("restore"): monkeypatch.undo() - raise Exception + return (1, None) - monkeypatch.setattr("yunohost.backup.hook_exec", custom_hook_exec) - mocker.spy(m18n, "n") + monkeypatch.setattr("yunohost.hook.hook_exec", custom_hook_exec) assert not _is_installed("wordpress") - with pytest.raises(YunohostError): - backup_restore(system=None, name=backup_list()["archives"][0], - apps=["wordpress"]) + with message(mocker, "app_restore_script_failed"): + with raiseYunohostError(mocker, "restore_nothings_done"): + backup_restore( + system=None, name=backup_list()["archives"][0], apps=["wordpress"] + ) - m18n.n.assert_any_call('restore_app_failed', app='wordpress') - m18n.n.assert_any_call('restore_nothings_done') assert not _is_installed("wordpress") -@pytest.mark.with_wordpress_archive_from_2p4 +@pytest.mark.with_wordpress_archive_from_3p8 def test_restore_app_not_enough_free_space(monkeypatch, mocker): - def custom_free_space_in_directory(dirpath): return 0 - monkeypatch.setattr("yunohost.backup.free_space_in_directory", - custom_free_space_in_directory) - mocker.spy(m18n, "n") + monkeypatch.setattr( + "yunohost.backup.free_space_in_directory", custom_free_space_in_directory + ) assert not _is_installed("wordpress") - with pytest.raises(YunohostError): - backup_restore(system=None, name=backup_list()["archives"][0], - apps=["wordpress"]) + with raiseYunohostError(mocker, "restore_not_enough_disk_space"): + backup_restore( + system=None, name=backup_list()["archives"][0], apps=["wordpress"] + ) - m18n.n.assert_any_call('restore_not_enough_disk_space', - free_space=0, - margin=ANY, - needed_space=ANY) assert not _is_installed("wordpress") -@pytest.mark.with_wordpress_archive_from_2p4 +@pytest.mark.with_wordpress_archive_from_3p8 def test_restore_app_not_in_backup(mocker): assert not _is_installed("wordpress") assert not _is_installed("yoloswag") - mocker.spy(m18n, "n") + with message(mocker, "backup_archive_app_not_found", app="yoloswag"): + with raiseYunohostError(mocker, "restore_nothings_done"): + backup_restore( + system=None, name=backup_list()["archives"][0], apps=["yoloswag"] + ) - with pytest.raises(YunohostError): - backup_restore(system=None, name=backup_list()["archives"][0], - apps=["yoloswag"]) - - m18n.n.assert_any_call('backup_archive_app_not_found', app="yoloswag") assert not _is_installed("wordpress") assert not _is_installed("yoloswag") -@pytest.mark.with_wordpress_archive_from_2p4 +@pytest.mark.with_wordpress_archive_from_3p8 +@pytest.mark.with_custom_domain("yolo.test") def test_restore_app_already_installed(mocker): assert not _is_installed("wordpress") - backup_restore(system=None, name=backup_list()["archives"][0], - apps=["wordpress"]) + with message(mocker, "restore_complete"): + backup_restore( + system=None, name=backup_list()["archives"][0], apps=["wordpress"] + ) assert _is_installed("wordpress") - mocker.spy(m18n, "n") - with pytest.raises(YunohostError): - backup_restore(system=None, name=backup_list()["archives"][0], - apps=["wordpress"]) - - m18n.n.assert_any_call('restore_already_installed_app', app="wordpress") - m18n.n.assert_any_call('restore_nothings_done') + with raiseYunohostError(mocker, "restore_already_installed_apps"): + backup_restore( + system=None, name=backup_list()["archives"][0], apps=["wordpress"] + ) assert _is_installed("wordpress") -@pytest.mark.with_backup_legacy_app_installed -def test_backup_and_restore_legacy_app(): +@pytest.mark.with_legacy_app_installed +def test_backup_and_restore_legacy_app(mocker): - _test_backup_and_restore_app("backup_legacy_app") + _test_backup_and_restore_app(mocker, "legacy_app") @pytest.mark.with_backup_recommended_app_installed -def test_backup_and_restore_recommended_app(): +def test_backup_and_restore_recommended_app(mocker): - _test_backup_and_restore_app("backup_recommended_app") + _test_backup_and_restore_app(mocker, "backup_recommended_app") @pytest.mark.with_backup_recommended_app_installed_with_ynh_restore -def test_backup_and_restore_with_ynh_restore(): +def test_backup_and_restore_with_ynh_restore(mocker): - _test_backup_and_restore_app("backup_recommended_app") + _test_backup_and_restore_app(mocker, "backup_recommended_app") -def _test_backup_and_restore_app(app): +@pytest.mark.with_permission_app_installed +def test_backup_and_restore_permission_app(mocker): + + res = user_permission_list(full=True)["permissions"] + assert "permissions_app.main" in res + assert "permissions_app.admin" in res + assert "permissions_app.dev" in res + assert res["permissions_app.main"]["url"] == "/" + assert res["permissions_app.admin"]["url"] == "/admin" + assert res["permissions_app.dev"]["url"] == "/dev" + + assert "visitors" in res["permissions_app.main"]["allowed"] + assert "all_users" in res["permissions_app.main"]["allowed"] + assert res["permissions_app.admin"]["allowed"] == ["alice"] + assert res["permissions_app.dev"]["allowed"] == [] + + _test_backup_and_restore_app(mocker, "permissions_app") + + res = user_permission_list(full=True)["permissions"] + assert "permissions_app.main" in res + assert "permissions_app.admin" in res + assert "permissions_app.dev" in res + assert res["permissions_app.main"]["url"] == "/" + assert res["permissions_app.admin"]["url"] == "/admin" + assert res["permissions_app.dev"]["url"] == "/dev" + + assert "visitors" in res["permissions_app.main"]["allowed"] + assert "all_users" in res["permissions_app.main"]["allowed"] + assert res["permissions_app.admin"]["allowed"] == ["alice"] + assert res["permissions_app.dev"]["allowed"] == [] + + +def _test_backup_and_restore_app(mocker, app): # Create a backup of this app - backup_create(system=None, apps=[app]) + with message(mocker, "backup_created"): + backup_create(system=None, apps=[app]) archives = backup_list()["archives"] assert len(archives) == 1 @@ -531,18 +607,18 @@ def _test_backup_and_restore_app(app): # Uninstall the app app_remove(app) assert not app_is_installed(app) - assert app not in user_permission_list()['permissions'] + assert app + ".main" not in user_permission_list()["permissions"] # Restore the app - backup_restore(system=None, name=archives[0], - apps=[app]) + with message(mocker, "restore_complete"): + backup_restore(system=None, name=archives[0], apps=[app]) assert app_is_installed(app) # Check permission - per_list = user_permission_list()['permissions'] - assert app in per_list - assert "main" in per_list[app] + per_list = user_permission_list()["permissions"] + assert app + ".main" in per_list + # # Some edge cases # @@ -557,21 +633,57 @@ def test_restore_archive_with_no_json(mocker): assert "badbackup" in backup_list()["archives"] - mocker.spy(m18n, "n") - with pytest.raises(YunohostError): + with raiseYunohostError(mocker, "backup_archive_cant_retrieve_info_json"): backup_restore(name="badbackup", force=True) - m18n.n.assert_any_call('backup_invalid_archive') -def test_backup_binds_are_readonly(monkeypatch): +@pytest.mark.with_wordpress_archive_from_3p8 +def test_restore_archive_with_bad_archive(mocker): - def custom_mount_and_backup(self, backup_manager): - self.manager = backup_manager + # Break the archive + os.system( + "head -n 1000 /home/yunohost.backup/archives/backup_wordpress_from_3p8.tar.gz > /home/yunohost.backup/archives/backup_wordpress_from_3p8_bad.tar.gz" + ) + + assert "backup_wordpress_from_3p8_bad" in backup_list()["archives"] + + with raiseYunohostError(mocker, "backup_archive_corrupted"): + backup_restore(name="backup_wordpress_from_3p8_bad", force=True) + + clean_tmp_backup_directory() + + +def test_restore_archive_with_custom_hook(mocker): + + custom_restore_hook_folder = os.path.join(CUSTOM_HOOK_FOLDER, "restore") + os.system("touch %s/99-yolo" % custom_restore_hook_folder) + + # Backup with custom hook system + with message(mocker, "backup_created"): + backup_create(system=[], apps=None) + archives = backup_list()["archives"] + assert len(archives) == 1 + + # Restore system with custom hook + with message(mocker, "restore_complete"): + backup_restore( + name=backup_list()["archives"][0], system=[], apps=None, force=True + ) + + os.system("rm %s/99-yolo" % custom_restore_hook_folder) + + +def test_backup_binds_are_readonly(mocker, monkeypatch): + def custom_mount_and_backup(self): self._organize_files() - confssh = os.path.join(self.work_dir, "conf/ssh") - output = subprocess.check_output("touch %s/test 2>&1 || true" % confssh, - shell=True, env={'LANG': 'en_US.UTF-8'}) + conf = os.path.join(self.work_dir, "conf/ynh/dkim") + output = subprocess.check_output( + "touch %s/test 2>&1 || true" % conf, + shell=True, + env={"LANG": "en_US.UTF-8"}, + ) + output = output.decode() assert "Read-only file system" in output @@ -580,8 +692,10 @@ def test_backup_binds_are_readonly(monkeypatch): self.clean() - monkeypatch.setattr("yunohost.backup.BackupMethod.mount_and_backup", - custom_mount_and_backup) + monkeypatch.setattr( + "yunohost.backup.BackupMethod.mount_and_backup", custom_mount_and_backup + ) # Create the backup - backup_create(system=[]) + with message(mocker, "backup_created"): + backup_create(system=[]) diff --git a/src/yunohost/tests/test_changeurl.py b/src/yunohost/tests/test_changeurl.py index cb9b5d290..e375bd9f0 100644 --- a/src/yunohost/tests/test_changeurl.py +++ b/src/yunohost/tests/test_changeurl.py @@ -1,6 +1,9 @@ import pytest import time import requests +import os + +from .conftest import get_test_apps_dir from yunohost.app import app_install, app_change_url, app_remove, app_map from yunohost.domain import _get_maindomain @@ -8,11 +11,12 @@ from yunohost.domain import _get_maindomain from yunohost.utils.error import YunohostError # Get main domain -maindomain = _get_maindomain() +maindomain = "" def setup_function(function): - pass + global maindomain + maindomain = _get_maindomain() def teardown_function(function): @@ -20,8 +24,11 @@ def teardown_function(function): def install_changeurl_app(path): - app_install("./tests/apps/change_url_app_ynh", - args="domain=%s&path=%s" % (maindomain, path), force=True) + app_install( + os.path.join(get_test_apps_dir(), "change_url_app_ynh"), + args="domain=%s&path=%s" % (maindomain, path), + force=True, + ) def check_changeurl_app(path): @@ -31,7 +38,9 @@ def check_changeurl_app(path): assert appmap[maindomain][path]["id"] == "change_url_app" - r = requests.get("https://127.0.0.1%s/" % path, headers={"domain": maindomain}, verify=False) + r = requests.get( + "https://127.0.0.1%s/" % path, headers={"domain": maindomain}, verify=False + ) assert r.status_code == 200 diff --git a/src/yunohost/tests/test_dns.py b/src/yunohost/tests/test_dns.py new file mode 100644 index 000000000..497cab2fd --- /dev/null +++ b/src/yunohost/tests/test_dns.py @@ -0,0 +1,80 @@ +import pytest + +from moulinette.utils.filesystem import read_toml + +from yunohost.domain import domain_add, domain_remove +from yunohost.dns import ( + DOMAIN_REGISTRAR_LIST_PATH, + _get_dns_zone_for_domain, + _get_registrar_config_section, + _build_dns_conf, +) + + +def setup_function(function): + + clean() + + +def teardown_function(function): + + clean() + + +def clean(): + pass + + +# DNS utils testing +def test_get_dns_zone_from_domain_existing(): + assert _get_dns_zone_for_domain("yunohost.org") == "yunohost.org" + assert _get_dns_zone_for_domain("donate.yunohost.org") == "yunohost.org" + assert _get_dns_zone_for_domain("fr.wikipedia.org") == "wikipedia.org" + assert _get_dns_zone_for_domain("www.fr.wikipedia.org") == "wikipedia.org" + assert ( + _get_dns_zone_for_domain("non-existing-domain.yunohost.org") == "yunohost.org" + ) + assert _get_dns_zone_for_domain("yolo.nohost.me") == "nohost.me" + assert _get_dns_zone_for_domain("foo.yolo.nohost.me") == "nohost.me" + assert _get_dns_zone_for_domain("yolo.tld") == "yolo.tld" + assert _get_dns_zone_for_domain("foo.yolo.tld") == "yolo.tld" + + +# Domain registrar testing +def test_registrar_list_integrity(): + assert read_toml(DOMAIN_REGISTRAR_LIST_PATH) + + +def test_magic_guess_registrar_weird_domain(): + assert _get_registrar_config_section("yolo.tld")["registrar"]["value"] is None + + +def test_magic_guess_registrar_ovh(): + assert ( + _get_registrar_config_section("yolo.yunohost.org")["registrar"]["value"] + == "ovh" + ) + + +def test_magic_guess_registrar_yunodyndns(): + assert ( + _get_registrar_config_section("yolo.nohost.me")["registrar"]["value"] + == "yunohost" + ) + + +@pytest.fixture +def example_domain(): + domain_add("example.tld") + yield "example.tld" + domain_remove("example.tld") + + +def test_domain_dns_suggest(example_domain): + + assert _build_dns_conf(example_domain) + + +# def domain_dns_push(domain, dry_run): +# import yunohost.dns +# return yunohost.dns.domain_registrar_push(domain, dry_run) diff --git a/src/yunohost/tests/test_domains.py b/src/yunohost/tests/test_domains.py new file mode 100644 index 000000000..02d60ead4 --- /dev/null +++ b/src/yunohost/tests/test_domains.py @@ -0,0 +1,118 @@ +import pytest +import os + +from moulinette.core import MoulinetteError + +from yunohost.utils.error import YunohostValidationError +from yunohost.domain import ( + DOMAIN_SETTINGS_DIR, + _get_maindomain, + domain_add, + domain_remove, + domain_list, + domain_main_domain, + domain_config_get, + domain_config_set, +) + +TEST_DOMAINS = ["example.tld", "sub.example.tld", "other-example.com"] + + +def setup_function(function): + + # Save domain list in variable to avoid multiple calls to domain_list() + domains = domain_list()["domains"] + + # First domain is main domain + if not TEST_DOMAINS[0] in domains: + domain_add(TEST_DOMAINS[0]) + else: + # Reset settings if any + os.system(f"rm -rf {DOMAIN_SETTINGS_DIR}/{TEST_DOMAINS[0]}.yml") + + if not _get_maindomain() == TEST_DOMAINS[0]: + domain_main_domain(TEST_DOMAINS[0]) + + # Clear other domains + for domain in domains: + if domain not in TEST_DOMAINS or domain == TEST_DOMAINS[2]: + # Clean domains not used for testing + domain_remove(domain) + elif domain in TEST_DOMAINS: + # Reset settings if any + os.system(f"rm -rf {DOMAIN_SETTINGS_DIR}/{domain}.yml") + + # Create classical second domain of not exist + if TEST_DOMAINS[1] not in domains: + domain_add(TEST_DOMAINS[1]) + + # Third domain is not created + + clean() + + +def teardown_function(function): + + clean() + + +def clean(): + pass + + +# Domains management testing +def test_domain_add(): + assert TEST_DOMAINS[2] not in domain_list()["domains"] + domain_add(TEST_DOMAINS[2]) + assert TEST_DOMAINS[2] in domain_list()["domains"] + + +def test_domain_add_existing_domain(): + with pytest.raises(MoulinetteError): + assert TEST_DOMAINS[1] in domain_list()["domains"] + domain_add(TEST_DOMAINS[1]) + + +def test_domain_remove(): + assert TEST_DOMAINS[1] in domain_list()["domains"] + domain_remove(TEST_DOMAINS[1]) + assert TEST_DOMAINS[1] not in domain_list()["domains"] + + +def test_main_domain(): + current_main_domain = _get_maindomain() + assert domain_main_domain()["current_main_domain"] == current_main_domain + + +def test_main_domain_change_unknown(): + with pytest.raises(YunohostValidationError): + domain_main_domain(TEST_DOMAINS[2]) + + +def test_change_main_domain(): + assert _get_maindomain() != TEST_DOMAINS[1] + domain_main_domain(TEST_DOMAINS[1]) + assert _get_maindomain() == TEST_DOMAINS[1] + + +# Domain settings testing +def test_domain_config_get_default(): + assert domain_config_get(TEST_DOMAINS[0], "feature.xmpp.xmpp") == 1 + assert domain_config_get(TEST_DOMAINS[1], "feature.xmpp.xmpp") == 0 + + +def test_domain_config_get_export(): + + assert domain_config_get(TEST_DOMAINS[0], export=True)["xmpp"] == 1 + assert domain_config_get(TEST_DOMAINS[1], export=True)["xmpp"] == 0 + + +def test_domain_config_set(): + assert domain_config_get(TEST_DOMAINS[1], "feature.xmpp.xmpp") == 0 + domain_config_set(TEST_DOMAINS[1], "feature.xmpp.xmpp", "yes") + assert domain_config_get(TEST_DOMAINS[1], "feature.xmpp.xmpp") == 1 + + +def test_domain_configs_unknown(): + with pytest.raises(YunohostValidationError): + domain_config_get(TEST_DOMAINS[2], "feature.xmpp.xmpp.xmpp") diff --git a/src/yunohost/tests/test_ldapauth.py b/src/yunohost/tests/test_ldapauth.py new file mode 100644 index 000000000..a95dea443 --- /dev/null +++ b/src/yunohost/tests/test_ldapauth.py @@ -0,0 +1,59 @@ +import pytest +import os + +from yunohost.authenticators.ldap_admin import Authenticator as LDAPAuth +from yunohost.tools import tools_adminpw + +from moulinette import m18n +from moulinette.core import MoulinetteError + + +def setup_function(function): + + if os.system("systemctl is-active slapd") != 0: + os.system("systemctl start slapd && sleep 3") + + tools_adminpw("yunohost", check_strength=False) + + +def test_authenticate(): + LDAPAuth().authenticate_credentials(credentials="yunohost") + + +def test_authenticate_with_wrong_password(): + with pytest.raises(MoulinetteError) as exception: + LDAPAuth().authenticate_credentials(credentials="bad_password_lul") + + translation = m18n.n("invalid_password") + expected_msg = translation.format() + assert expected_msg in str(exception) + + +def test_authenticate_server_down(mocker): + os.system("systemctl stop slapd && sleep 3") + + # Now if slapd is down, moulinette tries to restart it + mocker.patch("os.system") + mocker.patch("time.sleep") + with pytest.raises(MoulinetteError) as exception: + LDAPAuth().authenticate_credentials(credentials="yunohost") + + translation = m18n.n("ldap_server_down") + expected_msg = translation.format() + assert expected_msg in str(exception) + + +def test_authenticate_change_password(): + + LDAPAuth().authenticate_credentials(credentials="yunohost") + + tools_adminpw("plopette", check_strength=False) + + with pytest.raises(MoulinetteError) as exception: + LDAPAuth().authenticate_credentials(credentials="yunohost") + + translation = m18n.n("invalid_password") + expected_msg = translation.format() + assert expected_msg in str(exception) + + LDAPAuth().authenticate_credentials(credentials="plopette") diff --git a/src/yunohost/tests/test_permission.py b/src/yunohost/tests/test_permission.py index d309a8211..b33c2f213 100644 --- a/src/yunohost/tests/test_permission.py +++ b/src/yunohost/tests/test_permission.py @@ -1,44 +1,217 @@ +import socket +import requests import pytest +import string +import os +import json +import shutil -from moulinette.core import MoulinetteError -from yunohost.app import app_install, app_remove, app_change_url, app_list -from yunohost.user import user_list, user_create, user_permission_list, user_delete, user_group_list, user_group_delete, user_permission_add, user_permission_remove, user_permission_clear -from yunohost.permission import permission_add, permission_update, permission_remove -from yunohost.domain import _get_maindomain -from yunohost.utils.error import YunohostError +from .conftest import message, raiseYunohostError, get_test_apps_dir + +from yunohost.app import ( + app_install, + app_upgrade, + app_remove, + app_change_url, + app_map, + _installed_apps, + APPS_SETTING_PATH, + _set_app_settings, + _get_app_settings, +) +from yunohost.user import ( + user_list, + user_create, + user_delete, + user_group_list, + user_group_delete, +) +from yunohost.permission import ( + user_permission_update, + user_permission_list, + user_permission_reset, + permission_create, + permission_delete, + permission_url, +) +from yunohost.domain import _get_maindomain, domain_add, domain_remove, domain_list # Get main domain -maindomain = _get_maindomain() +maindomain = "" +other_domains = [] +dummy_password = "test123Ynh" + +# Dirty patch of DNS resolution. Force the DNS to 127.0.0.1 address even if dnsmasq have the public address. +# Mainly used for 'can_access_webpage' function + +prv_getaddrinfo = socket.getaddrinfo + + +def _permission_create_with_dummy_app( + permission, + allowed=None, + url=None, + additional_urls=None, + auth_header=True, + label=None, + show_tile=False, + protected=True, + sync_perm=True, + domain=None, + path=None, +): + app = permission.split(".")[0] + if app not in _installed_apps(): + app_setting_path = os.path.join(APPS_SETTING_PATH, app) + if not os.path.exists(app_setting_path): + os.makedirs(app_setting_path) + settings = {"id": app, "dummy_permission_app": True} + if domain: + settings["domain"] = domain + if path: + settings["path"] = path + _set_app_settings(app, settings) + + with open(os.path.join(APPS_SETTING_PATH, app, "manifest.json"), "w") as f: + json.dump( + { + "name": app, + "id": app, + "description": {"en": "Dummy app to test permissions"}, + }, + f, + ) + permission_create( + permission=permission, + allowed=allowed, + url=url, + additional_urls=additional_urls, + auth_header=auth_header, + label=label, + show_tile=show_tile, + protected=protected, + sync_perm=sync_perm, + ) + + +def _clear_dummy_app_settings(): + # Clean dummy app settings + for app in _installed_apps(): + if _get_app_settings(app).get("dummy_permission_app", False): + app_setting_path = os.path.join(APPS_SETTING_PATH, app) + if os.path.exists(app_setting_path): + shutil.rmtree(app_setting_path) + def clean_user_groups_permission(): - for u in user_list()['users']: + for u in user_list()["users"]: user_delete(u) - for g in user_group_list()['groups']: - if g != "all_users": + for g in user_group_list()["groups"]: + if g not in ["all_users", "visitors"]: user_group_delete(g) - for a, per in user_permission_list()['permissions'].items(): - if a in ['wiki', 'blog', 'site']: - for p in per: - permission_remove(a, p, force=True, sync_perm=False) + for p in user_permission_list()["permissions"]: + if any( + p.startswith(name) + for name in ["wiki", "blog", "site", "web", "permissions_app"] + ): + permission_delete(p, force=True, sync_perm=False) + socket.getaddrinfo = prv_getaddrinfo + def setup_function(function): clean_user_groups_permission() - user_create("alice", "Alice", "White", "alice@" + maindomain, "test123Ynh") - user_create("bob", "Bob", "Snow", "bob@" + maindomain, "test123Ynh") - permission_add("wiki", "main", [maindomain + "/wiki"], sync_perm=False) - permission_add("blog", "main", sync_perm=False) + global maindomain + global other_domains + maindomain = _get_maindomain() + + markers = { + m.name: {"args": m.args, "kwargs": m.kwargs} + for m in function.__dict__.get("pytestmark", []) + } + + if "other_domains" in markers: + other_domains = [ + "domain_%s.dev" % string.ascii_lowercase[number] + for number in range(markers["other_domains"]["kwargs"]["number"]) + ] + for domain in other_domains: + if domain not in domain_list()["domains"]: + domain_add(domain) + + # Dirty patch of DNS resolution. Force the DNS to 127.0.0.1 address even if dnsmasq have the public address. + # Mainly used for 'can_access_webpage' function + dns_cache = {(maindomain, 443, 0, 1): [(2, 1, 6, "", ("127.0.0.1", 443))]} + for domain in other_domains: + dns_cache[(domain, 443, 0, 1)] = [(2, 1, 6, "", ("127.0.0.1", 443))] + + def new_getaddrinfo(*args): + try: + return dns_cache[args] + except KeyError: + res = prv_getaddrinfo(*args) + dns_cache[args] = res + return res + + socket.getaddrinfo = new_getaddrinfo + + user_create("alice", "Alice", "White", maindomain, dummy_password) + user_create("bob", "Bob", "Snow", maindomain, dummy_password) + _permission_create_with_dummy_app( + permission="wiki.main", + url="/", + additional_urls=["/whatever", "/idontnow"], + auth_header=False, + label="Wiki", + show_tile=True, + allowed=["all_users"], + protected=False, + sync_perm=False, + domain=maindomain, + path="/wiki", + ) + _permission_create_with_dummy_app( + permission="blog.main", + url="/", + auth_header=True, + show_tile=False, + protected=False, + sync_perm=False, + allowed=["alice"], + domain=maindomain, + path="/blog", + ) + _permission_create_with_dummy_app( + permission="blog.api", allowed=["visitors"], protected=True, sync_perm=True + ) - user_permission_add(["blog"], "main", group="alice") def teardown_function(function): clean_user_groups_permission() + global other_domains + for domain in other_domains: + domain_remove(domain) + other_domains = [] + + _clear_dummy_app_settings() + try: app_remove("permissions_app") - except: + except Exception: pass + try: + app_remove("legacy_app") + except Exception: + pass + + +def teardown_module(module): + global other_domains + for domain in other_domains: + domain_remove(domain) + @pytest.fixture(autouse=True) def check_LDAP_db_integrity_call(): @@ -46,6 +219,7 @@ def check_LDAP_db_integrity_call(): yield check_LDAP_db_integrity() + def check_LDAP_db_integrity(): # Here we check that all attributes in all object are sychronized. # Here is the list of attributes per object: @@ -57,180 +231,392 @@ def check_LDAP_db_integrity(): # One part should be done automatically by the "memberOf" overlay of LDAP. # The other part is done by the the "permission_sync_to_user" function of the permission module - from yunohost.utils.ldap import _get_ldap_interface + from yunohost.utils.ldap import _get_ldap_interface, _ldap_path_extract + ldap = _get_ldap_interface() - user_search = ldap.search('ou=users,dc=yunohost,dc=org', - '(&(objectclass=person)(!(uid=root))(!(uid=nobody)))', - ['uid', 'memberOf', 'permission']) - group_search = ldap.search('ou=groups,dc=yunohost,dc=org', - '(objectclass=groupOfNamesYnh)', - ['cn', 'member', 'memberUid', 'permission']) - permission_search = ldap.search('ou=permission,dc=yunohost,dc=org', - '(objectclass=permissionYnh)', - ['cn', 'groupPermission', 'inheritPermission', 'memberUid']) + user_search = ldap.search( + "ou=users,dc=yunohost,dc=org", + "(&(objectclass=person)(!(uid=root))(!(uid=nobody)))", + ["uid", "memberOf", "permission"], + ) + group_search = ldap.search( + "ou=groups,dc=yunohost,dc=org", + "(objectclass=groupOfNamesYnh)", + ["cn", "member", "memberUid", "permission"], + ) + permission_search = ldap.search( + "ou=permission,dc=yunohost,dc=org", + "(objectclass=permissionYnh)", + ["cn", "groupPermission", "inheritPermission", "memberUid"], + ) - user_map = {u['uid'][0]: u for u in user_search} - group_map = {g['cn'][0]: g for g in group_search} - permission_map = {p['cn'][0]: p for p in permission_search} + user_map = {u["uid"][0]: u for u in user_search} + group_map = {g["cn"][0]: g for g in group_search} + permission_map = {p["cn"][0]: p for p in permission_search} for user in user_search: - user_dn = 'uid=' + user['uid'][0] + ',ou=users,dc=yunohost,dc=org' - group_list = [m.split("=")[1].split(",")[0] for m in user['memberOf']] - permission_list = [] - if 'permission' in user: - permission_list = [m.split("=")[1].split(",")[0] for m in user['permission']] + user_dn = "uid=" + user["uid"][0] + ",ou=users,dc=yunohost,dc=org" + group_list = [_ldap_path_extract(m, "cn") for m in user["memberOf"]] + permission_list = [ + _ldap_path_extract(m, "cn") for m in user.get("permission", []) + ] + # This user's DN sould be found in all groups it is a member of for group in group_list: - assert user_dn in group_map[group]['member'] + assert user_dn in group_map[group]["member"] + + # This user's DN should be found in all perms it has access to for permission in permission_list: - assert user_dn in permission_map[permission]['inheritPermission'] + assert user_dn in permission_map[permission]["inheritPermission"] for permission in permission_search: - permission_dn = 'cn=' + permission['cn'][0] + ',ou=permission,dc=yunohost,dc=org' - user_list = [] - group_list = [] - if 'inheritPermission' in permission: - user_list = [m.split("=")[1].split(",")[0] for m in permission['inheritPermission']] - assert set(user_list) == set(permission['memberUid']) - if 'groupPermission' in permission: - group_list = [m.split("=")[1].split(",")[0] for m in permission['groupPermission']] + permission_dn = ( + "cn=" + permission["cn"][0] + ",ou=permission,dc=yunohost,dc=org" + ) + # inheritPermission uid's should match memberUids + user_list = [ + _ldap_path_extract(m, "uid") + for m in permission.get("inheritPermission", []) + ] + assert set(user_list) == set(permission.get("memberUid", [])) + + # This perm's DN should be found on all related users it is related to for user in user_list: - assert permission_dn in user_map[user]['permission'] + assert permission_dn in user_map[user]["permission"] + + # Same for groups : we should find the permission's DN for all related groups + group_list = [ + _ldap_path_extract(m, "cn") for m in permission.get("groupPermission", []) + ] for group in group_list: - assert permission_dn in group_map[group]['permission'] - if 'member' in group_map[group]: - user_list_in_group = [m.split("=")[1].split(",")[0] for m in group_map[group]['member']] - assert set(user_list_in_group) <= set(user_list) + assert permission_dn in group_map[group]["permission"] + + # The list of user in the group should be a subset of all users related to the current permission + users_in_group = [ + _ldap_path_extract(m, "uid") for m in group_map[group].get("member", []) + ] + assert set(users_in_group) <= set(user_list) for group in group_search: - group_dn = 'cn=' + group['cn'][0] + ',ou=groups,dc=yunohost,dc=org' - user_list = [] - permission_list = [] - if 'member' in group: - user_list = [m.split("=")[1].split(",")[0] for m in group['member']] - if group['cn'][0] in user_list: - # If it's the main group of the user it's normal that it is not in the memberUid - g_list = list(user_list) - g_list.remove(group['cn'][0]) - if 'memberUid' in group: - assert set(g_list) == set(group['memberUid']) - else: - assert g_list == [] - else: - assert set(user_list) == set(group['memberUid']) - if 'permission' in group: - permission_list = [m.split("=")[1].split(",")[0] for m in group['permission']] + group_dn = "cn=" + group["cn"][0] + ",ou=groups,dc=yunohost,dc=org" + user_list = [_ldap_path_extract(m, "uid") for m in group.get("member", [])] + # For primary groups, we should find that : + # - len(user_list) is 1 (a primary group has only 1 member) + # - the group name should be an existing yunohost user + # - memberUid is empty (meaning no other member than the corresponding user) + if group["cn"][0] in user_list: + assert len(user_list) == 1 + assert group["cn"][0] in user_map + assert group.get("memberUid", []) == [] + # Otherwise, user_list and memberUid should have the same content + else: + assert set(user_list) == set(group.get("memberUid", [])) + + # For all users members, this group should be in the "memberOf" on the other side for user in user_list: - assert group_dn in user_map[user]['memberOf'] + assert group_dn in user_map[user]["memberOf"] + + # For all the permissions of this group, the group should be among the "groupPermission" on the other side + permission_list = [ + _ldap_path_extract(m, "cn") for m in group.get("permission", []) + ] for permission in permission_list: - assert group_dn in permission_map[permission]['groupPermission'] - if 'inheritPermission' in permission_map: - allowed_user_list = [m.split("=")[1].split(",")[0] for m in permission_map[permission]['inheritPermission']] - assert set(user_list) <= set(allowed_user_list) + assert group_dn in permission_map[permission]["groupPermission"] + + # And the list of user of this group (user_list) should be a subset of all allowed users for this perm... + allowed_user_list = [ + _ldap_path_extract(m, "uid") + for m in permission_map[permission].get("inheritPermission", []) + ] + assert set(user_list) <= set(allowed_user_list) def check_permission_for_apps(): # We check that the for each installed apps we have at last the "main" permission # and we don't have any permission linked to no apps. The only exception who is not liked to an app - # is mail, metronome, and sftp + # is mail, xmpp, and sftp - from yunohost.utils.ldap import _get_ldap_interface - ldap = _get_ldap_interface() - permission_search = ldap.search('ou=permission,dc=yunohost,dc=org', - '(objectclass=permissionYnh)', - ['cn', 'groupPermission', 'inheritPermission', 'memberUid']) + app_perms = user_permission_list(ignore_system_perms=True)["permissions"].keys() - installed_apps = {app['id'] for app in app_list(installed=True)['apps']} - permission_list_set = {permission['cn'][0].split(".")[1] for permission in permission_search} + # Keep only the prefix so that + # ["foo.main", "foo.pwet", "bar.main"] + # becomes + # {"bar", "foo"} + # and compare this to the list of installed apps ... + + app_perms_prefix = set(p.split(".")[0] for p in app_perms) + + assert set(_installed_apps()) == app_perms_prefix + + +def can_access_webpage(webpath, logged_as=None): + + webpath = webpath.rstrip("/") + sso_url = "https://" + maindomain + "/yunohost/sso/" + + # Anonymous access + if not logged_as: + r = requests.get(webpath, verify=False) + # Login as a user using dummy password + else: + with requests.Session() as session: + session.post( + sso_url, + data={"user": logged_as, "password": dummy_password}, + headers={ + "Referer": sso_url, + "Content-Type": "application/x-www-form-urlencoded", + }, + verify=False, + ) + # We should have some cookies related to authentication now + assert session.cookies + r = session.get(webpath, verify=False) + + # If we can't access it, we got redirected to the SSO + return not r.url.startswith(sso_url) - extra_service_permission = set(['mail', 'metronome']) - if 'sftp' in permission_list_set: - extra_service_permission.add('sftp') - assert installed_apps == permission_list_set - extra_service_permission # # List functions # -def test_list_permission(): - res = user_permission_list()['permissions'] - assert "wiki" in res - assert "main" in res['wiki'] - assert "blog" in res - assert "main" in res['blog'] - assert "mail" in res - assert "main" in res['mail'] - assert "metronome" in res - assert "main" in res['metronome'] - assert ["all_users"] == res['wiki']['main']['allowed_groups'] - assert ["alice"] == res['blog']['main']['allowed_groups'] - assert set(["alice", "bob"]) == set(res['wiki']['main']['allowed_users']) - assert ["alice"] == res['blog']['main']['allowed_users'] - assert [maindomain + "/wiki"] == res['wiki']['main']['URL'] +def test_permission_list(): + res = user_permission_list(full=True)["permissions"] + + assert "mail.main" in res + assert "xmpp.main" in res + + assert "wiki.main" in res + assert "blog.main" in res + assert "blog.api" in res + + assert res["wiki.main"]["allowed"] == ["all_users"] + assert res["blog.main"]["allowed"] == ["alice"] + assert res["blog.api"]["allowed"] == ["visitors"] + assert set(res["wiki.main"]["corresponding_users"]) == set(["alice", "bob"]) + assert res["blog.main"]["corresponding_users"] == ["alice"] + assert res["blog.api"]["corresponding_users"] == [] + assert res["wiki.main"]["url"] == "/" + assert res["blog.main"]["url"] == "/" + assert res["blog.api"]["url"] is None + assert set(res["wiki.main"]["additional_urls"]) == {"/whatever", "/idontnow"} + assert res["wiki.main"]["protected"] is False + assert res["blog.main"]["protected"] is False + assert res["blog.api"]["protected"] is True + assert res["wiki.main"]["label"] == "Wiki" + assert res["blog.main"]["label"] == "Blog" + assert res["blog.api"]["label"] == "Blog (api)" + assert res["wiki.main"]["show_tile"] is True + assert res["blog.main"]["show_tile"] is False + assert res["blog.api"]["show_tile"] is False + assert res["wiki.main"]["auth_header"] is False + assert res["blog.main"]["auth_header"] is True + assert res["blog.api"]["auth_header"] is True + + res = user_permission_list(full=True, absolute_urls=True)["permissions"] + assert res["wiki.main"]["url"] == maindomain + "/wiki" + assert res["blog.main"]["url"] == maindomain + "/blog" + assert res["blog.api"]["url"] is None + assert set(res["wiki.main"]["additional_urls"]) == { + maindomain + "/wiki/whatever", + maindomain + "/wiki/idontnow", + } + assert res["blog.main"]["additional_urls"] == [] + assert res["blog.api"]["additional_urls"] == [] + # # Create - Remove functions # -def test_add_permission_1(): - permission_add("site", "test") - res = user_permission_list()['permissions'] - assert "site" in res - assert "test" in res['site'] - assert "all_users" in res['site']['test']['allowed_groups'] - assert set(["alice", "bob"]) == set(res['site']['test']['allowed_users']) +def test_permission_create_main(mocker): + with message(mocker, "permission_created", permission="site.main"): + permission_create("site.main", allowed=["all_users"], protected=False) -def test_add_permission_2(): - permission_add("site", "main", default_allow=False) + res = user_permission_list(full=True)["permissions"] + assert "site.main" in res + assert res["site.main"]["allowed"] == ["all_users"] + assert set(res["site.main"]["corresponding_users"]) == set(["alice", "bob"]) + assert res["site.main"]["protected"] is False - res = user_permission_list()['permissions'] - assert "site" in res - assert "main" in res['site'] - assert [] == res['site']['main']['allowed_groups'] - assert [] == res['site']['main']['allowed_users'] -def test_remove_permission(): - permission_remove("wiki", "main", force=True) +def test_permission_create_extra(mocker): + with message(mocker, "permission_created", permission="site.test"): + permission_create("site.test") + + res = user_permission_list(full=True)["permissions"] + assert "site.test" in res + # all_users is only enabled by default on .main perms + assert "all_users" not in res["site.test"]["allowed"] + assert res["site.test"]["corresponding_users"] == [] + assert res["site.test"]["protected"] is False + + +def test_permission_create_with_specific_user(): + permission_create("site.test", allowed=["alice"]) + + res = user_permission_list(full=True)["permissions"] + assert "site.test" in res + assert res["site.test"]["allowed"] == ["alice"] + + +def test_permission_create_with_tile_management(mocker): + with message(mocker, "permission_created", permission="site.main"): + _permission_create_with_dummy_app( + "site.main", + allowed=["all_users"], + label="The Site", + show_tile=False, + domain=maindomain, + path="/site", + ) + + res = user_permission_list(full=True)["permissions"] + assert "site.main" in res + assert res["site.main"]["label"] == "The Site" + assert res["site.main"]["show_tile"] is False + + +def test_permission_create_with_tile_management_with_main_default_value(mocker): + with message(mocker, "permission_created", permission="site.main"): + _permission_create_with_dummy_app( + "site.main", + allowed=["all_users"], + show_tile=True, + url="/", + domain=maindomain, + path="/site", + ) + + res = user_permission_list(full=True)["permissions"] + assert "site.main" in res + assert res["site.main"]["label"] == "Site" + assert res["site.main"]["show_tile"] is True + + +def test_permission_create_with_tile_management_with_not_main_default_value(mocker): + with message(mocker, "permission_created", permission="wiki.api"): + _permission_create_with_dummy_app( + "wiki.api", + allowed=["all_users"], + show_tile=True, + url="/", + domain=maindomain, + path="/site", + ) + + res = user_permission_list(full=True)["permissions"] + assert "wiki.api" in res + assert res["wiki.api"]["label"] == "Wiki (api)" + assert res["wiki.api"]["show_tile"] is True + + +def test_permission_create_with_urls_management_without_url(mocker): + with message(mocker, "permission_created", permission="wiki.api"): + _permission_create_with_dummy_app( + "wiki.api", allowed=["all_users"], domain=maindomain, path="/site" + ) + + res = user_permission_list(full=True)["permissions"] + assert "wiki.api" in res + assert res["wiki.api"]["url"] is None + assert res["wiki.api"]["additional_urls"] == [] + assert res["wiki.api"]["auth_header"] is True + + +def test_permission_create_with_urls_management_simple_domain(mocker): + with message(mocker, "permission_created", permission="site.main"): + _permission_create_with_dummy_app( + "site.main", + allowed=["all_users"], + url="/", + additional_urls=["/whatever", "/idontnow"], + auth_header=False, + domain=maindomain, + path="/site", + ) + + res = user_permission_list(full=True, absolute_urls=True)["permissions"] + assert "site.main" in res + assert res["site.main"]["url"] == maindomain + "/site" + assert set(res["site.main"]["additional_urls"]) == { + maindomain + "/site/whatever", + maindomain + "/site/idontnow", + } + assert res["site.main"]["auth_header"] is False + + +@pytest.mark.other_domains(number=2) +def test_permission_create_with_urls_management_multiple_domain(mocker): + with message(mocker, "permission_created", permission="site.main"): + _permission_create_with_dummy_app( + "site.main", + allowed=["all_users"], + url=maindomain + "/site/something", + additional_urls=[other_domains[0] + "/blabla", other_domains[1] + "/ahh"], + auth_header=True, + domain=maindomain, + path="/site", + ) + + res = user_permission_list(full=True, absolute_urls=True)["permissions"] + assert "site.main" in res + assert res["site.main"]["url"] == maindomain + "/site/something" + assert set(res["site.main"]["additional_urls"]) == { + other_domains[0] + "/blabla", + other_domains[1] + "/ahh", + } + assert res["site.main"]["auth_header"] is True + + +def test_permission_delete(mocker): + with message(mocker, "permission_deleted", permission="wiki.main"): + permission_delete("wiki.main", force=True) + + res = user_permission_list()["permissions"] + assert "wiki.main" not in res + + with message(mocker, "permission_deleted", permission="blog.api"): + permission_delete("blog.api", force=False) + + res = user_permission_list()["permissions"] + assert "blog.api" not in res - res = user_permission_list()['permissions'] - assert "wiki" not in res # # Error on create - remove function # -def test_add_bad_permission(): - # Create permission with same name - with pytest.raises(YunohostError): - permission_add("wiki", "main") -def test_remove_bad_permission(): - # Remove not existant permission - with pytest.raises(MoulinetteError): - permission_remove("non_exit", "main", force=True) +def test_permission_create_already_existing(mocker): + with raiseYunohostError(mocker, "permission_already_exist"): + permission_create("wiki.main") - res = user_permission_list()['permissions'] - assert "wiki" in res - assert "main" in res['wiki'] - assert "blog" in res - assert "main" in res['blog'] - assert "mail" in res - assert "main" in res ['mail'] - assert "metronome" in res - assert "main" in res['metronome'] -def test_remove_main_permission(): - with pytest.raises(YunohostError): - permission_remove("blog", "main") +def test_permission_delete_doesnt_existing(mocker): + with raiseYunohostError(mocker, "permission_not_found"): + permission_delete("doesnt.exist", force=True) + + res = user_permission_list()["permissions"] + assert "wiki.main" in res + assert "blog.main" in res + assert "mail.main" in res + assert "xmpp.main" in res + + +def test_permission_delete_main_without_force(mocker): + with raiseYunohostError(mocker, "permission_cannot_remove_main"): + permission_delete("blog.main") + + res = user_permission_list()["permissions"] + assert "blog.main" in res - res = user_permission_list()['permissions'] - assert "mail" in res - assert "main" in res['mail'] # # Update functions @@ -238,182 +624,538 @@ def test_remove_main_permission(): # user side functions -def test_allow_first_group(): - # Remove permission to all_users and define per users - user_permission_add(["wiki"], "main", group="alice") - res = user_permission_list()['permissions'] - assert ['alice'] == res['wiki']['main']['allowed_users'] - assert ['alice'] == res['wiki']['main']['allowed_groups'] +def test_permission_add_group(mocker): + with message(mocker, "permission_updated", permission="wiki.main"): + user_permission_update("wiki.main", add="alice") -def test_allow_other_group(): - # Allow new user in a permission - user_permission_add(["blog"], "main", group="bob") + res = user_permission_list(full=True)["permissions"] + assert set(res["wiki.main"]["allowed"]) == set(["all_users", "alice"]) + assert set(res["wiki.main"]["corresponding_users"]) == set(["alice", "bob"]) - res = user_permission_list()['permissions'] - assert set(["alice", "bob"]) == set(res['blog']['main']['allowed_users']) - assert set(["alice", "bob"]) == set(res['blog']['main']['allowed_groups']) -def test_disallow_group_1(): - # Disallow a user in a permission - user_permission_remove(["blog"], "main", group="alice") +def test_permission_remove_group(mocker): + with message(mocker, "permission_updated", permission="blog.main"): + user_permission_update("blog.main", remove="alice") - res = user_permission_list()['permissions'] - assert [] == res['blog']['main']['allowed_users'] - assert [] == res['blog']['main']['allowed_groups'] + res = user_permission_list(full=True)["permissions"] + assert res["blog.main"]["allowed"] == [] + assert res["blog.main"]["corresponding_users"] == [] -def test_allow_group_1(): - # Allow a user when he is already allowed - user_permission_add(["blog"], "main", group="alice") - res = user_permission_list()['permissions'] - assert ["alice"] == res['blog']['main']['allowed_users'] - assert ["alice"] == res['blog']['main']['allowed_groups'] +def test_permission_add_and_remove_group(mocker): + with message(mocker, "permission_updated", permission="wiki.main"): + user_permission_update("wiki.main", add="alice", remove="all_users") -def test_disallow_group_1(): - # Disallow a user when he is already disallowed - user_permission_remove(["blog"], "main", group="bob") + res = user_permission_list(full=True)["permissions"] + assert res["wiki.main"]["allowed"] == ["alice"] + assert res["wiki.main"]["corresponding_users"] == ["alice"] - res = user_permission_list()['permissions'] - assert ["alice"] == res['blog']['main']['allowed_users'] - assert ["alice"] == res['blog']['main']['allowed_groups'] -def test_reset_permission(): +def test_permission_add_group_already_allowed(mocker): + with message( + mocker, "permission_already_allowed", permission="blog.main", group="alice" + ): + user_permission_update("blog.main", add="alice") + + res = user_permission_list(full=True)["permissions"] + assert res["blog.main"]["allowed"] == ["alice"] + assert res["blog.main"]["corresponding_users"] == ["alice"] + + +def test_permission_remove_group_already_not_allowed(mocker): + with message( + mocker, "permission_already_disallowed", permission="blog.main", group="bob" + ): + user_permission_update("blog.main", remove="bob") + + res = user_permission_list(full=True)["permissions"] + assert res["blog.main"]["allowed"] == ["alice"] + assert res["blog.main"]["corresponding_users"] == ["alice"] + + +def test_permission_reset(mocker): + with message(mocker, "permission_updated", permission="blog.main"): + user_permission_reset("blog.main") + + res = user_permission_list(full=True)["permissions"] + assert res["blog.main"]["allowed"] == ["all_users"] + assert set(res["blog.main"]["corresponding_users"]) == set(["alice", "bob"]) + + +def test_permission_reset_idempotency(): # Reset permission - user_permission_clear(["blog"], "main") + user_permission_reset("blog.main") + user_permission_reset("blog.main") - res = user_permission_list()['permissions'] - assert set(["alice", "bob"]) == set(res['blog']['main']['allowed_users']) - assert ["all_users"] == res['blog']['main']['allowed_groups'] + res = user_permission_list(full=True)["permissions"] + assert res["blog.main"]["allowed"] == ["all_users"] + assert set(res["blog.main"]["corresponding_users"]) == set(["alice", "bob"]) -# internal functions -def test_add_url_1(): - # Add URL in permission which hasn't any URL defined - permission_update("blog", "main", add_url=[maindomain + "/testA"]) +def test_permission_change_label(mocker): + with message(mocker, "permission_updated", permission="wiki.main"): + user_permission_update("wiki.main", label="New Wiki") - res = user_permission_list()['permissions'] - assert [maindomain + "/testA"] == res['blog']['main']['URL'] + res = user_permission_list(full=True)["permissions"] + assert res["wiki.main"]["label"] == "New Wiki" -def test_add_url_2(): - # Add a second URL in a permission - permission_update("wiki", "main", add_url=[maindomain + "/testA"]) - res = user_permission_list()['permissions'] - assert set([maindomain + "/testA", maindomain + "/wiki"]) == set(res['wiki']['main']['URL']) +def test_permission_change_label_with_same_value(mocker): + with message(mocker, "permission_updated", permission="wiki.main"): + user_permission_update("wiki.main", label="Wiki") -def test_remove_url_1(): - permission_update("wiki", "main", remove_url=[maindomain + "/wiki"]) + res = user_permission_list(full=True)["permissions"] + assert res["wiki.main"]["label"] == "Wiki" - res = user_permission_list()['permissions'] - assert 'URL' not in res['wiki']['main'] -def test_add_url_3(): - # Add a url already added - permission_update("wiki", "main", add_url=[maindomain + "/wiki"]) +def test_permission_switch_show_tile(mocker): + # Note that from the actionmap the value is passed as string, not as bool + # Try with lowercase + with message(mocker, "permission_updated", permission="wiki.main"): + user_permission_update("wiki.main", show_tile="false") - res = user_permission_list()['permissions'] - assert [maindomain + "/wiki"] == res['wiki']['main']['URL'] + res = user_permission_list(full=True)["permissions"] + assert res["wiki.main"]["show_tile"] is False -def test_remove_url_2(): - # Remove a url not added (with a permission which contain some URL) - permission_update("wiki", "main", remove_url=[maindomain + "/not_exist"]) + # Try with uppercase + with message(mocker, "permission_updated", permission="wiki.main"): + user_permission_update("wiki.main", show_tile="TRUE") - res = user_permission_list()['permissions'] - assert [maindomain + "/wiki"] == res['wiki']['main']['URL'] + res = user_permission_list(full=True)["permissions"] + assert res["wiki.main"]["show_tile"] is True -def test_remove_url_2(): - # Remove a url not added (with a permission which contain no URL) - permission_update("blog", "main", remove_url=[maindomain + "/not_exist"]) - res = user_permission_list()['permissions'] - assert 'URL' not in res['blog']['main'] +def test_permission_switch_show_tile_with_same_value(mocker): + # Note that from the actionmap the value is passed as string, not as bool + with message(mocker, "permission_updated", permission="wiki.main"): + user_permission_update("wiki.main", show_tile="True") + + res = user_permission_list(full=True)["permissions"] + assert res["wiki.main"]["show_tile"] is True + # # Error on update function # -def test_disallow_bad_group_1(): - # Disallow a group when the group all_users is allowed - with pytest.raises(YunohostError): - user_permission_remove("wiki", "main", group="alice") - res = user_permission_list()['permissions'] - assert ["all_users"] == res['wiki']['main']['allowed_groups'] - assert set(["alice", "bob"]) == set(res['wiki']['main']['allowed_users']) +def test_permission_add_group_that_doesnt_exist(mocker): + with raiseYunohostError(mocker, "group_unknown"): + user_permission_update("blog.main", add="doesnt_exist") -def test_allow_bad_user(): - # Allow a non existant group - with pytest.raises(YunohostError): - user_permission_add(["blog"], "main", group="not_exist") + res = user_permission_list(full=True)["permissions"] + assert res["blog.main"]["allowed"] == ["alice"] + assert res["blog.main"]["corresponding_users"] == ["alice"] - res = user_permission_list()['permissions'] - assert ["alice"] == res['blog']['main']['allowed_groups'] - assert ["alice"] == res['blog']['main']['allowed_users'] -def test_disallow_bad_group_2(): - # Disallow a non existant group - with pytest.raises(YunohostError): - user_permission_remove(["blog"], "main", group="not_exist") +def test_permission_update_permission_that_doesnt_exist(mocker): + with raiseYunohostError(mocker, "permission_not_found"): + user_permission_update("doesnt.exist", add="alice") - res = user_permission_list()['permissions'] - assert ["alice"] == res['blog']['main']['allowed_groups'] - assert ["alice"] == res['blog']['main']['allowed_users'] -def test_allow_bad_permission_1(): - # Allow a user to a non existant permission - with pytest.raises(YunohostError): - user_permission_add(["wiki"], "not_exit", group="alice") +def test_permission_protected_update(mocker): + res = user_permission_list(full=True)["permissions"] + assert res["blog.api"]["allowed"] == ["visitors"] + + with raiseYunohostError(mocker, "permission_protected"): + user_permission_update("blog.api", remove="visitors") + + res = user_permission_list(full=True)["permissions"] + assert res["blog.api"]["allowed"] == ["visitors"] + + user_permission_update("blog.api", remove="visitors", force=True) + res = user_permission_list(full=True)["permissions"] + assert res["blog.api"]["allowed"] == [] + + with raiseYunohostError(mocker, "permission_protected"): + user_permission_update("blog.api", add="visitors") + + res = user_permission_list(full=True)["permissions"] + assert res["blog.api"]["allowed"] == [] + + +# Permission url management + + +def test_permission_redefine_url(): + permission_url("blog.main", url="/pwet") + + res = user_permission_list(full=True)["permissions"] + assert res["blog.main"]["url"] == "/pwet" + + +def test_permission_remove_url(): + permission_url("blog.main", clear_urls=True) + + res = user_permission_list(full=True)["permissions"] + assert res["blog.main"]["url"] is None + + +def test_permission_main_url_regex(): + permission_url("blog.main", url="re:/[a-z]+reboy/.*") + + res = user_permission_list(full=True)["permissions"] + assert res["blog.main"]["url"] == "re:/[a-z]+reboy/.*" + + res = user_permission_list(full=True, absolute_urls=True)["permissions"] + assert res["blog.main"]["url"] == "re:%s/blog/[a-z]+reboy/.*" % maindomain.replace( + ".", r"\." + ) + + +def test_permission_main_url_bad_regex(mocker): + with raiseYunohostError(mocker, "invalid_regex"): + permission_url("blog.main", url="re:/[a-z]++reboy/.*") + + +@pytest.mark.other_domains(number=1) +def test_permission_add_additional_url(): + permission_url("wiki.main", add_url=[other_domains[0] + "/heyby", "/myhouse"]) + + res = user_permission_list(full=True, absolute_urls=True)["permissions"] + assert res["wiki.main"]["url"] == maindomain + "/wiki" + assert set(res["wiki.main"]["additional_urls"]) == { + maindomain + "/wiki/whatever", + maindomain + "/wiki/idontnow", + other_domains[0] + "/heyby", + maindomain + "/wiki/myhouse", + } + + +def test_permission_add_additional_regex(): + permission_url("blog.main", add_url=["re:/[a-z]+reboy/.*"]) + + res = user_permission_list(full=True)["permissions"] + assert res["blog.main"]["additional_urls"] == ["re:/[a-z]+reboy/.*"] + + res = user_permission_list(full=True, absolute_urls=True)["permissions"] + assert res["blog.main"]["additional_urls"] == [ + "re:%s/blog/[a-z]+reboy/.*" % maindomain.replace(".", r"\.") + ] + + +def test_permission_add_additional_bad_regex(mocker): + with raiseYunohostError(mocker, "invalid_regex"): + permission_url("blog.main", add_url=["re:/[a-z]++reboy/.*"]) + + +def test_permission_remove_additional_url(): + permission_url("wiki.main", remove_url=["/whatever"]) + + res = user_permission_list(full=True, absolute_urls=True)["permissions"] + assert res["wiki.main"]["url"] == maindomain + "/wiki" + assert res["wiki.main"]["additional_urls"] == [maindomain + "/wiki/idontnow"] + + +def test_permssion_add_additional_url_already_exist(): + permission_url("wiki.main", add_url=["/whatever", "/myhouse"]) + permission_url("wiki.main", add_url=["/whatever"]) + + res = user_permission_list(full=True, absolute_urls=True)["permissions"] + assert res["wiki.main"]["url"] == maindomain + "/wiki" + assert set(res["wiki.main"]["additional_urls"]) == { + maindomain + "/wiki/whatever", + maindomain + "/wiki/idontnow", + maindomain + "/wiki/myhouse", + } + + +def test_permission_remove_additional_url_dont_exist(): + permission_url("wiki.main", remove_url=["/shouldntexist", "/whatever"]) + permission_url("wiki.main", remove_url=["/shouldntexist"]) + + res = user_permission_list(full=True, absolute_urls=True)["permissions"] + assert res["wiki.main"]["url"] == maindomain + "/wiki" + assert res["wiki.main"]["additional_urls"] == [maindomain + "/wiki/idontnow"] + + +def test_permission_clear_additional_url(): + permission_url("wiki.main", clear_urls=True) + + res = user_permission_list(full=True)["permissions"] + assert res["wiki.main"]["url"] is None + assert res["wiki.main"]["additional_urls"] == [] + + +def test_permission_switch_auth_header(): + permission_url("wiki.main", auth_header=True) + + res = user_permission_list(full=True)["permissions"] + assert res["wiki.main"]["auth_header"] is True + + permission_url("wiki.main", auth_header=False) + + res = user_permission_list(full=True)["permissions"] + assert res["wiki.main"]["auth_header"] is False + + +def test_permission_switch_auth_header_with_same_value(): + permission_url("wiki.main", auth_header=False) + + res = user_permission_list(full=True)["permissions"] + assert res["wiki.main"]["auth_header"] is False + + +# Permission protected + + +def test_permission_switch_protected(): + user_permission_update("wiki.main", protected=True) + + res = user_permission_list(full=True)["permissions"] + assert res["wiki.main"]["protected"] is True + + user_permission_update("wiki.main", protected=False) + + res = user_permission_list(full=True)["permissions"] + assert res["wiki.main"]["protected"] is False + + +def test_permission_switch_protected_with_same_value(): + user_permission_update("wiki.main", protected=False) + + res = user_permission_list(full=True)["permissions"] + assert res["wiki.main"]["protected"] is False + + +# Test SSOWAT conf generation + + +def test_ssowat_conf(): + with open("/etc/ssowat/conf.json") as f: + res = json.load(f) + + permissions = res["permissions"] + assert "wiki.main" in permissions + assert "blog.main" in permissions + assert ( + "blog.api" not in permissions + ) # blog.api has no url/additional url defined and therefore is not added to ssowat conf + + assert set(permissions["wiki.main"]["users"]) == {"alice", "bob"} + assert permissions["blog.main"]["users"] == ["alice"] + + assert permissions["wiki.main"]["uris"][0] == maindomain + "/wiki" + + assert set(permissions["wiki.main"]["uris"]) == { + maindomain + "/wiki", + maindomain + "/wiki/whatever", + maindomain + "/wiki/idontnow", + } + assert permissions["blog.main"]["uris"] == [maindomain + "/blog"] + + assert permissions["wiki.main"]["public"] is False + assert permissions["blog.main"]["public"] is False + + assert permissions["wiki.main"]["auth_header"] is False + assert permissions["blog.main"]["auth_header"] is True + + assert permissions["wiki.main"]["label"] == "Wiki" + assert permissions["blog.main"]["label"] == "Blog" + + assert permissions["wiki.main"]["show_tile"] is True + assert permissions["blog.main"]["show_tile"] is False + + +def test_show_tile_cant_be_enabled(): + _permission_create_with_dummy_app( + permission="site.main", + auth_header=False, + label="Site", + show_tile=True, + allowed=["all_users"], + protected=False, + sync_perm=False, + domain=maindomain, + path="/site", + ) + + _permission_create_with_dummy_app( + permission="web.main", + url="re:/[a-z]{3}/bla", + auth_header=False, + label="Web", + show_tile=True, + allowed=["all_users"], + protected=False, + sync_perm=True, + domain=maindomain, + path="/web", + ) + + permissions = user_permission_list(full=True)["permissions"] + + assert permissions["site.main"]["show_tile"] is False + assert permissions["web.main"]["show_tile"] is False -def test_allow_bad_permission_2(): - # Allow a user to a non existant permission - with pytest.raises(YunohostError): - user_permission_add(["not_exit"], "main", group="alice") # # Application interaction # -def test_install_app(): - app_install("./tests/apps/permissions_app_ynh", - args="domain=%s&path=%s&admin=%s" % (maindomain, "/urlpermissionapp", "alice"), force=True) - res = user_permission_list()['permissions'] - assert "permissions_app" in res - assert "main" in res['permissions_app'] - assert [maindomain + "/urlpermissionapp"] == res['permissions_app']['main']['URL'] - assert [maindomain + "/urlpermissionapp/admin"] == res['permissions_app']['admin']['URL'] - assert [maindomain + "/urlpermissionapp/dev"] == res['permissions_app']['dev']['URL'] +@pytest.mark.other_domains(number=1) +def test_permission_app_install(): + app_install( + os.path.join(get_test_apps_dir(), "permissions_app_ynh"), + args="domain=%s&domain_2=%s&path=%s&is_public=0&admin=%s" + % (maindomain, other_domains[0], "/urlpermissionapp", "alice"), + force=True, + ) - assert ["all_users"] == res['permissions_app']['main']['allowed_groups'] - assert set(["alice", "bob"]) == set(res['permissions_app']['main']['allowed_users']) + res = user_permission_list(full=True)["permissions"] + assert "permissions_app.main" in res + assert "permissions_app.admin" in res + assert "permissions_app.dev" in res + assert res["permissions_app.main"]["url"] == "/" + assert res["permissions_app.admin"]["url"] == "/admin" + assert res["permissions_app.dev"]["url"] == "/dev" - assert ["alice"] == res['permissions_app']['admin']['allowed_groups'] - assert ["alice"] == res['permissions_app']['admin']['allowed_users'] + assert res["permissions_app.main"]["allowed"] == ["all_users"] + assert set(res["permissions_app.main"]["corresponding_users"]) == set( + ["alice", "bob"] + ) - assert ["all_users"] == res['permissions_app']['dev']['allowed_groups'] - assert set(["alice", "bob"]) == set(res['permissions_app']['dev']['allowed_users']) + assert res["permissions_app.admin"]["allowed"] == ["alice"] + assert res["permissions_app.admin"]["corresponding_users"] == ["alice"] -def test_remove_app(): - app_install("./tests/apps/permissions_app_ynh", - args="domain=%s&path=%s&admin=%s" % (maindomain, "/urlpermissionapp", "alice"), force=True) + assert res["permissions_app.dev"]["allowed"] == [] + assert set(res["permissions_app.dev"]["corresponding_users"]) == set() + + # Check that we get the right stuff in app_map, which is used to generate the ssowatconf + assert maindomain + "/urlpermissionapp" in app_map(user="alice").keys() + user_permission_update("permissions_app.main", remove="all_users", add="bob") + assert maindomain + "/urlpermissionapp" not in app_map(user="alice").keys() + assert maindomain + "/urlpermissionapp" in app_map(user="bob").keys() + + +@pytest.mark.other_domains(number=1) +def test_permission_app_remove(): + app_install( + os.path.join(get_test_apps_dir(), "permissions_app_ynh"), + args="domain=%s&domain_2=%s&path=%s&is_public=0&admin=%s" + % (maindomain, other_domains[0], "/urlpermissionapp", "alice"), + force=True, + ) app_remove("permissions_app") - res = user_permission_list()['permissions'] - assert "permissions_app" not in res + # Check all permissions for this app got deleted + res = user_permission_list(full=True)["permissions"] + assert not any(p.startswith("permissions_app.") for p in res.keys()) -def test_change_url(): - app_install("./tests/apps/permissions_app_ynh", - args="domain=%s&path=%s&admin=%s" % (maindomain, "/urlpermissionapp", "alice"), force=True) - res = user_permission_list()['permissions'] - assert [maindomain + "/urlpermissionapp"] == res['permissions_app']['main']['URL'] - assert [maindomain + "/urlpermissionapp/admin"] == res['permissions_app']['admin']['URL'] - assert [maindomain + "/urlpermissionapp/dev"] == res['permissions_app']['dev']['URL'] +@pytest.mark.other_domains(number=1) +def test_permission_app_change_url(): + app_install( + os.path.join(get_test_apps_dir(), "permissions_app_ynh"), + args="domain=%s&domain_2=%s&path=%s&admin=%s" + % (maindomain, other_domains[0], "/urlpermissionapp", "alice"), + force=True, + ) + + # FIXME : should rework this test to look for differences in the generated app map / app tiles ... + res = user_permission_list(full=True)["permissions"] + assert res["permissions_app.main"]["url"] == "/" + assert res["permissions_app.admin"]["url"] == "/admin" + assert res["permissions_app.dev"]["url"] == "/dev" app_change_url("permissions_app", maindomain, "/newchangeurl") - res = user_permission_list()['permissions'] - assert [maindomain + "/newchangeurl"] == res['permissions_app']['main']['URL'] - assert [maindomain + "/newchangeurl/admin"] == res['permissions_app']['admin']['URL'] - assert [maindomain + "/newchangeurl/dev"] == res['permissions_app']['dev']['URL'] + res = user_permission_list(full=True)["permissions"] + assert res["permissions_app.main"]["url"] == "/" + assert res["permissions_app.admin"]["url"] == "/admin" + assert res["permissions_app.dev"]["url"] == "/dev" + + +@pytest.mark.other_domains(number=1) +def test_permission_protection_management_by_helper(): + app_install( + os.path.join(get_test_apps_dir(), "permissions_app_ynh"), + args="domain=%s&domain_2=%s&path=%s&admin=%s" + % (maindomain, other_domains[0], "/urlpermissionapp", "alice"), + force=True, + ) + + res = user_permission_list(full=True)["permissions"] + assert res["permissions_app.main"]["protected"] is False + assert res["permissions_app.admin"]["protected"] is True + assert res["permissions_app.dev"]["protected"] is False + + app_upgrade( + ["permissions_app"], + file=os.path.join(get_test_apps_dir(), "permissions_app_ynh"), + ) + + res = user_permission_list(full=True)["permissions"] + assert res["permissions_app.main"]["protected"] is False + assert res["permissions_app.admin"]["protected"] is False + assert res["permissions_app.dev"]["protected"] is True + + +@pytest.mark.other_domains(number=1) +def test_permission_app_propagation_on_ssowat(): + + app_install( + os.path.join(get_test_apps_dir(), "permissions_app_ynh"), + args="domain=%s&domain_2=%s&path=%s&is_public=1&admin=%s" + % (maindomain, other_domains[0], "/urlpermissionapp", "alice"), + force=True, + ) + + res = user_permission_list(full=True)["permissions"] + assert "visitors" in res["permissions_app.main"]["allowed"] + assert "all_users" in res["permissions_app.main"]["allowed"] + + app_webroot = "https://%s/urlpermissionapp" % maindomain + assert can_access_webpage(app_webroot, logged_as=None) + assert can_access_webpage(app_webroot, logged_as="alice") + + user_permission_update( + "permissions_app.main", remove=["visitors", "all_users"], add="bob" + ) + res = user_permission_list(full=True)["permissions"] + + assert not can_access_webpage(app_webroot, logged_as=None) + assert not can_access_webpage(app_webroot, logged_as="alice") + assert can_access_webpage(app_webroot, logged_as="bob") + + # Test admin access, as configured during install, only alice should be able to access it + + # alice gotta be allowed on the main permission to access the admin tho + user_permission_update("permissions_app.main", remove="bob", add="all_users") + + assert not can_access_webpage(app_webroot + "/admin", logged_as=None) + assert can_access_webpage(app_webroot + "/admin", logged_as="alice") + assert not can_access_webpage(app_webroot + "/admin", logged_as="bob") + + +@pytest.mark.other_domains(number=1) +def test_permission_legacy_app_propagation_on_ssowat(): + + app_install( + os.path.join(get_test_apps_dir(), "legacy_app_ynh"), + args="domain=%s&domain_2=%s&path=%s" + % (maindomain, other_domains[0], "/legacy"), + force=True, + ) + + # App is configured as public by default using the legacy unprotected_uri mechanics + # It should automatically be migrated during the install + res = user_permission_list(full=True)["permissions"] + assert "visitors" in res["legacy_app.main"]["allowed"] + assert "all_users" in res["legacy_app.main"]["allowed"] + + app_webroot = "https://%s/legacy" % maindomain + + assert can_access_webpage(app_webroot, logged_as=None) + assert can_access_webpage(app_webroot, logged_as="alice") + + # Try to update the permission and check that permissions are still consistent + user_permission_update( + "legacy_app.main", remove=["visitors", "all_users"], add="bob" + ) + + assert not can_access_webpage(app_webroot, logged_as=None) + assert not can_access_webpage(app_webroot, logged_as="alice") + assert can_access_webpage(app_webroot, logged_as="bob") diff --git a/src/yunohost/tests/test_questions.py b/src/yunohost/tests/test_questions.py new file mode 100644 index 000000000..cf4e67733 --- /dev/null +++ b/src/yunohost/tests/test_questions.py @@ -0,0 +1,2095 @@ +import sys +import pytest +import os + +from mock import patch +from io import StringIO + +from moulinette import Moulinette + +from yunohost import domain, user +from yunohost.utils.config import ( + ask_questions_and_parse_answers, + PasswordQuestion, + DomainQuestion, + PathQuestion, + BooleanQuestion, + FileQuestion, +) +from yunohost.utils.error import YunohostError, YunohostValidationError + + +""" +Argument default format: +{ + "name": "the_name", + "type": "one_of_the_available_type", // "sting" is not specified + "ask": { + "en": "the question in english", + "fr": "the question in french" + }, + "help": { + "en": "some help text in english", + "fr": "some help text in french" + }, + "example": "an example value", // optional + "default", "some stuff", // optional, not available for all types + "optional": true // optional, will skip if not answered +} + +User answers: +{"name": "value", ...} +""" + + +def test_question_empty(): + ask_questions_and_parse_answers([], {}) == [] + + +def test_question_string(): + questions = [ + { + "name": "some_string", + "type": "string", + } + ] + answers = {"some_string": "some_value"} + + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_string" + assert out.type == "string" + assert out.value == "some_value" + + +def test_question_string_from_query_string(): + + questions = [ + { + "name": "some_string", + "type": "string", + } + ] + answers = "foo=bar&some_string=some_value&lorem=ipsum" + + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_string" + assert out.type == "string" + assert out.value == "some_value" + + +def test_question_string_default_type(): + questions = [ + { + "name": "some_string", + } + ] + answers = {"some_string": "some_value"} + + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_string" + assert out.type == "string" + assert out.value == "some_value" + + +def test_question_string_no_input(): + questions = [ + { + "name": "some_string", + } + ] + answers = {} + + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_string_input(): + questions = [ + { + "name": "some_string", + "ask": "some question", + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_string" + assert out.type == "string" + assert out.value == "some_value" + + +def test_question_string_input_no_ask(): + questions = [ + { + "name": "some_string", + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_string" + assert out.type == "string" + assert out.value == "some_value" + + +def test_question_string_no_input_optional(): + questions = [ + { + "name": "some_string", + "optional": True, + } + ] + answers = {} + with patch.object(os, "isatty", return_value=False): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_string" + assert out.type == "string" + assert out.value == "" + + +def test_question_string_optional_with_input(): + questions = [ + { + "name": "some_string", + "ask": "some question", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_string" + assert out.type == "string" + assert out.value == "some_value" + + +def test_question_string_optional_with_empty_input(): + questions = [ + { + "name": "some_string", + "ask": "some question", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value=""), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_string" + assert out.type == "string" + assert out.value == "" + + +def test_question_string_optional_with_input_without_ask(): + questions = [ + { + "name": "some_string", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_string" + assert out.type == "string" + assert out.value == "some_value" + + +def test_question_string_no_input_default(): + questions = [ + { + "name": "some_string", + "ask": "some question", + "default": "some_value", + } + ] + answers = {} + with patch.object(os, "isatty", return_value=False): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_string" + assert out.type == "string" + assert out.value == "some_value" + + +def test_question_string_input_test_ask(): + ask_text = "some question" + questions = [ + { + "name": "some_string", + "ask": ask_text, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + prompt.assert_called_with( + message=ask_text, + is_password=False, + confirm=False, + prefill="", + is_multiline=False, + autocomplete=[], + help=None, + ) + + +def test_question_string_input_test_ask_with_default(): + ask_text = "some question" + default_text = "some example" + questions = [ + { + "name": "some_string", + "ask": ask_text, + "default": default_text, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + prompt.assert_called_with( + message=ask_text, + is_password=False, + confirm=False, + prefill=default_text, + is_multiline=False, + autocomplete=[], + help=None, + ) + + +@pytest.mark.skip # we should do something with this example +def test_question_string_input_test_ask_with_example(): + ask_text = "some question" + example_text = "some example" + questions = [ + { + "name": "some_string", + "ask": ask_text, + "example": example_text, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + assert ask_text in prompt.call_args[1]["message"] + assert example_text in prompt.call_args[1]["message"] + + +@pytest.mark.skip # we should do something with this help +def test_question_string_input_test_ask_with_help(): + ask_text = "some question" + help_text = "some_help" + questions = [ + { + "name": "some_string", + "ask": ask_text, + "help": help_text, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + assert ask_text in prompt.call_args[1]["message"] + assert help_text in prompt.call_args[1]["message"] + + +def test_question_string_with_choice(): + questions = [{"name": "some_string", "type": "string", "choices": ["fr", "en"]}] + answers = {"some_string": "fr"} + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_string" + assert out.type == "string" + assert out.value == "fr" + + +def test_question_string_with_choice_prompt(): + questions = [{"name": "some_string", "type": "string", "choices": ["fr", "en"]}] + answers = {"some_string": "fr"} + with patch.object(Moulinette, "prompt", return_value="fr"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_string" + assert out.type == "string" + assert out.value == "fr" + + +def test_question_string_with_choice_bad(): + questions = [{"name": "some_string", "type": "string", "choices": ["fr", "en"]}] + answers = {"some_string": "bad"} + + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_string_with_choice_ask(): + ask_text = "some question" + choices = ["fr", "en", "es", "it", "ru"] + questions = [ + { + "name": "some_string", + "ask": ask_text, + "choices": choices, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="ru") as prompt, patch.object( + os, "isatty", return_value=True + ): + ask_questions_and_parse_answers(questions, answers) + assert ask_text in prompt.call_args[1]["message"] + + for choice in choices: + assert choice in prompt.call_args[1]["message"] + + +def test_question_string_with_choice_default(): + questions = [ + { + "name": "some_string", + "type": "string", + "choices": ["fr", "en"], + "default": "en", + } + ] + answers = {} + with patch.object(os, "isatty", return_value=False): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_string" + assert out.type == "string" + assert out.value == "en" + + +def test_question_password(): + questions = [ + { + "name": "some_password", + "type": "password", + } + ] + answers = {"some_password": "some_value"} + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_password" + assert out.type == "password" + assert out.value == "some_value" + + +def test_question_password_no_input(): + questions = [ + { + "name": "some_password", + "type": "password", + } + ] + answers = {} + + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_password_input(): + questions = [ + { + "name": "some_password", + "type": "password", + "ask": "some question", + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_password" + assert out.type == "password" + assert out.value == "some_value" + + +def test_question_password_input_no_ask(): + questions = [ + { + "name": "some_password", + "type": "password", + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_password" + assert out.type == "password" + assert out.value == "some_value" + + +def test_question_password_no_input_optional(): + questions = [ + { + "name": "some_password", + "type": "password", + "optional": True, + } + ] + answers = {} + + with patch.object(os, "isatty", return_value=False): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_password" + assert out.type == "password" + assert out.value == "" + + questions = [ + {"name": "some_password", "type": "password", "optional": True, "default": ""} + ] + + with patch.object(os, "isatty", return_value=False): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_password" + assert out.type == "password" + assert out.value == "" + + +def test_question_password_optional_with_input(): + questions = [ + { + "name": "some_password", + "ask": "some question", + "type": "password", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_password" + assert out.type == "password" + assert out.value == "some_value" + + +def test_question_password_optional_with_empty_input(): + questions = [ + { + "name": "some_password", + "ask": "some question", + "type": "password", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value=""), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_password" + assert out.type == "password" + assert out.value == "" + + +def test_question_password_optional_with_input_without_ask(): + questions = [ + { + "name": "some_password", + "type": "password", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_password" + assert out.type == "password" + assert out.value == "some_value" + + +def test_question_password_no_input_default(): + questions = [ + { + "name": "some_password", + "type": "password", + "ask": "some question", + "default": "some_value", + } + ] + answers = {} + + # no default for password! + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + ask_questions_and_parse_answers(questions, answers) + + +@pytest.mark.skip # this should raises +def test_question_password_no_input_example(): + questions = [ + { + "name": "some_password", + "type": "password", + "ask": "some question", + "example": "some_value", + } + ] + answers = {"some_password": "some_value"} + + # no example for password! + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_password_input_test_ask(): + ask_text = "some question" + questions = [ + { + "name": "some_password", + "type": "password", + "ask": ask_text, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + prompt.assert_called_with( + message=ask_text, + is_password=True, + confirm=False, + prefill="", + is_multiline=False, + autocomplete=[], + help=None, + ) + + +@pytest.mark.skip # we should do something with this example +def test_question_password_input_test_ask_with_example(): + ask_text = "some question" + example_text = "some example" + questions = [ + { + "name": "some_password", + "type": "password", + "ask": ask_text, + "example": example_text, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + assert ask_text in prompt.call_args[1]["message"] + assert example_text in prompt.call_args[1]["message"] + + +@pytest.mark.skip # we should do something with this help +def test_question_password_input_test_ask_with_help(): + ask_text = "some question" + help_text = "some_help" + questions = [ + { + "name": "some_password", + "type": "password", + "ask": ask_text, + "help": help_text, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + assert ask_text in prompt.call_args[1]["message"] + assert help_text in prompt.call_args[1]["message"] + + +def test_question_password_bad_chars(): + questions = [ + { + "name": "some_password", + "type": "password", + "ask": "some question", + "example": "some_value", + } + ] + + for i in PasswordQuestion.forbidden_chars: + with pytest.raises(YunohostError), patch.object( + os, "isatty", return_value=False + ): + ask_questions_and_parse_answers(questions, {"some_password": i * 8}) + + +def test_question_password_strong_enough(): + questions = [ + { + "name": "some_password", + "type": "password", + "ask": "some question", + "example": "some_value", + } + ] + + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + # too short + ask_questions_and_parse_answers(questions, {"some_password": "a"}) + + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + ask_questions_and_parse_answers(questions, {"some_password": "password"}) + + +def test_question_password_optional_strong_enough(): + questions = [ + { + "name": "some_password", + "ask": "some question", + "type": "password", + "optional": True, + } + ] + + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + # too short + ask_questions_and_parse_answers(questions, {"some_password": "a"}) + + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + ask_questions_and_parse_answers(questions, {"some_password": "password"}) + + +def test_question_path(): + questions = [ + { + "name": "some_path", + "type": "path", + } + ] + answers = {"some_path": "/some_value"} + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_path" + assert out.type == "path" + assert out.value == "/some_value" + + +def test_question_path_no_input(): + questions = [ + { + "name": "some_path", + "type": "path", + } + ] + answers = {} + + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_path_input(): + questions = [ + { + "name": "some_path", + "type": "path", + "ask": "some question", + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="/some_value"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_path" + assert out.type == "path" + assert out.value == "/some_value" + + +def test_question_path_input_no_ask(): + questions = [ + { + "name": "some_path", + "type": "path", + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="/some_value"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_path" + assert out.type == "path" + assert out.value == "/some_value" + + +def test_question_path_no_input_optional(): + questions = [ + { + "name": "some_path", + "type": "path", + "optional": True, + } + ] + answers = {} + with patch.object(os, "isatty", return_value=False): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_path" + assert out.type == "path" + assert out.value == "" + + +def test_question_path_optional_with_input(): + questions = [ + { + "name": "some_path", + "ask": "some question", + "type": "path", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="/some_value"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_path" + assert out.type == "path" + assert out.value == "/some_value" + + +def test_question_path_optional_with_empty_input(): + questions = [ + { + "name": "some_path", + "ask": "some question", + "type": "path", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value=""), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_path" + assert out.type == "path" + assert out.value == "" + + +def test_question_path_optional_with_input_without_ask(): + questions = [ + { + "name": "some_path", + "type": "path", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="/some_value"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_path" + assert out.type == "path" + assert out.value == "/some_value" + + +def test_question_path_no_input_default(): + questions = [ + { + "name": "some_path", + "ask": "some question", + "type": "path", + "default": "some_value", + } + ] + answers = {} + with patch.object(os, "isatty", return_value=False): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_path" + assert out.type == "path" + assert out.value == "/some_value" + + +def test_question_path_input_test_ask(): + ask_text = "some question" + questions = [ + { + "name": "some_path", + "type": "path", + "ask": ask_text, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + prompt.assert_called_with( + message=ask_text, + is_password=False, + confirm=False, + prefill="", + is_multiline=False, + autocomplete=[], + help=None, + ) + + +def test_question_path_input_test_ask_with_default(): + ask_text = "some question" + default_text = "someexample" + questions = [ + { + "name": "some_path", + "type": "path", + "ask": ask_text, + "default": default_text, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + prompt.assert_called_with( + message=ask_text, + is_password=False, + confirm=False, + prefill=default_text, + is_multiline=False, + autocomplete=[], + help=None, + ) + + +@pytest.mark.skip # we should do something with this example +def test_question_path_input_test_ask_with_example(): + ask_text = "some question" + example_text = "some example" + questions = [ + { + "name": "some_path", + "type": "path", + "ask": ask_text, + "example": example_text, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + assert ask_text in prompt.call_args[1]["message"] + assert example_text in prompt.call_args[1]["message"] + + +@pytest.mark.skip # we should do something with this help +def test_question_path_input_test_ask_with_help(): + ask_text = "some question" + help_text = "some_help" + questions = [ + { + "name": "some_path", + "type": "path", + "ask": ask_text, + "help": help_text, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + assert ask_text in prompt.call_args[1]["message"] + assert help_text in prompt.call_args[1]["message"] + + +def test_question_boolean(): + questions = [ + { + "name": "some_boolean", + "type": "boolean", + } + ] + answers = {"some_boolean": "y"} + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_boolean" + assert out.type == "boolean" + assert out.value == 1 + + +def test_question_boolean_all_yes(): + questions = [ + { + "name": "some_boolean", + "type": "boolean", + } + ] + + for value in ["Y", "yes", "Yes", "YES", "1", 1, True, "True", "TRUE", "true"]: + out = ask_questions_and_parse_answers(questions, {"some_boolean": value})[0] + assert out.name == "some_boolean" + assert out.type == "boolean" + assert out.value == 1 + + +def test_question_boolean_all_no(): + questions = [ + { + "name": "some_boolean", + "type": "boolean", + } + ] + + for value in ["n", "N", "no", "No", "No", "0", 0, False, "False", "FALSE", "false"]: + out = ask_questions_and_parse_answers(questions, {"some_boolean": value})[0] + assert out.name == "some_boolean" + assert out.type == "boolean" + assert out.value == 0 + + +# XXX apparently boolean are always False (0) by default, I'm not sure what to think about that +def test_question_boolean_no_input(): + questions = [ + { + "name": "some_boolean", + "type": "boolean", + } + ] + answers = {} + + with patch.object(os, "isatty", return_value=False): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.value == 0 + + +def test_question_boolean_bad_input(): + questions = [ + { + "name": "some_boolean", + "type": "boolean", + } + ] + answers = {"some_boolean": "stuff"} + + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_boolean_input(): + questions = [ + { + "name": "some_boolean", + "type": "boolean", + "ask": "some question", + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="y"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + assert out.value == 1 + + with patch.object(Moulinette, "prompt", return_value="n"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + assert out.value == 0 + + +def test_question_boolean_input_no_ask(): + questions = [ + { + "name": "some_boolean", + "type": "boolean", + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="y"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + assert out.value == 1 + + +def test_question_boolean_no_input_optional(): + questions = [ + { + "name": "some_boolean", + "type": "boolean", + "optional": True, + } + ] + answers = {} + with patch.object(os, "isatty", return_value=False): + out = ask_questions_and_parse_answers(questions, answers)[0] + assert out.value == 0 + + +def test_question_boolean_optional_with_input(): + questions = [ + { + "name": "some_boolean", + "ask": "some question", + "type": "boolean", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="y"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + assert out.value == 1 + + +def test_question_boolean_optional_with_empty_input(): + questions = [ + { + "name": "some_boolean", + "ask": "some question", + "type": "boolean", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value=""), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.value == 0 + + +def test_question_boolean_optional_with_input_without_ask(): + questions = [ + { + "name": "some_boolean", + "type": "boolean", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="n"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.value == 0 + + +def test_question_boolean_no_input_default(): + questions = [ + { + "name": "some_boolean", + "ask": "some question", + "type": "boolean", + "default": 0, + } + ] + answers = {} + + with patch.object(os, "isatty", return_value=False): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.value == 0 + + +def test_question_boolean_bad_default(): + questions = [ + { + "name": "some_boolean", + "ask": "some question", + "type": "boolean", + "default": "bad default", + } + ] + answers = {} + with pytest.raises(YunohostError): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_boolean_input_test_ask(): + ask_text = "some question" + questions = [ + { + "name": "some_boolean", + "type": "boolean", + "ask": ask_text, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value=0) as prompt, patch.object( + os, "isatty", return_value=True + ): + ask_questions_and_parse_answers(questions, answers) + prompt.assert_called_with( + message=ask_text + " [yes | no]", + is_password=False, + confirm=False, + prefill="no", + is_multiline=False, + autocomplete=[], + help=None, + ) + + +def test_question_boolean_input_test_ask_with_default(): + ask_text = "some question" + default_text = 1 + questions = [ + { + "name": "some_boolean", + "type": "boolean", + "ask": ask_text, + "default": default_text, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value=1) as prompt, patch.object( + os, "isatty", return_value=True + ): + ask_questions_and_parse_answers(questions, answers) + prompt.assert_called_with( + message=ask_text + " [yes | no]", + is_password=False, + confirm=False, + prefill="yes", + is_multiline=False, + autocomplete=[], + help=None, + ) + + +def test_question_domain_empty(): + questions = [ + { + "name": "some_domain", + "type": "domain", + } + ] + main_domain = "my_main_domain.com" + answers = {} + + with patch.object( + domain, "_get_maindomain", return_value="my_main_domain.com" + ), patch.object( + domain, "domain_list", return_value={"domains": [main_domain]} + ), patch.object( + os, "isatty", return_value=False + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_domain" + assert out.type == "domain" + assert out.value == main_domain + + +def test_question_domain(): + main_domain = "my_main_domain.com" + domains = [main_domain] + questions = [ + { + "name": "some_domain", + "type": "domain", + } + ] + + answers = {"some_domain": main_domain} + + with patch.object( + domain, "_get_maindomain", return_value=main_domain + ), patch.object(domain, "domain_list", return_value={"domains": domains}): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_domain" + assert out.type == "domain" + assert out.value == main_domain + + +def test_question_domain_two_domains(): + main_domain = "my_main_domain.com" + other_domain = "some_other_domain.tld" + domains = [main_domain, other_domain] + + questions = [ + { + "name": "some_domain", + "type": "domain", + } + ] + answers = {"some_domain": other_domain} + + with patch.object( + domain, "_get_maindomain", return_value=main_domain + ), patch.object(domain, "domain_list", return_value={"domains": domains}): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_domain" + assert out.type == "domain" + assert out.value == other_domain + + answers = {"some_domain": main_domain} + + with patch.object( + domain, "_get_maindomain", return_value=main_domain + ), patch.object(domain, "domain_list", return_value={"domains": domains}): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_domain" + assert out.type == "domain" + assert out.value == main_domain + + +def test_question_domain_two_domains_wrong_answer(): + main_domain = "my_main_domain.com" + other_domain = "some_other_domain.tld" + domains = [main_domain, other_domain] + + questions = [ + { + "name": "some_domain", + "type": "domain", + } + ] + answers = {"some_domain": "doesnt_exist.pouet"} + + with patch.object( + domain, "_get_maindomain", return_value=main_domain + ), patch.object(domain, "domain_list", return_value={"domains": domains}): + with pytest.raises(YunohostError), patch.object( + os, "isatty", return_value=False + ): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_domain_two_domains_default_no_ask(): + main_domain = "my_main_domain.com" + other_domain = "some_other_domain.tld" + domains = [main_domain, other_domain] + + questions = [ + { + "name": "some_domain", + "type": "domain", + } + ] + answers = {} + + with patch.object( + domain, "_get_maindomain", return_value=main_domain + ), patch.object( + domain, "domain_list", return_value={"domains": domains} + ), patch.object( + os, "isatty", return_value=False + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_domain" + assert out.type == "domain" + assert out.value == main_domain + + +def test_question_domain_two_domains_default(): + main_domain = "my_main_domain.com" + other_domain = "some_other_domain.tld" + domains = [main_domain, other_domain] + + questions = [{"name": "some_domain", "type": "domain", "ask": "choose a domain"}] + answers = {} + + with patch.object( + domain, "_get_maindomain", return_value=main_domain + ), patch.object( + domain, "domain_list", return_value={"domains": domains} + ), patch.object( + os, "isatty", return_value=False + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_domain" + assert out.type == "domain" + assert out.value == main_domain + + +def test_question_domain_two_domains_default_input(): + main_domain = "my_main_domain.com" + other_domain = "some_other_domain.tld" + domains = [main_domain, other_domain] + + questions = [{"name": "some_domain", "type": "domain", "ask": "choose a domain"}] + answers = {} + + with patch.object( + domain, "_get_maindomain", return_value=main_domain + ), patch.object( + domain, "domain_list", return_value={"domains": domains} + ), patch.object( + os, "isatty", return_value=True + ): + with patch.object(Moulinette, "prompt", return_value=main_domain): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_domain" + assert out.type == "domain" + assert out.value == main_domain + + with patch.object(Moulinette, "prompt", return_value=other_domain): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_domain" + assert out.type == "domain" + assert out.value == other_domain + + +def test_question_user_empty(): + users = { + "some_user": { + "ssh_allowed": False, + "username": "some_user", + "mailbox-quota": "0", + "mail": "p@ynh.local", + "fullname": "the first name the last name", + } + } + + questions = [ + { + "name": "some_user", + "type": "user", + } + ] + answers = {} + + with patch.object(user, "user_list", return_value={"users": users}): + with pytest.raises(YunohostError), patch.object( + os, "isatty", return_value=False + ): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_user(): + username = "some_user" + users = { + username: { + "ssh_allowed": False, + "username": "some_user", + "mailbox-quota": "0", + "mail": "p@ynh.local", + "fullname": "the first name the last name", + } + } + + questions = [ + { + "name": "some_user", + "type": "user", + } + ] + answers = {"some_user": username} + + with patch.object(user, "user_list", return_value={"users": users}), patch.object( + user, "user_info", return_value={} + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_user" + assert out.type == "user" + assert out.value == username + + +def test_question_user_two_users(): + username = "some_user" + other_user = "some_other_user" + users = { + username: { + "ssh_allowed": False, + "username": "some_user", + "mailbox-quota": "0", + "mail": "p@ynh.local", + "fullname": "the first name the last name", + }, + other_user: { + "ssh_allowed": False, + "username": "some_user", + "mailbox-quota": "0", + "mail": "z@ynh.local", + "fullname": "john doe", + }, + } + + questions = [ + { + "name": "some_user", + "type": "user", + } + ] + answers = {"some_user": other_user} + + with patch.object(user, "user_list", return_value={"users": users}), patch.object( + user, "user_info", return_value={} + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_user" + assert out.type == "user" + assert out.value == other_user + + answers = {"some_user": username} + + with patch.object(user, "user_list", return_value={"users": users}), patch.object( + user, "user_info", return_value={} + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_user" + assert out.type == "user" + assert out.value == username + + +def test_question_user_two_users_wrong_answer(): + username = "my_username.com" + other_user = "some_other_user" + users = { + username: { + "ssh_allowed": False, + "username": "some_user", + "mailbox-quota": "0", + "mail": "p@ynh.local", + "fullname": "the first name the last name", + }, + other_user: { + "ssh_allowed": False, + "username": "some_user", + "mailbox-quota": "0", + "mail": "z@ynh.local", + "fullname": "john doe", + }, + } + + questions = [ + { + "name": "some_user", + "type": "user", + } + ] + answers = {"some_user": "doesnt_exist.pouet"} + + with patch.object(user, "user_list", return_value={"users": users}): + with pytest.raises(YunohostError), patch.object( + os, "isatty", return_value=False + ): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_user_two_users_no_default(): + username = "my_username.com" + other_user = "some_other_user.tld" + users = { + username: { + "ssh_allowed": False, + "username": "some_user", + "mailbox-quota": "0", + "mail": "p@ynh.local", + "fullname": "the first name the last name", + }, + other_user: { + "ssh_allowed": False, + "username": "some_user", + "mailbox-quota": "0", + "mail": "z@ynh.local", + "fullname": "john doe", + }, + } + + questions = [{"name": "some_user", "type": "user", "ask": "choose a user"}] + answers = {} + + with patch.object(user, "user_list", return_value={"users": users}): + with pytest.raises(YunohostError), patch.object( + os, "isatty", return_value=False + ): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_user_two_users_default_input(): + username = "my_username.com" + other_user = "some_other_user.tld" + users = { + username: { + "ssh_allowed": False, + "username": "some_user", + "mailbox-quota": "0", + "mail": "p@ynh.local", + "fullname": "the first name the last name", + }, + other_user: { + "ssh_allowed": False, + "username": "some_user", + "mailbox-quota": "0", + "mail": "z@ynh.local", + "fullname": "john doe", + }, + } + + questions = [{"name": "some_user", "type": "user", "ask": "choose a user"}] + answers = {} + + with patch.object(user, "user_list", return_value={"users": users}), patch.object( + os, "isatty", return_value=True + ): + with patch.object(user, "user_info", return_value={}): + + with patch.object(Moulinette, "prompt", return_value=username): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_user" + assert out.type == "user" + assert out.value == username + + with patch.object(Moulinette, "prompt", return_value=other_user): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_user" + assert out.type == "user" + assert out.value == other_user + + +def test_question_number(): + questions = [ + { + "name": "some_number", + "type": "number", + } + ] + answers = {"some_number": 1337} + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_number" + assert out.type == "number" + assert out.value == 1337 + + +def test_question_number_no_input(): + questions = [ + { + "name": "some_number", + "type": "number", + } + ] + answers = {} + + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_number_bad_input(): + questions = [ + { + "name": "some_number", + "type": "number", + } + ] + answers = {"some_number": "stuff"} + + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + ask_questions_and_parse_answers(questions, answers) + + answers = {"some_number": 1.5} + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_number_input(): + questions = [ + { + "name": "some_number", + "type": "number", + "ask": "some question", + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="1337"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_number" + assert out.type == "number" + assert out.value == 1337 + + with patch.object(Moulinette, "prompt", return_value=1337), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_number" + assert out.type == "number" + assert out.value == 1337 + + with patch.object(Moulinette, "prompt", return_value="0"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_number" + assert out.type == "number" + assert out.value == 0 + + +def test_question_number_input_no_ask(): + questions = [ + { + "name": "some_number", + "type": "number", + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="1337"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_number" + assert out.type == "number" + assert out.value == 1337 + + +def test_question_number_no_input_optional(): + questions = [ + { + "name": "some_number", + "type": "number", + "optional": True, + } + ] + answers = {} + with patch.object(os, "isatty", return_value=False): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_number" + assert out.type == "number" + assert out.value is None + + +def test_question_number_optional_with_input(): + questions = [ + { + "name": "some_number", + "ask": "some question", + "type": "number", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="1337"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_number" + assert out.type == "number" + assert out.value == 1337 + + +def test_question_number_optional_with_input_without_ask(): + questions = [ + { + "name": "some_number", + "type": "number", + "optional": True, + } + ] + answers = {} + + with patch.object(Moulinette, "prompt", return_value="0"), patch.object( + os, "isatty", return_value=True + ): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_number" + assert out.type == "number" + assert out.value == 0 + + +def test_question_number_no_input_default(): + questions = [ + { + "name": "some_number", + "ask": "some question", + "type": "number", + "default": 1337, + } + ] + answers = {} + with patch.object(os, "isatty", return_value=False): + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_number" + assert out.type == "number" + assert out.value == 1337 + + +def test_question_number_bad_default(): + questions = [ + { + "name": "some_number", + "ask": "some question", + "type": "number", + "default": "bad default", + } + ] + answers = {} + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + ask_questions_and_parse_answers(questions, answers) + + +def test_question_number_input_test_ask(): + ask_text = "some question" + questions = [ + { + "name": "some_number", + "type": "number", + "ask": ask_text, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="1111" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + prompt.assert_called_with( + message=ask_text, + is_password=False, + confirm=False, + prefill="", + is_multiline=False, + autocomplete=[], + help=None, + ) + + +def test_question_number_input_test_ask_with_default(): + ask_text = "some question" + default_value = 1337 + questions = [ + { + "name": "some_number", + "type": "number", + "ask": ask_text, + "default": default_value, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="1111" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + prompt.assert_called_with( + message=ask_text, + is_password=False, + confirm=False, + prefill=str(default_value), + is_multiline=False, + autocomplete=[], + help=None, + ) + + +@pytest.mark.skip # we should do something with this example +def test_question_number_input_test_ask_with_example(): + ask_text = "some question" + example_value = 1337 + questions = [ + { + "name": "some_number", + "type": "number", + "ask": ask_text, + "example": example_value, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="1111" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + assert ask_text in prompt.call_args[1]["message"] + assert example_value in prompt.call_args[1]["message"] + + +@pytest.mark.skip # we should do something with this help +def test_question_number_input_test_ask_with_help(): + ask_text = "some question" + help_value = 1337 + questions = [ + { + "name": "some_number", + "type": "number", + "ask": ask_text, + "help": help_value, + } + ] + answers = {} + + with patch.object( + Moulinette, "prompt", return_value="1111" + ) as prompt, patch.object(os, "isatty", return_value=True): + ask_questions_and_parse_answers(questions, answers) + assert ask_text in prompt.call_args[1]["message"] + assert help_value in prompt.call_args[1]["message"] + + +def test_question_display_text(): + questions = [{"name": "some_app", "type": "display_text", "ask": "foobar"}] + answers = {} + + with patch.object(sys, "stdout", new_callable=StringIO) as stdout, patch.object( + os, "isatty", return_value=True + ): + ask_questions_and_parse_answers(questions, answers) + assert "foobar" in stdout.getvalue() + + +def test_question_file_from_cli(): + + FileQuestion.clean_upload_dirs() + + filename = "/tmp/ynh_test_question_file" + os.system(f"rm -f {filename}") + os.system(f"echo helloworld > {filename}") + + questions = [ + { + "name": "some_file", + "type": "file", + } + ] + answers = {"some_file": filename} + + out = ask_questions_and_parse_answers(questions, answers)[0] + + assert out.name == "some_file" + assert out.type == "file" + + # The file is supposed to be copied somewhere else + assert out.value != filename + assert out.value.startswith("/tmp/") + assert os.path.exists(out.value) + assert "helloworld" in open(out.value).read().strip() + + FileQuestion.clean_upload_dirs() + + assert not os.path.exists(out.value) + + +def test_question_file_from_api(): + + FileQuestion.clean_upload_dirs() + + from base64 import b64encode + + b64content = b64encode("helloworld".encode()) + questions = [ + { + "name": "some_file", + "type": "file", + } + ] + answers = {"some_file": b64content} + + interface_type_bkp = Moulinette.interface.type + try: + Moulinette.interface.type = "api" + out = ask_questions_and_parse_answers(questions, answers)[0] + finally: + Moulinette.interface.type = interface_type_bkp + + assert out.name == "some_file" + assert out.type == "file" + + assert out.value.startswith("/tmp/") + assert os.path.exists(out.value) + assert "helloworld" in open(out.value).read().strip() + + FileQuestion.clean_upload_dirs() + + assert not os.path.exists(out.value) + + +def test_normalize_boolean_nominal(): + + assert BooleanQuestion.normalize("yes") == 1 + assert BooleanQuestion.normalize("Yes") == 1 + assert BooleanQuestion.normalize(" yes ") == 1 + assert BooleanQuestion.normalize("y") == 1 + assert BooleanQuestion.normalize("true") == 1 + assert BooleanQuestion.normalize("True") == 1 + assert BooleanQuestion.normalize("on") == 1 + assert BooleanQuestion.normalize("1") == 1 + assert BooleanQuestion.normalize(1) == 1 + + assert BooleanQuestion.normalize("no") == 0 + assert BooleanQuestion.normalize("No") == 0 + assert BooleanQuestion.normalize(" no ") == 0 + assert BooleanQuestion.normalize("n") == 0 + assert BooleanQuestion.normalize("false") == 0 + assert BooleanQuestion.normalize("False") == 0 + assert BooleanQuestion.normalize("off") == 0 + assert BooleanQuestion.normalize("0") == 0 + assert BooleanQuestion.normalize(0) == 0 + + assert BooleanQuestion.normalize("") is None + assert BooleanQuestion.normalize(" ") is None + assert BooleanQuestion.normalize(" none ") is None + assert BooleanQuestion.normalize("None") is None + assert BooleanQuestion.normalize("noNe") is None + assert BooleanQuestion.normalize(None) is None + + +def test_normalize_boolean_humanize(): + + assert BooleanQuestion.humanize("yes") == "yes" + assert BooleanQuestion.humanize("true") == "yes" + assert BooleanQuestion.humanize("on") == "yes" + + assert BooleanQuestion.humanize("no") == "no" + assert BooleanQuestion.humanize("false") == "no" + assert BooleanQuestion.humanize("off") == "no" + + +def test_normalize_boolean_invalid(): + + with pytest.raises(YunohostValidationError): + BooleanQuestion.normalize("yesno") + with pytest.raises(YunohostValidationError): + BooleanQuestion.normalize("foobar") + with pytest.raises(YunohostValidationError): + BooleanQuestion.normalize("enabled") + + +def test_normalize_boolean_special_yesno(): + + customyesno = {"yes": "enabled", "no": "disabled"} + + assert BooleanQuestion.normalize("yes", customyesno) == "enabled" + assert BooleanQuestion.normalize("true", customyesno) == "enabled" + assert BooleanQuestion.normalize("enabled", customyesno) == "enabled" + assert BooleanQuestion.humanize("yes", customyesno) == "yes" + assert BooleanQuestion.humanize("true", customyesno) == "yes" + assert BooleanQuestion.humanize("enabled", customyesno) == "yes" + + assert BooleanQuestion.normalize("no", customyesno) == "disabled" + assert BooleanQuestion.normalize("false", customyesno) == "disabled" + assert BooleanQuestion.normalize("disabled", customyesno) == "disabled" + assert BooleanQuestion.humanize("no", customyesno) == "no" + assert BooleanQuestion.humanize("false", customyesno) == "no" + assert BooleanQuestion.humanize("disabled", customyesno) == "no" + + +def test_normalize_domain(): + + assert DomainQuestion.normalize("https://yolo.swag/") == "yolo.swag" + assert DomainQuestion.normalize("http://yolo.swag") == "yolo.swag" + assert DomainQuestion.normalize("yolo.swag/") == "yolo.swag" + + +def test_normalize_path(): + + assert PathQuestion.normalize("") == "/" + assert PathQuestion.normalize("") == "/" + assert PathQuestion.normalize("macnuggets") == "/macnuggets" + assert PathQuestion.normalize("/macnuggets") == "/macnuggets" + assert PathQuestion.normalize(" /macnuggets ") == "/macnuggets" + assert PathQuestion.normalize("/macnuggets") == "/macnuggets" + assert PathQuestion.normalize("mac/nuggets") == "/mac/nuggets" + assert PathQuestion.normalize("/macnuggets/") == "/macnuggets" + assert PathQuestion.normalize("macnuggets/") == "/macnuggets" + assert PathQuestion.normalize("////macnuggets///") == "/macnuggets" diff --git a/src/yunohost/tests/test_regenconf.py b/src/yunohost/tests/test_regenconf.py new file mode 100644 index 000000000..f454f33e3 --- /dev/null +++ b/src/yunohost/tests/test_regenconf.py @@ -0,0 +1,210 @@ +import os + +from .conftest import message +from yunohost.domain import domain_add, domain_remove, domain_list +from yunohost.regenconf import ( + regen_conf, + manually_modified_files, + _get_conf_hashes, + _force_clear_hashes, +) + +TEST_DOMAIN = "secondarydomain.test" +TEST_DOMAIN_NGINX_CONFIG = "/etc/nginx/conf.d/%s.conf" % TEST_DOMAIN +TEST_DOMAIN_DNSMASQ_CONFIG = "/etc/dnsmasq.d/%s" % TEST_DOMAIN +SSHD_CONFIG = "/etc/ssh/sshd_config" + + +def setup_function(function): + + _force_clear_hashes([TEST_DOMAIN_NGINX_CONFIG]) + clean() + + +def teardown_function(function): + + clean() + _force_clear_hashes([TEST_DOMAIN_NGINX_CONFIG]) + + +def clean(): + + assert os.system("pgrep slapd >/dev/null") == 0 + assert os.system("pgrep nginx >/dev/null") == 0 + + if TEST_DOMAIN in domain_list()["domains"]: + domain_remove(TEST_DOMAIN) + assert not os.path.exists(TEST_DOMAIN_NGINX_CONFIG) + + os.system("rm -f %s" % TEST_DOMAIN_NGINX_CONFIG) + + assert os.system("nginx -t 2>/dev/null") == 0 + + assert not os.path.exists(TEST_DOMAIN_NGINX_CONFIG) + assert TEST_DOMAIN_NGINX_CONFIG not in _get_conf_hashes("nginx") + assert TEST_DOMAIN_NGINX_CONFIG not in manually_modified_files() + + regen_conf(["ssh"], force=True) + + +def test_add_domain(): + + domain_add(TEST_DOMAIN) + + assert TEST_DOMAIN in domain_list()["domains"] + + assert os.path.exists(TEST_DOMAIN_NGINX_CONFIG) + + assert TEST_DOMAIN_NGINX_CONFIG in _get_conf_hashes("nginx") + assert TEST_DOMAIN_NGINX_CONFIG not in manually_modified_files() + + +def test_add_and_edit_domain_conf(): + + domain_add(TEST_DOMAIN) + + assert os.path.exists(TEST_DOMAIN_NGINX_CONFIG) + assert TEST_DOMAIN_NGINX_CONFIG in _get_conf_hashes("nginx") + assert TEST_DOMAIN_NGINX_CONFIG not in manually_modified_files() + + os.system("echo ' ' >> %s" % TEST_DOMAIN_NGINX_CONFIG) + + assert TEST_DOMAIN_NGINX_CONFIG in manually_modified_files() + + +def test_add_domain_conf_already_exists(): + + os.system("echo ' ' >> %s" % TEST_DOMAIN_NGINX_CONFIG) + + domain_add(TEST_DOMAIN) + + assert os.path.exists(TEST_DOMAIN_NGINX_CONFIG) + assert TEST_DOMAIN_NGINX_CONFIG in _get_conf_hashes("nginx") + assert TEST_DOMAIN_NGINX_CONFIG not in manually_modified_files() + + +def test_ssh_conf_unmanaged(): + + _force_clear_hashes([SSHD_CONFIG]) + + assert SSHD_CONFIG not in _get_conf_hashes("ssh") + + regen_conf() + + assert SSHD_CONFIG in _get_conf_hashes("ssh") + + +def test_ssh_conf_unmanaged_and_manually_modified(mocker): + + _force_clear_hashes([SSHD_CONFIG]) + os.system("echo ' ' >> %s" % SSHD_CONFIG) + + assert SSHD_CONFIG not in _get_conf_hashes("ssh") + + regen_conf() + + assert SSHD_CONFIG in _get_conf_hashes("ssh") + assert SSHD_CONFIG in manually_modified_files() + + with message(mocker, "regenconf_need_to_explicitly_specify_ssh"): + regen_conf(force=True) + + assert SSHD_CONFIG in _get_conf_hashes("ssh") + assert SSHD_CONFIG in manually_modified_files() + + regen_conf(["ssh"], force=True) + + assert SSHD_CONFIG in _get_conf_hashes("ssh") + assert SSHD_CONFIG not in manually_modified_files() + + +def test_stale_hashes_get_removed_if_empty(): + """ + This is intended to test that if a file gets removed and is indeed removed, + we don't keep a useless empty hash corresponding to an old file. + In this case, we test this using the dnsmasq conf file (we don't do this + using the nginx conf file because it's already force-removed during + domain_remove()) + """ + + domain_add(TEST_DOMAIN) + + assert os.path.exists(TEST_DOMAIN_DNSMASQ_CONFIG) + assert TEST_DOMAIN_DNSMASQ_CONFIG in _get_conf_hashes("dnsmasq") + + domain_remove(TEST_DOMAIN) + + assert not os.path.exists(TEST_DOMAIN_DNSMASQ_CONFIG) + assert TEST_DOMAIN_DNSMASQ_CONFIG not in _get_conf_hashes("dnsmasq") + + +def test_stale_hashes_if_file_manually_deleted(): + """ + Same as other test, but manually delete the file in between and check + behavior + """ + + domain_add(TEST_DOMAIN) + + assert os.path.exists(TEST_DOMAIN_DNSMASQ_CONFIG) + assert TEST_DOMAIN_DNSMASQ_CONFIG in _get_conf_hashes("dnsmasq") + + os.remove(TEST_DOMAIN_DNSMASQ_CONFIG) + + assert not os.path.exists(TEST_DOMAIN_DNSMASQ_CONFIG) + + regen_conf(names=["dnsmasq"]) + + assert not os.path.exists(TEST_DOMAIN_DNSMASQ_CONFIG) + assert TEST_DOMAIN_DNSMASQ_CONFIG in _get_conf_hashes("dnsmasq") + + domain_remove(TEST_DOMAIN) + + assert not os.path.exists(TEST_DOMAIN_DNSMASQ_CONFIG) + assert TEST_DOMAIN_DNSMASQ_CONFIG not in _get_conf_hashes("dnsmasq") + + +# This test only works if you comment the part at the end of the regen-conf in +# dnsmasq that auto-flag /etc/dnsmasq.d/foo.bar as "to be removed" (using touch) +# ... But we want to keep it because they also possibly flag files that were +# never known by the regen-conf (e.g. if somebody adds a +# /etc/dnsmasq.d/my.custom.extension) +# Ideally we could use a system that's able to properly state 'no file in this +# folder should exist except the ones excplicitly defined by regen-conf' but +# that's too much work for the scope of this commit. +# +# ... Anyway, the proper way to write these tests would be to use a dummy +# regen-conf hook just for tests but meh I'm lazy +# +# def test_stale_hashes_if_file_manually_modified(): +# """ +# Same as other test, but manually delete the file in between and check +# behavior +# """ +# +# domain_add(TEST_DOMAIN) +# +# assert os.path.exists(TEST_DOMAIN_DNSMASQ_CONFIG) +# assert TEST_DOMAIN_DNSMASQ_CONFIG in _get_conf_hashes("dnsmasq") +# +# os.system("echo '#pwet' > %s" % TEST_DOMAIN_DNSMASQ_CONFIG) +# +# assert os.path.exists(TEST_DOMAIN_DNSMASQ_CONFIG) +# assert open(TEST_DOMAIN_DNSMASQ_CONFIG).read().strip() == "#pwet" +# +# regen_conf(names=["dnsmasq"]) +# +# assert os.path.exists(TEST_DOMAIN_DNSMASQ_CONFIG) +# assert open(TEST_DOMAIN_DNSMASQ_CONFIG).read().strip() == "#pwet" +# assert TEST_DOMAIN_DNSMASQ_CONFIG in _get_conf_hashes("dnsmasq") +# +# domain_remove(TEST_DOMAIN) +# +# assert os.path.exists(TEST_DOMAIN_DNSMASQ_CONFIG) +# assert open(TEST_DOMAIN_DNSMASQ_CONFIG).read().strip() == "#pwet" +# assert TEST_DOMAIN_DNSMASQ_CONFIG in _get_conf_hashes("dnsmasq") +# +# regen_conf(names=["dnsmasq"], force=True) +# +# assert not os.path.exists(TEST_DOMAIN_DNSMASQ_CONFIG) +# assert TEST_DOMAIN_DNSMASQ_CONFIG not in _get_conf_hashes("dnsmasq") diff --git a/src/yunohost/tests/test_service.py b/src/yunohost/tests/test_service.py new file mode 100644 index 000000000..88013a3fe --- /dev/null +++ b/src/yunohost/tests/test_service.py @@ -0,0 +1,142 @@ +import os + +from .conftest import raiseYunohostError + +from yunohost.service import ( + _get_services, + _save_services, + service_status, + service_add, + service_remove, + service_log, + service_reload_or_restart, +) + + +def setup_function(function): + + clean() + + +def teardown_function(function): + + clean() + + +def clean(): + + # To run these tests, we assume ssh(d) service exists and is running + assert os.system("pgrep sshd >/dev/null") == 0 + + services = _get_services() + assert "ssh" in services + + if "dummyservice" in services: + del services["dummyservice"] + + if "networking" in services: + del services["networking"] + + _save_services(services) + + if os.path.exists("/etc/nginx/conf.d/broken.conf"): + os.remove("/etc/nginx/conf.d/broken.conf") + os.system("systemctl reload-or-restart nginx") + + +def test_service_status_all(): + + status = service_status() + assert "ssh" in status.keys() + assert status["ssh"]["status"] == "running" + + +def test_service_status_single(): + + status = service_status("ssh") + assert "status" in status.keys() + assert status["status"] == "running" + + +def test_service_log(): + + logs = service_log("ssh") + assert "journalctl" in logs.keys() + assert "/var/log/auth.log" in logs.keys() + + +def test_service_status_unknown_service(mocker): + + with raiseYunohostError(mocker, "service_unknown"): + service_status(["ssh", "doesnotexists"]) + + +def test_service_add(): + + service_add("dummyservice", description="A dummy service to run tests") + assert "dummyservice" in service_status().keys() + + +def test_service_add_real_service(): + + service_add("networking") + assert "networking" in service_status().keys() + + +def test_service_remove(): + + service_add("dummyservice", description="A dummy service to run tests") + assert "dummyservice" in service_status().keys() + service_remove("dummyservice") + assert "dummyservice" not in service_status().keys() + + +def test_service_remove_service_that_doesnt_exists(mocker): + + assert "dummyservice" not in service_status().keys() + + with raiseYunohostError(mocker, "service_unknown"): + service_remove("dummyservice") + + assert "dummyservice" not in service_status().keys() + + +def test_service_update_to_add_properties(): + + service_add("dummyservice", description="dummy") + assert not _get_services()["dummyservice"].get("test_status") + service_add("dummyservice", description="dummy", test_status="true") + assert _get_services()["dummyservice"].get("test_status") == "true" + + +def test_service_update_to_change_properties(): + + service_add("dummyservice", description="dummy", test_status="false") + assert _get_services()["dummyservice"].get("test_status") == "false" + service_add("dummyservice", description="dummy", test_status="true") + assert _get_services()["dummyservice"].get("test_status") == "true" + + +def test_service_update_to_remove_properties(): + + service_add("dummyservice", description="dummy", test_status="false") + assert _get_services()["dummyservice"].get("test_status") == "false" + service_add("dummyservice", description="dummy", test_status="") + assert not _get_services()["dummyservice"].get("test_status") + + +def test_service_conf_broken(): + + os.system("echo pwet > /etc/nginx/conf.d/broken.conf") + + status = service_status("nginx") + assert status["status"] == "running" + assert status["configuration"] == "broken" + assert "broken.conf" in status["configuration-details"][0] + + # Service reload-or-restart should check that the conf ain't valid + # before reload-or-restart, hence the service should still be running + service_reload_or_restart("nginx") + assert status["status"] == "running" + + os.remove("/etc/nginx/conf.d/broken.conf") diff --git a/src/yunohost/tests/test_settings.py b/src/yunohost/tests/test_settings.py index 0da12597f..1a9063e56 100644 --- a/src/yunohost/tests/test_settings.py +++ b/src/yunohost/tests/test_settings.py @@ -1,12 +1,28 @@ import os import json +import glob import pytest from yunohost.utils.error import YunohostError -from yunohost.settings import settings_get, settings_list, _get_settings, \ - settings_set, settings_reset, settings_reset_all, \ - SETTINGS_PATH_OTHER_LOCATION, SETTINGS_PATH +import yunohost.settings as settings + +from yunohost.settings import ( + settings_get, + settings_list, + _get_settings, + settings_set, + settings_reset, + settings_reset_all, + SETTINGS_PATH_OTHER_LOCATION, + SETTINGS_PATH, + DEFAULTS, +) + +DEFAULTS["example.bool"] = {"type": "bool", "default": True} +DEFAULTS["example.int"] = {"type": "int", "default": 42} +DEFAULTS["example.string"] = {"type": "string", "default": "yolo swag"} +DEFAULTS["example.enum"] = {"type": "enum", "default": "a", "choices": ["a", "b", "c"]} def setup_function(function): @@ -15,6 +31,15 @@ def setup_function(function): def teardown_function(function): os.system("mv /etc/yunohost/settings.json.saved /etc/yunohost/settings.json") + for filename in glob.glob("/etc/yunohost/settings-*.json"): + os.remove(filename) + + +def monkey_get_setting_description(key): + return "Dummy %s setting" % key.split(".")[-1] + + +settings._get_setting_description = monkey_get_setting_description def test_settings_get_bool(): @@ -22,7 +47,12 @@ def test_settings_get_bool(): def test_settings_get_full_bool(): - assert settings_get("example.bool", True) == {"type": "bool", "value": True, "default": True, "description": "Example boolean option"} + assert settings_get("example.bool", True) == { + "type": "bool", + "value": True, + "default": True, + "description": "Dummy bool setting", + } def test_settings_get_int(): @@ -30,7 +60,12 @@ def test_settings_get_int(): def test_settings_get_full_int(): - assert settings_get("example.int", True) == {"type": "int", "value": 42, "default": 42, "description": "Example int option"} + assert settings_get("example.int", True) == { + "type": "int", + "value": 42, + "default": 42, + "description": "Dummy int setting", + } def test_settings_get_string(): @@ -38,7 +73,12 @@ def test_settings_get_string(): def test_settings_get_full_string(): - assert settings_get("example.string", True) == {"type": "string", "value": "yolo swag", "default": "yolo swag", "description": "Example string option"} + assert settings_get("example.string", True) == { + "type": "string", + "value": "yolo swag", + "default": "yolo swag", + "description": "Dummy string setting", + } def test_settings_get_enum(): @@ -46,7 +86,13 @@ def test_settings_get_enum(): def test_settings_get_full_enum(): - assert settings_get("example.enum", True) == {"type": "enum", "value": "a", "default": "a", "description": "Example enum option", "choices": ["a", "b", "c"]} + assert settings_get("example.enum", True) == { + "type": "enum", + "value": "a", + "default": "a", + "description": "Dummy enum setting", + "choices": ["a", "b", "c"], + } def test_settings_get_doesnt_exists(): @@ -60,7 +106,10 @@ def test_settings_list(): def test_settings_set(): settings_set("example.bool", False) - assert settings_get("example.bool") == False + assert settings_get("example.bool") is False + + settings_set("example.bool", "on") + assert settings_get("example.bool") is True def test_settings_set_int(): @@ -112,7 +161,12 @@ def test_settings_set_bad_value_enum(): def test_settings_list_modified(): settings_set("example.int", 21) - assert settings_list()["example.int"] == {'default': 42, 'description': 'Example int option', 'type': 'int', 'value': 21} + assert settings_list()["example.int"] == { + "default": 42, + "description": "Dummy int setting", + "type": "int", + "value": 21, + } def test_reset(): diff --git a/src/yunohost/tests/test_user-group.py b/src/yunohost/tests/test_user-group.py index 3973f0c7d..60e748108 100644 --- a/src/yunohost/tests/test_user-group.py +++ b/src/yunohost/tests/test_user-group.py @@ -1,56 +1,80 @@ import pytest -from moulinette.core import MoulinetteError -from yunohost.user import user_list, user_info, user_group_list, user_create, user_delete, user_update, user_group_add, user_group_delete, user_group_update, user_group_info +from .conftest import message, raiseYunohostError + +from yunohost.user import ( + user_list, + user_info, + user_create, + user_delete, + user_update, + user_import, + user_export, + FIELDS_FOR_IMPORT, + FIRST_ALIASES, + user_group_list, + user_group_create, + user_group_delete, + user_group_update, +) from yunohost.domain import _get_maindomain -from yunohost.utils.error import YunohostError from yunohost.tests.test_permission import check_LDAP_db_integrity # Get main domain -maindomain = _get_maindomain() +maindomain = "" + def clean_user_groups(): - for u in user_list()['users']: - user_delete(u) + for u in user_list()["users"]: + user_delete(u, purge=True) - for g in user_group_list()['groups']: - if g != "all_users": + for g in user_group_list()["groups"]: + if g not in ["all_users", "visitors"]: user_group_delete(g) + def setup_function(function): clean_user_groups() - user_create("alice", "Alice", "White", "alice@" + maindomain, "test123Ynh") - user_create("bob", "Bob", "Snow", "bob@" + maindomain, "test123Ynh") - user_create("jack", "Jack", "Black", "jack@" + maindomain, "test123Ynh") + global maindomain + maindomain = _get_maindomain() + + user_create("alice", "Alice", "White", maindomain, "test123Ynh") + user_create("bob", "Bob", "Snow", maindomain, "test123Ynh") + user_create("jack", "Jack", "Black", maindomain, "test123Ynh") + + user_group_create("dev") + user_group_create("apps") + user_group_update("dev", add=["alice"]) + user_group_update("apps", add=["bob"]) - user_group_add("dev") - user_group_add("apps") - user_group_update("dev", add_user=["alice"]) - user_group_update("apps", add_user=["bob"]) def teardown_function(function): clean_user_groups() + @pytest.fixture(autouse=True) def check_LDAP_db_integrity_call(): check_LDAP_db_integrity() yield check_LDAP_db_integrity() + # # List functions # + def test_list_users(): - res = user_list()['users'] + res = user_list()["users"] assert "alice" in res assert "bob" in res assert "jack" in res + def test_list_groups(): - res = user_group_list()['groups'] + res = user_group_list()["groups"] assert "all_users" in res assert "alice" in res @@ -58,153 +82,263 @@ def test_list_groups(): assert "jack" in res for u in ["alice", "bob", "jack"]: assert u in res - assert u in res[u]['members'] - assert u in res["all_users"]['members'] + assert u in res[u]["members"] + assert u in res["all_users"]["members"] + # # Create - Remove functions # -def test_create_user(): - user_create("albert", "Albert", "Good", "alber@" + maindomain, "test123Ynh") - group_res = user_group_list()['groups'] - assert "albert" in user_list()['users'] +def test_create_user(mocker): + + with message(mocker, "user_created"): + user_create("albert", "Albert", "Good", maindomain, "test123Ynh") + + group_res = user_group_list()["groups"] + assert "albert" in user_list()["users"] assert "albert" in group_res - assert "albert" in group_res['albert']['members'] - assert "albert" in group_res['all_users']['members'] + assert "albert" in group_res["albert"]["members"] + assert "albert" in group_res["all_users"]["members"] -def test_del_user(): - user_delete("alice") - group_res = user_group_list()['groups'] +def test_del_user(mocker): + + with message(mocker, "user_deleted"): + user_delete("alice") + + group_res = user_group_list()["groups"] assert "alice" not in user_list() assert "alice" not in group_res - assert "alice" not in group_res['all_users']['members'] + assert "alice" not in group_res["all_users"]["members"] -def test_add_group(): - user_group_add("adminsys") - group_res = user_group_list()['groups'] +def test_import_user(mocker): + import csv + from io import StringIO + + fieldnames = [ + "username", + "firstname", + "lastname", + "password", + "mailbox-quota", + "mail", + "mail-alias", + "mail-forward", + "groups", + ] + with StringIO() as csv_io: + writer = csv.DictWriter(csv_io, fieldnames, delimiter=";", quotechar='"') + writer.writeheader() + writer.writerow( + { + "username": "albert", + "firstname": "Albert", + "lastname": "Good", + "password": "", + "mailbox-quota": "1G", + "mail": "albert@" + maindomain, + "mail-alias": "albert2@" + maindomain, + "mail-forward": "albert@example.com", + "groups": "dev", + } + ) + writer.writerow( + { + "username": "alice", + "firstname": "Alice", + "lastname": "White", + "password": "", + "mailbox-quota": "1G", + "mail": "alice@" + maindomain, + "mail-alias": "alice1@" + maindomain + ",alice2@" + maindomain, + "mail-forward": "", + "groups": "apps", + } + ) + csv_io.seek(0) + with message(mocker, "user_import_success"): + user_import(csv_io, update=True, delete=True) + + group_res = user_group_list()["groups"] + user_res = user_list(list(FIELDS_FOR_IMPORT.keys()))["users"] + assert "albert" in user_res + assert "alice" in user_res + assert "bob" not in user_res + assert len(user_res["alice"]["mail-alias"]) == 2 + assert "albert" in group_res["dev"]["members"] + assert "alice" in group_res["apps"]["members"] + assert "alice" not in group_res["dev"]["members"] + + +def test_export_user(mocker): + result = user_export() + aliases = ",".join([alias + maindomain for alias in FIRST_ALIASES]) + should_be = ( + "username;firstname;lastname;password;mail;mail-alias;mail-forward;mailbox-quota;groups\r\n" + f"alice;Alice;White;;alice@{maindomain};{aliases};;0;dev\r\n" + f"bob;Bob;Snow;;bob@{maindomain};;;0;apps\r\n" + f"jack;Jack;Black;;jack@{maindomain};;;0;" + ) + assert result == should_be + + +def test_create_group(mocker): + + with message(mocker, "group_created", group="adminsys"): + user_group_create("adminsys") + + group_res = user_group_list()["groups"] assert "adminsys" in group_res - assert "members" not in group_res['adminsys'] + assert "members" in group_res["adminsys"].keys() + assert group_res["adminsys"]["members"] == [] -def test_del_group(): - user_group_delete("dev") - group_res = user_group_list()['groups'] +def test_del_group(mocker): + + with message(mocker, "group_deleted", group="dev"): + user_group_delete("dev") + + group_res = user_group_list()["groups"] assert "dev" not in group_res + # # Error on create / remove function # -def test_add_bad_user_1(): - # Check email already exist - with pytest.raises(MoulinetteError): - user_create("alice2", "Alice", "White", "alice@" + maindomain, "test123Ynh") -def test_add_bad_user_2(): - # Check to short password - with pytest.raises(MoulinetteError): - user_create("other", "Alice", "White", "other@" + maindomain, "12") +def test_create_user_with_password_too_simple(mocker): + with raiseYunohostError(mocker, "password_listed"): + user_create("other", "Alice", "White", maindomain, "12") -def test_add_bad_user_3(): - # Check user already exist - with pytest.raises(MoulinetteError): - user_create("alice", "Alice", "White", "other@" + maindomain, "test123Ynh") -def test_del_bad_user_1(): - # Check user not found - with pytest.raises(MoulinetteError): - user_delete("not_exit") +def test_create_user_already_exists(mocker): + with raiseYunohostError(mocker, "user_already_exists"): + user_create("alice", "Alice", "White", maindomain, "test123Ynh") -def test_add_bad_group_1(): + +def test_create_user_with_domain_that_doesnt_exists(mocker): + with raiseYunohostError(mocker, "domain_name_unknown"): + user_create("alice", "Alice", "White", "doesnt.exists", "test123Ynh") + + +def test_update_user_with_mail_address_already_taken(mocker): + with raiseYunohostError(mocker, "user_update_failed"): + user_update("bob", add_mailalias="alice@" + maindomain) + + +def test_update_user_with_mail_address_with_unknown_domain(mocker): + with raiseYunohostError(mocker, "mail_domain_unknown"): + user_update("alice", add_mailalias="alice@doesnt.exists") + + +def test_del_user_that_does_not_exist(mocker): + with raiseYunohostError(mocker, "user_unknown"): + user_delete("doesnt_exist") + + +def test_create_group_all_users(mocker): # Check groups already exist with special group "all_users" - with pytest.raises(YunohostError): - user_group_add("all_users") + with raiseYunohostError(mocker, "group_already_exist"): + user_group_create("all_users") -def test_add_bad_group_2(): - # Check groups already exist (for standard groups) - with pytest.raises(MoulinetteError): - user_group_add("dev") -def test_del_bad_group_1(): - # Check not allowed to remove this groups - with pytest.raises(YunohostError): +def test_create_group_already_exists(mocker): + # Check groups already exist (regular groups) + with raiseYunohostError(mocker, "group_already_exist"): + user_group_create("dev") + + +def test_del_group_all_users(mocker): + with raiseYunohostError(mocker, "group_cannot_be_deleted"): user_group_delete("all_users") -def test_del_bad_group_2(): - # Check groups not found - with pytest.raises(MoulinetteError): - user_group_delete("not_exit") + +def test_del_group_that_does_not_exist(mocker): + with raiseYunohostError(mocker, "group_unknown"): + user_group_delete("doesnt_exist") + # # Update function # -def test_update_user_1(): - user_update("alice", firstname="NewName", lastname="NewLast") + +def test_update_user(mocker): + with message(mocker, "user_updated"): + user_update("alice", firstname="NewName", lastname="NewLast") info = user_info("alice") - assert "NewName" == info['firstname'] - assert "NewLast" == info['lastname'] + assert info["firstname"] == "NewName" + assert info["lastname"] == "NewLast" -def test_update_group_1(): - user_group_update("dev", add_user=["bob"]) - group_res = user_group_list()['groups'] - assert set(["alice", "bob"]) == set(group_res['dev']['members']) +def test_update_group_add_user(mocker): + with message(mocker, "group_updated", group="dev"): + user_group_update("dev", add=["bob"]) -def test_update_group_2(): - # Try to add a user in a group when the user is already in - user_group_update("apps", add_user=["bob"]) + group_res = user_group_list()["groups"] + assert set(group_res["dev"]["members"]) == set(["alice", "bob"]) - group_res = user_group_list()['groups'] - assert ["bob"] == group_res['apps']['members'] -def test_update_group_3(): - # Try to remove a user in a group - user_group_update("apps", remove_user=["bob"]) +def test_update_group_add_user_already_in(mocker): + with message(mocker, "group_user_already_in_group", user="bob", group="apps"): + user_group_update("apps", add=["bob"]) - group_res = user_group_list()['groups'] - assert "members" not in group_res['apps'] + group_res = user_group_list()["groups"] + assert group_res["apps"]["members"] == ["bob"] -def test_update_group_4(): - # Try to remove a user in a group when it is not already in - user_group_update("apps", remove_user=["jack"]) - group_res = user_group_list()['groups'] - assert ["bob"] == group_res['apps']['members'] +def test_update_group_remove_user(mocker): + with message(mocker, "group_updated", group="apps"): + user_group_update("apps", remove=["bob"]) + + group_res = user_group_list()["groups"] + assert group_res["apps"]["members"] == [] + + +def test_update_group_remove_user_not_already_in(mocker): + with message(mocker, "group_user_not_in_group", user="jack", group="apps"): + user_group_update("apps", remove=["jack"]) + + group_res = user_group_list()["groups"] + assert group_res["apps"]["members"] == ["bob"] + # # Error on update functions # -def test_bad_update_user_1(): - # Check user not found - with pytest.raises(YunohostError): - user_update("not_exit", firstname="NewName", lastname="NewLast") + +def test_update_user_that_doesnt_exist(mocker): + with raiseYunohostError(mocker, "user_unknown"): + user_update("doesnt_exist", firstname="NewName", lastname="NewLast") -def bad_update_group_1(): - # Check groups not found - with pytest.raises(YunohostError): - user_group_update("not_exit", add_user=["alice"]) +def test_update_group_that_doesnt_exist(mocker): + with raiseYunohostError(mocker, "group_unknown"): + user_group_update("doesnt_exist", add=["alice"]) -def test_bad_update_group_2(): - # Check remove user in groups "all_users" not allowed - with pytest.raises(YunohostError): - user_group_update("all_users", remove_user=["alice"]) -def test_bad_update_group_3(): - # Check remove user in it own group not allowed - with pytest.raises(YunohostError): - user_group_update("alice", remove_user=["alice"]) +def test_update_group_all_users_manually(mocker): + with raiseYunohostError(mocker, "group_cannot_edit_all_users"): + user_group_update("all_users", remove=["alice"]) -def test_bad_update_group_1(): - # Check add bad user in group - with pytest.raises(YunohostError): - user_group_update("dev", add_user=["not_exist"]) + assert "alice" in user_group_list()["groups"]["all_users"]["members"] - assert "not_exist" not in user_group_list()["groups"]["dev"] + +def test_update_group_primary_manually(mocker): + with raiseYunohostError(mocker, "group_cannot_edit_primary_group"): + user_group_update("alice", remove=["alice"]) + + assert "alice" in user_group_list()["groups"]["alice"]["members"] + + +def test_update_group_add_user_that_doesnt_exist(mocker): + with raiseYunohostError(mocker, "user_unknown"): + user_group_update("dev", add=["doesnt_exist"]) + + assert "doesnt_exist" not in user_group_list()["groups"]["dev"]["members"] diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index c63f1ed33..ed8c04153 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -25,94 +25,45 @@ """ import re import os -import yaml -import json import subprocess -import pwd -import socket -from xmlrpclib import Fault +import time from importlib import import_module -from collections import OrderedDict +from packaging import version +from typing import List -from moulinette import msignals, m18n +from moulinette import Moulinette, m18n from moulinette.utils.log import getActionLogger from moulinette.utils.process import check_output, call_async_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_maindomain, _set_maindomain +from moulinette.utils.filesystem import read_yaml, write_to_yaml + +from yunohost.app import ( + _update_apps_catalog, + app_info, + app_upgrade, + _initialize_apps_catalog_system, +) +from yunohost.domain import domain_add from yunohost.dyndns import _dyndns_available, _dyndns_provides from yunohost.firewall import firewall_upnp -from yunohost.service import service_status, service_start, service_enable +from yunohost.service import service_start, service_enable from yunohost.regenconf import regen_conf -from yunohost.monitor import monitor_disk, monitor_system -from yunohost.utils.packages import ynh_packages_version, _dump_sources_list, _list_upgradable_apt_packages -from yunohost.utils.network import get_public_ip -from yunohost.utils.error import YunohostError +from yunohost.utils.packages import ( + _dump_sources_list, + _list_upgradable_apt_packages, + ynh_packages_version, +) +from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.log import is_unit_operation, OperationLogger # FIXME this is a duplicate from apps.py -APPS_SETTING_PATH = '/etc/yunohost/apps/' -MIGRATIONS_STATE_PATH = "/etc/yunohost/migrations_state.json" +APPS_SETTING_PATH = "/etc/yunohost/apps/" +MIGRATIONS_STATE_PATH = "/etc/yunohost/migrations.yaml" -logger = getActionLogger('yunohost.tools') +logger = getActionLogger("yunohost.tools") -def tools_ldapinit(): - """ - YunoHost LDAP initialization - - - """ - - with open('/usr/share/yunohost/yunohost-config/moulinette/ldap_scheme.yml') as f: - ldap_map = yaml.load(f) - - from yunohost.utils.ldap import _get_ldap_interface - ldap = _get_ldap_interface() - - for rdn, attr_dict in ldap_map['parents'].items(): - try: - ldap.add(rdn, attr_dict) - except Exception as e: - logger.warn("Error when trying to inject '%s' -> '%s' into ldap: %s" % (rdn, attr_dict, e)) - - for rdn, attr_dict in ldap_map['children'].items(): - try: - ldap.add(rdn, attr_dict) - except Exception as e: - logger.warn("Error when trying to inject '%s' -> '%s' into ldap: %s" % (rdn, attr_dict, e)) - - for rdn, attr_dict in ldap_map['depends_children'].items(): - try: - ldap.add(rdn, attr_dict) - except Exception as e: - logger.warn("Error when trying to inject '%s' -> '%s' into ldap: %s" % (rdn, attr_dict, e)) - - admin_dict = { - 'cn': 'admin', - 'uid': 'admin', - 'description': 'LDAP Administrator', - 'gidNumber': '1007', - 'uidNumber': '1007', - 'homeDirectory': '/home/admin', - 'loginShell': '/bin/bash', - 'objectClass': ['organizationalRole', 'posixAccount', 'simpleSecurityObject'], - 'userPassword': 'yunohost' - } - - ldap.update('cn=admin', admin_dict) - - # Force nscd to refresh cache to take admin creation into account - subprocess.call(['nscd', '-i', 'passwd']) - - # Check admin actually exists now - try: - pwd.getpwnam("admin") - except KeyError: - logger.error(m18n.n('ldap_init_failed_to_create_admin')) - raise YunohostError('installation_failed') - - logger.success(m18n.n('ldap_initialized')) +def tools_versions(): + return ynh_packages_version() def tools_adminpw(new_password, check_strength=True): @@ -133,91 +84,59 @@ def tools_adminpw(new_password, check_strength=True): # UNIX seems to not like password longer than 127 chars ... # e.g. SSH login gets broken (or even 'su admin' when entering the password) if len(new_password) >= 127: - raise YunohostError('admin_password_too_long') + raise YunohostValidationError("admin_password_too_long") new_hash = _hash_user_password(new_password) from yunohost.utils.ldap import _get_ldap_interface + ldap = _get_ldap_interface() try: - ldap.update("cn=admin", {"userPassword": new_hash, }) - except: - logger.exception('unable to change admin password') - raise YunohostError('admin_password_change_failed') + ldap.update( + "cn=admin", + {"userPassword": [new_hash]}, + ) + except Exception as e: + logger.error("unable to change admin password : %s" % e) + raise YunohostError("admin_password_change_failed") else: # Write as root password try: hash_root = spwd.getspnam("root").sp_pwd - with open('/etc/shadow', 'r') as before_file: + with open("/etc/shadow", "r") as before_file: before = before_file.read() - with open('/etc/shadow', 'w') as after_file: - after_file.write(before.replace("root:" + hash_root, - "root:" + new_hash.replace('{CRYPT}', ''))) - except IOError: - logger.warning(m18n.n('root_password_desynchronized')) + with open("/etc/shadow", "w") as after_file: + after_file.write( + before.replace( + "root:" + hash_root, "root:" + new_hash.replace("{CRYPT}", "") + ) + ) + # An IOError may be thrown if for some reason we can't read/write /etc/passwd + # A KeyError could also be thrown if 'root' is not in /etc/passwd in the first place (for example because no password defined ?) + # (c.f. the line about getspnam) + except (IOError, KeyError): + logger.warning(m18n.n("root_password_desynchronized")) return logger.info(m18n.n("root_password_replaced_by_admin_password")) - logger.success(m18n.n('admin_password_changed')) + logger.success(m18n.n("admin_password_changed")) -@is_unit_operation() -def tools_maindomain(operation_logger, new_domain=None): - """ - Check the current main domain, or change it +def tools_maindomain(new_main_domain=None): + from yunohost.domain import domain_main_domain - Keyword argument: - new_domain -- The new domain to be set as the main domain - - """ - - # If no new domain specified, we return the current main domain - if not new_domain: - return {'current_main_domain': _get_maindomain()} - - # Check domain exists - if new_domain not in domain_list()['domains']: - raise YunohostError('domain_unknown') - - operation_logger.related_to.append(('domain', new_domain)) - operation_logger.start() - - # Apply changes to ssl certs - ssl_key = "/etc/ssl/private/yunohost_key.pem" - ssl_crt = "/etc/ssl/private/yunohost_crt.pem" - new_ssl_key = "/etc/yunohost/certs/%s/key.pem" % new_domain - new_ssl_crt = "/etc/yunohost/certs/%s/crt.pem" % new_domain - - try: - if os.path.exists(ssl_key) or os.path.lexists(ssl_key): - os.remove(ssl_key) - if os.path.exists(ssl_crt) or os.path.lexists(ssl_crt): - os.remove(ssl_crt) - - os.symlink(new_ssl_key, ssl_key) - os.symlink(new_ssl_crt, ssl_crt) - - _set_maindomain(new_domain) - except Exception as e: - logger.warning("%s" % e, exc_info=1) - raise YunohostError('maindomain_change_failed') - - _set_hostname(new_domain) - - # Generate SSOwat configuration file - app_ssowatconf() - - # Regen configurations - try: - with open('/etc/yunohost/installed', 'r'): - regen_conf() - except IOError: - pass - - logger.success(m18n.n('maindomain_changed')) + logger.warning( + m18n.g( + "deprecated_command_alias", + prog="yunohost", + old="tools maindomain", + new="domain main-domain", + ) + ) + return domain_main_domain(new_main_domain=new_main_domain) def _set_hostname(hostname, pretty_hostname=None): @@ -229,26 +148,24 @@ def _set_hostname(hostname, pretty_hostname=None): pretty_hostname = "(YunoHost/%s)" % hostname # First clear nsswitch cache for hosts to make sure hostname is resolved... - subprocess.call(['nscd', '-i', 'hosts']) + subprocess.call(["nscd", "-i", "hosts"]) # Then call hostnamectl commands = [ - "sudo hostnamectl --static set-hostname".split() + [hostname], - "sudo hostnamectl --transient set-hostname".split() + [hostname], - "sudo hostnamectl --pretty set-hostname".split() + [pretty_hostname] + "hostnamectl --static set-hostname".split() + [hostname], + "hostnamectl --transient set-hostname".split() + [hostname], + "hostnamectl --pretty set-hostname".split() + [pretty_hostname], ] for command in commands: - p = subprocess.Popen(command, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) + p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) out, _ = p.communicate() if p.returncode != 0: logger.warning(command) logger.warning(out) - logger.error(m18n.n('domain_hostname_failed')) + logger.error(m18n.n("domain_hostname_failed")) else: logger.debug(out) @@ -259,17 +176,23 @@ def _detect_virt(): You can check the man of the command to have a list of possible outputs... """ - p = subprocess.Popen("systemd-detect-virt".split(), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) + p = subprocess.Popen( + "systemd-detect-virt".split(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) out, _ = p.communicate() return out.split()[0] @is_unit_operation() -def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, - force_password=False): +def tools_postinstall( + operation_logger, + domain, + password, + ignore_dyndns=False, + force_password=False, + force_diskspace=False, +): """ YunoHost post-install @@ -281,12 +204,30 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, """ from yunohost.utils.password import assert_password_is_strong_enough + from yunohost.domain import domain_main_domain + import psutil dyndns_provider = "dyndns.yunohost.org" # Do some checks at first - if os.path.isfile('/etc/yunohost/installed'): - raise YunohostError('yunohost_already_installed') + if os.path.isfile("/etc/yunohost/installed"): + raise YunohostValidationError("yunohost_already_installed") + + if os.path.isdir("/etc/yunohost/apps") and os.listdir("/etc/yunohost/apps") != []: + raise YunohostValidationError( + "It looks like you're trying to re-postinstall a system that was already working previously ... If you recently had some bug or issues with your installation, please first discuss with the team on how to fix the situation instead of savagely re-running the postinstall ...", + raw_msg=True, + ) + + # Check there's at least 10 GB on the rootfs... + disk_partitions = sorted(psutil.disk_partitions(), key=lambda k: k.mountpoint) + main_disk_partitions = [d for d in disk_partitions if d.mountpoint in ["/", "/var"]] + main_space = sum( + [psutil.disk_usage(d.mountpoint).total for d in main_disk_partitions] + ) + GB = 1024 ** 3 + if not force_diskspace and main_space < 10 * GB: + raise YunohostValidationError("postinstall_low_rootfsspace") # Check password if not force_password: @@ -300,9 +241,10 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, # If an exception is thrown, most likely we don't have internet # connectivity or something. Assume that this domain isn't manageable # and inform the user that we could not contact the dyndns host server. - except: - logger.warning(m18n.n('dyndns_provider_unreachable', - provider=dyndns_provider)) + except Exception: + logger.warning( + m18n.n("dyndns_provider_unreachable", provider=dyndns_provider) + ) is_nohostme_or_nohost = False # If this is a nohost.me/noho.st, actually check for availability @@ -315,121 +257,52 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, dyndns = True # If not, abort the postinstall else: - raise YunohostError('dyndns_unavailable', domain=domain) + raise YunohostValidationError("dyndns_unavailable", domain=domain) else: dyndns = False else: dyndns = False + if os.system("iptables -V >/dev/null 2>/dev/null") != 0: + raise YunohostValidationError( + "iptables/nftables does not seems to be working on your setup. You may be in a container or your kernel does have the proper modules loaded. Sometimes, rebooting the machine may solve the issue.", + raw_msg=True, + ) + operation_logger.start() - logger.info(m18n.n('yunohost_installing')) - - regen_conf(['nslcd', 'nsswitch'], force=True) - - # Initialize LDAP for YunoHost - # TODO: Improve this part by integrate ldapinit into conf_regen hook - tools_ldapinit() - - # Create required folders - folders_to_create = [ - '/etc/yunohost/apps', - '/etc/yunohost/certs', - '/var/cache/yunohost/repo', - '/home/yunohost.backup', - '/home/yunohost.app' - ] - - for folder in filter(lambda x: not os.path.exists(x), folders_to_create): - os.makedirs(folder) - - # Change folders permissions - os.system('chmod 755 /home/yunohost.app') - - # Set hostname to avoid amavis bug - if os.system('hostname -d >/dev/null') != 0: - os.system('hostname yunohost.yunohost.org') - - # Add a temporary SSOwat rule to redirect SSO to admin page - try: - with open('/etc/ssowat/conf.json.persistent') as json_conf: - ssowat_conf = json.loads(str(json_conf.read())) - except ValueError as e: - raise YunohostError('ssowat_persistent_conf_read_error', error=str(e)) - except IOError: - ssowat_conf = {} - - if 'redirected_urls' not in ssowat_conf: - ssowat_conf['redirected_urls'] = {} - - ssowat_conf['redirected_urls']['/'] = domain + '/yunohost/admin' - - try: - with open('/etc/ssowat/conf.json.persistent', 'w+') as f: - json.dump(ssowat_conf, f, sort_keys=True, indent=4) - except IOError as e: - raise YunohostError('ssowat_persistent_conf_write_error', error=str(e)) - - os.system('chmod 644 /etc/ssowat/conf.json.persistent') - - # Create SSL CA - regen_conf(['ssl'], force=True) - ssl_dir = '/usr/share/yunohost/yunohost-config/ssl/yunoCA' - # (Update the serial so that it's specific to this very instance) - os.system("openssl rand -hex 19 > %s/serial" % ssl_dir) - commands = [ - 'rm %s/index.txt' % ssl_dir, - 'touch %s/index.txt' % ssl_dir, - 'cp %s/openssl.cnf %s/openssl.ca.cnf' % (ssl_dir, ssl_dir), - 'sed -i s/yunohost.org/%s/g %s/openssl.ca.cnf ' % (domain, ssl_dir), - 'openssl req -x509 -new -config %s/openssl.ca.cnf -days 3650 -out %s/ca/cacert.pem -keyout %s/ca/cakey.pem -nodes -batch' % (ssl_dir, ssl_dir, ssl_dir), - 'cp %s/ca/cacert.pem /etc/ssl/certs/ca-yunohost_crt.pem' % ssl_dir, - 'update-ca-certificates' - ] - - for command in commands: - p = subprocess.Popen( - command.split(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - - out, _ = p.communicate() - - if p.returncode != 0: - logger.warning(out) - raise YunohostError('yunohost_ca_creation_failed') - else: - logger.debug(out) - - logger.success(m18n.n('yunohost_ca_creation_success')) + logger.info(m18n.n("yunohost_installing")) # New domain config - regen_conf(['nsswitch'], force=True) domain_add(domain, dyndns) - tools_maindomain(domain) + domain_main_domain(domain) - # Change LDAP admin password + # Update LDAP admin and create home dir tools_adminpw(password, check_strength=not force_password) # Enable UPnP silently and reload firewall - firewall_upnp('enable', no_refresh=True) + firewall_upnp("enable", no_refresh=True) - # Setup the default apps list with cron job + # Initialize the apps catalog system + _initialize_apps_catalog_system() + + # Try to update the apps catalog ... + # we don't fail miserably if this fails, + # because that could be for example an offline installation... try: - app_fetchlist(name="yunohost", - url="https://app.yunohost.org/apps.json") + _update_apps_catalog() except Exception as e: logger.warning(str(e)) - _install_appslist_fetch_cron() - # Init migrations (skip them, no need to run them on a fresh system) _skip_all_migrations() - os.system('touch /etc/yunohost/installed') + os.system("touch /etc/yunohost/installed") # Enable and start YunoHost firewall at boot time service_enable("yunohost-firewall") service_start("yunohost-firewall") - regen_conf(force=True) + regen_conf(names=["ssh"], force=True) # Restore original ssh conf, as chosen by the # admin during the initial install @@ -440,51 +313,65 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, # the initial, existing sshd configuration # instead of YunoHost's recommended conf # - original_sshd_conf = '/etc/ssh/sshd_config.before_yunohost' + 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) - regen_conf(names=["ssh"], force=True) + os.rename(original_sshd_conf, "/etc/ssh/sshd_config") - logger.success(m18n.n('yunohost_configured')) + regen_conf(force=True) - logger.warning(m18n.n('recommend_to_add_first_user')) + logger.success(m18n.n("yunohost_configured")) + + logger.warning(m18n.n("yunohost_postinstall_end_tip")) -def tools_regen_conf(names=[], with_diff=False, force=False, dry_run=False, - list_pending=False): +def tools_regen_conf( + names=[], with_diff=False, force=False, dry_run=False, list_pending=False +): return regen_conf(names, with_diff, force, dry_run, list_pending) -def tools_update(apps=False, system=False): +def tools_update(target=None, apps=False, system=False): """ Update apps & system package cache - - Keyword arguments: - system -- Fetch available system packages upgrades (equivalent to apt update) - apps -- Fetch the application list to check which apps can be upgraded """ - # If neither --apps nor --system specified, do both - if not apps and not system: - apps = True - system = True + # Legacy options (--system, --apps) + if apps or system: + logger.warning( + "Using 'yunohost tools update' with --apps / --system is deprecated, just write 'yunohost tools update apps system' (no -- prefix anymore)" + ) + if apps and system: + target = "all" + elif apps: + target = "apps" + else: + target = "system" + + elif not target: + target = "all" + + if target not in ["system", "apps", "all"]: + raise YunohostError( + "Unknown target %s, should be 'system', 'apps' or 'all'" % target, + raw_msg=True, + ) upgradable_system_packages = [] - if system: + if target in ["system", "all"]: # Update APT cache # LC_ALL=C is here to make sure the results are in english - command = "LC_ALL=C apt update" + command = "LC_ALL=C apt-get update -o Acquire::Retries=3" # Filter boring message about "apt not having a stable CLI interface" # Also keep track of wether or not we encountered a warning... warnings = [] def is_legit_warning(m): - legit_warning = m.rstrip() and "apt does not have a stable CLI interface" not in m.rstrip() + legit_warning = ( + m.rstrip() + and "apt does not have a stable CLI interface" not in m.rstrip() + ) if legit_warning: warnings.append(m) return legit_warning @@ -493,35 +380,43 @@ def tools_update(apps=False, system=False): # stdout goes to debug lambda l: logger.debug(l.rstrip()), # stderr goes to warning except for the boring apt messages - lambda l: logger.warning(l.rstrip()) if is_legit_warning(l) else logger.debug(l.rstrip()) + lambda l: logger.warning(l.rstrip()) + if is_legit_warning(l) + else logger.debug(l.rstrip()), ) - logger.info(m18n.n('updating_apt_cache')) + logger.info(m18n.n("updating_apt_cache")) returncode = call_async_output(command, callbacks, shell=True) if returncode != 0: - raise YunohostError('update_apt_cache_failed', sourceslist='\n'.join(_dump_sources_list())) + raise YunohostError( + "update_apt_cache_failed", sourceslist="\n".join(_dump_sources_list()) + ) elif warnings: - logger.error(m18n.n('update_apt_cache_warning', sourceslist='\n'.join(_dump_sources_list()))) + logger.error( + m18n.n( + "update_apt_cache_warning", + sourceslist="\n".join(_dump_sources_list()), + ) + ) upgradable_system_packages = list(_list_upgradable_apt_packages()) - logger.debug(m18n.n('done')) + logger.debug(m18n.n("done")) upgradable_apps = [] - if apps: - logger.info(m18n.n('updating_app_lists')) + if target in ["apps", "all"]: try: - app_fetchlist() + _update_apps_catalog() except YunohostError as e: - logger.error(m18n.n('tools_update_failed_to_app_fetchlist'), error=e) + logger.error(str(e)) upgradable_apps = list(_list_upgradable_apps()) if len(upgradable_apps) == 0 and len(upgradable_system_packages) == 0: - logger.info(m18n.n('already_up_to_date')) + logger.info(m18n.n("already_up_to_date")) - return {'system': upgradable_system_packages, 'apps': upgradable_apps} + return {"system": upgradable_system_packages, "apps": upgradable_apps} def _list_upgradable_apps(): @@ -529,29 +424,40 @@ def _list_upgradable_apps(): app_list_installed = os.listdir(APPS_SETTING_PATH) for app_id in app_list_installed: - app_dict = app_info(app_id, raw=True) + app_dict = app_info(app_id, full=True) if app_dict["upgradable"] == "yes": - current_version = app_dict.get("version", "?") - current_commit = app_dict.get("status", {}).get("remote", {}).get("revision", "?")[:7] - new_version = app_dict.get("manifest",{}).get("version","?") - new_commit = app_dict.get("git", {}).get("revision", "?")[:7] + # FIXME : would make more sense for these infos to be computed + # directly in app_info and used to check the upgradability of + # the app... + current_version = app_dict.get("manifest", {}).get("version", "?") + current_commit = app_dict.get("settings", {}).get("current_revision", "?")[ + :7 + ] + new_version = ( + app_dict.get("from_catalog", {}).get("manifest", {}).get("version", "?") + ) + new_commit = ( + app_dict.get("from_catalog", {}).get("git", {}).get("revision", "?")[:7] + ) if current_version == new_version: current_version += " (" + current_commit + ")" new_version += " (" + new_commit + ")" yield { - 'id': app_id, - 'label': app_dict['settings']['label'], - 'current_version': current_version, - 'new_version': new_version + "id": app_id, + "label": app_dict["label"], + "current_version": current_version, + "new_version": new_version, } @is_unit_operation() -def tools_upgrade(operation_logger, apps=None, system=False): +def tools_upgrade( + operation_logger, target=None, apps=False, system=False, allow_yunohost_upgrade=True +): """ Update apps & package cache, then display changelog @@ -560,44 +466,56 @@ def tools_upgrade(operation_logger, apps=None, system=False): system -- True to upgrade system """ from yunohost.utils import packages + if packages.dpkg_is_broken(): - raise YunohostError("dpkg_is_broken") + raise YunohostValidationError("dpkg_is_broken") # Check for obvious conflict with other dpkg/apt commands already running in parallel if not packages.dpkg_lock_available(): - raise YunohostError("dpkg_lock_not_available") + raise YunohostValidationError("dpkg_lock_not_available") - if system is not False and apps is not None: - raise YunohostError("tools_upgrade_cant_both") + # Legacy options management (--system, --apps) + if target is None: - if system is False and apps is None: - raise YunohostError("tools_upgrade_at_least_one") + logger.warning( + "Using 'yunohost tools upgrade' with --apps / --system is deprecated, just write 'yunohost tools upgrade apps' or 'system' (no -- prefix anymore)" + ) + + if (system, apps) == (True, True): + raise YunohostValidationError("tools_upgrade_cant_both") + + if (system, apps) == (False, False): + raise YunohostValidationError("tools_upgrade_at_least_one") + + target = "apps" if apps else "system" + + if target not in ["apps", "system"]: + raise Exception( + "Uhoh ?! tools_upgrade should have 'apps' or 'system' value for argument target" + ) # # Apps # This is basically just an alias to yunohost app upgrade ... # - if apps is not None: + if target == "apps": # Make sure there's actually something to upgrade upgradable_apps = [app["id"] for app in _list_upgradable_apps()] if not upgradable_apps: - logger.info(m18n.n("app_no_upgrade")) - return - elif len(apps) and all(app not in upgradable_apps for app in apps): logger.info(m18n.n("apps_already_up_to_date")) return # Actually start the upgrades try: - app_upgrade(app=apps) + app_upgrade(app=upgradable_apps) except Exception as e: - logger.warning('unable to upgrade apps: %s' % str(e)) - logger.error(m18n.n('app_upgrade_some_app_failed')) + logger.warning("unable to upgrade apps: %s" % str(e)) + logger.error(m18n.n("app_upgrade_some_app_failed")) return @@ -605,28 +523,34 @@ def tools_upgrade(operation_logger, apps=None, system=False): # System # - if system is True: + if target == "system": # Check that there's indeed some packages to upgrade upgradables = list(_list_upgradable_apt_packages()) if not upgradables: - logger.info(m18n.n('already_up_to_date')) + logger.info(m18n.n("already_up_to_date")) - logger.info(m18n.n('upgrading_packages')) + logger.info(m18n.n("upgrading_packages")) operation_logger.start() # Critical packages are packages that we can't just upgrade # randomly from yunohost itself... upgrading them is likely to - critical_packages = ("moulinette", "yunohost", "yunohost-admin", "ssowat", "python") + critical_packages = ["moulinette", "yunohost", "yunohost-admin", "ssowat"] - critical_packages_upgradable = [p for p in upgradables if p["name"] in critical_packages] - noncritical_packages_upgradable = [p for p in upgradables if p["name"] not in critical_packages] + critical_packages_upgradable = [ + p["name"] for p in upgradables if p["name"] in critical_packages + ] + noncritical_packages_upgradable = [ + p["name"] for p in upgradables if p["name"] not in critical_packages + ] # Prepare dist-upgrade command dist_upgrade = "DEBIAN_FRONTEND=noninteractive" dist_upgrade += " APT_LISTCHANGES_FRONTEND=none" dist_upgrade += " apt-get" - dist_upgrade += " --fix-broken --show-upgraded --assume-yes" + dist_upgrade += ( + " --fix-broken --show-upgraded --assume-yes --quiet -o=Dpkg::Use-Pty=0" + ) for conf_flag in ["old", "miss", "def"]: dist_upgrade += ' -o Dpkg::Options::="--force-conf{}"'.format(conf_flag) dist_upgrade += " dist-upgrade" @@ -646,26 +570,45 @@ def tools_upgrade(operation_logger, apps=None, system=False): held_packages = check_output("apt-mark showhold").split("\n") if any(p not in held_packages for p in critical_packages): logger.warning(m18n.n("tools_upgrade_cant_hold_critical_packages")) - operation_logger.error(m18n.n('packages_upgrade_failed')) - raise YunohostError(m18n.n('packages_upgrade_failed')) + operation_logger.error(m18n.n("packages_upgrade_failed")) + raise YunohostError(m18n.n("packages_upgrade_failed")) logger.debug("Running apt command :\n{}".format(dist_upgrade)) + def is_relevant(line): + irrelevants = [ + "service sudo-ldap already provided", + "Reading database ...", + ] + return all(i not in line.rstrip() for i in irrelevants) + callbacks = ( - lambda l: logger.info("+" + l.rstrip() + "\r"), - lambda l: logger.warning(l.rstrip()), + lambda l: logger.info("+ " + l.rstrip() + "\r") + if is_relevant(l) + else logger.debug(l.rstrip() + "\r"), + lambda l: logger.warning(l.rstrip()) + if is_relevant(l) + else logger.debug(l.rstrip()), ) returncode = call_async_output(dist_upgrade, callbacks, shell=True) if returncode != 0: - logger.warning('tools_upgrade_regular_packages_failed', - packages_list=', '.join(noncritical_packages_upgradable)) - operation_logger.error(m18n.n('packages_upgrade_failed')) - raise YunohostError(m18n.n('packages_upgrade_failed')) + upgradables = list(_list_upgradable_apt_packages()) + noncritical_packages_upgradable = [ + p["name"] for p in upgradables if p["name"] not in critical_packages + ] + logger.warning( + m18n.n( + "tools_upgrade_regular_packages_failed", + packages_list=", ".join(noncritical_packages_upgradable), + ) + ) + operation_logger.error(m18n.n("packages_upgrade_failed")) + raise YunohostError(m18n.n("packages_upgrade_failed")) # # Critical packages upgrade # - if critical_packages_upgradable: + if critical_packages_upgradable and allow_yunohost_upgrade: logger.info(m18n.n("tools_upgrade_special_packages")) @@ -677,13 +620,13 @@ def tools_upgrade(operation_logger, apps=None, system=False): held_packages = check_output("apt-mark showhold").split("\n") if any(p in held_packages for p in critical_packages): logger.warning(m18n.n("tools_upgrade_cant_unhold_critical_packages")) - operation_logger.error(m18n.n('packages_upgrade_failed')) - raise YunohostError(m18n.n('packages_upgrade_failed')) + operation_logger.error(m18n.n("packages_upgrade_failed")) + raise YunohostError(m18n.n("packages_upgrade_failed")) # # Here we use a dirty hack to run a command after the current # "yunohost tools upgrade", because the upgrade of yunohost - # will also trigger other yunohost commands (e.g. "yunohost tools migrations migrate") + # will also trigger other yunohost commands (e.g. "yunohost tools migrations run") # (also the upgrade of the package, if executed from the webadmin, is # likely to kill/restart the api which is in turn likely to kill this # command before it ends...) @@ -692,9 +635,19 @@ def tools_upgrade(operation_logger, apps=None, system=False): dist_upgrade = dist_upgrade + " 2>&1 | tee -a {}".format(logfile) MOULINETTE_LOCK = "/var/run/moulinette_yunohost.lock" - wait_until_end_of_yunohost_command = "(while [ -f {} ]; do sleep 2; done)".format(MOULINETTE_LOCK) - mark_success = "(echo 'Done!' | tee -a {} && echo 'success: true' >> {})".format(logfile, operation_logger.md_path) - mark_failure = "(echo 'Failed :(' | tee -a {} && echo 'success: false' >> {})".format(logfile, operation_logger.md_path) + wait_until_end_of_yunohost_command = ( + "(while [ -f {} ]; do sleep 2; done)".format(MOULINETTE_LOCK) + ) + mark_success = ( + "(echo 'Done!' | tee -a {} && echo 'success: true' >> {})".format( + logfile, operation_logger.md_path + ) + ) + mark_failure = ( + "(echo 'Failed :(' | tee -a {} && echo 'success: false' >> {})".format( + logfile, operation_logger.md_path + ) + ) update_log_metadata = "sed -i \"s/ended_at: .*$/ended_at: $(date -u +'%Y-%m-%d %H:%M:%S.%N')/\" {}" update_log_metadata = update_log_metadata.format(operation_logger.md_path) @@ -705,18 +658,23 @@ def tools_upgrade(operation_logger, apps=None, system=False): # the huge command launched by os.system) operation_logger.ended_at = "notyet" - upgrade_completed = "\n" + m18n.n("tools_upgrade_special_packages_completed") + upgrade_completed = "\n" + m18n.n( + "tools_upgrade_special_packages_completed" + ) command = "({wait} && {dist_upgrade}) && {mark_success} || {mark_failure}; {update_metadata}; echo '{done}'".format( - wait=wait_until_end_of_yunohost_command, - dist_upgrade=dist_upgrade, - mark_success=mark_success, - mark_failure=mark_failure, - update_metadata=update_log_metadata, - done=upgrade_completed) + wait=wait_until_end_of_yunohost_command, + dist_upgrade=dist_upgrade, + mark_success=mark_success, + mark_failure=mark_failure, + update_metadata=update_log_metadata, + done=upgrade_completed, + ) logger.warning(m18n.n("tools_upgrade_special_packages_explanation")) logger.debug("Running command :\n{}".format(command)) - open("/tmp/yunohost-selfupgrade", "w").write("rm /tmp/yunohost-selfupgrade; " + command) + open("/tmp/yunohost-selfupgrade", "w").write( + "rm /tmp/yunohost-selfupgrade; " + command + ) # Using systemd-run --scope is like nohup/disown and &, but more robust somehow # (despite using nohup/disown and &, the self-upgrade process was still getting killed...) # ref: https://unix.stackexchange.com/questions/420594/why-process-killed-with-nohup @@ -725,224 +683,27 @@ def tools_upgrade(operation_logger, apps=None, system=False): return else: - logger.success(m18n.n('system_upgraded')) + logger.success(m18n.n("system_upgraded")) operation_logger.success() -def tools_diagnosis(private=False): - """ - Return global info about current yunohost instance to help debugging - - """ - diagnosis = OrderedDict() - - # Debian release - try: - with open('/etc/debian_version', 'r') as f: - debian_version = f.read().rstrip() - except IOError as e: - logger.warning(m18n.n('diagnosis_debian_version_error', error=format(e)), exc_info=1) - else: - diagnosis['host'] = "Debian %s" % debian_version - - # Kernel version - try: - with open('/proc/sys/kernel/osrelease', 'r') as f: - kernel_version = f.read().rstrip() - except IOError as e: - logger.warning(m18n.n('diagnosis_kernel_version_error', error=format(e)), exc_info=1) - else: - diagnosis['kernel'] = kernel_version - - # Packages version - diagnosis['packages'] = ynh_packages_version() - - diagnosis["backports"] = check_output("dpkg -l |awk '/^ii/ && $3 ~ /bpo[6-8]/ {print $2}'").split() - - # Server basic monitoring - diagnosis['system'] = OrderedDict() - try: - disks = monitor_disk(units=['filesystem'], human_readable=True) - except (YunohostError, Fault) as e: - logger.warning(m18n.n('diagnosis_monitor_disk_error', error=format(e)), exc_info=1) - else: - diagnosis['system']['disks'] = {} - for disk in disks: - if isinstance(disks[disk], str): - diagnosis['system']['disks'][disk] = disks[disk] - else: - diagnosis['system']['disks'][disk] = 'Mounted on %s, %s (%s free)' % ( - disks[disk]['mnt_point'], - disks[disk]['size'], - disks[disk]['avail'] - ) - - try: - system = monitor_system(units=['cpu', 'memory'], human_readable=True) - except YunohostError as e: - logger.warning(m18n.n('diagnosis_monitor_system_error', error=format(e)), exc_info=1) - else: - diagnosis['system']['memory'] = { - 'ram': '%s (%s free)' % (system['memory']['ram']['total'], system['memory']['ram']['free']), - 'swap': '%s (%s free)' % (system['memory']['swap']['total'], system['memory']['swap']['free']), - } - - # nginx -t - p = subprocess.Popen("nginx -t".split(), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) - out, _ = p.communicate() - diagnosis["nginx"] = out.strip().split("\n") - if p.returncode != 0: - logger.error(out) - - # Services status - services = service_status() - diagnosis['services'] = {} - - for service in services: - diagnosis['services'][service] = "%s (%s)" % (services[service]['status'], services[service]['loaded']) - - # YNH Applications - try: - applications = app_list()['apps'] - except YunohostError as e: - diagnosis['applications'] = m18n.n('diagnosis_no_apps') - else: - diagnosis['applications'] = {} - for application in applications: - if application['installed']: - diagnosis['applications'][application['id']] = application['label'] if application['label'] else application['name'] - - # Private data - if private: - diagnosis['private'] = OrderedDict() - - # Public IP - diagnosis['private']['public_ip'] = {} - diagnosis['private']['public_ip']['IPv4'] = get_public_ip(4) - diagnosis['private']['public_ip']['IPv6'] = get_public_ip(6) - - # Domains - diagnosis['private']['domains'] = domain_list()['domains'] - - diagnosis['private']['regen_conf'] = regen_conf(with_diff=True, dry_run=True) - - try: - diagnosis['security'] = { - "CVE-2017-5754": { - "name": "meltdown", - "vulnerable": _check_if_vulnerable_to_meltdown(), - } - } - except Exception as e: - import traceback - traceback.print_exc() - logger.warning("Unable to check for meltdown vulnerability: %s" % e) - - return diagnosis - - -def _check_if_vulnerable_to_meltdown(): - # meltdown CVE: https://security-tracker.debian.org/tracker/CVE-2017-5754 - - # We use a cache file to avoid re-running the script so many times, - # which can be expensive (up to around 5 seconds on ARM) - # and make the admin appear to be slow (c.f. the calls to diagnosis - # from the webadmin) - # - # The cache is in /tmp and shall disappear upon reboot - # *or* we compare it to dpkg.log modification time - # such that it's re-ran if there was package upgrades - # (e.g. from yunohost) - cache_file = "/tmp/yunohost-meltdown-diagnosis" - dpkg_log = "/var/log/dpkg.log" - if os.path.exists(cache_file): - if not os.path.exists(dpkg_log) or os.path.getmtime(cache_file) > os.path.getmtime(dpkg_log): - logger.debug("Using cached results for meltdown checker, from %s" % cache_file) - return read_json(cache_file)[0]["VULNERABLE"] - - # script taken from https://github.com/speed47/spectre-meltdown-checker - # script commit id is store directly in the script - file_dir = os.path.split(__file__)[0] - SCRIPT_PATH = os.path.join(file_dir, "./vendor/spectre-meltdown-checker/spectre-meltdown-checker.sh") - - # '--variant 3' corresponds to Meltdown - # example output from the script: - # [{"NAME":"MELTDOWN","CVE":"CVE-2017-5754","VULNERABLE":false,"INFOS":"PTI mitigates the vulnerability"}] - try: - logger.debug("Running meltdown vulnerability checker") - call = subprocess.Popen("bash %s --batch json --variant 3" % - SCRIPT_PATH, shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - - # TODO / FIXME : here we are ignoring error messages ... - # in particular on RPi2 and other hardware, the script complains about - # "missing some kernel info (see -v), accuracy might be reduced" - # Dunno what to do about that but we probably don't want to harass - # users with this warning ... - output, err = call.communicate() - assert call.returncode in (0, 2, 3), "Return code: %s" % call.returncode - - # If there are multiple lines, sounds like there was some messages - # in stdout that are not json >.> ... Try to get the actual json - # stuff which should be the last line - output = output.strip() - if "\n" in output: - logger.debug("Original meltdown checker output : %s" % output) - output = output.split("\n")[-1] - - CVEs = json.loads(output) - assert len(CVEs) == 1 - assert CVEs[0]["NAME"] == "MELTDOWN" - except Exception as e: - import traceback - traceback.print_exc() - logger.warning("Something wrong happened when trying to diagnose Meltdown vunerability, exception: %s" % e) - raise Exception("Command output for failed meltdown check: '%s'" % output) - - logger.debug("Writing results from meltdown checker to cache file, %s" % cache_file) - write_to_json(cache_file, CVEs) - return CVEs[0]["VULNERABLE"] - - -def tools_port_available(port): - """ - Check availability of a local port - - Keyword argument: - port -- Port to check - - """ - try: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(1) - s.connect(("localhost", int(port))) - s.close() - except socket.error: - return True - else: - return False - - @is_unit_operation() def tools_shutdown(operation_logger, force=False): shutdown = force if not shutdown: try: # Ask confirmation for server shutdown - i = msignals.prompt(m18n.n('server_shutdown_confirm', answers='y/N')) + i = Moulinette.prompt(m18n.n("server_shutdown_confirm", answers="y/N")) except NotImplemented: pass else: - if i.lower() == 'y' or i.lower() == 'yes': + if i.lower() == "y" or i.lower() == "yes": shutdown = True if shutdown: operation_logger.start() - logger.warn(m18n.n('server_shutdown')) - subprocess.check_call(['systemctl', 'poweroff']) + logger.warn(m18n.n("server_shutdown")) + subprocess.check_call(["systemctl", "poweroff"]) @is_unit_operation() @@ -951,201 +712,16 @@ def tools_reboot(operation_logger, force=False): if not reboot: try: # Ask confirmation for restoring - i = msignals.prompt(m18n.n('server_reboot_confirm', answers='y/N')) + i = Moulinette.prompt(m18n.n("server_reboot_confirm", answers="y/N")) except NotImplemented: pass else: - if i.lower() == 'y' or i.lower() == 'yes': + if i.lower() == "y" or i.lower() == "yes": reboot = True if reboot: operation_logger.start() - logger.warn(m18n.n('server_reboot')) - subprocess.check_call(['systemctl', 'reboot']) - - -def tools_migrations_list(pending=False, done=False): - """ - List existing migrations - """ - - # Check for option conflict - if pending and done: - raise YunohostError("migrations_list_conflict_pending_done") - - # Get all migrations - migrations = _get_migrations_list() - - # If asked, filter pending or done migrations - if pending or done: - last_migration = tools_migrations_state()["last_run_migration"] - last_migration = last_migration["number"] if last_migration else -1 - if done: - migrations = [m for m in migrations if m.number <= last_migration] - if pending: - migrations = [m for m in migrations if m.number > last_migration] - - # Reduce to dictionnaries - migrations = [{"id": migration.id, - "number": migration.number, - "name": migration.name, - "mode": migration.mode, - "description": migration.description, - "disclaimer": migration.disclaimer} for migration in migrations] - - return {"migrations": migrations} - - -def tools_migrations_migrate(target=None, skip=False, auto=False, accept_disclaimer=False): - """ - Perform migrations - """ - - # state is a datastructure that represents the last run migration - # it has this form: - # { - # "last_run_migration": { - # "number": "00xx", - # "name": "some name", - # } - # } - state = tools_migrations_state() - - last_run_migration_number = state["last_run_migration"]["number"] if state["last_run_migration"] else 0 - - # load all migrations - migrations = _get_migrations_list() - migrations = sorted(migrations, key=lambda x: x.number) - - if not migrations: - logger.info(m18n.n('migrations_no_migrations_to_run')) - return - - all_migration_numbers = [x.number for x in migrations] - - if target is None: - target = migrations[-1].number - - # validate input, target must be "0" or a valid number - elif target != 0 and target not in all_migration_numbers: - raise YunohostError('migrations_bad_value_for_target', ", ".join(map(str, all_migration_numbers))) - - logger.debug(m18n.n('migrations_current_target', target)) - - # no new migrations to run - if target == last_run_migration_number: - logger.info(m18n.n('migrations_no_migrations_to_run')) - return - - logger.debug(m18n.n('migrations_show_last_migration', last_run_migration_number)) - - # we need to run missing migrations - if last_run_migration_number < target: - logger.debug(m18n.n('migrations_forward')) - # drop all already run migrations - migrations = filter(lambda x: target >= x.number > last_run_migration_number, migrations) - mode = "forward" - - # we need to go backward on already run migrations - elif last_run_migration_number > target: - logger.debug(m18n.n('migrations_backward')) - # drop all not already run migrations - migrations = filter(lambda x: target < x.number <= last_run_migration_number, migrations) - mode = "backward" - - else: # can't happen, this case is handle before - raise Exception() - - # effectively run selected migrations - for migration in migrations: - - if not skip: - # If we are migrating in "automatic mode" (i.e. from debian configure - # during an upgrade of the package) but we are asked to run migrations - # to be ran manually by the user, stop there and ask the user to - # run the migration manually. - if auto and migration.mode == "manual": - logger.warn(m18n.n('migrations_to_be_ran_manually', - number=migration.number, - name=migration.name)) - break - - # If some migrations have disclaimers, - if migration.disclaimer: - # require the --accept-disclaimer option. Otherwise, stop everything - # here and display the disclaimer - if not accept_disclaimer: - logger.warn(m18n.n('migrations_need_to_accept_disclaimer', - number=migration.number, - name=migration.name, - disclaimer=migration.disclaimer)) - break - # --accept-disclaimer will only work for the first migration - else: - accept_disclaimer = False - - # Start register change on system - operation_logger = OperationLogger('tools_migrations_migrate_' + mode) - operation_logger.start() - - if not skip: - - logger.info(m18n.n('migrations_show_currently_running_migration', - number=migration.number, name=migration.name)) - - try: - migration.operation_logger = operation_logger - if mode == "forward": - migration.migrate() - elif mode == "backward": - migration.backward() - else: # can't happen - raise Exception("Illegal state for migration: '%s', should be either 'forward' or 'backward'" % mode) - except Exception as e: - # migration failed, let's stop here but still update state because - # we managed to run the previous ones - msg = m18n.n('migrations_migration_has_failed', - exception=e, - number=migration.number, - name=migration.name) - logger.error(msg, exc_info=1) - operation_logger.error(msg) - break - else: - logger.success(m18n.n('migrations_success', - number=migration.number, name=migration.name)) - - else: # if skip - logger.warn(m18n.n('migrations_skip_migration', - number=migration.number, - name=migration.name)) - - # update the state to include the latest run migration - state["last_run_migration"] = { - "number": migration.number, - "name": migration.name - } - - operation_logger.success() - - # Skip migrations one at a time - if skip: - break - - # special case where we want to go back from the start - if target == 0: - state["last_run_migration"] = None - - write_to_json(MIGRATIONS_STATE_PATH, state) - - -def tools_migrations_state(): - """ - Show current migration state - """ - if not os.path.exists(MIGRATIONS_STATE_PATH): - return {"last_run_migration": None} - - return read_json(MIGRATIONS_STATE_PATH) + logger.warn(m18n.n("server_reboot")) + subprocess.check_call(["systemctl", "reboot"]) def tools_shell(command=None): @@ -1156,6 +732,7 @@ def tools_shell(command=None): """ from yunohost.utils.ldap import _get_ldap_interface + ldap = _get_ldap_interface() if command: @@ -1165,20 +742,222 @@ def tools_shell(command=None): logger.warn("The \033[1;34mldap\033[0m interface is available in this context") try: from IPython import embed + embed() except ImportError: - logger.warn("You don't have IPython installed, consider installing it as it is way better than the standard shell.") + logger.warn( + "You don't have IPython installed, consider installing it as it is way better than the standard shell." + ) logger.warn("Falling back on the standard shell.") import readline # will allow Up/Down/History in the console + readline # to please pyflakes import code + vars = globals().copy() vars.update(locals()) shell = code.InteractiveConsole(vars) shell.interact() +# ############################################ # +# # +# Migrations management # +# # +# ############################################ # + + +def tools_migrations_list(pending=False, done=False): + """ + List existing migrations + """ + + # Check for option conflict + if pending and done: + raise YunohostValidationError("migrations_list_conflict_pending_done") + + # Get all migrations + migrations = _get_migrations_list() + + # Reduce to dictionnaries + migrations = [ + { + "id": migration.id, + "number": migration.number, + "name": migration.name, + "mode": migration.mode, + "state": migration.state, + "description": migration.description, + "disclaimer": migration.disclaimer, + } + for migration in migrations + ] + + # If asked, filter pending or done migrations + if pending or done: + if done: + migrations = [m for m in migrations if m["state"] != "pending"] + if pending: + migrations = [m for m in migrations if m["state"] == "pending"] + + return {"migrations": migrations} + + +def tools_migrations_run( + targets=[], skip=False, auto=False, force_rerun=False, accept_disclaimer=False +): + """ + Perform migrations + + targets A list migrations to run (all pendings by default) + --skip Skip specified migrations (to be used only if you know what you are doing) (must explicit which migrations) + --auto Automatic mode, won't run manual migrations (to be used only if you know what you are doing) + --force-rerun Re-run already-ran migrations (to be used only if you know what you are doing)(must explicit which migrations) + --accept-disclaimer Accept disclaimers of migrations (please read them before using this option) (only valid for one migration) + """ + + all_migrations = _get_migrations_list() + + # Small utility that allows up to get a migration given a name, id or number later + def get_matching_migration(target): + for m in all_migrations: + if m.id == target or m.name == target or m.id.split("_")[0] == target: + return m + + raise YunohostValidationError("migrations_no_such_migration", id=target) + + # auto, skip and force are exclusive options + if auto + skip + force_rerun > 1: + raise YunohostValidationError("migrations_exclusive_options") + + # If no target specified + if not targets: + # skip, revert or force require explicit targets + if skip or force_rerun: + raise YunohostValidationError("migrations_must_provide_explicit_targets") + + # Otherwise, targets are all pending migrations + targets = [m for m in all_migrations if m.state == "pending"] + + # If explicit targets are provided, we shall validate them + else: + targets = [get_matching_migration(t) for t in targets] + done = [t.id for t in targets if t.state != "pending"] + pending = [t.id for t in targets if t.state == "pending"] + + if skip and done: + raise YunohostValidationError( + "migrations_not_pending_cant_skip", ids=", ".join(done) + ) + if force_rerun and pending: + raise YunohostValidationError( + "migrations_pending_cant_rerun", ids=", ".join(pending) + ) + if not (skip or force_rerun) and done: + raise YunohostValidationError("migrations_already_ran", ids=", ".join(done)) + + # So, is there actually something to do ? + if not targets: + logger.info(m18n.n("migrations_no_migrations_to_run")) + return + + # Actually run selected migrations + for migration in targets: + + # If we are migrating in "automatic mode" (i.e. from debian configure + # during an upgrade of the package) but we are asked for running + # migrations to be ran manually by the user, stop there and ask the + # user to run the migration manually. + if auto and migration.mode == "manual": + logger.warn(m18n.n("migrations_to_be_ran_manually", id=migration.id)) + + # We go to the next migration + continue + + # Check for migration dependencies + if not skip: + dependencies = [ + get_matching_migration(dep) for dep in migration.dependencies + ] + pending_dependencies = [ + dep.id for dep in dependencies if dep.state == "pending" + ] + if pending_dependencies: + logger.error( + m18n.n( + "migrations_dependencies_not_satisfied", + id=migration.id, + dependencies_id=", ".join(pending_dependencies), + ) + ) + continue + + # If some migrations have disclaimers (and we're not trying to skip them) + if migration.disclaimer and not skip: + # require the --accept-disclaimer option. + # Otherwise, go to the next migration + if not accept_disclaimer: + logger.warn( + m18n.n( + "migrations_need_to_accept_disclaimer", + id=migration.id, + disclaimer=migration.disclaimer, + ) + ) + continue + # --accept-disclaimer will only work for the first migration + else: + accept_disclaimer = False + + # Start register change on system + operation_logger = OperationLogger("tools_migrations_migrate_forward") + operation_logger.start() + + if skip: + logger.warn(m18n.n("migrations_skip_migration", id=migration.id)) + migration.state = "skipped" + _write_migration_state(migration.id, "skipped") + operation_logger.success() + else: + + try: + migration.operation_logger = operation_logger + logger.info(m18n.n("migrations_running_forward", id=migration.id)) + migration.run() + except Exception as e: + # migration failed, let's stop here but still update state because + # we managed to run the previous ones + msg = m18n.n( + "migrations_migration_has_failed", exception=e, id=migration.id + ) + logger.error(msg, exc_info=1) + operation_logger.error(msg) + else: + logger.success(m18n.n("migrations_success_forward", id=migration.id)) + migration.state = "done" + _write_migration_state(migration.id, "done") + + operation_logger.success() + + +def tools_migrations_state(): + """ + Show current migration state + """ + if not os.path.exists(MIGRATIONS_STATE_PATH): + return {"migrations": {}} + + return read_yaml(MIGRATIONS_STATE_PATH) + + +def _write_migration_state(migration_id, state): + + current_states = tools_migrations_state() + current_states["migrations"][migration_id] = state + write_to_yaml(MIGRATIONS_STATE_PATH, current_states) + + def _get_migrations_list(): migrations = [] @@ -1191,11 +970,28 @@ def _get_migrations_list(): migrations_path = data_migrations.__path__[0] if not os.path.exists(migrations_path): - logger.warn(m18n.n('migrations_cant_reach_migration_file', migrations_path)) + logger.warn(m18n.n("migrations_cant_reach_migration_file", migrations_path)) return migrations - for migration_file in filter(lambda x: re.match("^\d+_[a-zA-Z0-9_]+\.py$", x), os.listdir(migrations_path)): - migrations.append(_load_migration(migration_file)) + # states is a datastructure that represents the last run migration + # it has this form: + # { + # "0001_foo": "skipped", + # "0004_baz": "done", + # "0002_bar": "skipped", + # "0005_zblerg": "done", + # } + # (in particular, pending migrations / not already ran are not listed + states = tools_migrations_state()["migrations"] + + for migration_file in [ + x + for x in os.listdir(migrations_path) + if re.match(r"^\d+_[a-zA-Z0-9_]+\.py$", x) + ]: + m = _load_migration(migration_file) + m.state = states.get(m.id, "pending") + migrations.append(m) return sorted(migrations, key=lambda m: m.id) @@ -1211,21 +1007,24 @@ def _get_migration_by_name(migration_name): raise AssertionError("Unable to find migration with name %s" % migration_name) migrations_path = data_migrations.__path__[0] - migrations_found = filter(lambda x: re.match("^\d+_%s\.py$" % migration_name, x), os.listdir(migrations_path)) + migrations_found = [ + x + for x in os.listdir(migrations_path) + if re.match(r"^\d+_%s\.py$" % migration_name, x) + ] - assert len(migrations_found) == 1, "Unable to find migration with name %s" % migration_name + assert len(migrations_found) == 1, ( + "Unable to find migration with name %s" % migration_name + ) return _load_migration(migrations_found[0]) def _load_migration(migration_file): - migration_id = migration_file[:-len(".py")] + migration_id = migration_file[: -len(".py")] - number, name = migration_id.split("_", 1) - - logger.debug(m18n.n('migrations_loading_migration', - number=number, name=name)) + logger.debug(m18n.n("migrations_loading_migration", id=migration_id)) try: # this is python builtin method to import a module using a name, we @@ -1233,12 +1032,14 @@ def _load_migration(migration_file): # able to run it in the next loop module = import_module("yunohost.data_migrations.{}".format(migration_id)) return module.MyMigration(migration_id) - except Exception: + except Exception as e: import traceback + traceback.print_exc() - raise YunohostError('migrations_error_failed_to_load_migration', - number=number, name=name) + raise YunohostError( + "migrations_failed_to_load_migration", id=migration_id, error=e + ) def _skip_all_migrations(): @@ -1247,18 +1048,65 @@ def _skip_all_migrations(): This is meant to be used during postinstall to initialize the migration system. """ - state = tools_migrations_state() + all_migrations = _get_migrations_list() + new_states = {"migrations": {}} + for migration in all_migrations: + new_states["migrations"][migration.id] = "skipped" + write_to_yaml(MIGRATIONS_STATE_PATH, new_states) - # load all migrations - migrations = _get_migrations_list() - migrations = sorted(migrations, key=lambda x: x.number) - last_migration = migrations[-1] - state["last_run_migration"] = { - "number": last_migration.number, - "name": last_migration.name - } - write_to_json(MIGRATIONS_STATE_PATH, state) +def _tools_migrations_run_after_system_restore(backup_version): + + all_migrations = _get_migrations_list() + + current_version = version.parse(ynh_packages_version()["yunohost"]["version"]) + backup_version = version.parse(backup_version) + + if backup_version == current_version: + return + + for migration in all_migrations: + if ( + hasattr(migration, "introduced_in_version") + and version.parse(migration.introduced_in_version) > backup_version + and hasattr(migration, "run_after_system_restore") + ): + try: + logger.info(m18n.n("migrations_running_forward", id=migration.id)) + migration.run_after_system_restore() + except Exception as e: + msg = m18n.n( + "migrations_migration_has_failed", exception=e, id=migration.id + ) + logger.error(msg, exc_info=1) + raise + + +def _tools_migrations_run_before_app_restore(backup_version, app_id): + + all_migrations = _get_migrations_list() + + current_version = version.parse(ynh_packages_version()["yunohost"]["version"]) + backup_version = version.parse(backup_version) + + if backup_version == current_version: + return + + for migration in all_migrations: + if ( + hasattr(migration, "introduced_in_version") + and version.parse(migration.introduced_in_version) > backup_version + and hasattr(migration, "run_before_app_restore") + ): + try: + logger.info(m18n.n("migrations_running_forward", id=migration.id)) + migration.run_before_app_restore(app_id) + except Exception as e: + msg = m18n.n( + "migrations_migration_has_failed", exception=e, id=migration.id + ) + logger.error(msg, exc_info=1) + raise class Migration(object): @@ -1266,21 +1114,18 @@ class Migration(object): # Those are to be implemented by daughter classes mode = "auto" - - def forward(self): - raise NotImplementedError() - - def backward(self): - pass + dependencies: List[ + str + ] = [] # List of migration ids required before running this migration @property def disclaimer(self): return None - # The followings shouldn't be overriden + def run(self): + raise NotImplementedError() - def migrate(self): - self.forward() + # The followings shouldn't be overriden def __init__(self, id_): self.id = id_ @@ -1290,3 +1135,49 @@ class Migration(object): @property def description(self): return m18n.n("migration_description_%s" % self.id) + + def ldap_migration(run): + def func(self): + + # Backup LDAP before the migration + logger.info(m18n.n("migration_ldap_backup_before_migration")) + try: + backup_folder = "/home/yunohost.backup/premigration/" + time.strftime( + "%Y%m%d-%H%M%S", time.gmtime() + ) + os.makedirs(backup_folder, 0o750) + os.system("systemctl stop slapd") + os.system(f"cp -r --preserve /etc/ldap {backup_folder}/ldap_config") + os.system(f"cp -r --preserve /var/lib/ldap {backup_folder}/ldap_db") + os.system( + f"cp -r --preserve /etc/yunohost/apps {backup_folder}/apps_settings" + ) + except Exception as e: + raise YunohostError( + "migration_ldap_can_not_backup_before_migration", error=str(e) + ) + finally: + os.system("systemctl start slapd") + + try: + run(self, backup_folder) + except Exception: + logger.warning( + m18n.n("migration_ldap_migration_failed_trying_to_rollback") + ) + os.system("systemctl stop slapd") + # To be sure that we don't keep some part of the old config + os.system("rm -r /etc/ldap/slapd.d") + os.system(f"cp -r --preserve {backup_folder}/ldap_config/. /etc/ldap/") + os.system(f"cp -r --preserve {backup_folder}/ldap_db/. /var/lib/ldap/") + os.system( + f"cp -r --preserve {backup_folder}/apps_settings/. /etc/yunohost/apps/" + ) + os.system("systemctl start slapd") + os.system(f"rm -r {backup_folder}") + logger.info(m18n.n("migration_ldap_rollback_success")) + raise + else: + os.system(f"rm -r {backup_folder}") + + return func diff --git a/src/yunohost/user.py b/src/yunohost/user.py index a6c262ed7..7d89af443 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -27,96 +27,122 @@ import os import re import pwd import grp -import json import crypt import random import string import subprocess +import copy -from moulinette import m18n -from yunohost.utils.error import YunohostError +from moulinette import Moulinette, m18n from moulinette.utils.log import getActionLogger +from moulinette.utils.process import check_output + +from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.service import service_status from yunohost.log import is_unit_operation -logger = getActionLogger('yunohost.user') +logger = getActionLogger("yunohost.user") + +FIELDS_FOR_IMPORT = { + "username": r"^[a-z0-9_]+$", + "firstname": r"^([^\W\d_]{1,30}[ ,.\'-]{0,3})+$", + "lastname": r"^([^\W\d_]{1,30}[ ,.\'-]{0,3})+$", + "password": r"^|(.{3,})$", + "mail": r"^([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}))$", + "mail-alias": r"^|([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$", + "mail-forward": r"^|([\w\+.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$", + "mailbox-quota": r"^(\d+[bkMGT])|0|$", + "groups": r"^|([a-z0-9_]+(,?[a-z0-9_]+)*)$", +} + +FIRST_ALIASES = ["root@", "admin@", "webmaster@", "postmaster@", "abuse@"] def user_list(fields=None): - """ - List users - Keyword argument: - filter -- LDAP filter used to search - offset -- Starting number for user fetching - limit -- Maximum number of user fetched - fields -- fields to fetch - - """ from yunohost.utils.ldap import _get_ldap_interface - user_attrs = { - 'uid': 'username', - 'cn': 'fullname', - 'mail': 'mail', - 'maildrop': 'mail-forward', - 'loginShell': 'shell', - 'homeDirectory': 'home_path', - 'mailuserquota': 'mailbox-quota' + ldap_attrs = { + "username": "uid", + "password": "", # We can't request password in ldap + "fullname": "cn", + "firstname": "givenName", + "lastname": "sn", + "mail": "mail", + "mail-alias": "mail", + "mail-forward": "maildrop", + "mailbox-quota": "mailuserquota", + "groups": "memberOf", + "shell": "loginShell", + "home-path": "homeDirectory", } - attrs = ['uid'] + def display_default(values, _): + return values[0] if len(values) == 1 else values + + display = { + "password": lambda values, user: "", + "mail": lambda values, user: display_default(values[:1], user), + "mail-alias": lambda values, _: values[1:], + "mail-forward": lambda values, user: [ + forward for forward in values if forward != user["uid"][0] + ], + "groups": lambda values, user: [ + group[3:].split(",")[0] + for group in values + if not group.startswith("cn=all_users,") + and not group.startswith("cn=" + user["uid"][0] + ",") + ], + "shell": lambda values, _: len(values) > 0 + and values[0].strip() == "/bin/false", + } + + attrs = set(["uid"]) users = {} - if fields: - keys = user_attrs.keys() - for attr in fields: - if attr in keys: - attrs.append(attr) - else: - raise YunohostError('field_invalid', attr) - else: - attrs = ['uid', 'cn', 'mail', 'mailuserquota', 'loginShell'] + if not fields: + fields = ["username", "fullname", "mail", "mailbox-quota"] + + for field in fields: + if field in ldap_attrs: + attrs.add(ldap_attrs[field]) + else: + raise YunohostError("field_invalid", field) ldap = _get_ldap_interface() - result = ldap.search('ou=users,dc=yunohost,dc=org', - '(&(objectclass=person)(!(uid=root))(!(uid=nobody)))', - attrs) + result = ldap.search( + "ou=users,dc=yunohost,dc=org", + "(&(objectclass=person)(!(uid=root))(!(uid=nobody)))", + attrs, + ) for user in result: 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 + for field in fields: + values = [] + if ldap_attrs[field] in user: + values = user[ldap_attrs[field]] + entry[field] = display.get(field, display_default)(values, user) - entry[user_attrs[attr]] = values[0] + users[user["uid"][0]] = entry - uid = entry[user_attrs['uid']] - users[uid] = entry - - return {'users': users} + return {"users": users} -@is_unit_operation([('username', 'user')]) -def user_create(operation_logger, username, firstname, lastname, mail, password, - mailbox_quota="0"): - """ - Create user +@is_unit_operation([("username", "user")]) +def user_create( + operation_logger, + username, + firstname, + lastname, + domain, + password, + mailbox_quota="0", + mail=None, + from_import=False, +): - Keyword argument: - firstname - lastname - username -- Must be unique - mail -- Main mail address must be unique - password - mailbox_quota -- Mailbox size quota - - """ - from yunohost.domain import domain_list, _get_maindomain + from yunohost.domain import domain_list, _get_maindomain, _assert_domain_exists from yunohost.hook import hook_callback from yunohost.utils.password import assert_password_is_strong_enough from yunohost.utils.ldap import _get_ldap_interface @@ -124,117 +150,147 @@ def user_create(operation_logger, username, firstname, lastname, mail, password, # Ensure sufficiently complex password assert_password_is_strong_enough("user", password) + if mail is not None: + logger.warning( + "Packagers ! Using --mail in 'yunohost user create' is deprecated ... please use --domain instead." + ) + domain = mail.split("@")[-1] + + # Validate domain used for email address/xmpp account + if domain is None: + if Moulinette.interface.type == "api": + raise YunohostValidationError( + "Invalid usage, you should specify a domain argument" + ) + else: + # On affiche les differents domaines possibles + Moulinette.display(m18n.n("domains_available")) + for domain in domain_list()["domains"]: + Moulinette.display("- {}".format(domain)) + + maindomain = _get_maindomain() + domain = Moulinette.prompt( + m18n.n("ask_user_domain") + " (default: %s)" % maindomain + ) + if not domain: + domain = maindomain + + # Check that the domain exists + _assert_domain_exists(domain) + + mail = username + "@" + domain ldap = _get_ldap_interface() + if username in user_list()["users"]: + raise YunohostValidationError("user_already_exists", user=username) + # Validate uniqueness of username and mail in LDAP - ldap.validate_uniqueness({ - 'uid': username, - 'mail': mail, - 'cn': username - }) + try: + ldap.validate_uniqueness({"uid": username, "mail": mail, "cn": username}) + except Exception as e: + raise YunohostValidationError("user_creation_failed", user=username, error=e) # Validate uniqueness of username in system users all_existing_usernames = {x.pw_name for x in pwd.getpwall()} if username in all_existing_usernames: - raise YunohostError('system_username_exists') + raise YunohostValidationError("system_username_exists") main_domain = _get_maindomain() - aliases = [ - 'root@' + main_domain, - 'admin@' + main_domain, - 'webmaster@' + main_domain, - 'postmaster@' + main_domain, - ] + aliases = [alias + main_domain for alias in FIRST_ALIASES] if mail in aliases: - raise YunohostError('mail_unavailable') + raise YunohostValidationError("mail_unavailable") - # Check that the mail domain exists - if mail.split("@")[1] not in domain_list()['domains']: - raise YunohostError('mail_domain_unknown', domain=mail.split("@")[1]) - - operation_logger.start() + if not from_import: + operation_logger.start() # Get random UID/GID - all_uid = {x.pw_uid for x in pwd.getpwall()} - all_gid = {x.gr_gid for x in grp.getgrall()} + all_uid = {str(x.pw_uid) for x in pwd.getpwall()} + all_gid = {str(x.gr_gid) for x in grp.getgrall()} uid_guid_found = False while not uid_guid_found: - uid = str(random.randint(200, 99999)) + # LXC uid number is limited to 65536 by default + uid = str(random.randint(1001, 65000)) uid_guid_found = uid not in all_uid and uid not in all_gid # Adapt values for LDAP - fullname = '%s %s' % (firstname, lastname) + fullname = "%s %s" % (firstname, lastname) + attr_dict = { - 'objectClass': ['mailAccount', 'inetOrgPerson', 'posixAccount', 'userPermissionYnh'], - 'givenName': firstname, - 'sn': lastname, - 'displayName': fullname, - 'cn': fullname, - 'uid': username, - 'mail': mail, - 'maildrop': username, - 'mailuserquota': mailbox_quota, - 'userPassword': _hash_user_password(password), - 'gidNumber': uid, - 'uidNumber': uid, - 'homeDirectory': '/home/' + username, - 'loginShell': '/bin/false' + "objectClass": [ + "mailAccount", + "inetOrgPerson", + "posixAccount", + "userPermissionYnh", + ], + "givenName": [firstname], + "sn": [lastname], + "displayName": [fullname], + "cn": [fullname], + "uid": [username], + "mail": mail, # NOTE: this one seems to be already a list + "maildrop": [username], + "mailuserquota": [mailbox_quota], + "userPassword": [_hash_user_password(password)], + "gidNumber": [uid], + "uidNumber": [uid], + "homeDirectory": ["/home/" + username], + "loginShell": ["/bin/bash"], } # If it is the first user, add some aliases - if not ldap.search(base='ou=users,dc=yunohost,dc=org', filter='uid=*'): - attr_dict['mail'] = [attr_dict['mail']] + aliases + if not ldap.search(base="ou=users,dc=yunohost,dc=org", filter="uid=*"): + attr_dict["mail"] = [attr_dict["mail"]] + aliases - # If exists, remove the redirection from the SSO - try: - with open('/etc/ssowat/conf.json.persistent') as json_conf: - ssowat_conf = json.loads(str(json_conf.read())) - except ValueError as e: - raise YunohostError('ssowat_persistent_conf_read_error', error=e.strerror) - except IOError: - ssowat_conf = {} + try: + ldap.add("uid=%s,ou=users" % username, attr_dict) + except Exception as e: + raise YunohostError("user_creation_failed", user=username, error=e) - if 'redirected_urls' in ssowat_conf and '/' in ssowat_conf['redirected_urls']: - del ssowat_conf['redirected_urls']['/'] - try: - with open('/etc/ssowat/conf.json.persistent', 'w+') as f: - json.dump(ssowat_conf, f, sort_keys=True, indent=4) - except IOError as e: - raise YunohostError('ssowat_persistent_conf_write_error', error=e.strerror) + # Invalidate passwd and group to take user and group creation into account + subprocess.call(["nscd", "-i", "passwd"]) + subprocess.call(["nscd", "-i", "group"]) - if ldap.add('uid=%s,ou=users' % username, attr_dict): - # Invalidate passwd to take user creation into account - subprocess.call(['nscd', '-i', 'passwd']) + try: + # Attempt to create user home folder + subprocess.check_call(["mkhomedir_helper", username]) + except subprocess.CalledProcessError: + home = f"/home/{username}" + if not os.path.isdir(home): + logger.warning(m18n.n("user_home_creation_failed", home=home), exc_info=1) - try: - # Attempt to create user home folder - subprocess.check_call( - ['su', '-', username, '-c', "''"]) - except subprocess.CalledProcessError: - if not os.path.isdir('/home/{0}'.format(username)): - logger.warning(m18n.n('user_home_creation_failed'), - exc_info=1) + try: + subprocess.check_call( + ["setfacl", "-m", "g:all_users:---", "/home/%s" % username] + ) + except subprocess.CalledProcessError: + logger.warning("Failed to protect /home/%s" % username, exc_info=1) - # Create group for user and add to group 'all_users' - user_group_add(groupname=username, gid=uid, sync_perm=False) - user_group_update(groupname=username, add_user=username, force=True, sync_perm=False) - user_group_update(groupname='all_users', add_user=username, force=True, sync_perm=True) + # Create group for user and add to group 'all_users' + user_group_create(groupname=username, gid=uid, primary_group=True, sync_perm=False) + user_group_update(groupname="all_users", add=username, force=True, sync_perm=True) - # TODO: Send a welcome mail to user - logger.success(m18n.n('user_created')) + # Trigger post_user_create hooks + env_dict = { + "YNH_USER_USERNAME": username, + "YNH_USER_MAIL": mail, + "YNH_USER_PASSWORD": password, + "YNH_USER_FIRSTNAME": firstname, + "YNH_USER_LASTNAME": lastname, + } - hook_callback('post_user_create', - args=[username, mail, password, firstname, lastname]) + hook_callback("post_user_create", args=[username, mail], env=env_dict) - return {'fullname': fullname, 'username': username, 'mail': mail} + # TODO: Send a welcome mail to user + if not from_import: + logger.success(m18n.n("user_created")) - raise YunohostError('user_creation_failed') + return {"fullname": fullname, "username": username, "mail": mail} -@is_unit_operation([('username', 'user')]) -def user_delete(operation_logger, username, purge=False): +@is_unit_operation([("username", "user")]) +def user_delete(operation_logger, username, purge=False, from_import=False): """ Delete user @@ -246,41 +302,61 @@ def user_delete(operation_logger, username, purge=False): from yunohost.hook import hook_callback from yunohost.utils.ldap import _get_ldap_interface - operation_logger.start() + if username not in user_list()["users"]: + raise YunohostValidationError("user_unknown", user=username) + + if not from_import: + operation_logger.start() + + user_group_update("all_users", remove=username, force=True, sync_perm=False) + for group, infos in user_group_list()["groups"].items(): + if group == "all_users": + continue + # If the user is in this group (and it's not the primary group), + # remove the member from the group + if username != group and username in infos["members"]: + user_group_update(group, remove=username, sync_perm=False) + + # Delete primary group if it exists (why wouldnt it exists ? because some + # epic bug happened somewhere else and only a partial removal was + # performed...) + if username in user_group_list()["groups"].keys(): + user_group_delete(username, force=True, sync_perm=True) ldap = _get_ldap_interface() - if ldap.remove('uid=%s,ou=users' % username): - # Invalidate passwd to take user deletion into account - subprocess.call(['nscd', '-i', 'passwd']) + try: + ldap.remove("uid=%s,ou=users" % username) + except Exception as e: + raise YunohostError("user_deletion_failed", user=username, error=e) - if purge: - subprocess.call(['rm', '-rf', '/home/{0}'.format(username)]) - subprocess.call(['rm', '-rf', '/var/mail/{0}'.format(username)]) - else: - raise YunohostError('user_deletion_failed') + # Invalidate passwd to take user deletion into account + subprocess.call(["nscd", "-i", "passwd"]) - user_group_delete(username, force=True, sync_perm=True) + if purge: + subprocess.call(["rm", "-rf", "/home/{0}".format(username)]) + subprocess.call(["rm", "-rf", "/var/mail/{0}".format(username)]) - group_list = ldap.search('ou=groups,dc=yunohost,dc=org', - '(&(objectclass=groupOfNamesYnh)(memberUid=%s))' - % username, ['cn']) - for group in group_list: - user_list = ldap.search('ou=groups,dc=yunohost,dc=org', - 'cn=' + group['cn'][0], - ['memberUid'])[0] - user_list['memberUid'].remove(username) - if not ldap.update('cn=%s,ou=groups' % group['cn'][0], user_list): - raise YunohostError('group_update_failed') + hook_callback("post_user_delete", args=[username, purge]) - hook_callback('post_user_delete', args=[username, purge]) - - logger.success(m18n.n('user_deleted')) + if not from_import: + logger.success(m18n.n("user_deleted")) -@is_unit_operation([('username', 'user')], exclude=['change_password']) -def user_update(operation_logger, username, firstname=None, lastname=None, mail=None, - change_password=None, add_mailforward=None, remove_mailforward=None, - add_mailalias=None, remove_mailalias=None, mailbox_quota=None): +@is_unit_operation([("username", "user")], exclude=["change_password"]) +def user_update( + operation_logger, + username, + firstname=None, + lastname=None, + mail=None, + change_password=None, + add_mailforward=None, + remove_mailforward=None, + add_mailalias=None, + remove_mailalias=None, + mailbox_quota=None, + from_import=False, +): """ Update user informations @@ -300,103 +376,154 @@ def user_update(operation_logger, username, firstname=None, lastname=None, mail= from yunohost.app import app_ssowatconf from yunohost.utils.password import assert_password_is_strong_enough from yunohost.utils.ldap import _get_ldap_interface + from yunohost.hook import hook_callback - domains = domain_list()['domains'] + domains = domain_list()["domains"] # Populate user informations ldap = _get_ldap_interface() - attrs_to_fetch = ['givenName', 'sn', 'mail', 'maildrop'] - result = ldap.search(base='ou=users,dc=yunohost,dc=org', filter='uid=' + username, attrs=attrs_to_fetch) + attrs_to_fetch = ["givenName", "sn", "mail", "maildrop"] + result = ldap.search( + base="ou=users,dc=yunohost,dc=org", + filter="uid=" + username, + attrs=attrs_to_fetch, + ) if not result: - raise YunohostError('user_unknown', user=username) + raise YunohostValidationError("user_unknown", user=username) user = result[0] + env_dict = {"YNH_USER_USERNAME": username} # Get modifications from arguments new_attr_dict = {} if firstname: - new_attr_dict['givenName'] = firstname # TODO: Validate - new_attr_dict['cn'] = new_attr_dict['displayName'] = firstname + ' ' + user['sn'][0] + new_attr_dict["givenName"] = [firstname] # TODO: Validate + new_attr_dict["cn"] = new_attr_dict["displayName"] = [ + firstname + " " + user["sn"][0] + ] + env_dict["YNH_USER_FIRSTNAME"] = firstname if lastname: - new_attr_dict['sn'] = lastname # TODO: Validate - new_attr_dict['cn'] = new_attr_dict['displayName'] = user['givenName'][0] + ' ' + lastname + new_attr_dict["sn"] = [lastname] # TODO: Validate + new_attr_dict["cn"] = new_attr_dict["displayName"] = [ + user["givenName"][0] + " " + lastname + ] + env_dict["YNH_USER_LASTNAME"] = lastname if lastname and firstname: - new_attr_dict['cn'] = new_attr_dict['displayName'] = firstname + ' ' + lastname + new_attr_dict["cn"] = new_attr_dict["displayName"] = [ + firstname + " " + lastname + ] - if change_password: + # change_password is None if user_update is not called to change the password + if change_password is not None and change_password != "": + # when in the cli interface if the option to change the password is called + # without a specified value, change_password will be set to the const 0. + # In this case we prompt for the new password. + if Moulinette.interface.type == "cli" and not change_password: + change_password = Moulinette.prompt( + m18n.n("ask_password"), is_password=True, confirm=True + ) # Ensure sufficiently complex password assert_password_is_strong_enough("user", change_password) - new_attr_dict['userPassword'] = _hash_user_password(change_password) + new_attr_dict["userPassword"] = [_hash_user_password(change_password)] + env_dict["YNH_USER_PASSWORD"] = change_password if mail: main_domain = _get_maindomain() - aliases = [ - 'root@' + main_domain, - 'admin@' + main_domain, - 'webmaster@' + main_domain, - 'postmaster@' + main_domain, - ] - ldap.validate_uniqueness({'mail': mail}) - if mail[mail.find('@') + 1:] not in domains: - raise YunohostError('mail_domain_unknown', domain=mail[mail.find('@') + 1:]) - if mail in aliases: - raise YunohostError('mail_unavailable') + aliases = [alias + main_domain for alias in FIRST_ALIASES] - del user['mail'][0] - new_attr_dict['mail'] = [mail] + user['mail'] + # If the requested mail address is already as main address or as an alias by this user + if mail in user["mail"]: + user["mail"].remove(mail) + # Othewise, check that this mail address is not already used by this user + else: + try: + ldap.validate_uniqueness({"mail": mail}) + except Exception as e: + raise YunohostError("user_update_failed", user=username, error=e) + if mail[mail.find("@") + 1 :] not in domains: + raise YunohostError( + "mail_domain_unknown", domain=mail[mail.find("@") + 1 :] + ) + if mail in aliases: + raise YunohostValidationError("mail_unavailable") + + new_attr_dict["mail"] = [mail] + user["mail"][1:] if add_mailalias: if not isinstance(add_mailalias, list): add_mailalias = [add_mailalias] for mail in add_mailalias: - ldap.validate_uniqueness({'mail': mail}) - if mail[mail.find('@') + 1:] not in domains: - raise YunohostError('mail_domain_unknown', domain=mail[mail.find('@') + 1:]) - user['mail'].append(mail) - new_attr_dict['mail'] = user['mail'] + # (c.f. similar stuff as before) + if mail in user["mail"]: + user["mail"].remove(mail) + else: + try: + ldap.validate_uniqueness({"mail": mail}) + except Exception as e: + raise YunohostError("user_update_failed", user=username, error=e) + if mail[mail.find("@") + 1 :] not in domains: + raise YunohostError( + "mail_domain_unknown", domain=mail[mail.find("@") + 1 :] + ) + user["mail"].append(mail) + new_attr_dict["mail"] = user["mail"] if remove_mailalias: if not isinstance(remove_mailalias, list): remove_mailalias = [remove_mailalias] for mail in remove_mailalias: - if len(user['mail']) > 1 and mail in user['mail'][1:]: - user['mail'].remove(mail) + if len(user["mail"]) > 1 and mail in user["mail"][1:]: + user["mail"].remove(mail) else: - raise YunohostError('mail_alias_remove_failed', mail=mail) - new_attr_dict['mail'] = user['mail'] + raise YunohostValidationError("mail_alias_remove_failed", mail=mail) + new_attr_dict["mail"] = user["mail"] + + if "mail" in new_attr_dict: + env_dict["YNH_USER_MAILS"] = ",".join(new_attr_dict["mail"]) if add_mailforward: if not isinstance(add_mailforward, list): add_mailforward = [add_mailforward] for mail in add_mailforward: - if mail in user['maildrop'][1:]: + if mail in user["maildrop"][1:]: continue - user['maildrop'].append(mail) - new_attr_dict['maildrop'] = user['maildrop'] + user["maildrop"].append(mail) + new_attr_dict["maildrop"] = user["maildrop"] if remove_mailforward: if not isinstance(remove_mailforward, list): remove_mailforward = [remove_mailforward] for mail in remove_mailforward: - if len(user['maildrop']) > 1 and mail in user['maildrop'][1:]: - user['maildrop'].remove(mail) + if len(user["maildrop"]) > 1 and mail in user["maildrop"][1:]: + user["maildrop"].remove(mail) else: - raise YunohostError('mail_forward_remove_failed', mail=mail) - new_attr_dict['maildrop'] = user['maildrop'] + raise YunohostValidationError("mail_forward_remove_failed", mail=mail) + new_attr_dict["maildrop"] = user["maildrop"] + + if "maildrop" in new_attr_dict: + env_dict["YNH_USER_MAILFORWARDS"] = ",".join(new_attr_dict["maildrop"]) if mailbox_quota is not None: - new_attr_dict['mailuserquota'] = mailbox_quota + new_attr_dict["mailuserquota"] = [mailbox_quota] + env_dict["YNH_USER_MAILQUOTA"] = mailbox_quota - operation_logger.start() + if not from_import: + operation_logger.start() - if ldap.update('uid=%s,ou=users' % username, new_attr_dict): - logger.success(m18n.n('user_updated')) + try: + ldap.update("uid=%s,ou=users" % username, new_attr_dict) + except Exception as e: + raise YunohostError("user_update_failed", user=username, error=e) + + # Trigger post_user_update hooks + hook_callback("post_user_update", env=env_dict) + + if not from_import: app_ssowatconf() + logger.success(m18n.n("user_updated")) return user_info(username) - else: - raise YunohostError('user_update_failed') def user_info(username): @@ -411,150 +538,450 @@ def user_info(username): ldap = _get_ldap_interface() - user_attrs = [ - 'cn', 'mail', 'uid', 'maildrop', 'givenName', 'sn', 'mailuserquota' - ] + user_attrs = ["cn", "mail", "uid", "maildrop", "givenName", "sn", "mailuserquota"] - if len(username.split('@')) == 2: - filter = 'mail=' + username + if len(username.split("@")) == 2: + filter = "mail=" + username else: - filter = 'uid=' + username + filter = "uid=" + username - result = ldap.search('ou=users,dc=yunohost,dc=org', filter, user_attrs) + result = ldap.search("ou=users,dc=yunohost,dc=org", filter, user_attrs) if result: user = result[0] else: - raise YunohostError('user_unknown', user=username) + raise YunohostValidationError("user_unknown", user=username) result_dict = { - 'username': user['uid'][0], - 'fullname': user['cn'][0], - 'firstname': user['givenName'][0], - 'lastname': user['sn'][0], - 'mail': user['mail'][0] + "username": user["uid"][0], + "fullname": user["cn"][0], + "firstname": user["givenName"][0], + "lastname": user["sn"][0], + "mail": user["mail"][0], + "mail-aliases": [], + "mail-forward": [], } - if len(user['mail']) > 1: - result_dict['mail-aliases'] = user['mail'][1:] + if len(user["mail"]) > 1: + result_dict["mail-aliases"] = user["mail"][1:] - if len(user['maildrop']) > 1: - result_dict['mail-forward'] = user['maildrop'][1:] + if len(user["maildrop"]) > 1: + result_dict["mail-forward"] = user["maildrop"][1:] - if 'mailuserquota' in user: - userquota = user['mailuserquota'][0] + if "mailuserquota" in user: + userquota = user["mailuserquota"][0] if isinstance(userquota, int): userquota = str(userquota) # Test if userquota is '0' or '0M' ( quota pattern is ^(\d+[bkMGT])|0$ ) - is_limited = not re.match('0[bkMGT]?', userquota) - storage_use = '?' + is_limited = not re.match("0[bkMGT]?", userquota) + storage_use = "?" if service_status("dovecot")["status"] != "running": - logger.warning(m18n.n('mailbox_used_space_dovecot_down')) - elif not user_permission_list(app="mail", permission="main", username=username)['permissions']: - logger.warning(m18n.n('mailbox_disabled', user=username)) + logger.warning(m18n.n("mailbox_used_space_dovecot_down")) + elif username not in user_permission_info("mail.main")["corresponding_users"]: + logger.warning(m18n.n("mailbox_disabled", user=username)) else: - cmd = 'doveadm -f flow quota get -u %s' % user['uid'][0] - cmd_result = subprocess.check_output(cmd, stderr=subprocess.STDOUT, - shell=True) + try: + cmd = "doveadm -f flow quota get -u %s" % user["uid"][0] + cmd_result = check_output(cmd) + except Exception as e: + cmd_result = "" + logger.warning("Failed to fetch quota info ... : %s " % str(e)) + # Exemple of return value for cmd: # """Quota name=User quota Type=STORAGE Value=0 Limit=- %=0 # Quota name=User quota Type=MESSAGE Value=0 Limit=- %=0""" - has_value = re.search(r'Value=(\d+)', cmd_result) + has_value = re.search(r"Value=(\d+)", cmd_result) if has_value: storage_use = int(has_value.group(1)) storage_use = _convertSize(storage_use) if is_limited: - has_percent = re.search(r'%=(\d+)', cmd_result) + has_percent = re.search(r"%=(\d+)", cmd_result) if has_percent: percentage = int(has_percent.group(1)) - storage_use += ' (%s%%)' % percentage + storage_use += " (%s%%)" % percentage - result_dict['mailbox-quota'] = { - 'limit': userquota if is_limited else m18n.n('unlimit'), - 'use': storage_use + result_dict["mailbox-quota"] = { + "limit": userquota if is_limited else m18n.n("unlimit"), + "use": storage_use, } - if result: - return result_dict + return result_dict + + +def user_export(): + """ + Export users into CSV + + Keyword argument: + csv -- CSV file with columns username;firstname;lastname;password;mailbox-quota;mail;mail-alias;mail-forward;groups + + """ + import csv # CSV are needed only in this function + from io import StringIO + + with StringIO() as csv_io: + writer = csv.DictWriter( + csv_io, list(FIELDS_FOR_IMPORT.keys()), delimiter=";", quotechar='"' + ) + writer.writeheader() + users = user_list(list(FIELDS_FOR_IMPORT.keys()))["users"] + for username, user in users.items(): + user["mail-alias"] = ",".join(user["mail-alias"]) + user["mail-forward"] = ",".join(user["mail-forward"]) + user["groups"] = ",".join(user["groups"]) + writer.writerow(user) + + body = csv_io.getvalue().rstrip() + if Moulinette.interface.type == "api": + # We return a raw bottle HTTPresponse (instead of serializable data like + # list/dict, ...), which is gonna be picked and used directly by moulinette + from bottle import HTTPResponse + + response = HTTPResponse( + body=body, + headers={ + "Content-Disposition": "attachment; filename=users.csv", + "Content-Type": "text/csv", + }, + ) + return response else: - raise YunohostError('user_info_failed') + return body + + +@is_unit_operation() +def user_import(operation_logger, csvfile, update=False, delete=False): + """ + Import users from CSV + + Keyword argument: + csvfile -- CSV file with columns username;firstname;lastname;password;mailbox_quota;mail;alias;forward;groups + + """ + + import csv # CSV are needed only in this function + from moulinette.utils.text import random_ascii + from yunohost.permission import permission_sync_to_user + from yunohost.app import app_ssowatconf + from yunohost.domain import domain_list + + # Pre-validate data and prepare what should be done + actions = {"created": [], "updated": [], "deleted": []} + is_well_formatted = True + + def to_list(str_list): + L = str_list.split(",") if str_list else [] + L = [l.strip() for l in L] + return L + + existing_users = user_list()["users"] + existing_groups = user_group_list()["groups"] + existing_domains = domain_list()["domains"] + + reader = csv.DictReader(csvfile, delimiter=";", quotechar='"') + users_in_csv = [] + + missing_columns = [ + key for key in FIELDS_FOR_IMPORT.keys() if key not in reader.fieldnames + ] + if missing_columns: + raise YunohostValidationError( + "user_import_missing_columns", columns=", ".join(missing_columns) + ) + + for user in reader: + + # Validate column values against regexes + format_errors = [ + f"{key}: '{user[key]}' doesn't match the expected format" + for key, validator in FIELDS_FOR_IMPORT.items() + if user[key] is None or not re.match(validator, user[key]) + ] + + # Check for duplicated username lines + if user["username"] in users_in_csv: + format_errors.append(f"username '{user['username']}' duplicated") + users_in_csv.append(user["username"]) + + # Validate that groups exist + user["groups"] = to_list(user["groups"]) + unknown_groups = [g for g in user["groups"] if g not in existing_groups] + if unknown_groups: + format_errors.append( + f"username '{user['username']}': unknown groups %s" + % ", ".join(unknown_groups) + ) + + # Validate that domains exist + user["mail-alias"] = to_list(user["mail-alias"]) + user["mail-forward"] = to_list(user["mail-forward"]) + user["domain"] = user["mail"].split("@")[1] + + unknown_domains = [] + if user["domain"] not in existing_domains: + unknown_domains.append(user["domain"]) + + unknown_domains += [ + mail.split("@", 1)[1] + for mail in user["mail-alias"] + if mail.split("@", 1)[1] not in existing_domains + ] + unknown_domains = set(unknown_domains) + + if unknown_domains: + format_errors.append( + f"username '{user['username']}': unknown domains %s" + % ", ".join(unknown_domains) + ) + + if format_errors: + logger.error( + m18n.n( + "user_import_bad_line", + line=reader.line_num, + details=", ".join(format_errors), + ) + ) + is_well_formatted = False + continue + + # Choose what to do with this line and prepare data + user["mailbox-quota"] = user["mailbox-quota"] or "0" + + # User creation + if user["username"] not in existing_users: + # Generate password if not exists + # This could be used when reset password will be merged + if not user["password"]: + user["password"] = random_ascii(70) + actions["created"].append(user) + # User update + elif update: + actions["updated"].append(user) + + if delete: + actions["deleted"] = [ + user for user in existing_users if user not in users_in_csv + ] + + if delete and not users_in_csv: + logger.error( + "You used the delete option with an empty csv file ... You probably did not really mean to do that, did you !?" + ) + is_well_formatted = False + + if not is_well_formatted: + raise YunohostValidationError("user_import_bad_file") + + total = len(actions["created"] + actions["updated"] + actions["deleted"]) + + if total == 0: + logger.info(m18n.n("user_import_nothing_to_do")) + return + + # Apply creation, update and deletion operation + result = {"created": 0, "updated": 0, "deleted": 0, "errors": 0} + + def progress(info=""): + progress.nb += 1 + width = 20 + bar = int(progress.nb * width / total) + bar = "[" + "#" * bar + "." * (width - bar) + "]" + if info: + bar += " > " + info + if progress.old == bar: + return + progress.old = bar + logger.info(bar) + + progress.nb = 0 + progress.old = "" + + def on_failure(user, exception): + result["errors"] += 1 + logger.error(user + ": " + str(exception)) + + def update(new_infos, old_infos=False): + remove_alias = None + remove_forward = None + remove_groups = [] + add_groups = new_infos["groups"] + if old_infos: + new_infos["mail"] = ( + None if old_infos["mail"] == new_infos["mail"] else new_infos["mail"] + ) + remove_alias = list( + set(old_infos["mail-alias"]) - set(new_infos["mail-alias"]) + ) + remove_forward = list( + set(old_infos["mail-forward"]) - set(new_infos["mail-forward"]) + ) + new_infos["mail-alias"] = list( + set(new_infos["mail-alias"]) - set(old_infos["mail-alias"]) + ) + new_infos["mail-forward"] = list( + set(new_infos["mail-forward"]) - set(old_infos["mail-forward"]) + ) + + remove_groups = list(set(old_infos["groups"]) - set(new_infos["groups"])) + add_groups = list(set(new_infos["groups"]) - set(old_infos["groups"])) + + for group, infos in existing_groups.items(): + # Loop only on groups in 'remove_groups' + # Ignore 'all_users' and primary group + if ( + group in ["all_users", new_infos["username"]] + or group not in remove_groups + ): + continue + # If the user is in this group (and it's not the primary group), + # remove the member from the group + if new_infos["username"] in infos["members"]: + user_group_update( + group, + remove=new_infos["username"], + sync_perm=False, + from_import=True, + ) + + user_update( + new_infos["username"], + new_infos["firstname"], + new_infos["lastname"], + new_infos["mail"], + new_infos["password"], + mailbox_quota=new_infos["mailbox-quota"], + mail=new_infos["mail"], + add_mailalias=new_infos["mail-alias"], + remove_mailalias=remove_alias, + remove_mailforward=remove_forward, + add_mailforward=new_infos["mail-forward"], + from_import=True, + ) + + for group in add_groups: + if group in ["all_users", new_infos["username"]]: + continue + user_group_update( + group, add=new_infos["username"], sync_perm=False, from_import=True + ) + + users = user_list(list(FIELDS_FOR_IMPORT.keys()))["users"] + operation_logger.start() + # We do delete and update before to avoid mail uniqueness issues + for user in actions["deleted"]: + try: + user_delete(user, purge=True, from_import=True) + result["deleted"] += 1 + except YunohostError as e: + on_failure(user, e) + progress(f"Deleting {user}") + + for user in actions["updated"]: + try: + update(user, users[user["username"]]) + result["updated"] += 1 + except YunohostError as e: + on_failure(user["username"], e) + progress(f"Updating {user['username']}") + + for user in actions["created"]: + try: + user_create( + user["username"], + user["firstname"], + user["lastname"], + user["domain"], + user["password"], + user["mailbox-quota"], + from_import=True, + ) + update(user) + result["created"] += 1 + except YunohostError as e: + on_failure(user["username"], e) + progress(f"Creating {user['username']}") + + permission_sync_to_user() + app_ssowatconf() + + if result["errors"]: + msg = m18n.n("user_import_partial_failed") + if result["created"] + result["updated"] + result["deleted"] == 0: + msg = m18n.n("user_import_failed") + logger.error(msg) + operation_logger.error(msg) + else: + logger.success(m18n.n("user_import_success")) + operation_logger.success() + return result # # Group subcategory # -def user_group_list(fields=None): +def user_group_list(short=False, full=False, include_primary_groups=True): """ List users Keyword argument: - filter -- LDAP filter used to search - offset -- Starting number for user fetching - limit -- Maximum number of user fetched - fields -- fields to fetch - + short -- Only list the name of the groups without any additional info + full -- List all the info available for each groups + include_primary_groups -- Include groups corresponding to users (which should always only contains this user) + This option is set to false by default in the action map because we don't want to have + these displayed when the user runs `yunohost user group list`, but internally we do want + to list them when called from other functions """ - from yunohost.utils.ldap import _get_ldap_interface + + # Fetch relevant informations + + from yunohost.utils.ldap import _get_ldap_interface, _ldap_path_extract + ldap = _get_ldap_interface() - group_attr = { - 'cn': 'groupname', - 'member': 'members', - 'permission': 'permission' - } - attrs = ['cn'] + groups_infos = ldap.search( + "ou=groups,dc=yunohost,dc=org", + "(objectclass=groupOfNamesYnh)", + ["cn", "member", "permission"], + ) + + # Parse / organize information to be outputed + + users = user_list()["users"] groups = {} + for infos in groups_infos: - if fields: - keys = group_attr.keys() - for attr in fields: - if attr in keys: - attrs.append(attr) - else: - raise YunohostError('field_invalid', attr) - else: - attrs = ['cn', 'member'] + name = infos["cn"][0] - result = ldap.search('ou=groups,dc=yunohost,dc=org', - '(objectclass=groupOfNamesYnh)', - attrs) - - for group in result: - # The group "admins" should be hidden for the user - if group_attr['cn'] == "admins": + if not include_primary_groups and name in users: continue - entry = {} - for attr, values in group.items(): - if values: - if attr == "member": - entry[group_attr[attr]] = [] - for v in values: - entry[group_attr[attr]].append(v.split("=")[1].split(",")[0]) - elif attr == "permission": - entry[group_attr[attr]] = {} - for v in values: - permission = v.split("=")[1].split(",")[0].split(".")[1] - pType = v.split("=")[1].split(",")[0].split(".")[0] - if permission in entry[group_attr[attr]]: - entry[group_attr[attr]][permission].append(pType) - else: - entry[group_attr[attr]][permission] = [pType] - else: - entry[group_attr[attr]] = values[0] - groupname = entry[group_attr['cn']] - groups[groupname] = entry + groups[name] = {} - return {'groups': groups} + groups[name]["members"] = [ + _ldap_path_extract(p, "uid") for p in infos.get("member", []) + ] + + if full: + groups[name]["permissions"] = [ + _ldap_path_extract(p, "cn") for p in infos.get("permission", []) + ] + + if short: + groups = list(groups.keys()) + + return {"groups": groups} -@is_unit_operation([('groupname', 'user')]) -def user_group_add(operation_logger, groupname, gid=None, sync_perm=True): +@is_unit_operation([("groupname", "group")]) +def user_group_create( + operation_logger, groupname, gid=None, primary_group=False, sync_perm=True +): """ Create group @@ -565,21 +992,29 @@ def user_group_add(operation_logger, groupname, gid=None, sync_perm=True): from yunohost.permission import permission_sync_to_user from yunohost.utils.ldap import _get_ldap_interface - operation_logger.start() - ldap = _get_ldap_interface() # Validate uniqueness of groupname in LDAP - conflict = ldap.get_conflict({ - 'cn': groupname - }, base_dn='ou=groups,dc=yunohost,dc=org') + conflict = ldap.get_conflict( + {"cn": groupname}, base_dn="ou=groups,dc=yunohost,dc=org" + ) if conflict: - raise YunohostError('group_name_already_exist', name=groupname) + raise YunohostValidationError("group_already_exist", group=groupname) # Validate uniqueness of groupname in system group all_existing_groupnames = {x.gr_name for x in grp.getgrall()} if groupname in all_existing_groupnames: - raise YunohostError('system_groupname_exists') + if primary_group: + logger.warning( + m18n.n("group_already_exist_on_system_but_removing_it", group=groupname) + ) + subprocess.check_call( + "sed --in-place '/^%s:/d' /etc/group" % groupname, shell=True + ) + else: + raise YunohostValidationError( + "group_already_exist_on_system", group=groupname + ) if not gid: # Get random GID @@ -591,21 +1026,35 @@ def user_group_add(operation_logger, groupname, gid=None, sync_perm=True): uid_guid_found = gid not in all_gid attr_dict = { - 'objectClass': ['top', 'groupOfNamesYnh', 'posixGroup'], - 'cn': groupname, - 'gidNumber': gid, + "objectClass": ["top", "groupOfNamesYnh", "posixGroup"], + "cn": groupname, + "gidNumber": gid, } - if ldap.add('cn=%s,ou=groups' % groupname, attr_dict): - logger.success(m18n.n('group_created', group=groupname)) - if sync_perm: - permission_sync_to_user() - return {'name': groupname} + # Here we handle the creation of a primary group + # We want to initialize this group to contain the corresponding user + # (then we won't be able to add/remove any user in this group) + if primary_group: + attr_dict["member"] = ["uid=" + groupname + ",ou=users,dc=yunohost,dc=org"] - raise YunohostError('group_creation_failed', group=groupname) + operation_logger.start() + try: + ldap.add("cn=%s,ou=groups" % groupname, attr_dict) + except Exception as e: + raise YunohostError("group_creation_failed", group=groupname, error=e) + + if sync_perm: + permission_sync_to_user() + + if not primary_group: + logger.success(m18n.n("group_created", group=groupname)) + else: + logger.debug(m18n.n("group_created", group=groupname)) + + return {"name": groupname} -@is_unit_operation([('groupname', 'user')]) +@is_unit_operation([("groupname", "group")]) def user_group_delete(operation_logger, groupname, force=False, sync_perm=True): """ Delete user @@ -617,105 +1066,134 @@ def user_group_delete(operation_logger, groupname, force=False, sync_perm=True): from yunohost.permission import permission_sync_to_user from yunohost.utils.ldap import _get_ldap_interface - forbidden_groups = ["all_users", "admins"] + user_list(fields=['uid'])['users'].keys() - if not force and groupname in forbidden_groups: - raise YunohostError('group_deletion_not_allowed', group=groupname) + existing_groups = list(user_group_list()["groups"].keys()) + if groupname not in existing_groups: + raise YunohostValidationError("group_unknown", group=groupname) + + # Refuse to delete primary groups of a user (e.g. group 'sam' related to user 'sam') + # without the force option... + # + # We also can't delete "all_users" because that's a special group... + existing_users = list(user_list()["users"].keys()) + undeletable_groups = existing_users + ["all_users", "visitors"] + if groupname in undeletable_groups and not force: + raise YunohostValidationError("group_cannot_be_deleted", group=groupname) operation_logger.start() ldap = _get_ldap_interface() - if not ldap.remove('cn=%s,ou=groups' % groupname): - raise YunohostError('group_deletion_failed', group=groupname) + try: + ldap.remove("cn=%s,ou=groups" % groupname) + except Exception as e: + raise YunohostError("group_deletion_failed", group=groupname, error=e) - logger.success(m18n.n('group_deleted', group=groupname)) if sync_perm: permission_sync_to_user() + if groupname not in existing_users: + logger.success(m18n.n("group_deleted", group=groupname)) + else: + logger.debug(m18n.n("group_deleted", group=groupname)) -@is_unit_operation([('groupname', 'user')]) -def user_group_update(operation_logger, groupname, add_user=None, remove_user=None, force=False, sync_perm=True): + +@is_unit_operation([("groupname", "group")]) +def user_group_update( + operation_logger, + groupname, + add=None, + remove=None, + force=False, + sync_perm=True, + from_import=False, +): """ Update user informations Keyword argument: groupname -- Groupname to update - add_user -- User to add in group - remove_user -- User to remove in group + add -- User(s) to add in group + remove -- User(s) to remove in group """ from yunohost.permission import permission_sync_to_user from yunohost.utils.ldap import _get_ldap_interface - if (groupname == 'all_users' or groupname == 'admins') and not force: - raise YunohostError('edit_group_not_allowed', group=groupname) + existing_users = list(user_list()["users"].keys()) - ldap = _get_ldap_interface() + # Refuse to edit a primary group of a user (e.g. group 'sam' related to user 'sam') + # Those kind of group should only ever contain the user (e.g. sam) and only this one. + # We also can't edit "all_users" without the force option because that's a special group... + if not force: + if groupname == "all_users": + raise YunohostValidationError("group_cannot_edit_all_users") + elif groupname == "visitors": + raise YunohostValidationError("group_cannot_edit_visitors") + elif groupname in existing_users: + raise YunohostValidationError( + "group_cannot_edit_primary_group", group=groupname + ) - # Populate group informations - attrs_to_fetch = ['member'] - result = ldap.search(base='ou=groups,dc=yunohost,dc=org', - filter='cn=' + groupname, attrs=attrs_to_fetch) - if not result: - raise YunohostError('group_unknown', group=groupname) - group = result[0] + # We extract the uid for each member of the group to keep a simple flat list of members + current_group = user_group_info(groupname)["members"] + new_group = copy.copy(current_group) - new_group_list = {'member': set(), 'memberUid': set()} - if 'member' in group: - new_group_list['member'] = set(group['member']) - else: - group['member'] = [] + if add: + users_to_add = [add] if not isinstance(add, list) else add - existing_users = user_list(fields=['uid'])['users'].keys() - - if add_user: - if not isinstance(add_user, list): - add_user = [add_user] - - for user in add_user: + for user in users_to_add: if user not in existing_users: - raise YunohostError('user_unknown', user=user) + raise YunohostValidationError("user_unknown", user=user) - for user in add_user: - userDN = "uid=" + user + ",ou=users,dc=yunohost,dc=org" - if userDN in group['member']: - logger.warning(m18n.n('user_already_in_group', user=user, group=groupname)) - new_group_list['member'].add(userDN) - - if remove_user: - if not isinstance(remove_user, list): - remove_user = [remove_user] - - for user in remove_user: - if user == groupname: - raise YunohostError('remove_user_of_group_not_allowed', user=user, group=groupname) - - for user in remove_user: - userDN = "uid=" + user + ",ou=users,dc=yunohost,dc=org" - if 'member' in group and userDN in group['member']: - new_group_list['member'].remove(userDN) + if user in current_group: + logger.warning( + m18n.n("group_user_already_in_group", user=user, group=groupname) + ) else: - logger.warning(m18n.n('user_not_in_group', user=user, group=groupname)) + operation_logger.related_to.append(("user", user)) - # Sychronise memberUid with member (to keep the posix group structure) - # In posixgroup the main group of each user is only written in the gid number of the user - for member in new_group_list['member']: - member_Uid = member.split("=")[1].split(",")[0] - # Don't add main user in the group. - # Note that in the Unix system the main user of the group is linked by the gid in the user attribute. - # So the main user need to be not in the memberUid list of his own group. - if member_Uid != groupname: - new_group_list['memberUid'].add(member_Uid) + new_group += users_to_add - operation_logger.start() + if remove: + users_to_remove = [remove] if not isinstance(remove, list) else remove - if new_group_list['member'] != set(group['member']): - if not ldap.update('cn=%s,ou=groups' % groupname, new_group_list): - raise YunohostError('group_update_failed', group=groupname) + for user in users_to_remove: + if user not in current_group: + logger.warning( + m18n.n("group_user_not_in_group", user=user, group=groupname) + ) + else: + operation_logger.related_to.append(("user", user)) + + # Remove users_to_remove from new_group + # Kinda like a new_group -= users_to_remove + new_group = [u for u in new_group if u not in users_to_remove] + + new_group_dns = [ + "uid=" + user + ",ou=users,dc=yunohost,dc=org" for user in new_group + ] + + if set(new_group) != set(current_group): + if not from_import: + operation_logger.start() + ldap = _get_ldap_interface() + try: + ldap.update( + "cn=%s,ou=groups" % groupname, + {"member": set(new_group_dns), "memberUid": set(new_group)}, + ) + except Exception as e: + raise YunohostError("group_update_failed", group=groupname, error=e) - logger.success(m18n.n('group_updated', group=groupname)) if sync_perm: permission_sync_to_user() - return user_group_info(groupname) + + if not from_import: + if groupname != "all_users": + logger.success(m18n.n("group_updated", group=groupname)) + else: + logger.debug(m18n.n("group_updated", group=groupname)) + + return user_group_info(groupname) def user_group_info(groupname): @@ -727,60 +1205,107 @@ def user_group_info(groupname): """ - from yunohost.utils.ldap import _get_ldap_interface + from yunohost.utils.ldap import _get_ldap_interface, _ldap_path_extract + ldap = _get_ldap_interface() - group_attrs = [ - 'cn', 'member', 'permission' - ] - result = ldap.search('ou=groups,dc=yunohost,dc=org', "cn=" + groupname, group_attrs) + # Fetch info for this group + result = ldap.search( + "ou=groups,dc=yunohost,dc=org", + "cn=" + groupname, + ["cn", "member", "permission"], + ) if not result: - raise YunohostError('group_unknown', group=groupname) + raise YunohostValidationError("group_unknown", group=groupname) - group = result[0] + infos = result[0] - result_dict = { - 'groupname': group['cn'][0], - 'member': None + # Format data + + return { + "members": [_ldap_path_extract(p, "uid") for p in infos.get("member", [])], + "permissions": [ + _ldap_path_extract(p, "cn") for p in infos.get("permission", []) + ], } - if 'member' in group: - result_dict['member'] = {m.split("=")[1].split(",")[0] for m in group['member']} - return result_dict + + +def user_group_add(groupname, usernames, force=False, sync_perm=True): + """ + Add user(s) to a group + + Keyword argument: + groupname -- Groupname to update + usernames -- User(s) to add in the group + + """ + return user_group_update(groupname, add=usernames, force=force, sync_perm=sync_perm) + + +def user_group_remove(groupname, usernames, force=False, sync_perm=True): + """ + Remove user(s) from a group + + Keyword argument: + groupname -- Groupname to update + usernames -- User(s) to remove from the group + + """ + return user_group_update( + groupname, remove=usernames, force=force, sync_perm=sync_perm + ) # # Permission subcategory # -def user_permission_list(app=None, permission=None, username=None, group=None, sync_perm=True): + +def user_permission_list(short=False, full=False, apps=[]): import yunohost.permission - return yunohost.permission.user_permission_list(app, permission, username, group) + + return yunohost.permission.user_permission_list( + short, full, absolute_urls=True, apps=apps + ) -@is_unit_operation([('app', 'user')]) -def user_permission_add(operation_logger, app, permission="main", username=None, group=None, sync_perm=True): +def user_permission_update(permission, label=None, show_tile=None, sync_perm=True): import yunohost.permission - return yunohost.permission.user_permission_update(operation_logger, app, permission=permission, - add_username=username, add_group=group, - del_username=None, del_group=None, - sync_perm=sync_perm) + + return yunohost.permission.user_permission_update( + permission, label=label, show_tile=show_tile, sync_perm=sync_perm + ) -@is_unit_operation([('app', 'user')]) -def user_permission_remove(operation_logger, app, permission="main", username=None, group=None, sync_perm=True): +def user_permission_add(permission, names, protected=None, force=False, sync_perm=True): import yunohost.permission - return yunohost.permission.user_permission_update(operation_logger, app, permission=permission, - add_username=None, add_group=None, - del_username=username, del_group=group, - sync_perm=sync_perm) + + return yunohost.permission.user_permission_update( + permission, add=names, protected=protected, force=force, sync_perm=sync_perm + ) -@is_unit_operation([('app', 'user')]) -def user_permission_clear(operation_logger, app, permission=None, sync_perm=True): +def user_permission_remove( + permission, names, protected=None, force=False, sync_perm=True +): import yunohost.permission - return yunohost.permission.user_permission_clear(operation_logger, app, permission, - sync_perm=sync_perm) + + return yunohost.permission.user_permission_update( + permission, remove=names, protected=protected, force=force, sync_perm=sync_perm + ) + + +def user_permission_reset(permission, sync_perm=True): + import yunohost.permission + + return yunohost.permission.user_permission_reset(permission, sync_perm=sync_perm) + + +def user_permission_info(permission): + import yunohost.permission + + return yunohost.permission.user_permission_info(permission) # @@ -789,14 +1314,6 @@ def user_permission_clear(operation_logger, app, permission=None, sync_perm=True import yunohost.ssh -def user_ssh_allow(username): - return yunohost.ssh.user_ssh_allow(username) - - -def user_ssh_disallow(username): - return yunohost.ssh.user_ssh_disallow(username) - - def user_ssh_list_keys(username): return yunohost.ssh.user_ssh_list_keys(username) @@ -808,17 +1325,18 @@ def user_ssh_add_key(username, key, comment): def user_ssh_remove_key(username, key): return yunohost.ssh.user_ssh_remove_key(username, key) + # # End SSH subcategory # -def _convertSize(num, suffix=''): - for unit in ['K', 'M', 'G', 'T', 'P', 'E', 'Z']: +def _convertSize(num, suffix=""): + for unit in ["K", "M", "G", "T", "P", "E", "Z"]: if abs(num) < 1024.0: return "%3.1f%s%s" % (num, unit, suffix) num /= 1024.0 - return "%.1f%s%s" % (num, 'Yi', suffix) + return "%.1f%s%s" % (num, "Yi", suffix) def _hash_user_password(password): @@ -844,7 +1362,7 @@ def _hash_user_password(password): """ char_set = string.ascii_uppercase + string.ascii_lowercase + string.digits + "./" - salt = ''.join([random.SystemRandom().choice(char_set) for x in range(16)]) + salt = "".join([random.SystemRandom().choice(char_set) for x in range(16)]) - salt = '$6$' + salt + '$' - return '{CRYPT}' + crypt.crypt(str(password), salt) + salt = "$6$" + salt + "$" + return "{CRYPT}" + crypt.crypt(str(password), salt) diff --git a/src/yunohost/utils/config.py b/src/yunohost/utils/config.py new file mode 100644 index 000000000..27a9e1533 --- /dev/null +++ b/src/yunohost/utils/config.py @@ -0,0 +1,1096 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2018 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 os +import re +import urllib.parse +import tempfile +import shutil +from collections import OrderedDict +from typing import Optional, Dict, List, Union, Any, Mapping + +from moulinette.interfaces.cli import colorize +from moulinette import Moulinette, m18n +from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import ( + read_file, + write_to_file, + read_toml, + read_yaml, + write_to_yaml, + mkdir, +) + +from yunohost.utils.i18n import _value_for_locale +from yunohost.utils.error import YunohostError, YunohostValidationError +from yunohost.log import OperationLogger + +logger = getActionLogger("yunohost.config") +CONFIG_PANEL_VERSION_SUPPORTED = 1.0 + + +class ConfigPanel: + def __init__(self, config_path, save_path=None): + self.config_path = config_path + self.save_path = save_path + self.config = {} + self.values = {} + self.new_values = {} + + def get(self, key="", mode="classic"): + self.filter_key = key or "" + + # Read config panel toml + self._get_config_panel() + + if not self.config: + raise YunohostValidationError("config_no_panel") + + # Read or get values and hydrate the config + self._load_current_values() + self._hydrate() + + # In 'classic' mode, we display the current value if key refer to an option + if self.filter_key.count(".") == 2 and mode == "classic": + option = self.filter_key.split(".")[-1] + return self.values.get(option, None) + + # Format result in 'classic' or 'export' mode + logger.debug(f"Formating result in '{mode}' mode") + result = {} + for panel, section, option in self._iterate(): + key = f"{panel['id']}.{section['id']}.{option['id']}" + if mode == "export": + result[option["id"]] = option.get("current_value") + continue + + ask = None + if "ask" in option: + ask = _value_for_locale(option["ask"]) + elif "i18n" in self.config: + ask = m18n.n(self.config["i18n"] + "_" + option["id"]) + + if mode == "full": + # edit self.config directly + option["ask"] = ask + else: + result[key] = {"ask": ask} + if "current_value" in option: + question_class = ARGUMENTS_TYPE_PARSERS[ + option.get("type", "string") + ] + result[key]["value"] = question_class.humanize( + option["current_value"], option + ) + # FIXME: semantics, technically here this is not about a prompt... + if question_class.hide_user_input_in_prompt: + result[key][ + "value" + ] = "**************" # Prevent displaying password in `config get` + + if mode == "full": + return self.config + else: + return result + + def set( + self, key=None, value=None, args=None, args_file=None, operation_logger=None + ): + self.filter_key = key or "" + + # Read config panel toml + self._get_config_panel() + + if not self.config: + raise YunohostValidationError("config_no_panel") + + if (args is not None or args_file is not None) and value is not None: + raise YunohostValidationError( + "You should either provide a value, or a serie of args/args_file, but not both at the same time", + raw_msg=True, + ) + + if self.filter_key.count(".") != 2 and value is not None: + raise YunohostValidationError("config_cant_set_value_on_section") + + # Import and parse pre-answered options + logger.debug("Import and parse pre-answered options") + args = urllib.parse.parse_qs(args or "", keep_blank_values=True) + self.args = {key: ",".join(value_) for key, value_ in args.items()} + + if args_file: + # Import YAML / JSON file but keep --args values + self.args = {**read_yaml(args_file), **self.args} + + if value is not None: + self.args = {self.filter_key.split(".")[-1]: value} + + # Read or get values and hydrate the config + self._load_current_values() + self._hydrate() + self._ask() + + if operation_logger: + operation_logger.start() + + try: + self._apply() + except YunohostError: + raise + # Script got manually interrupted ... + # N.B. : KeyboardInterrupt does not inherit from Exception + except (KeyboardInterrupt, EOFError): + error = m18n.n("operation_interrupted") + logger.error(m18n.n("config_apply_failed", error=error)) + raise + # Something wrong happened in Yunohost's code (most probably hook_exec) + except Exception: + import traceback + + error = m18n.n("unexpected_error", error="\n" + traceback.format_exc()) + logger.error(m18n.n("config_apply_failed", error=error)) + raise + finally: + # Delete files uploaded from API + # FIXME : this is currently done in the context of config panels, + # but could also happen in the context of app install ... (or anywhere else + # where we may parse args etc...) + FileQuestion.clean_upload_dirs() + + self._reload_services() + + logger.success("Config updated as expected") + operation_logger.success() + + def _get_toml(self): + return read_toml(self.config_path) + + def _get_config_panel(self): + + # Split filter_key + filter_key = self.filter_key.split(".") if self.filter_key != "" else [] + if len(filter_key) > 3: + raise YunohostError( + f"The filter key {filter_key} has too many sub-levels, the max is 3.", + raw_msg=True, + ) + + if not os.path.exists(self.config_path): + logger.debug(f"Config panel {self.config_path} doesn't exists") + return None + + toml_config_panel = self._get_toml() + + # Check TOML config panel is in a supported version + if float(toml_config_panel["version"]) < CONFIG_PANEL_VERSION_SUPPORTED: + raise YunohostError( + "config_version_not_supported", version=toml_config_panel["version"] + ) + + # Transform toml format into internal format + format_description = { + "root": { + "properties": ["version", "i18n"], + "defaults": {"version": 1.0}, + }, + "panels": { + "properties": ["name", "services", "actions", "help"], + "defaults": { + "services": [], + "actions": {"apply": {"en": "Apply"}}, + }, + }, + "sections": { + "properties": ["name", "services", "optional", "help", "visible"], + "defaults": { + "name": "", + "services": [], + "optional": True, + }, + }, + "options": { + "properties": [ + "ask", + "type", + "bind", + "help", + "example", + "default", + "style", + "icon", + "placeholder", + "visible", + "optional", + "choices", + "yes", + "no", + "pattern", + "limit", + "min", + "max", + "step", + "accept", + "redact", + ], + "defaults": {}, + }, + } + + def _build_internal_config_panel(raw_infos, level): + """Convert TOML in internal format ('full' mode used by webadmin) + Here are some properties of 1.0 config panel in toml: + - node properties and node children are mixed, + - text are in english only + - some properties have default values + This function detects all children nodes and put them in a list + """ + + defaults = format_description[level]["defaults"] + properties = format_description[level]["properties"] + + # Start building the ouput (merging the raw infos + defaults) + out = {key: raw_infos.get(key, value) for key, value in defaults.items()} + + # Now fill the sublevels (+ apply filter_key) + i = list(format_description).index(level) + sublevel = list(format_description)[i + 1] if level != "options" else None + search_key = filter_key[i] if len(filter_key) > i else False + + for key, value in raw_infos.items(): + # Key/value are a child node + if ( + isinstance(value, OrderedDict) + and key not in properties + and sublevel + ): + # We exclude all nodes not referenced by the filter_key + if search_key and key != search_key: + continue + subnode = _build_internal_config_panel(value, sublevel) + subnode["id"] = key + if level == "root": + subnode.setdefault("name", {"en": key.capitalize()}) + elif level == "sections": + subnode["name"] = key # legacy + subnode.setdefault("optional", raw_infos.get("optional", True)) + out.setdefault(sublevel, []).append(subnode) + # Key/value are a property + else: + if key not in properties: + logger.warning(f"Unknown key '{key}' found in config panel") + # Todo search all i18n keys + out[key] = ( + value if key not in ["ask", "help", "name"] else {"en": value} + ) + return out + + self.config = _build_internal_config_panel(toml_config_panel, "root") + + try: + self.config["panels"][0]["sections"][0]["options"][0] + except (KeyError, IndexError): + raise YunohostValidationError( + "config_unknown_filter_key", filter_key=self.filter_key + ) + + # List forbidden keywords from helpers and sections toml (to avoid conflict) + forbidden_keywords = [ + "old", + "app", + "changed", + "file_hash", + "binds", + "types", + "formats", + "getter", + "setter", + "short_setting", + "type", + "bind", + "nothing_changed", + "changes_validated", + "result", + "max_progression", + ] + forbidden_keywords += format_description["sections"] + + for _, _, option in self._iterate(): + if option["id"] in forbidden_keywords: + raise YunohostError("config_forbidden_keyword", keyword=option["id"]) + return self.config + + def _hydrate(self): + # Hydrating config panel with current value + logger.debug("Hydrating config with current values") + for _, _, option in self._iterate(): + if option["id"] not in self.values: + allowed_empty_types = ["alert", "display_text", "markdown", "file"] + if ( + option["type"] in allowed_empty_types + or option.get("bind") == "null" + ): + continue + else: + raise YunohostError( + f"Config panel question '{option['id']}' should be initialized with a value during install or upgrade.", + raw_msg=True, + ) + value = self.values[option["name"]] + # In general, the value is just a simple value. + # Sometimes it could be a dict used to overwrite the option itself + value = value if isinstance(value, dict) else {"current_value": value} + option.update(value) + + return self.values + + def _ask(self): + logger.debug("Ask unanswered question and prevalidate data") + + if "i18n" in self.config: + for panel, section, option in self._iterate(): + if "ask" not in option: + option["ask"] = m18n.n(self.config["i18n"] + "_" + option["id"]) + + def display_header(message): + """CLI panel/section header display""" + if Moulinette.interface.type == "cli" and self.filter_key.count(".") < 2: + Moulinette.display(colorize(message, "purple")) + + for panel, section, obj in self._iterate(["panel", "section"]): + if panel == obj: + name = _value_for_locale(panel["name"]) + display_header(f"\n{'='*40}\n>>>> {name}\n{'='*40}") + continue + name = _value_for_locale(section["name"]) + if name: + display_header(f"\n# {name}") + + # Check and ask unanswered questions + questions = ask_questions_and_parse_answers(section["options"], self.args) + self.new_values.update( + { + question.name: question.value + for question in questions + if question.value is not None + } + ) + + self.errors = None + + def _get_default_values(self): + return { + option["id"]: option["default"] + for _, _, option in self._iterate() + if "default" in option + } + + def _load_current_values(self): + """ + Retrieve entries in YAML file + And set default values if needed + """ + + # Retrieve entries in the YAML + on_disk_settings = {} + if os.path.exists(self.save_path) and os.path.isfile(self.save_path): + on_disk_settings = read_yaml(self.save_path) or {} + + # Inject defaults if needed (using the magic .update() ;)) + self.values = self._get_default_values() + self.values.update(on_disk_settings) + + def _apply(self): + logger.info("Saving the new configuration...") + dir_path = os.path.dirname(os.path.realpath(self.save_path)) + if not os.path.exists(dir_path): + mkdir(dir_path, mode=0o700) + + values_to_save = {**self.values, **self.new_values} + if self.save_mode == "diff": + defaults = self._get_default_values() + values_to_save = { + k: v for k, v in values_to_save.items() if defaults.get(k) != v + } + + # Save the settings to the .yaml file + write_to_yaml(self.save_path, values_to_save) + + def _reload_services(self): + + from yunohost.service import service_reload_or_restart + + services_to_reload = set() + for panel, section, obj in self._iterate(["panel", "section", "option"]): + services_to_reload |= set(obj.get("services", [])) + + services_to_reload = list(services_to_reload) + services_to_reload.sort(key="nginx".__eq__) + if services_to_reload: + logger.info("Reloading services...") + for service in services_to_reload: + if hasattr(self, "app"): + service = service.replace("__APP__", self.app) + service_reload_or_restart(service) + + def _iterate(self, trigger=["option"]): + for panel in self.config.get("panels", []): + if "panel" in trigger: + yield (panel, None, panel) + for section in panel.get("sections", []): + if "section" in trigger: + yield (panel, section, section) + if "option" in trigger: + for option in section.get("options", []): + yield (panel, section, option) + + +class Question(object): + hide_user_input_in_prompt = False + pattern: Optional[Dict] = None + + def __init__(self, question: Dict[str, Any]): + self.name = question["name"] + self.type = question.get("type", "string") + self.default = question.get("default", None) + self.optional = question.get("optional", False) + self.choices = question.get("choices", []) + self.pattern = question.get("pattern", self.pattern) + self.ask = question.get("ask", {"en": self.name}) + self.help = question.get("help") + self.redact = question.get("redact", False) + # .current_value is the currently stored value + self.current_value = question.get("current_value") + # .value is the "proposed" value which we got from the user + self.value = question.get("value") + + # Empty value is parsed as empty string + if self.default == "": + self.default = None + + @staticmethod + def humanize(value, option={}): + return str(value) + + @staticmethod + def normalize(value, option={}): + if isinstance(value, str): + value = value.strip() + return value + + def _prompt(self, text): + prefill = "" + if self.current_value is not None: + prefill = self.humanize(self.current_value, self) + elif self.default is not None: + prefill = self.humanize(self.default, self) + self.value = Moulinette.prompt( + message=text, + is_password=self.hide_user_input_in_prompt, + confirm=False, + prefill=prefill, + is_multiline=(self.type == "text"), + autocomplete=self.choices, + help=_value_for_locale(self.help), + ) + + def ask_if_needed(self): + for i in range(5): + # Display question if no value filled or if it's a readonly message + if Moulinette.interface.type == "cli" and os.isatty(1): + text_for_user_input_in_cli = self._format_text_for_user_input_in_cli() + if getattr(self, "readonly", False): + Moulinette.display(text_for_user_input_in_cli) + elif self.value is None: + self._prompt(text_for_user_input_in_cli) + + # Apply default value + class_default = getattr(self, "default_value", None) + if self.value in [None, ""] and ( + self.default is not None or class_default is not None + ): + self.value = class_default if self.default is None else self.default + + try: + # Normalize and validate + self.value = self.normalize(self.value, self) + self._prevalidate() + except YunohostValidationError as e: + # If in interactive cli, re-ask the current question + if i < 4 and Moulinette.interface.type == "cli" and os.isatty(1): + logger.error(str(e)) + self.value = None + continue + + # Otherwise raise the ValidationError + raise + + break + + self.value = self._post_parse_value() + + return self.value + + def _prevalidate(self): + if self.value in [None, ""] and not self.optional: + raise YunohostValidationError("app_argument_required", name=self.name) + + # we have an answer, do some post checks + if self.value not in [None, ""]: + if self.choices and self.value not in self.choices: + raise YunohostValidationError( + "app_argument_choice_invalid", + name=self.name, + value=self.value, + choices=", ".join(self.choices), + ) + if self.pattern and not re.match(self.pattern["regexp"], str(self.value)): + raise YunohostValidationError( + self.pattern["error"], + name=self.name, + value=self.value, + ) + + def _format_text_for_user_input_in_cli(self): + + text_for_user_input_in_cli = _value_for_locale(self.ask) + + if self.choices: + + # Prevent displaying a shitload of choices + # (e.g. 100+ available users when choosing an app admin...) + choices = ( + list(self.choices.values()) + if isinstance(self.choices, dict) + else self.choices + ) + choices_to_display = choices[:20] + remaining_choices = len(choices[20:]) + + if remaining_choices > 0: + choices_to_display += [ + m18n.n("other_available_options", n=remaining_choices) + ] + + choices_to_display = " | ".join(choices_to_display) + + text_for_user_input_in_cli += f" [{choices_to_display}]" + + return text_for_user_input_in_cli + + def _post_parse_value(self): + if not self.redact: + return self.value + + # Tell the operation_logger to redact all password-type / secret args + # Also redact the % escaped version of the password that might appear in + # the 'args' section of metadata (relevant for password with non-alphanumeric char) + data_to_redact = [] + if self.value and isinstance(self.value, str): + data_to_redact.append(self.value) + if self.current_value and isinstance(self.current_value, str): + data_to_redact.append(self.current_value) + data_to_redact += [ + urllib.parse.quote(data) + for data in data_to_redact + if urllib.parse.quote(data) != data + ] + + for operation_logger in OperationLogger._instances: + operation_logger.data_to_redact.extend(data_to_redact) + + return self.value + + +class StringQuestion(Question): + argument_type = "string" + default_value = "" + + +class EmailQuestion(StringQuestion): + pattern = { + "regexp": r"^.+@.+", + "error": "config_validate_email", # i18n: config_validate_email + } + + +class URLQuestion(StringQuestion): + pattern = { + "regexp": r"^https?://.*$", + "error": "config_validate_url", # i18n: config_validate_url + } + + +class DateQuestion(StringQuestion): + pattern = { + "regexp": r"^\d{4}-\d\d-\d\d$", + "error": "config_validate_date", # i18n: config_validate_date + } + + def _prevalidate(self): + from datetime import datetime + + super()._prevalidate() + + if self.value not in [None, ""]: + try: + datetime.strptime(self.value, "%Y-%m-%d") + except ValueError: + raise YunohostValidationError("config_validate_date") + + +class TimeQuestion(StringQuestion): + pattern = { + "regexp": r"^(1[12]|0?\d):[0-5]\d$", + "error": "config_validate_time", # i18n: config_validate_time + } + + +class ColorQuestion(StringQuestion): + pattern = { + "regexp": r"^#[ABCDEFabcdef\d]{3,6}$", + "error": "config_validate_color", # i18n: config_validate_color + } + + +class TagsQuestion(Question): + argument_type = "tags" + + @staticmethod + def humanize(value, option={}): + if isinstance(value, list): + return ",".join(value) + return value + + @staticmethod + def normalize(value, option={}): + if isinstance(value, list): + return ",".join(value) + if isinstance(value, str): + value = value.strip() + return value + + def _prevalidate(self): + values = self.value + if isinstance(values, str): + values = values.split(",") + elif values is None: + values = [] + for value in values: + self.value = value + super()._prevalidate() + self.value = values + + def _post_parse_value(self): + if isinstance(self.value, list): + self.value = ",".join(self.value) + return super()._post_parse_value() + + +class PasswordQuestion(Question): + hide_user_input_in_prompt = True + argument_type = "password" + default_value = "" + forbidden_chars = "{}" + + def __init__(self, question): + super().__init__(question) + self.redact = True + if self.default is not None: + raise YunohostValidationError( + "app_argument_password_no_default", name=self.name + ) + + def _prevalidate(self): + super()._prevalidate() + + if self.value not in [None, ""]: + if any(char in self.value for char in self.forbidden_chars): + raise YunohostValidationError( + "pattern_password_app", forbidden_chars=self.forbidden_chars + ) + + # If it's an optional argument the value should be empty or strong enough + from yunohost.utils.password import assert_password_is_strong_enough + + assert_password_is_strong_enough("user", self.value) + + +class PathQuestion(Question): + argument_type = "path" + default_value = "" + + @staticmethod + def normalize(value, option={}): + + option = option.__dict__ if isinstance(option, Question) else option + + if not value.strip(): + if option.get("optional"): + return "" + # Hmpf here we could just have a "else" case + # but we also want PathQuestion.normalize("") to return "/" + # (i.e. if no option is provided, hence .get("optional") is None + elif option.get("optional") is False: + raise YunohostValidationError( + "app_argument_invalid", + name=option.get("name"), + error="Question is mandatory", + ) + + return "/" + value.strip().strip(" /") + + +class BooleanQuestion(Question): + argument_type = "boolean" + default_value = 0 + yes_answers = ["1", "yes", "y", "true", "t", "on"] + no_answers = ["0", "no", "n", "false", "f", "off"] + + @staticmethod + def humanize(value, option={}): + + option = option.__dict__ if isinstance(option, Question) else option + + yes = option.get("yes", 1) + no = option.get("no", 0) + + value = BooleanQuestion.normalize(value, option) + + if value == yes: + return "yes" + if value == no: + return "no" + if value is None: + return "" + + raise YunohostValidationError( + "app_argument_choice_invalid", + name=option.get("name"), + value=value, + choices="yes/no", + ) + + @staticmethod + def normalize(value, option={}): + + option = option.__dict__ if isinstance(option, Question) else option + + if isinstance(value, str): + value = value.strip() + + technical_yes = option.get("yes", 1) + technical_no = option.get("no", 0) + + no_answers = BooleanQuestion.no_answers + yes_answers = BooleanQuestion.yes_answers + + assert ( + str(technical_yes).lower() not in no_answers + ), f"'yes' value can't be in {no_answers}" + assert ( + str(technical_no).lower() not in yes_answers + ), f"'no' value can't be in {yes_answers}" + + no_answers += [str(technical_no).lower()] + yes_answers += [str(technical_yes).lower()] + + strvalue = str(value).lower() + + if strvalue in yes_answers: + return technical_yes + if strvalue in no_answers: + return technical_no + + if strvalue in ["none", ""]: + return None + + raise YunohostValidationError( + "app_argument_choice_invalid", + name=option.get("name"), + value=strvalue, + choices="yes/no", + ) + + def __init__(self, question): + super().__init__(question) + self.yes = question.get("yes", 1) + self.no = question.get("no", 0) + if self.default is None: + self.default = self.no + + def _format_text_for_user_input_in_cli(self): + text_for_user_input_in_cli = super()._format_text_for_user_input_in_cli() + + text_for_user_input_in_cli += " [yes | no]" + + return text_for_user_input_in_cli + + def get(self, key, default=None): + return getattr(self, key, default) + + +class DomainQuestion(Question): + argument_type = "domain" + + def __init__(self, question): + from yunohost.domain import domain_list, _get_maindomain + + super().__init__(question) + + if self.default is None: + self.default = _get_maindomain() + + self.choices = domain_list()["domains"] + + @staticmethod + def normalize(value, option={}): + if value.startswith("https://"): + value = value[len("https://") :] + elif value.startswith("http://"): + value = value[len("http://") :] + + # Remove trailing slashes + value = value.rstrip("/").lower() + + return value + + +class UserQuestion(Question): + argument_type = "user" + + def __init__(self, question): + from yunohost.user import user_list, user_info + from yunohost.domain import _get_maindomain + + super().__init__(question) + self.choices = list(user_list()["users"].keys()) + + if not self.choices: + raise YunohostValidationError( + "app_argument_invalid", + name=self.name, + error="You should create a YunoHost user first.", + ) + + if self.default is None: + root_mail = "root@%s" % _get_maindomain() + for user in self.choices: + if root_mail in user_info(user).get("mail-aliases", []): + self.default = user + break + + +class NumberQuestion(Question): + argument_type = "number" + default_value = None + + def __init__(self, question): + super().__init__(question) + self.min = question.get("min", None) + self.max = question.get("max", None) + self.step = question.get("step", None) + + @staticmethod + def normalize(value, option={}): + + if isinstance(value, int): + return value + + if isinstance(value, str): + value = value.strip() + + if isinstance(value, str) and value.isdigit(): + return int(value) + + if value in [None, ""]: + return value + + option = option.__dict__ if isinstance(option, Question) else option + raise YunohostValidationError( + "app_argument_invalid", + name=option.get("name"), + error=m18n.n("invalid_number"), + ) + + def _prevalidate(self): + super()._prevalidate() + if self.value in [None, ""]: + return + + if self.min is not None and int(self.value) < self.min: + raise YunohostValidationError( + "app_argument_invalid", + name=self.name, + error=m18n.n("invalid_number_min", min=self.min), + ) + + if self.max is not None and int(self.value) > self.max: + raise YunohostValidationError( + "app_argument_invalid", + name=self.name, + error=m18n.n("invalid_number_max", max=self.max), + ) + + +class DisplayTextQuestion(Question): + argument_type = "display_text" + readonly = True + + def __init__(self, question): + super().__init__(question) + + self.optional = True + self.style = question.get( + "style", "info" if question["type"] == "alert" else "" + ) + + def _format_text_for_user_input_in_cli(self): + text = _value_for_locale(self.ask) + + if self.style in ["success", "info", "warning", "danger"]: + color = { + "success": "green", + "info": "cyan", + "warning": "yellow", + "danger": "red", + } + prompt = m18n.g(self.style) if self.style != "danger" else m18n.n("danger") + return colorize(prompt, color[self.style]) + f" {text}" + else: + return text + + +class FileQuestion(Question): + argument_type = "file" + upload_dirs: List[str] = [] + + @classmethod + def clean_upload_dirs(cls): + # Delete files uploaded from API + for upload_dir in cls.upload_dirs: + if os.path.exists(upload_dir): + shutil.rmtree(upload_dir) + + def __init__(self, question): + super().__init__(question) + self.accept = question.get("accept", "") + + def _prevalidate(self): + if self.value is None: + self.value = self.current_value + + super()._prevalidate() + + if Moulinette.interface.type != "api": + if not self.value or not os.path.exists(str(self.value)): + raise YunohostValidationError( + "app_argument_invalid", + name=self.name, + error=m18n.n("file_does_not_exist", path=str(self.value)), + ) + + def _post_parse_value(self): + from base64 import b64decode + + if not self.value: + return self.value + + upload_dir = tempfile.mkdtemp(prefix="ynh_filequestion_") + _, file_path = tempfile.mkstemp(dir=upload_dir) + + FileQuestion.upload_dirs += [upload_dir] + + logger.debug(f"Saving file {self.name} for file question into {file_path}") + if Moulinette.interface.type != "api": + content = read_file(str(self.value), file_mode="rb") + + if Moulinette.interface.type == "api": + content = b64decode(self.value) + + write_to_file(file_path, content, file_mode="wb") + + self.value = file_path + + return self.value + + +ARGUMENTS_TYPE_PARSERS = { + "string": StringQuestion, + "text": StringQuestion, + "select": StringQuestion, + "tags": TagsQuestion, + "email": EmailQuestion, + "url": URLQuestion, + "date": DateQuestion, + "time": TimeQuestion, + "color": ColorQuestion, + "password": PasswordQuestion, + "path": PathQuestion, + "boolean": BooleanQuestion, + "domain": DomainQuestion, + "user": UserQuestion, + "number": NumberQuestion, + "range": NumberQuestion, + "display_text": DisplayTextQuestion, + "alert": DisplayTextQuestion, + "markdown": DisplayTextQuestion, + "file": FileQuestion, +} + + +def ask_questions_and_parse_answers( + questions: Dict, prefilled_answers: Union[str, Mapping[str, Any]] = {} +) -> List[Question]: + """Parse arguments store in either manifest.json or actions.json or from a + config panel against the user answers when they are present. + + Keyword arguments: + questions -- the arguments description store in yunohost + format from actions.json/toml, manifest.json/toml + or config_panel.json/toml + prefilled_answers -- a url "query-string" such as "domain=yolo.test&path=/foobar&admin=sam" + or a dict such as {"domain": "yolo.test", "path": "/foobar", "admin": "sam"} + """ + + if isinstance(prefilled_answers, str): + # FIXME FIXME : this is not uniform with config_set() which uses parse.qs (no l) + # parse_qsl parse single values + # whereas parse.qs return list of values (which is useful for tags, etc) + # For now, let's not migrate this piece of code to parse_qs + # Because Aleks believes some bits of the app CI rely on overriding values (e.g. foo=foo&...&foo=bar) + prefilled_answers = dict( + urllib.parse.parse_qsl(prefilled_answers or "", keep_blank_values=True) + ) + + if not prefilled_answers: + prefilled_answers = {} + + out = [] + + for question in questions: + question_class = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")] + question["value"] = prefilled_answers.get(question["name"]) + question = question_class(question) + + question.ask_if_needed() + out.append(question) + + return out diff --git a/src/yunohost/utils/dns.py b/src/yunohost/utils/dns.py new file mode 100644 index 000000000..3db75f949 --- /dev/null +++ b/src/yunohost/utils/dns.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2018 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 dns.resolver +from typing import List + +from moulinette.utils.filesystem import read_file + +YNH_DYNDNS_DOMAINS = ["nohost.me", "noho.st", "ynh.fr"] + +# Lazy dev caching to avoid re-reading the file multiple time when calling +# dig() often during same yunohost operation +external_resolvers_: List[str] = [] + + +def external_resolvers(): + + global external_resolvers_ + + if not external_resolvers_: + resolv_dnsmasq_conf = read_file("/etc/resolv.dnsmasq.conf").split("\n") + external_resolvers_ = [ + r.split(" ")[1] for r in resolv_dnsmasq_conf if r.startswith("nameserver") + ] + # We keep only ipv4 resolvers, otherwise on IPv4-only instances, IPv6 + # will be tried anyway resulting in super-slow dig requests that'll wait + # until timeout... + external_resolvers_ = [r for r in external_resolvers_ if ":" not in r] + + return external_resolvers_ + + +def dig( + qname, rdtype="A", timeout=5, resolvers="local", edns_size=1500, full_answers=False +): + """ + Do a quick DNS request and avoid the "search" trap inside /etc/resolv.conf + """ + + # It's very important to do the request with a qname ended by . + # If we don't and the domain fail, dns resolver try a second request + # by concatenate the qname with the end of the "hostname" + if not qname.endswith("."): + qname += "." + + if resolvers == "local": + resolvers = ["127.0.0.1"] + elif resolvers == "force_external": + resolvers = external_resolvers() + else: + assert isinstance(resolvers, list) + + resolver = dns.resolver.Resolver(configure=False) + resolver.use_edns(0, 0, edns_size) + resolver.nameservers = resolvers + # resolver.timeout is used to trigger the next DNS query on resolvers list. + # In python-dns 1.16, this value is set to 2.0. However, this means that if + # the 3 first dns resolvers in list are down, we wait 6 seconds before to + # run the DNS query to a DNS resolvers up... + # In diagnosis dnsrecords, with 10 domains this means at least 12min, too long. + resolver.timeout = 1.0 + # resolver.lifetime is the timeout for resolver.query() + # By default set it to 5 seconds to allow 4 resolvers to be unreachable. + resolver.lifetime = timeout + try: + answers = resolver.query(qname, rdtype) + except ( + dns.resolver.NXDOMAIN, + dns.resolver.NoNameservers, + dns.resolver.NoAnswer, + dns.exception.Timeout, + ) as e: + return ("nok", (e.__class__.__name__, e)) + + if not full_answers: + answers = [answer.to_text() for answer in answers] + + return ("ok", answers) diff --git a/src/yunohost/utils/error.py b/src/yunohost/utils/error.py index aeffabcf0..8405830e7 100644 --- a/src/yunohost/utils/error.py +++ b/src/yunohost/utils/error.py @@ -25,16 +25,38 @@ from moulinette import m18n class YunohostError(MoulinetteError): + http_code = 500 + """ Yunohost base exception - + The (only?) main difference with MoulinetteError being that keys are translated via m18n.n (namespace) instead of m18n.g (global?) """ - def __init__(self, key, raw_msg=False, *args, **kwargs): + def __init__(self, key, raw_msg=False, log_ref=None, *args, **kwargs): + self.key = key # Saving the key is useful for unit testing + self.kwargs = kwargs # Saving the key is useful for unit testing + self.log_ref = log_ref if raw_msg: msg = key else: msg = m18n.n(key, *args, **kwargs) + super(YunohostError, self).__init__(msg, raw_msg=True) + + def content(self): + + if not self.log_ref: + return super().content() + else: + return {"error": self.strerror, "log_ref": self.log_ref} + + +class YunohostValidationError(YunohostError): + + http_code = 400 + + def content(self): + + return {"error": self.strerror, "error_key": self.key, **self.kwargs} diff --git a/src/yunohost/utils/i18n.py b/src/yunohost/utils/i18n.py new file mode 100644 index 000000000..a0daf8181 --- /dev/null +++ b/src/yunohost/utils/i18n.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2018 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 + +""" +from moulinette import m18n + + +def _value_for_locale(values): + """ + Return proper value for current locale + + Keyword arguments: + values -- A dict of values associated to their locale + + Returns: + An utf-8 encoded string + + """ + if not isinstance(values, dict): + return values + + for lang in [m18n.locale, m18n.default_locale]: + try: + return values[lang] + except KeyError: + continue + + # Fallback to first value + return list(values.values())[0] diff --git a/src/yunohost/utils/ldap.py b/src/yunohost/utils/ldap.py index 186cdbdec..651d09f75 100644 --- a/src/yunohost/utils/ldap.py +++ b/src/yunohost/utils/ldap.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- - """ License Copyright (C) 2019 YunoHost @@ -19,27 +18,47 @@ """ +import os import atexit -from moulinette.core import init_authenticator +import logging +import ldap +import ldap.sasl +import time +import ldap.modlist as modlist + +from moulinette import m18n +from moulinette.core import MoulinetteError +from yunohost.utils.error import YunohostError + +logger = logging.getLogger("yunohost.utils.ldap") # We use a global variable to do some caching # to avoid re-authenticating in case we call _get_ldap_authenticator multiple times _ldap_interface = None + def _get_ldap_interface(): global _ldap_interface if _ldap_interface is None: - # Instantiate LDAP Authenticator - AUTH_IDENTIFIER = ('ldap', 'as-root') - AUTH_PARAMETERS = {'uri': 'ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi', - 'base_dn': 'dc=yunohost,dc=org', - 'user_rdn': 'gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth'} - _ldap_interface = init_authenticator(AUTH_IDENTIFIER, AUTH_PARAMETERS) + _ldap_interface = LDAPInterface() return _ldap_interface + +# We regularly want to extract stuff like 'bar' in ldap path like +# foo=bar,dn=users.example.org,ou=example.org,dc=org so this small helper allow +# to do this without relying of dozens of mysterious string.split()[0] +# +# e.g. using _ldap_path_extract(path, "foo") on the previous example will +# return bar +def _ldap_path_extract(path, info): + for element in path.split(","): + if element.startswith(info + "="): + return element[len(info + "=") :] + + # Add this to properly close / delete the ldap interface / authenticator # when Python exits ... # Otherwise there's a risk that some funky error appears at the very end @@ -49,4 +68,249 @@ def _destroy_ldap_interface(): if _ldap_interface is not None: del _ldap_interface + atexit.register(_destroy_ldap_interface) + + +class LDAPInterface: + def __init__(self): + logger.debug("initializing ldap interface") + + self.uri = "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi" + self.basedn = "dc=yunohost,dc=org" + self.rootdn = "gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" + self.connect() + + def connect(self): + def _reconnect(): + con = ldap.ldapobject.ReconnectLDAPObject( + self.uri, retry_max=10, retry_delay=0.5 + ) + con.sasl_non_interactive_bind_s("EXTERNAL") + return con + + try: + con = _reconnect() + except ldap.SERVER_DOWN: + # ldap is down, attempt to restart it before really failing + logger.warning(m18n.n("ldap_server_is_down_restart_it")) + os.system("systemctl restart slapd") + time.sleep(10) # waits 10 secondes so we are sure that slapd has restarted + try: + con = _reconnect() + except ldap.SERVER_DOWN: + raise YunohostError( + "Service slapd is not running but is required to perform this action ... " + "You can try to investigate what's happening with 'systemctl status slapd'", + raw_msg=True, + ) + + # Check that we are indeed logged in with the right identity + try: + # whoami_s return dn:..., then delete these 3 characters + who = con.whoami_s()[3:] + except Exception as e: + logger.warning("Error during ldap authentication process: %s", e) + raise + else: + if who != self.rootdn: + raise MoulinetteError("Not logged in with the expected userdn ?!") + else: + self.con = con + + def __del__(self): + """Disconnect and free ressources""" + if hasattr(self, "con") and self.con: + self.con.unbind_s() + + def search(self, base=None, filter="(objectClass=*)", attrs=["dn"]): + """Search in LDAP base + + Perform an LDAP search operation with given arguments and return + results as a list. + + Keyword arguments: + - base -- The dn to search into + - filter -- A string representation of the filter to apply + - attrs -- A list of attributes to fetch + + Returns: + A list of all results + + """ + if not base: + base = self.basedn + + try: + result = self.con.search_s(base, ldap.SCOPE_SUBTREE, filter, attrs) + except Exception as e: + raise MoulinetteError( + "error during LDAP search operation with: base='%s', " + "filter='%s', attrs=%s and exception %s" % (base, filter, attrs, e), + raw_msg=True, + ) + + result_list = [] + if not attrs or "dn" not in attrs: + result_list = [entry for dn, entry in result] + else: + for dn, entry in result: + entry["dn"] = [dn] + result_list.append(entry) + + def decode(value): + if isinstance(value, bytes): + value = value.decode("utf-8") + return value + + # result_list is for example : + # [{'virtualdomain': [b'test.com']}, {'virtualdomain': [b'yolo.test']}, + for stuff in result_list: + if isinstance(stuff, dict): + for key, values in stuff.items(): + stuff[key] = [decode(v) for v in values] + + return result_list + + def add(self, rdn, attr_dict): + """ + Add LDAP entry + + Keyword arguments: + rdn -- DN without domain + attr_dict -- Dictionnary of attributes/values to add + + Returns: + Boolean | MoulinetteError + + """ + dn = rdn + "," + self.basedn + ldif = modlist.addModlist(attr_dict) + for i, (k, v) in enumerate(ldif): + if isinstance(v, list): + v = [a.encode("utf-8") for a in v] + elif isinstance(v, str): + v = [v.encode("utf-8")] + ldif[i] = (k, v) + + try: + self.con.add_s(dn, ldif) + except Exception as e: + raise MoulinetteError( + "error during LDAP add operation with: rdn='%s', " + "attr_dict=%s and exception %s" % (rdn, attr_dict, e), + raw_msg=True, + ) + else: + return True + + def remove(self, rdn): + """ + Remove LDAP entry + + Keyword arguments: + rdn -- DN without domain + + Returns: + Boolean | MoulinetteError + + """ + dn = rdn + "," + self.basedn + try: + self.con.delete_s(dn) + except Exception as e: + raise MoulinetteError( + "error during LDAP delete operation with: rdn='%s' and exception %s" + % (rdn, e), + raw_msg=True, + ) + else: + return True + + def update(self, rdn, attr_dict, new_rdn=False): + """ + Modify LDAP entry + + Keyword arguments: + rdn -- DN without domain + attr_dict -- Dictionnary of attributes/values to add + new_rdn -- New RDN for modification + + Returns: + Boolean | MoulinetteError + + """ + dn = rdn + "," + self.basedn + actual_entry = self.search(base=dn, attrs=None) + ldif = modlist.modifyModlist(actual_entry[0], attr_dict, ignore_oldexistent=1) + + if ldif == []: + logger.debug("Nothing to update in LDAP") + return True + + try: + if new_rdn: + self.con.rename_s(dn, new_rdn) + new_base = dn.split(",", 1)[1] + dn = new_rdn + "," + new_base + + for i, (a, k, vs) in enumerate(ldif): + if isinstance(vs, list): + vs = [v.encode("utf-8") for v in vs] + elif isinstance(vs, str): + vs = [vs.encode("utf-8")] + ldif[i] = (a, k, vs) + + self.con.modify_ext_s(dn, ldif) + except Exception as e: + raise MoulinetteError( + "error during LDAP update operation with: rdn='%s', " + "attr_dict=%s, new_rdn=%s and exception: %s" + % (rdn, attr_dict, new_rdn, e), + raw_msg=True, + ) + else: + return True + + def validate_uniqueness(self, value_dict): + """ + Check uniqueness of values + + Keyword arguments: + value_dict -- Dictionnary of attributes/values to check + + Returns: + Boolean | MoulinetteError + + """ + attr_found = self.get_conflict(value_dict) + if attr_found: + logger.info( + "attribute '%s' with value '%s' is not unique", + attr_found[0], + attr_found[1], + ) + raise YunohostError( + "ldap_attribute_already_exists", + attribute=attr_found[0], + value=attr_found[1], + ) + return True + + def get_conflict(self, value_dict, base_dn=None): + """ + Check uniqueness of values + + Keyword arguments: + value_dict -- Dictionnary of attributes/values to check + + Returns: + None | tuple with Fist conflict attribute name and value + + """ + for attr, value in value_dict.items(): + if not self.search(base=base_dn, filter=attr + "=" + value): + continue + else: + return (attr, value) + return None diff --git a/src/yunohost/utils/legacy.py b/src/yunohost/utils/legacy.py new file mode 100644 index 000000000..eb92dd71f --- /dev/null +++ b/src/yunohost/utils/legacy.py @@ -0,0 +1,239 @@ +import os +from moulinette import m18n +from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import write_to_json, read_yaml + +from yunohost.user import user_list +from yunohost.app import ( + _installed_apps, + _get_app_settings, + _set_app_settings, +) +from yunohost.permission import ( + permission_create, + user_permission_update, + permission_sync_to_user, +) + +logger = getActionLogger("yunohost.legacy") + +LEGACY_PERMISSION_LABEL = { + ("nextcloud", "skipped"): "api", # .well-known + ("libreto", "skipped"): "pad access", # /[^/]+ + ("leed", "skipped"): "api", # /action.php, for cron task ... + ("mailman", "protected"): "admin", # /admin + ("prettynoemiecms", "protected"): "admin", # /admin + ("etherpad_mypads", "skipped"): "admin", # /admin + ("baikal", "protected"): "admin", # /admin/ + ("couchpotato", "unprotected"): "api", # /api + ("freshrss", "skipped"): "api", # /api/, + ("portainer", "skipped"): "api", # /api/webhooks/ + ("jeedom", "unprotected"): "api", # /core/api/jeeApi.php + ("bozon", "protected"): "user interface", # /index.php + ( + "limesurvey", + "protected", + ): "admin", # /index.php?r=admin,/index.php?r=plugins,/scripts + ("kanboard", "unprotected"): "api", # /jsonrpc.php + ("seafile", "unprotected"): "medias", # /media + ("ttrss", "skipped"): "api", # /public.php,/api,/opml.php?op=publish + ("libreerp", "protected"): "admin", # /web/database/manager + ("z-push", "skipped"): "api", # $domain/[Aa]uto[Dd]iscover/.* + ("radicale", "skipped"): "?", # $domain$path_url + ( + "jirafeau", + "protected", + ): "user interface", # $domain$path_url/$","$domain$path_url/admin.php.*$ + ("opensondage", "protected"): "admin", # $domain$path_url/admin/ + ( + "lstu", + "protected", + ): "user interface", # $domain$path_url/login$","$domain$path_url/logout$","$domain$path_url/api$","$domain$path_url/extensions$","$domain$path_url/stats$","$domain$path_url/d/.*$","$domain$path_url/a$","$domain$path_url/$ + ( + "lutim", + "protected", + ): "user interface", # $domain$path_url/stats/?$","$domain$path_url/manifest.webapp/?$","$domain$path_url/?$","$domain$path_url/[d-m]/.*$ + ( + "lufi", + "protected", + ): "user interface", # $domain$path_url/stats$","$domain$path_url/manifest.webapp$","$domain$path_url/$","$domain$path_url/d/.*$","$domain$path_url/m/.*$ + ( + "gogs", + "skipped", + ): "api", # $excaped_domain$excaped_path/[%w-.]*/[%w-.]*/git%-receive%-pack,$excaped_domain$excaped_path/[%w-.]*/[%w-.]*/git%-upload%-pack,$excaped_domain$excaped_path/[%w-.]*/[%w-.]*/info/refs +} + + +def legacy_permission_label(app, permission_type): + return LEGACY_PERMISSION_LABEL.get( + (app, permission_type), "Legacy %s urls" % permission_type + ) + + +def migrate_legacy_permission_settings(app=None): + + logger.info(m18n.n("migrating_legacy_permission_settings")) + apps = _installed_apps() + + if app: + if app not in apps: + logger.error( + "Can't migrate permission for app %s because it ain't installed..." + % app + ) + apps = [] + else: + apps = [app] + + for app in apps: + + settings = _get_app_settings(app) or {} + if settings.get("label"): + user_permission_update( + app + ".main", label=settings["label"], sync_perm=False + ) + del settings["label"] + + def _setting(name): + s = settings.get(name) + return s.split(",") if s else [] + + skipped_urls = [uri for uri in _setting("skipped_uris") if uri != "/"] + skipped_urls += ["re:" + regex for regex in _setting("skipped_regex")] + unprotected_urls = [uri for uri in _setting("unprotected_uris") if uri != "/"] + unprotected_urls += ["re:" + regex for regex in _setting("unprotected_regex")] + protected_urls = [uri for uri in _setting("protected_uris") if uri != "/"] + protected_urls += ["re:" + regex for regex in _setting("protected_regex")] + + if skipped_urls != []: + permission_create( + app + ".legacy_skipped_uris", + additional_urls=skipped_urls, + auth_header=False, + label=legacy_permission_label(app, "skipped"), + show_tile=False, + allowed="visitors", + protected=True, + sync_perm=False, + ) + if unprotected_urls != []: + permission_create( + app + ".legacy_unprotected_uris", + additional_urls=unprotected_urls, + auth_header=True, + label=legacy_permission_label(app, "unprotected"), + show_tile=False, + allowed="visitors", + protected=True, + sync_perm=False, + ) + if protected_urls != []: + permission_create( + app + ".legacy_protected_uris", + additional_urls=protected_urls, + auth_header=True, + label=legacy_permission_label(app, "protected"), + show_tile=False, + allowed=[], + protected=True, + sync_perm=False, + ) + + legacy_permission_settings = [ + "skipped_uris", + "unprotected_uris", + "protected_uris", + "skipped_regex", + "unprotected_regex", + "protected_regex", + ] + for key in legacy_permission_settings: + if key in settings: + del settings[key] + + _set_app_settings(app, settings) + + permission_sync_to_user() + + +def translate_legacy_rules_in_ssowant_conf_json_persistent(): + + persistent_file_name = "/etc/ssowat/conf.json.persistent" + if not os.path.exists(persistent_file_name): + return + + # Ugly hack because for some reason so many people have tabs in their conf.json.persistent ... + os.system(r"sed -i 's/\t/ /g' /etc/ssowat/conf.json.persistent") + + # Ugly hack to try not to misarably fail migration + persistent = read_yaml(persistent_file_name) + + legacy_rules = [ + "skipped_urls", + "unprotected_urls", + "protected_urls", + "skipped_regex", + "unprotected_regex", + "protected_regex", + ] + + if not any(legacy_rule in persistent for legacy_rule in legacy_rules): + return + + if not isinstance(persistent.get("permissions"), dict): + persistent["permissions"] = {} + + skipped_urls = persistent.get("skipped_urls", []) + [ + "re:" + r for r in persistent.get("skipped_regex", []) + ] + protected_urls = persistent.get("protected_urls", []) + [ + "re:" + r for r in persistent.get("protected_regex", []) + ] + unprotected_urls = persistent.get("unprotected_urls", []) + [ + "re:" + r for r in persistent.get("unprotected_regex", []) + ] + + known_users = list(user_list()["users"].keys()) + + for legacy_rule in legacy_rules: + if legacy_rule in persistent: + del persistent[legacy_rule] + + if skipped_urls: + persistent["permissions"]["custom_skipped"] = { + "users": [], + "label": "Custom permissions - skipped", + "show_tile": False, + "auth_header": False, + "public": True, + "uris": skipped_urls + + persistent["permissions"].get("custom_skipped", {}).get("uris", []), + } + + if unprotected_urls: + persistent["permissions"]["custom_unprotected"] = { + "users": [], + "label": "Custom permissions - unprotected", + "show_tile": False, + "auth_header": True, + "public": True, + "uris": unprotected_urls + + persistent["permissions"].get("custom_unprotected", {}).get("uris", []), + } + + if protected_urls: + persistent["permissions"]["custom_protected"] = { + "users": known_users, + "label": "Custom permissions - protected", + "show_tile": False, + "auth_header": True, + "public": False, + "uris": protected_urls + + persistent["permissions"].get("custom_protected", {}).get("uris", []), + } + + write_to_json(persistent_file_name, persistent, sort_keys=True, indent=4) + + logger.warning( + "YunoHost automatically translated some legacy rules in /etc/ssowat/conf.json.persistent to match the new permission system" + ) diff --git a/src/yunohost/utils/network.py b/src/yunohost/utils/network.py index 1f82a87b0..4474af14f 100644 --- a/src/yunohost/utils/network.py +++ b/src/yunohost/utils/network.py @@ -18,23 +18,71 @@ along with this program; if not, see http://www.gnu.org/licenses """ -import logging +import os import re -import subprocess -from moulinette.utils.network import download_text +import logging +import time -logger = logging.getLogger('yunohost.utils.network') +from moulinette.utils.filesystem import read_file, write_to_file +from moulinette.utils.network import download_text +from moulinette.utils.process import check_output + +logger = logging.getLogger("yunohost.utils.network") def get_public_ip(protocol=4): + + assert protocol in [4, 6], ( + "Invalid protocol version for get_public_ip: %s, expected 4 or 6" % protocol + ) + + cache_file = "/var/cache/yunohost/ipv%s" % protocol + cache_duration = 120 # 2 min + if ( + os.path.exists(cache_file) + and abs(os.path.getctime(cache_file) - time.time()) < cache_duration + ): + ip = read_file(cache_file).strip() + ip = ip if ip else None # Empty file (empty string) means there's no IP + logger.debug("Reusing IPv%s from cache: %s" % (protocol, ip)) + else: + ip = get_public_ip_from_remote_server(protocol) + logger.debug("IP fetched: %s" % ip) + write_to_file(cache_file, ip or "") + return ip + + +def get_public_ip_from_remote_server(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") + # We can know that ipv6 is not available directly if this file does not exists + if protocol == 6 and not os.path.exists("/proc/net/if_inet6"): + logger.debug( + "IPv6 appears not at all available on the system, so assuming there's no IP address for that version" + ) + return None + + # If we are indeed connected in ipv4 or ipv6, we should find a default route + routes = check_output("ip -%s route show table all" % protocol).split("\n") + + def is_default_route(r): + # Typically the default route starts with "default" + # But of course IPv6 is more complex ... e.g. on internet cube there's + # no default route but a /3 which acts as a default-like route... + # e.g. 2000:/3 dev tun0 ... + return r.startswith("default") or ( + ":" in r and re.match(r".*/[0-3]$", r.split()[0]) + ) + + if not any(is_default_route(r) for r in routes): + logger.debug( + "No default route for IPv%s, so assuming there's no IP address for that version" + % protocol + ) + return None + + url = "https://ip%s.yunohost.org" % (protocol if protocol != 4 else "") + logger.debug("Fetching IP from %s " % url) try: return download_text(url, timeout=30).strip() @@ -47,23 +95,27 @@ def get_network_interfaces(): # Get network devices and their addresses (raw infos from 'ip addr') devices_raw = {} - output = subprocess.check_output('ip addr show'.split()) - for d in re.split('^(?:[0-9]+: )', output, flags=re.MULTILINE): + output = check_output("ip addr show") + for d in re.split(r"^(?:[0-9]+: )", output, flags=re.MULTILINE): # Extract device name (1) and its addresses (2) - m = re.match('([^\s@]+)(?:@[\S]+)?: (.*)', d, flags=re.DOTALL) + m = re.match(r"([^\s@]+)(?:@[\S]+)?: (.*)", d, flags=re.DOTALL) if m: devices_raw[m.group(1)] = m.group(2) # Parse relevant informations for each of them - devices = {name: _extract_inet(addrs) for name, addrs in devices_raw.items() if name != "lo"} + devices = { + name: _extract_inet(addrs) + for name, addrs in devices_raw.items() + if name != "lo" + } return devices def get_gateway(): - output = subprocess.check_output('ip route show'.split()) - m = re.search('default via (.*) dev ([a-z]+[0-9]?)', output) + output = check_output("ip route show") + m = re.search(r"default via (.*) dev ([a-z]+[0-9]?)", output) if not m: return None @@ -71,9 +123,6 @@ def get_gateway(): return addr.popitem()[1] if len(addr) == 1 else None -# - - def _extract_inet(string, skip_netmask=False, skip_loopback=True): """ Extract IP addresses (v4 and/or v6) from a string limited to one @@ -89,28 +138,30 @@ def _extract_inet(string, skip_netmask=False, skip_loopback=True): A dict of {protocol: address} with protocol one of 'ipv4' or 'ipv6' """ - ip4_pattern = '((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}' - ip6_pattern = '(((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)' - ip4_pattern += '/[0-9]{1,2})' if not skip_netmask else ')' - ip6_pattern += '/[0-9]{1,3})' if not skip_netmask else ')' + ip4_pattern = ( + r"((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}" + ) + ip6_pattern = r"(((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)" + ip4_pattern += r"/[0-9]{1,2})" if not skip_netmask else ")" + ip6_pattern += r"/[0-9]{1,3})" if not skip_netmask else ")" result = {} for m in re.finditer(ip4_pattern, string): addr = m.group(1) - if skip_loopback and addr.startswith('127.'): + if skip_loopback and addr.startswith("127."): continue # Limit to only one result - result['ipv4'] = addr + result["ipv4"] = addr break for m in re.finditer(ip6_pattern, string): addr = m.group(1) - if skip_loopback and addr == '::1': + if skip_loopback and addr == "::1": continue # Limit to only one result - result['ipv6'] = addr + result["ipv6"] = addr break return result diff --git a/src/yunohost/utils/packages.py b/src/yunohost/utils/packages.py index 84901bbff..3105bc4c7 100644 --- a/src/yunohost/utils/packages.py +++ b/src/yunohost/utils/packages.py @@ -21,480 +21,108 @@ import re import os import logging -from collections import OrderedDict -import apt -from apt_pkg import version_compare +from moulinette.utils.process import check_output +from packaging import version -from moulinette import m18n +logger = logging.getLogger("yunohost.utils.packages") -logger = logging.getLogger('yunohost.utils.packages') +YUNOHOST_PACKAGES = ["yunohost", "yunohost-admin", "moulinette", "ssowat"] -# Exceptions ----------------------------------------------------------------- +def get_ynh_package_version(package): -class PackageException(Exception): + # Returns the installed version and release version ('stable' or 'testing' + # or 'unstable') - """Base exception related to a package + # NB: this is designed for yunohost packages only ! + # Not tested for any arbitrary packages that + # may handle changelog differently ! - Represent an exception related to the package named `pkgname`. If no - `message` is provided, it will first try to use the translation key - `message_key` if defined by the derived class. Otherwise, a standard - message will be used. + changelog = "/usr/share/doc/%s/changelog.gz" % package + cmd = "gzip -cd %s 2>/dev/null | head -n1" % changelog + if not os.path.exists(changelog): + return {"version": "?", "repo": "?"} + out = check_output(cmd).split() + # Output looks like : "yunohost (1.2.3) testing; urgency=medium" + return {"version": out[1].strip("()"), "repo": out[2].strip(";")} + +def meets_version_specifier(pkg_name, specifier): """ - message_key = 'package_unexpected_error' - - def __init__(self, pkgname, message=None): - super(PackageException, self).__init__( - message or m18n.n(self.message_key, pkgname=pkgname)) - self.pkgname = pkgname - - -class UnknownPackage(PackageException): - - """The package is not found in the cache.""" - message_key = 'package_unknown' - - -class UninstalledPackage(PackageException): - - """The package is not installed.""" - message_key = 'package_not_installed' - - -class InvalidSpecifier(ValueError): - - """An invalid specifier was found.""" - - -# Version specifier ---------------------------------------------------------- -# The packaging package has been a nice inspiration for the following classes. -# See: https://github.com/pypa/packaging - -class Specifier(object): - - """Unique package version specifier - - Restrict a package version according to the `spec`. It must be a string - containing a relation from the list below followed by a version number - value. The relations allowed are, as defined by the Debian Policy Manual: - - - `<<` for strictly lower - - `<=` for lower or equal - - `=` for exactly equal - - `>=` for greater or equal - - `>>` for strictly greater + Check if a package installed version meets specifier + specifier is something like ">> 1.2.3" """ - _regex_str = ( - r""" - (?P(<<|<=|=|>=|>>)) - \s* - (?P[^,;\s)]*) - """ - ) - _regex = re.compile( - r"^\s*" + _regex_str + r"\s*$", re.VERBOSE | re.IGNORECASE) - _relations = { - "<<": "lower_than", - "<=": "lower_or_equal_than", - "=": "equal", - ">=": "greater_or_equal_than", - ">>": "greater_than", + # In practice, this function is only used to check the yunohost version + # installed. + # We'll trim any ~foobar in the current installed version because it's not + # handled correctly by version.parse, but we don't care so much in that + # context + assert pkg_name in YUNOHOST_PACKAGES + pkg_version = get_ynh_package_version(pkg_name)["version"] + pkg_version = re.split(r"\~|\+|\-", pkg_version)[0] + pkg_version = version.parse(pkg_version) + + # Extract operator and version specifier + op, req_version = re.search(r"(<<|<=|=|>=|>>) *([\d\.]+)", specifier).groups() + req_version = version.parse(req_version) + + # Python2 had a builtin that returns (-1, 0, 1) depending on comparison + # c.f. https://stackoverflow.com/a/22490617 + def cmp(a, b): + return (a > b) - (a < b) + + deb_operators = { + "<<": lambda v1, v2: cmp(v1, v2) in [-1], + "<=": lambda v1, v2: cmp(v1, v2) in [-1, 0], + "=": lambda v1, v2: cmp(v1, v2) in [0], + ">=": lambda v1, v2: cmp(v1, v2) in [0, 1], + ">>": lambda v1, v2: cmp(v1, v2) in [1], } - def __init__(self, spec): - if isinstance(spec, basestring): - match = self._regex.search(spec) - if not match: - raise InvalidSpecifier("Invalid specifier: '{0}'".format(spec)) + return deb_operators[op](pkg_version, req_version) - self._spec = ( - match.group("relation").strip(), - match.group("version").strip(), - ) - elif isinstance(spec, self.__class__): - self._spec = spec._spec - else: - return NotImplemented - - def __repr__(self): - return "".format(str(self)) - - def __str__(self): - return "{0}{1}".format(*self._spec) - - def __hash__(self): - return hash(self._spec) - - def __eq__(self, other): - if isinstance(other, basestring): - try: - other = self.__class__(other) - except InvalidSpecifier: - return NotImplemented - elif not isinstance(other, self.__class__): - return NotImplemented - - return self._spec == other._spec - - def __ne__(self, other): - if isinstance(other, basestring): - try: - other = self.__class__(other) - except InvalidSpecifier: - return NotImplemented - elif not isinstance(other, self.__class__): - return NotImplemented - - return self._spec != other._spec - - def __and__(self, other): - return self.intersection(other) - - def __or__(self, other): - return self.union(other) - - def _get_relation(self, op): - return getattr(self, "_compare_{0}".format(self._relations[op])) - - def _compare_lower_than(self, version, spec): - return version_compare(version, spec) < 0 - - def _compare_lower_or_equal_than(self, version, spec): - return version_compare(version, spec) <= 0 - - def _compare_equal(self, version, spec): - return version_compare(version, spec) == 0 - - def _compare_greater_or_equal_than(self, version, spec): - return version_compare(version, spec) >= 0 - - def _compare_greater_than(self, version, spec): - return version_compare(version, spec) > 0 - - @property - def relation(self): - return self._spec[0] - - @property - def version(self): - return self._spec[1] - - def __contains__(self, item): - return self.contains(item) - - def intersection(self, other): - """Make the intersection of two specifiers - - Return a new `SpecifierSet` with version specifier(s) common to the - specifier and the other. - - Example: - >>> Specifier('>= 2.2') & '>> 2.2.1' == '>> 2.2.1' - >>> Specifier('>= 2.2') & '<< 2.3' == '>= 2.2, << 2.3' - - """ - if isinstance(other, basestring): - try: - other = self.__class__(other) - except InvalidSpecifier: - return NotImplemented - elif not isinstance(other, self.__class__): - return NotImplemented - - # store spec parts for easy access - rel1, v1 = self.relation, self.version - rel2, v2 = other.relation, other.version - result = [] - - if other == self: - result = [other] - elif rel1 == '=': - result = [self] if v1 in other else None - elif rel2 == '=': - result = [other] if v2 in self else None - elif v1 == v2: - result = [other if rel1[1] == '=' else self] - elif v2 in self or v1 in other: - is_self_greater = version_compare(v1, v2) > 0 - if rel1[0] == rel2[0]: - if rel1[0] == '>': - result = [self if is_self_greater else other] - else: - result = [other if is_self_greater else self] - else: - result = [self, other] - return SpecifierSet(result if result is not None else '') - - def union(self, other): - """Make the union of two version specifiers - - Return a new `SpecifierSet` with version specifiers from the - specifier and the other. - - Example: - >>> Specifier('>= 2.2') | '<< 2.3' == '>= 2.2, << 2.3' - - """ - if isinstance(other, basestring): - try: - other = self.__class__(other) - except InvalidSpecifier: - return NotImplemented - elif not isinstance(other, self.__class__): - return NotImplemented - - return SpecifierSet([self, other]) - - def contains(self, item): - """Check if the specifier contains an other - - Return whether the item is contained in the version specifier. - - Example: - >>> '2.2.1' in Specifier('<< 2.3') - >>> '2.4' not in Specifier('<< 2.3') - - """ - return self._get_relation(self.relation)(item, self.version) - - -class SpecifierSet(object): - - """A set of package version specifiers - - Combine several Specifier separated by a comma. It allows to restrict - more precisely a package version. Each package version specifier must be - meet. Note than an empty set of specifiers will always be meet. - - """ - - def __init__(self, specifiers): - if isinstance(specifiers, basestring): - specifiers = [s.strip() for s in specifiers.split(",") - if s.strip()] - - parsed = set() - for specifier in specifiers: - parsed.add(Specifier(specifier)) - - self._specs = frozenset(parsed) - - def __repr__(self): - return "".format(str(self)) - - def __str__(self): - return ",".join(sorted(str(s) for s in self._specs)) - - def __hash__(self): - return hash(self._specs) - - def __and__(self, other): - return self.intersection(other) - - def __or__(self, other): - return self.union(other) - - def __eq__(self, other): - if isinstance(other, basestring): - other = SpecifierSet(other) - elif isinstance(other, Specifier): - other = SpecifierSet(str(other)) - elif not isinstance(other, SpecifierSet): - return NotImplemented - - return self._specs == other._specs - - def __ne__(self, other): - if isinstance(other, basestring): - other = SpecifierSet(other) - elif isinstance(other, Specifier): - other = SpecifierSet(str(other)) - elif not isinstance(other, SpecifierSet): - return NotImplemented - - return self._specs != other._specs - - def __len__(self): - return len(self._specs) - - def __iter__(self): - return iter(self._specs) - - def __contains__(self, item): - return self.contains(item) - - def intersection(self, other): - """Make the intersection of two specifiers sets - - Return a new `SpecifierSet` with version specifier(s) common to the - set and the other. - - Example: - >>> SpecifierSet('>= 2.2') & '>> 2.2.1' == '>> 2.2.1' - >>> SpecifierSet('>= 2.2, << 2.4') & '<< 2.3' == '>= 2.2, << 2.3' - >>> SpecifierSet('>= 2.2, << 2.3') & '>= 2.4' == '' - - """ - if isinstance(other, basestring): - other = SpecifierSet(other) - elif not isinstance(other, SpecifierSet): - return NotImplemented - - specifiers = set(self._specs | other._specs) - intersection = [specifiers.pop()] if specifiers else [] - - for specifier in specifiers: - parsed = set() - for spec in intersection: - inter = spec & specifier - if not inter: - parsed.clear() - break - # TODO: validate with other specs in parsed - parsed.update(inter._specs) - intersection = parsed - if not intersection: - break - return SpecifierSet(intersection) - - def union(self, other): - """Make the union of two specifiers sets - - Return a new `SpecifierSet` with version specifiers from the set - and the other. - - Example: - >>> SpecifierSet('>= 2.2') | '<< 2.3' == '>= 2.2, << 2.3' - - """ - if isinstance(other, basestring): - other = SpecifierSet(other) - elif not isinstance(other, SpecifierSet): - return NotImplemented - - specifiers = SpecifierSet([]) - specifiers._specs = frozenset(self._specs | other._specs) - return specifiers - - def contains(self, item): - """Check if the set contains a version specifier - - Return whether the item is contained in all version specifiers. - - Example: - >>> '2.2.1' in SpecifierSet('>= 2.2, << 2.3') - >>> '2.4' not in SpecifierSet('>= 2.2, << 2.3') - - """ - return all( - s.contains(item) - for s in self._specs - ) - - -# Packages and cache helpers ------------------------------------------------- - -def get_installed_version(*pkgnames, **kwargs): - """Get the installed version of package(s) - - Retrieve one or more packages named `pkgnames` and return their installed - version as a dict or as a string if only one is requested and `as_dict` is - `False`. If `strict` is `True`, an exception will be raised if a package - is unknown or not installed. - - """ - versions = OrderedDict() - cache = apt.Cache() - - # Retrieve options - as_dict = kwargs.get('as_dict', False) - strict = kwargs.get('strict', False) - with_repo = kwargs.get('with_repo', False) - - for pkgname in pkgnames: - try: - pkg = cache[pkgname] - except KeyError: - if strict: - raise UnknownPackage(pkgname) - logger.warning(m18n.n('package_unknown', pkgname=pkgname)) - continue - - try: - version = pkg.installed.version - except AttributeError: - if strict: - raise UninstalledPackage(pkgname) - version = None - - try: - # stable, testing, unstable - repo = pkg.installed.origins[0].component - except AttributeError: - if strict: - raise UninstalledPackage(pkgname) - repo = "" - - if with_repo: - versions[pkgname] = { - "version": version, - # when we don't have component it's because it's from a local - # install or from an image (like in vagrant) - "repo": repo if repo else "local", - } - else: - versions[pkgname] = version - - if len(pkgnames) == 1 and not as_dict: - return versions[pkgnames[0]] - return versions - - -def meets_version_specifier(pkgname, specifier): - """Check if a package installed version meets specifier""" - spec = SpecifierSet(specifier) - return get_installed_version(pkgname) in spec - - -# YunoHost related methods --------------------------------------------------- def ynh_packages_version(*args, **kwargs): # from cli the received arguments are: # (Namespace(_callbacks=deque([]), _tid='_global', _to_return={}), []) {} # they don't seem to serve any purpose """Return the version of each YunoHost package""" - return get_installed_version( - 'yunohost', 'yunohost-admin', 'moulinette', 'ssowat', - with_repo=True - ) + from collections import OrderedDict + + packages = OrderedDict() + for package in YUNOHOST_PACKAGES: + packages[package] = get_ynh_package_version(package) + return packages def dpkg_is_broken(): + if check_output("dpkg --audit") != "": + return True # If dpkg is broken, /var/lib/dpkg/updates # will contains files like 0001, 0002, ... # ref: https://sources.debian.org/src/apt/1.4.9/apt-pkg/deb/debsystem.cc/#L141-L174 if not os.path.isdir("/var/lib/dpkg/updates/"): return False - return any(re.match("^[0-9]+$", f) - for f in os.listdir("/var/lib/dpkg/updates/")) + return any(re.match("^[0-9]+$", f) for f in os.listdir("/var/lib/dpkg/updates/")) + def dpkg_lock_available(): return os.system("lsof /var/lib/dpkg/lock >/dev/null") != 0 -def _list_upgradable_apt_packages(): - from moulinette.utils.process import check_output +def _list_upgradable_apt_packages(): # List upgradable packages # LC_ALL=C is here to make sure the results are in english upgradable_raw = check_output("LC_ALL=C apt list --upgradable") # Dirty parsing of the output - upgradable_raw = [l.strip() for l in upgradable_raw.split("\n") if l.strip()] + upgradable_raw = [ + line.strip() for line in upgradable_raw.split("\n") if line.strip() + ] for line in upgradable_raw: # Remove stupid warning and verbose messages >.> @@ -505,7 +133,7 @@ def _list_upgradable_apt_packages(): # yunohost/stable 3.5.0.2+201903211853 all [upgradable from: 3.4.2.4+201903080053] line = line.split() if len(line) != 6: - logger.warning("Failed to parse this line : %s" % ' '.join(line)) + logger.warning("Failed to parse this line : %s" % " ".join(line)) continue yield { diff --git a/src/yunohost/utils/password.py b/src/yunohost/utils/password.py index b4f7025f7..188850183 100644 --- a/src/yunohost/utils/password.py +++ b/src/yunohost/utils/password.py @@ -25,10 +25,18 @@ import json import string import subprocess -SMALL_PWD_LIST = ["yunohost", "olinuxino", "olinux", "raspberry", "admin", - "root", "test", "rpi"] +SMALL_PWD_LIST = [ + "yunohost", + "olinuxino", + "olinux", + "raspberry", + "admin", + "root", + "test", + "rpi", +] -MOST_USED_PASSWORDS = '/usr/share/yunohost/other/password/100000-most-used.txt' +MOST_USED_PASSWORDS = "/usr/share/yunohost/other/password/100000-most-used.txt" # Length, digits, lowers, uppers, others STRENGTH_LEVELS = [ @@ -44,7 +52,6 @@ def assert_password_is_strong_enough(profile, password): class PasswordValidator(object): - def __init__(self, profile): """ Initialize a password validator. @@ -60,10 +67,10 @@ class PasswordValidator(object): # from settings.py because this file is also meant to be # use as a script by ssowat. # (or at least that's my understanding -- Alex) - settings = json.load(open('/etc/yunohost/settings.json', "r")) + settings = json.load(open("/etc/yunohost/settings.json", "r")) setting_key = "security.password." + profile + ".strength" self.validation_strength = int(settings[setting_key]["value"]) - except Exception as e: + except Exception: # Fallback to default value if we can't fetch settings for some reason self.validation_strength = 1 @@ -83,15 +90,11 @@ class PasswordValidator(object): # on top (at least not the moulinette ones) # because the moulinette needs to be correctly initialized # as well as modules available in python's path. - import logging - from yunohost.utils.error import YunohostError - from moulinette.utils.log import getActionLogger - - logger = logging.getLogger('yunohost.utils.password') + from yunohost.utils.error import YunohostValidationError status, msg = self.validation_summary(password) if status == "error": - raise YunohostError(msg) + raise YunohostValidationError(msg) def validation_summary(self, password): """ @@ -108,8 +111,13 @@ class PasswordValidator(object): listed = password in SMALL_PWD_LIST or self.is_in_most_used_list(password) strength_level = self.strength_level(password) if listed: + # i18n: password_listed return ("error", "password_listed") if strength_level < self.validation_strength: + # i18n: password_too_simple_1 + # i18n: password_too_simple_2 + # i18n: password_too_simple_3 + # i18n: password_too_simple_4 return ("error", "password_too_simple_%s" % self.validation_strength) return ("success", "") @@ -175,22 +183,23 @@ class PasswordValidator(object): # Grep the password in the file # We use '-f -' to feed the pattern (= the password) through # stdin to avoid it being shown in ps -ef --forest... - command = "grep -q -f - %s" % MOST_USED_PASSWORDS + command = "grep -q -F -f - %s" % MOST_USED_PASSWORDS p = subprocess.Popen(command.split(), stdin=subprocess.PIPE) - p.communicate(input=password) + p.communicate(input=password.encode("utf-8")) return not bool(p.returncode) # This file is also meant to be used as an executable by # SSOwat to validate password from the portal when an user # change its password. -if __name__ == '__main__': +if __name__ == "__main__": if len(sys.argv) < 2: import getpass + pwd = getpass.getpass("") # print("usage: password.py PASSWORD") else: pwd = sys.argv[1] - status, msg = PasswordValidator('user').validation_summary(pwd) + status, msg = PasswordValidator("user").validation_summary(pwd) print(msg) sys.exit(0) diff --git a/src/yunohost/utils/yunopaste.py b/src/yunohost/utils/yunopaste.py index 89c62d761..0c3e3c998 100644 --- a/src/yunohost/utils/yunopaste.py +++ b/src/yunohost/utils/yunopaste.py @@ -2,25 +2,93 @@ import requests import json +import logging +from yunohost.domain import _get_maindomain, domain_list +from yunohost.utils.network import get_public_ip from yunohost.utils.error import YunohostError +logger = logging.getLogger("yunohost.utils.yunopaste") + def yunopaste(data): paste_server = "https://paste.yunohost.org" + try: + data = anonymize(data) + except Exception as e: + logger.warning( + "For some reason, YunoHost was not able to anonymize the pasted data. Sorry about that. Be careful about sharing the link, as it may contain somewhat private infos like domain names or IP addresses. Error: %s" + % e + ) + + data = data.encode() + try: r = requests.post("%s/documents" % paste_server, data=data, timeout=30) except Exception as e: - raise YunohostError("Something wrong happened while trying to paste data on paste.yunohost.org : %s" % str(e), raw_msg=True) + raise YunohostError( + "Something wrong happened while trying to paste data on paste.yunohost.org : %s" + % str(e), + raw_msg=True, + ) if r.status_code != 200: - raise YunohostError("Something wrong happened while trying to paste data on paste.yunohost.org : %s, %s" % (r.status_code, r.text), raw_msg=True) + raise YunohostError( + "Something wrong happened while trying to paste data on paste.yunohost.org : %s, %s" + % (r.status_code, r.text), + raw_msg=True, + ) try: url = json.loads(r.text)["key"] - except: - raise YunohostError("Uhoh, couldn't parse the answer from paste.yunohost.org : %s" % r.text, raw_msg=True) + except Exception: + raise YunohostError( + "Uhoh, couldn't parse the answer from paste.yunohost.org : %s" % r.text, + raw_msg=True, + ) return "%s/raw/%s" % (paste_server, url) + + +def anonymize(data): + def anonymize_domain(data, domain, redact): + data = data.replace(domain, redact) + # This stuff appears sometimes because some folder in + # /var/lib/metronome/ have some folders named this way + data = data.replace(domain.replace(".", "%2e"), redact.replace(".", "%2e")) + return data + + # First, let's replace every occurence of the main domain by "domain.tld" + # This should cover a good fraction of the info leaked + main_domain = _get_maindomain() + data = anonymize_domain(data, main_domain, "maindomain.tld") + + # Next, let's replace other domains. We do this in increasing lengths, + # because e.g. knowing that the domain is a sub-domain of another domain may + # still be informative. + # So e.g. if there's jitsi.foobar.com as a subdomain of foobar.com, it may + # be interesting to know that the log is about a supposedly dedicated domain + # for jisti (hopefully this explanation make sense). + domains = domain_list()["domains"] + domains = sorted(domains, key=lambda d: len(d)) + + count = 2 + for domain in domains: + if domain not in data: + continue + data = anonymize_domain(data, domain, "domain%s.tld" % count) + count += 1 + + # We also want to anonymize the ips + ipv4 = get_public_ip() + ipv6 = get_public_ip(6) + + if ipv4: + data = data.replace(str(ipv4), "xx.xx.xx.xx") + + if ipv6: + data = data.replace(str(ipv6), "xx:xx:xx:xx:xx:xx") + + return data diff --git a/src/yunohost/vendor/acme_tiny/acme_tiny.py b/src/yunohost/vendor/acme_tiny/acme_tiny.py index ba04e37ad..3c13d13ec 100644 --- a/src/yunohost/vendor/acme_tiny/acme_tiny.py +++ b/src/yunohost/vendor/acme_tiny/acme_tiny.py @@ -1,28 +1,41 @@ #!/usr/bin/env python # Copyright Daniel Roesler, under MIT license, see LICENSE at github.com/diafygi/acme-tiny import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging -try: - from urllib.request import urlopen, Request # Python 3 -except ImportError: - from urllib2 import urlopen, Request # Python 2 -DEFAULT_CA = "https://acme-v02.api.letsencrypt.org" # DEPRECATED! USE DEFAULT_DIRECTORY_URL INSTEAD +try: + from urllib.request import urlopen, Request # Python 3 +except ImportError: + from urllib2 import urlopen, Request # Python 2 + +DEFAULT_CA = "https://acme-v02.api.letsencrypt.org" # DEPRECATED! USE DEFAULT_DIRECTORY_URL INSTEAD DEFAULT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory" LOGGER = logging.getLogger(__name__) LOGGER.addHandler(logging.StreamHandler()) LOGGER.setLevel(logging.INFO) -def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA, disable_check=False, directory_url=DEFAULT_DIRECTORY_URL, contact=None): - directory, acct_headers, alg, jwk = None, None, None, None # global variables + +def get_crt( + account_key, + csr, + acme_dir, + log=LOGGER, + CA=DEFAULT_CA, + disable_check=False, + directory_url=DEFAULT_DIRECTORY_URL, + contact=None, +): + directory, acct_headers, alg, jwk = None, None, None, None # global variables # helper functions - base64 encode for jose spec def _b64(b): - return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "") + return base64.urlsafe_b64encode(b).decode("utf8").replace("=", "") # helper function - run external commands def _cmd(cmd_list, stdin=None, cmd_input=None, err_msg="Command Line Error"): - proc = subprocess.Popen(cmd_list, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc = subprocess.Popen( + cmd_list, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) out, err = proc.communicate(cmd_input) if proc.returncode != 0: raise IOError("{0}\n{1}".format(err_msg, err)) @@ -31,50 +44,87 @@ def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA, disable_check # helper function - make request and automatically parse json response def _do_request(url, data=None, err_msg="Error", depth=0): try: - resp = urlopen(Request(url, data=data, headers={"Content-Type": "application/jose+json", "User-Agent": "acme-tiny"})) - resp_data, code, headers = resp.read().decode("utf8"), resp.getcode(), resp.headers + resp = urlopen( + Request( + url, + data=data, + headers={ + "Content-Type": "application/jose+json", + "User-Agent": "acme-tiny", + }, + ) + ) + resp_data, code, headers = ( + resp.read().decode("utf8"), + resp.getcode(), + resp.headers, + ) except IOError as e: resp_data = e.read().decode("utf8") if hasattr(e, "read") else str(e) code, headers = getattr(e, "code", None), {} try: - resp_data = json.loads(resp_data) # try to parse json results + resp_data = json.loads(resp_data) # try to parse json results except ValueError: - pass # ignore json parsing errors - if depth < 100 and code == 400 and resp_data['type'] == "urn:ietf:params:acme:error:badNonce": - raise IndexError(resp_data) # allow 100 retrys for bad nonces + pass # ignore json parsing errors + if ( + depth < 100 + and code == 400 + and resp_data["type"] == "urn:ietf:params:acme:error:badNonce" + ): + raise IndexError(resp_data) # allow 100 retrys for bad nonces if code not in [200, 201, 204]: - raise ValueError("{0}:\nUrl: {1}\nData: {2}\nResponse Code: {3}\nResponse: {4}".format(err_msg, url, data, code, resp_data)) + raise ValueError( + "{0}:\nUrl: {1}\nData: {2}\nResponse Code: {3}\nResponse: {4}".format( + err_msg, url, data, code, resp_data + ) + ) return resp_data, code, headers # helper function - make signed requests def _send_signed_request(url, payload, err_msg, depth=0): - payload64 = _b64(json.dumps(payload).encode('utf8')) - new_nonce = _do_request(directory['newNonce'])[2]['Replay-Nonce'] + payload64 = "" if payload is None else _b64(json.dumps(payload).encode("utf8")) + new_nonce = _do_request(directory["newNonce"])[2]["Replay-Nonce"] protected = {"url": url, "alg": alg, "nonce": new_nonce} - protected.update({"jwk": jwk} if acct_headers is None else {"kid": acct_headers['Location']}) - protected64 = _b64(json.dumps(protected).encode('utf8')) - protected_input = "{0}.{1}".format(protected64, payload64).encode('utf8') - out = _cmd(["openssl", "dgst", "-sha256", "-sign", account_key], stdin=subprocess.PIPE, cmd_input=protected_input, err_msg="OpenSSL Error") - data = json.dumps({"protected": protected64, "payload": payload64, "signature": _b64(out)}) + protected.update( + {"jwk": jwk} if acct_headers is None else {"kid": acct_headers["Location"]} + ) + protected64 = _b64(json.dumps(protected).encode("utf8")) + protected_input = "{0}.{1}".format(protected64, payload64).encode("utf8") + out = _cmd( + ["openssl", "dgst", "-sha256", "-sign", account_key], + stdin=subprocess.PIPE, + cmd_input=protected_input, + err_msg="OpenSSL Error", + ) + data = json.dumps( + {"protected": protected64, "payload": payload64, "signature": _b64(out)} + ) try: - return _do_request(url, data=data.encode('utf8'), err_msg=err_msg, depth=depth) - except IndexError: # retry bad nonces (they raise IndexError) + return _do_request( + url, data=data.encode("utf8"), err_msg=err_msg, depth=depth + ) + except IndexError: # retry bad nonces (they raise IndexError) return _send_signed_request(url, payload, err_msg, depth=(depth + 1)) # helper function - poll until complete def _poll_until_not(url, pending_statuses, err_msg): - while True: - result, _, _ = _do_request(url, err_msg=err_msg) - if result['status'] in pending_statuses: - time.sleep(2) - continue - return result + result, t0 = None, time.time() + while result is None or result["status"] in pending_statuses: + assert time.time() - t0 < 3600, "Polling timeout" # 1 hour timeout + time.sleep(0 if result is None else 2) + result, _, _ = _send_signed_request(url, None, err_msg) + return result # parse account key to get public key log.info("Parsing account key...") - out = _cmd(["openssl", "rsa", "-in", account_key, "-noout", "-text"], err_msg="OpenSSL Error") + out = _cmd( + ["openssl", "rsa", "-in", account_key, "-noout", "-text"], + err_msg="OpenSSL Error", + ) pub_pattern = r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)" - pub_hex, pub_exp = re.search(pub_pattern, out.decode('utf8'), re.MULTILINE|re.DOTALL).groups() + pub_hex, pub_exp = re.search( + pub_pattern, out.decode("utf8"), re.MULTILINE | re.DOTALL + ).groups() pub_exp = "{0:x}".format(int(pub_exp)) pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp alg = "RS256" @@ -83,17 +133,24 @@ def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA, disable_check "kty": "RSA", "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), } - accountkey_json = json.dumps(jwk, sort_keys=True, separators=(',', ':')) - thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) + accountkey_json = json.dumps(jwk, sort_keys=True, separators=(",", ":")) + thumbprint = _b64(hashlib.sha256(accountkey_json.encode("utf8")).digest()) # find domains log.info("Parsing CSR...") - out = _cmd(["openssl", "req", "-in", csr, "-noout", "-text"], err_msg="Error loading {0}".format(csr)) + out = _cmd( + ["openssl", "req", "-in", csr, "-noout", "-text"], + err_msg="Error loading {0}".format(csr), + ) domains = set([]) - common_name = re.search(r"Subject:.*? CN\s?=\s?([^\s,;/]+)", out.decode('utf8')) + common_name = re.search(r"Subject:.*? CN\s?=\s?([^\s,;/]+)", out.decode("utf8")) if common_name is not None: domains.add(common_name.group(1)) - subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \n +([^\n]+)\n", out.decode('utf8'), re.MULTILINE|re.DOTALL) + subject_alt_names = re.search( + r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n", + out.decode("utf8"), + re.MULTILINE | re.DOTALL, + ) if subject_alt_names is not None: for san in subject_alt_names.group(1).split(", "): if san.startswith("DNS:"): @@ -102,34 +159,48 @@ def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA, disable_check # get the ACME directory of urls log.info("Getting directory...") - directory_url = CA + "/directory" if CA != DEFAULT_CA else directory_url # backwards compatibility with deprecated CA kwarg + directory_url = ( + CA + "/directory" if CA != DEFAULT_CA else directory_url + ) # backwards compatibility with deprecated CA kwarg directory, _, _ = _do_request(directory_url, err_msg="Error getting directory") log.info("Directory found!") # create account, update contact details (if any), and set the global key identifier log.info("Registering account...") reg_payload = {"termsOfServiceAgreed": True} - account, code, acct_headers = _send_signed_request(directory['newAccount'], reg_payload, "Error registering") + account, code, acct_headers = _send_signed_request( + directory["newAccount"], reg_payload, "Error registering" + ) log.info("Registered!" if code == 201 else "Already registered!") if contact is not None: - account, _, _ = _send_signed_request(acct_headers['Location'], {"contact": contact}, "Error updating contact details") - log.info("Updated contact details:\n{0}".format("\n".join(account['contact']))) + account, _, _ = _send_signed_request( + acct_headers["Location"], + {"contact": contact}, + "Error updating contact details", + ) + log.info("Updated contact details:\n{0}".format("\n".join(account["contact"]))) # create a new order log.info("Creating new order...") order_payload = {"identifiers": [{"type": "dns", "value": d} for d in domains]} - order, _, order_headers = _send_signed_request(directory['newOrder'], order_payload, "Error creating new order") + order, _, order_headers = _send_signed_request( + directory["newOrder"], order_payload, "Error creating new order" + ) log.info("Order created!") # get the authorizations that need to be completed - for auth_url in order['authorizations']: - authorization, _, _ = _do_request(auth_url, err_msg="Error getting challenges") - domain = authorization['identifier']['value'] + for auth_url in order["authorizations"]: + authorization, _, _ = _send_signed_request( + auth_url, None, "Error getting challenges" + ) + domain = authorization["identifier"]["value"] log.info("Verifying {0}...".format(domain)) # find the http-01 challenge and write the challenge file - challenge = [c for c in authorization['challenges'] if c['type'] == "http-01"][0] - token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) + challenge = [c for c in authorization["challenges"] if c["type"] == "http-01"][ + 0 + ] + token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge["token"]) keyauthorization = "{0}.{1}".format(token, thumbprint) wellknown_path = os.path.join(acme_dir, token) with open(wellknown_path, "w") as wellknown_file: @@ -137,38 +208,64 @@ def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA, disable_check # check that the file is in place try: - wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token) - assert(disable_check or _do_request(wellknown_url)[0] == keyauthorization) + wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format( + domain, token + ) + assert disable_check or _do_request(wellknown_url)[0] == keyauthorization except (AssertionError, ValueError) as e: - os.remove(wellknown_path) - raise ValueError("Wrote file to {0}, but couldn't download {1}: {2}".format(wellknown_path, wellknown_url, e)) + raise ValueError( + "Wrote file to {0}, but couldn't download {1}: {2}".format( + wellknown_path, wellknown_url, e + ) + ) # say the challenge is done - _send_signed_request(challenge['url'], {}, "Error submitting challenges: {0}".format(domain)) - authorization = _poll_until_not(auth_url, ["pending"], "Error checking challenge status for {0}".format(domain)) - if authorization['status'] != "valid": - raise ValueError("Challenge did not pass for {0}: {1}".format(domain, authorization)) + _send_signed_request( + challenge["url"], {}, "Error submitting challenges: {0}".format(domain) + ) + authorization = _poll_until_not( + auth_url, + ["pending"], + "Error checking challenge status for {0}".format(domain), + ) + if authorization["status"] != "valid": + raise ValueError( + "Challenge did not pass for {0}: {1}".format(domain, authorization) + ) + os.remove(wellknown_path) log.info("{0} verified!".format(domain)) # finalize the order with the csr log.info("Signing certificate...") - csr_der = _cmd(["openssl", "req", "-in", csr, "-outform", "DER"], err_msg="DER Export Error") - _send_signed_request(order['finalize'], {"csr": _b64(csr_der)}, "Error finalizing order") + csr_der = _cmd( + ["openssl", "req", "-in", csr, "-outform", "DER"], err_msg="DER Export Error" + ) + _send_signed_request( + order["finalize"], {"csr": _b64(csr_der)}, "Error finalizing order" + ) # poll the order to monitor when it's done - order = _poll_until_not(order_headers['Location'], ["pending", "processing"], "Error checking order status") - if order['status'] != "valid": + order = _poll_until_not( + order_headers["Location"], + ["pending", "processing"], + "Error checking order status", + ) + if order["status"] != "valid": raise ValueError("Order failed: {0}".format(order)) # download the certificate - certificate_pem, _, _ = _do_request(order['certificate'], err_msg="Certificate download failed") + certificate_pem, _, _ = _send_signed_request( + order["certificate"], None, "Certificate download failed" + ) log.info("Certificate signed!") return certificate_pem + def main(argv=None): parser = argparse.ArgumentParser( formatter_class=argparse.RawDescriptionHelpFormatter, - description=textwrap.dedent("""\ + description=textwrap.dedent( + """\ This script automates the process of getting a signed TLS certificate from Let's Encrypt using the ACME protocol. It will need to be run on your server and have access to your private account key, so PLEASE READ THROUGH IT! It's only ~200 lines, so it won't take long. @@ -178,21 +275,64 @@ def main(argv=None): Example Crontab Renewal (once per month): 0 0 1 * * python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > /path/to/signed_chain.crt 2>> /var/log/acme_tiny.log - """) + """ + ), + ) + parser.add_argument( + "--account-key", + required=True, + help="path to your Let's Encrypt account private key", + ) + parser.add_argument( + "--csr", required=True, help="path to your certificate signing request" + ) + parser.add_argument( + "--acme-dir", + required=True, + help="path to the .well-known/acme-challenge/ directory", + ) + parser.add_argument( + "--quiet", + action="store_const", + const=logging.ERROR, + help="suppress output except for errors", + ) + parser.add_argument( + "--disable-check", + default=False, + action="store_true", + help="disable checking if the challenge file is hosted correctly before telling the CA", + ) + parser.add_argument( + "--directory-url", + default=DEFAULT_DIRECTORY_URL, + help="certificate authority directory url, default is Let's Encrypt", + ) + parser.add_argument( + "--ca", default=DEFAULT_CA, help="DEPRECATED! USE --directory-url INSTEAD!" + ) + parser.add_argument( + "--contact", + metavar="CONTACT", + default=None, + nargs="*", + help="Contact details (e.g. mailto:aaa@bbb.com) for your account-key", ) - parser.add_argument("--account-key", required=True, help="path to your Let's Encrypt account private key") - parser.add_argument("--csr", required=True, help="path to your certificate signing request") - parser.add_argument("--acme-dir", required=True, help="path to the .well-known/acme-challenge/ directory") - parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors") - parser.add_argument("--disable-check", default=False, action="store_true", help="disable checking if the challenge file is hosted correctly before telling the CA") - parser.add_argument("--directory-url", default=DEFAULT_DIRECTORY_URL, help="certificate authority directory url, default is Let's Encrypt") - parser.add_argument("--ca", default=DEFAULT_CA, help="DEPRECATED! USE --directory-url INSTEAD!") - parser.add_argument("--contact", metavar="CONTACT", default=None, nargs="*", help="Contact details (e.g. mailto:aaa@bbb.com) for your account-key") args = parser.parse_args(argv) LOGGER.setLevel(args.quiet or LOGGER.level) - signed_crt = get_crt(args.account_key, args.csr, args.acme_dir, log=LOGGER, CA=args.ca, disable_check=args.disable_check, directory_url=args.directory_url, contact=args.contact) + signed_crt = get_crt( + args.account_key, + args.csr, + args.acme_dir, + log=LOGGER, + CA=args.ca, + disable_check=args.disable_check, + directory_url=args.directory_url, + contact=args.contact, + ) sys.stdout.write(signed_crt) -if __name__ == "__main__": # pragma: no cover + +if __name__ == "__main__": # pragma: no cover main(sys.argv[1:]) diff --git a/tests/_test_m18nkeys.py b/tests/_test_m18nkeys.py deleted file mode 100644 index ee8df0dc6..000000000 --- a/tests/_test_m18nkeys.py +++ /dev/null @@ -1,82 +0,0 @@ -# -*- coding: utf-8 -*- - -import re -import glob -import json -import yaml - -############################################################################### -# Find used keys in python code # -############################################################################### - -# This regex matches « foo » in patterns like « m18n.n( "foo" » -p = re.compile(r'm18n\.n\(\s*[\"\']([a-zA-Z1-9_]+)[\"\']') - -python_files = glob.glob("/vagrant/yunohost/src/yunohost/*.py") -python_files.extend(glob.glob("/vagrant/yunohost/src/yunohost/utils/*.py")) -python_files.append("/vagrant/yunohost/bin/yunohost") - -python_keys = set() -for python_file in python_files: - with open(python_file) as f: - keys_in_file = p.findall(f.read()) - for key in keys_in_file: - python_keys.add(key) - -############################################################################### -# Find keys used in actionmap # -############################################################################### - -actionmap_keys = set() -actionmap = yaml.load(open("../data/actionsmap/yunohost.yml")) -for _, category in actionmap.items(): - if "actions" not in category.keys(): - continue - for _, action in category["actions"].items(): - if "arguments" not in action.keys(): - continue - for _, argument in action["arguments"].items(): - if "extra" not in argument.keys(): - continue - if "password" in argument["extra"]: - actionmap_keys.add(argument["extra"]["password"]) - if "ask" in argument["extra"]: - actionmap_keys.add(argument["extra"]["ask"]) - if "pattern" in argument["extra"]: - actionmap_keys.add(argument["extra"]["pattern"][1]) - if "help" in argument["extra"]: - print argument["extra"]["help"] - -# These keys are used but difficult to parse -actionmap_keys.add("admin_password") - -############################################################################### -# Load en locale json keys # -############################################################################### - -en_locale_file = "/vagrant/yunohost/locales/en.json" -with open(en_locale_file) as f: - en_locale_json = json.loads(f.read()) - -en_locale_keys = set(en_locale_json.keys()) - -############################################################################### -# Compare keys used and keys defined # -############################################################################### - -used_keys = python_keys.union(actionmap_keys) - -keys_used_but_not_defined = used_keys.difference(en_locale_keys) -keys_defined_but_not_used = en_locale_keys.difference(used_keys) - -if len(keys_used_but_not_defined) != 0: - print "> Error ! Those keys are used in some files but not defined :" - for key in sorted(keys_used_but_not_defined): - print " - %s" % key - -if len(keys_defined_but_not_used) != 0: - print "> Warning ! Those keys are defined but seems unused :" - for key in sorted(keys_defined_but_not_used): - print " - %s" % key - - diff --git a/tests/add_missing_keys.py b/tests/add_missing_keys.py new file mode 100644 index 000000000..30c6c6640 --- /dev/null +++ b/tests/add_missing_keys.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- + +import os +import re +import glob +import json +import yaml +import subprocess + +############################################################################### +# Find used keys in python code # +############################################################################### + + +def find_expected_string_keys(): + + # Try to find : + # m18n.n( "foo" + # YunohostError("foo" + # YunohostValidationError("foo" + # # i18n: foo + p1 = re.compile(r"m18n\.n\(\n*\s*[\"\'](\w+)[\"\']") + p2 = re.compile(r"YunohostError\(\n*\s*[\'\"](\w+)[\'\"]") + p3 = re.compile(r"YunohostValidationError\(\n*\s*[\'\"](\w+)[\'\"]") + p4 = re.compile(r"# i18n: [\'\"]?(\w+)[\'\"]?") + + python_files = glob.glob("src/yunohost/*.py") + python_files.extend(glob.glob("src/yunohost/utils/*.py")) + python_files.extend(glob.glob("src/yunohost/data_migrations/*.py")) + python_files.extend(glob.glob("src/yunohost/authenticators/*.py")) + python_files.extend(glob.glob("data/hooks/diagnosis/*.py")) + python_files.append("bin/yunohost") + + for python_file in python_files: + content = open(python_file).read() + for m in p1.findall(content): + if m.endswith("_"): + continue + yield m + for m in p2.findall(content): + if m.endswith("_"): + continue + yield m + for m in p3.findall(content): + if m.endswith("_"): + continue + yield m + for m in p4.findall(content): + yield m + + # For each diagnosis, try to find strings like "diagnosis_stuff_foo" (c.f. diagnosis summaries) + # Also we expect to have "diagnosis_description_" for each diagnosis + p3 = re.compile(r"[\"\'](diagnosis_[a-z]+_\w+)[\"\']") + for python_file in glob.glob("data/hooks/diagnosis/*.py"): + content = open(python_file).read() + for m in p3.findall(content): + if m.endswith("_"): + # Ignore some name fragments which are actually concatenated with other stuff.. + continue + yield m + yield "diagnosis_description_" + os.path.basename(python_file)[:-3].split("-")[ + -1 + ] + + # For each migration, expect to find "migration_description_" + for path in glob.glob("src/yunohost/data_migrations/*.py"): + if "__init__" in path: + continue + yield "migration_description_" + os.path.basename(path)[:-3] + + # For each default service, expect to find "service_description_" + for service, info in yaml.safe_load( + open("data/templates/yunohost/services.yml") + ).items(): + if info is None: + continue + yield "service_description_" + service + + # For all unit operations, expect to find "log_" + # A unit operation is created either using the @is_unit_operation decorator + # or using OperationLogger( + cmd = "grep -hr '@is_unit_operation' src/yunohost/ -A3 2>/dev/null | grep '^def' | sed -E 's@^def (\\w+)\\(.*@\\1@g'" + for funcname in ( + subprocess.check_output(cmd, shell=True).decode("utf-8").strip().split("\n") + ): + yield "log_" + funcname + + p4 = re.compile(r"OperationLogger\(\n*\s*[\"\'](\w+)[\"\']") + for python_file in python_files: + content = open(python_file).read() + for m in ("log_" + match for match in p4.findall(content)): + yield m + + # Global settings descriptions + # Will be on a line like : ("service.ssh.allow_deprecated_dsa_hostkey", {"type": "bool", ... + p5 = re.compile(r" \(\n*\s*[\"\'](\w[\w\.]+)[\"\'],") + content = open("src/yunohost/settings.py").read() + for m in ( + "global_settings_setting_" + s.replace(".", "_") for s in p5.findall(content) + ): + yield m + + # Keys for the actionmap ... + for category in yaml.safe_load(open("data/actionsmap/yunohost.yml")).values(): + if "actions" not in category.keys(): + continue + for action in category["actions"].values(): + if "arguments" not in action.keys(): + continue + for argument in action["arguments"].values(): + extra = argument.get("extra") + if not extra: + continue + if "password" in extra: + yield extra["password"] + if "ask" in extra: + yield extra["ask"] + if "comment" in extra: + yield extra["comment"] + if "pattern" in extra: + yield extra["pattern"][1] + if "help" in extra: + yield extra["help"] + + # Hardcoded expected keys ... + yield "admin_password" # Not sure that's actually used nowadays... + + for method in ["tar", "copy", "custom"]: + yield "backup_applying_method_%s" % method + yield "backup_method_%s_finished" % method + + for level in ["danger", "thirdparty", "warning"]: + yield "confirm_app_install_%s" % level + + for errortype in ["not_found", "error", "warning", "success", "not_found_details"]: + yield "diagnosis_domain_expiration_%s" % errortype + yield "diagnosis_domain_not_found_details" + + for errortype in ["bad_status_code", "connection_error", "timeout"]: + yield "diagnosis_http_%s" % errortype + + yield "password_listed" + for i in [1, 2, 3, 4]: + yield "password_too_simple_%s" % i + + checks = [ + "outgoing_port_25_ok", + "ehlo_ok", + "fcrdns_ok", + "blacklist_ok", + "queue_ok", + "ehlo_bad_answer", + "ehlo_unreachable", + "ehlo_bad_answer_details", + "ehlo_unreachable_details", + ] + for check in checks: + yield "diagnosis_mail_%s" % check + + +############################################################################### +# Load en locale json keys # +############################################################################### + + +def keys_defined_for_en(): + return json.loads(open("locales/en.json").read()).keys() + + +############################################################################### +# Compare keys used and keys defined # +############################################################################### + + +expected_string_keys = set(find_expected_string_keys()) +keys_defined = set(keys_defined_for_en()) + + +undefined_keys = expected_string_keys.difference(keys_defined) +undefined_keys = sorted(undefined_keys) + + +j = json.loads(open("locales/en.json").read()) +for key in undefined_keys: + j[key] = "FIXME" + +json.dump( + j, + open("locales/en.json", "w"), + indent=4, + ensure_ascii=False, + sort_keys=True, +) diff --git a/tests/autofix_locale_format.py b/tests/autofix_locale_format.py new file mode 100644 index 000000000..f3825bd30 --- /dev/null +++ b/tests/autofix_locale_format.py @@ -0,0 +1,53 @@ +import re +import json +import glob + +# List all locale files (except en.json being the ref) +locale_folder = "../locales/" +locale_files = glob.glob(locale_folder + "*.json") +locale_files = [filename.split("/")[-1] for filename in locale_files] +locale_files.remove("en.json") + +reference = json.loads(open(locale_folder + "en.json").read()) + + +def fix_locale(locale_file): + + this_locale = json.loads(open(locale_folder + locale_file).read()) + fixed_stuff = False + + # We iterate over all keys/string in en.json + for key, string in reference.items(): + + # Ignore check if there's no translation yet for this key + if key not in this_locale: + continue + + # Then we check that every "{stuff}" (for python's .format()) + # should also be in the translated string, otherwise the .format + # will trigger an exception! + subkeys_in_ref = [k[0] for k in re.findall(r"{(\w+)(:\w)?}", string)] + subkeys_in_this_locale = [ + k[0] for k in re.findall(r"{(\w+)(:\w)?}", this_locale[key]) + ] + + if set(subkeys_in_ref) != set(subkeys_in_this_locale) and ( + len(subkeys_in_ref) == len(subkeys_in_this_locale) + ): + for i, subkey in enumerate(subkeys_in_ref): + this_locale[key] = this_locale[key].replace( + "{%s}" % subkeys_in_this_locale[i], "{%s}" % subkey + ) + fixed_stuff = True + + if fixed_stuff: + json.dump( + this_locale, + open(locale_folder + locale_file, "w"), + indent=4, + ensure_ascii=False, + ) + + +for locale_file in locale_files: + fix_locale(locale_file) diff --git a/tests/reformat_locales.py b/tests/reformat_locales.py new file mode 100644 index 000000000..86c2664d7 --- /dev/null +++ b/tests/reformat_locales.py @@ -0,0 +1,60 @@ +import re + + +def reformat(lang, transformations): + + locale = open(f"../locales/{lang}.json").read() + for pattern, replace in transformations.items(): + locale = re.compile(pattern).sub(replace, locale) + + open(f"../locales/{lang}.json", "w").write(locale) + + +###################################################### + +godamn_spaces_of_hell = [ + "\u00a0", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\u200A", + "\u202f", + "\u202F", + "\u3000", +] + +transformations = {s: " " for s in godamn_spaces_of_hell} +transformations.update( + { + "…": "...", + } +) + + +reformat("en", transformations) + +###################################################### + +transformations.update( + { + "courriel": "email", + "e-mail": "email", + "Courriel": "Email", + "E-mail": "Email", + "« ": "'", + "«": "'", + " »": "'", + "»": "'", + "’": "'", + # r"$(\w{1,2})'|( \w{1,2})'": r"\1\2’", + } +) + +reformat("fr", transformations) diff --git a/tests/remove_stale_translated_strings.py b/tests/remove_stale_translated_strings.py new file mode 100644 index 000000000..48f2180e4 --- /dev/null +++ b/tests/remove_stale_translated_strings.py @@ -0,0 +1,25 @@ +import json +import glob +from collections import OrderedDict + +locale_folder = "../locales/" +locale_files = glob.glob(locale_folder + "*.json") +locale_files = [filename.split("/")[-1] for filename in locale_files] +locale_files.remove("en.json") + +reference = json.loads(open(locale_folder + "en.json").read()) + +for locale_file in locale_files: + + print(locale_file) + this_locale = json.loads( + open(locale_folder + locale_file).read(), object_pairs_hook=OrderedDict + ) + this_locale_fixed = {k: v for k, v in this_locale.items() if k in reference} + + json.dump( + this_locale_fixed, + open(locale_folder + locale_file, "w"), + indent=4, + ensure_ascii=False, + ) diff --git a/tests/test_actionmap.py b/tests/test_actionmap.py index 08b868839..0b8abb152 100644 --- a/tests/test_actionmap.py +++ b/tests/test_actionmap.py @@ -1,4 +1,5 @@ import yaml + def test_yaml_syntax(): - yaml.load(open("data/actionsmap/yunohost.yml")) + yaml.safe_load(open("data/actionsmap/yunohost.yml")) diff --git a/tests/test_helpers.d/ynhtest_config.sh b/tests/test_helpers.d/ynhtest_config.sh new file mode 100644 index 000000000..b64943a48 --- /dev/null +++ b/tests/test_helpers.d/ynhtest_config.sh @@ -0,0 +1,662 @@ + +################# +# _ __ _ _ # +# | '_ \| | | | # +# | |_) | |_| | # +# | .__/ \__, | # +# | | __/ | # +# |_| |___/ # +# # +################# + +_read_py() { + local file="$1" + local key="$2" + python3 -c "exec(open('$file').read()); print($key)" +} + +ynhtest_config_read_py() { + + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.py" + + cat << EOF > $dummy_dir/dummy.py +# Some comment +FOO = None +ENABLED = False +# TITLE = "Old title" +TITLE = "Lorem Ipsum" +THEME = "colib'ris" +EMAIL = "root@example.com" # This is a comment without quotes +PORT = 1234 # This is a comment without quotes +URL = 'https://yunohost.org' +DICT = {} +DICT['ldap_base'] = "ou=users,dc=yunohost,dc=org" +DICT['ldap_conf'] = {} +DICT['ldap_conf']['user'] = "camille" +# YNH_ICI +DICT['TITLE'] = "Hello world" +EOF + + test "$(_read_py "$file" "FOO")" == "None" + test "$(ynh_read_var_in_file "$file" "FOO")" == "None" + + test "$(_read_py "$file" "ENABLED")" == "False" + test "$(ynh_read_var_in_file "$file" "ENABLED")" == "False" + + test "$(_read_py "$file" "TITLE")" == "Lorem Ipsum" + test "$(ynh_read_var_in_file "$file" "TITLE")" == "Lorem Ipsum" + + test "$(_read_py "$file" "THEME")" == "colib'ris" + test "$(ynh_read_var_in_file "$file" "THEME")" == "colib'ris" + + test "$(_read_py "$file" "EMAIL")" == "root@example.com" + test "$(ynh_read_var_in_file "$file" "EMAIL")" == "root@example.com" + + test "$(_read_py "$file" "PORT")" == "1234" + test "$(ynh_read_var_in_file "$file" "PORT")" == "1234" + + test "$(_read_py "$file" "URL")" == "https://yunohost.org" + test "$(ynh_read_var_in_file "$file" "URL")" == "https://yunohost.org" + + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + test "$(ynh_read_var_in_file "$file" "user")" == "camille" + + test "$(ynh_read_var_in_file "$file" "TITLE" "YNH_ICI")" == "Hello world" + + ! _read_py "$file" "NONEXISTENT" + test "$(ynh_read_var_in_file "$file" "NONEXISTENT")" == "YNH_NULL" + + ! _read_py "$file" "ENABLE" + test "$(ynh_read_var_in_file "$file" "ENABLE")" == "YNH_NULL" +} + +ynhtest_config_write_py() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.py" + + cat << EOF > $dummy_dir/dummy.py +# Some comment +FOO = None +ENABLED = False +# TITLE = "Old title" +TITLE = "Lorem Ipsum" +THEME = "colib'ris" +EMAIL = "root@example.com" # This is a comment without quotes +PORT = 1234 # This is a comment without quotes +URL = 'https://yunohost.org' +DICT = {} +DICT['ldap_base'] = "ou=users,dc=yunohost,dc=org" +# YNH_ICI +DICT['TITLE'] = "Hello world" +EOF + + ynh_write_var_in_file "$file" "FOO" "bar" + test "$(_read_py "$file" "FOO")" == "bar" + test "$(ynh_read_var_in_file "$file" "FOO")" == "bar" + + ynh_write_var_in_file "$file" "ENABLED" "True" + test "$(_read_py "$file" "ENABLED")" == "True" + test "$(ynh_read_var_in_file "$file" "ENABLED")" == "True" + + ynh_write_var_in_file "$file" "TITLE" "Foo Bar" + test "$(_read_py "$file" "TITLE")" == "Foo Bar" + test "$(ynh_read_var_in_file "$file" "TITLE")" == "Foo Bar" + + ynh_write_var_in_file "$file" "THEME" "super-awesome-theme" + test "$(_read_py "$file" "THEME")" == "super-awesome-theme" + test "$(ynh_read_var_in_file "$file" "THEME")" == "super-awesome-theme" + + ynh_write_var_in_file "$file" "EMAIL" "sam@domain.tld" + test "$(_read_py "$file" "EMAIL")" == "sam@domain.tld" + test "$(ynh_read_var_in_file "$file" "EMAIL")" == "sam@domain.tld" + + ynh_write_var_in_file "$file" "PORT" "5678" + test "$(_read_py "$file" "PORT")" == "5678" + test "$(ynh_read_var_in_file "$file" "PORT")" == "5678" + + ynh_write_var_in_file "$file" "URL" "https://domain.tld/foobar" + test "$(_read_py "$file" "URL")" == "https://domain.tld/foobar" + test "$(ynh_read_var_in_file "$file" "URL")" == "https://domain.tld/foobar" + + ynh_write_var_in_file "$file" "ldap_base" "ou=users,dc=yunohost,dc=org" + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + ynh_write_var_in_file "$file" "TITLE" "YOLO" "YNH_ICI" + test "$(ynh_read_var_in_file "$file" "TITLE" "YNH_ICI")" == "YOLO" + + ! ynh_write_var_in_file "$file" "NONEXISTENT" "foobar" + ! _read_py "$file" "NONEXISTENT" + test "$(ynh_read_var_in_file "$file" "NONEXISTENT")" == "YNH_NULL" + + ! ynh_write_var_in_file "$file" "ENABLE" "foobar" + ! _read_py "$file" "ENABLE" + test "$(ynh_read_var_in_file "$file" "ENABLE")" == "YNH_NULL" + +} + +############### +# _ _ # +# (_) (_) # +# _ _ __ _ # +# | | '_ \| | # +# | | | | | | # +# |_|_| |_|_| # +# # +############### + +_read_ini() { + local file="$1" + local key="$2" + python3 -c "import configparser; c = configparser.ConfigParser(); c.read('$file'); print(c['main']['$key'])" +} + +ynhtest_config_read_ini() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.ini" + + cat << EOF > $file +# Some comment +; Another comment +[main] +foo = null +enabled = False +# title = Old title +title = Lorem Ipsum +theme = colib'ris +email = root@example.com ; This is a comment without quotes +port = 1234 ; This is a comment without quotes +url = https://yunohost.org +[dict] + ldap_base = ou=users,dc=yunohost,dc=org +EOF + + test "$(_read_ini "$file" "foo")" == "null" + test "$(ynh_read_var_in_file "$file" "foo")" == "null" + + test "$(_read_ini "$file" "enabled")" == "False" + test "$(ynh_read_var_in_file "$file" "enabled")" == "False" + + test "$(_read_ini "$file" "title")" == "Lorem Ipsum" + test "$(ynh_read_var_in_file "$file" "title")" == "Lorem Ipsum" + + test "$(_read_ini "$file" "theme")" == "colib'ris" + test "$(ynh_read_var_in_file "$file" "theme")" == "colib'ris" + + #test "$(_read_ini "$file" "email")" == "root@example.com" + test "$(ynh_read_var_in_file "$file" "email")" == "root@example.com" + + #test "$(_read_ini "$file" "port")" == "1234" + test "$(ynh_read_var_in_file "$file" "port")" == "1234" + + test "$(_read_ini "$file" "url")" == "https://yunohost.org" + test "$(ynh_read_var_in_file "$file" "url")" == "https://yunohost.org" + + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + ! _read_ini "$file" "nonexistent" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! _read_ini "$file" "enable" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" + +} + +ynhtest_config_write_ini() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.ini" + + cat << EOF > $file +# Some comment +; Another comment +[main] +foo = null +enabled = False +# title = Old title +title = Lorem Ipsum +theme = colib'ris +email = root@example.com # This is a comment without quotes +port = 1234 # This is a comment without quotes +url = https://yunohost.org +[dict] + ldap_base = ou=users,dc=yunohost,dc=org +EOF + + ynh_write_var_in_file "$file" "foo" "bar" + test "$(_read_ini "$file" "foo")" == "bar" + test "$(ynh_read_var_in_file "$file" "foo")" == "bar" + + ynh_write_var_in_file "$file" "enabled" "True" + test "$(_read_ini "$file" "enabled")" == "True" + test "$(ynh_read_var_in_file "$file" "enabled")" == "True" + + ynh_write_var_in_file "$file" "title" "Foo Bar" + test "$(_read_ini "$file" "title")" == "Foo Bar" + test "$(ynh_read_var_in_file "$file" "title")" == "Foo Bar" + + ynh_write_var_in_file "$file" "theme" "super-awesome-theme" + test "$(_read_ini "$file" "theme")" == "super-awesome-theme" + test "$(ynh_read_var_in_file "$file" "theme")" == "super-awesome-theme" + + ynh_write_var_in_file "$file" "email" "sam@domain.tld" + test "$(_read_ini "$file" "email")" == "sam@domain.tld # This is a comment without quotes" + test "$(ynh_read_var_in_file "$file" "email")" == "sam@domain.tld" + + ynh_write_var_in_file "$file" "port" "5678" + test "$(_read_ini "$file" "port")" == "5678 # This is a comment without quotes" + test "$(ynh_read_var_in_file "$file" "port")" == "5678" + + ynh_write_var_in_file "$file" "url" "https://domain.tld/foobar" + test "$(_read_ini "$file" "url")" == "https://domain.tld/foobar" + test "$(ynh_read_var_in_file "$file" "url")" == "https://domain.tld/foobar" + + ynh_write_var_in_file "$file" "ldap_base" "ou=users,dc=yunohost,dc=org" + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + ! ynh_write_var_in_file "$file" "nonexistent" "foobar" + ! _read_ini "$file" "nonexistent" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! ynh_write_var_in_file "$file" "enable" "foobar" + ! _read_ini "$file" "enable" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" + +} + +############################# +# _ # +# | | # +# _ _ __ _ _ __ ___ | | # +# | | | |/ _` | '_ ` _ \| | # +# | |_| | (_| | | | | | | | # +# \__, |\__,_|_| |_| |_|_| # +# __/ | # +# |___/ # +# # +############################# + +_read_yaml() { + local file="$1" + local key="$2" + python3 -c "import yaml; print(yaml.safe_load(open('$file'))['$key'])" +} + +ynhtest_config_read_yaml() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.yml" + + cat << EOF > $file +# Some comment +foo: +enabled: false +# title: old title +title: Lorem Ipsum +theme: colib'ris +email: root@example.com # This is a comment without quotes +port: 1234 # This is a comment without quotes +url: https://yunohost.org +dict: + ldap_base: ou=users,dc=yunohost,dc=org +EOF + + test "$(_read_yaml "$file" "foo")" == "None" + test "$(ynh_read_var_in_file "$file" "foo")" == "" + + test "$(_read_yaml "$file" "enabled")" == "False" + test "$(ynh_read_var_in_file "$file" "enabled")" == "false" + + test "$(_read_yaml "$file" "title")" == "Lorem Ipsum" + test "$(ynh_read_var_in_file "$file" "title")" == "Lorem Ipsum" + + test "$(_read_yaml "$file" "theme")" == "colib'ris" + test "$(ynh_read_var_in_file "$file" "theme")" == "colib'ris" + + test "$(_read_yaml "$file" "email")" == "root@example.com" + test "$(ynh_read_var_in_file "$file" "email")" == "root@example.com" + + test "$(_read_yaml "$file" "port")" == "1234" + test "$(ynh_read_var_in_file "$file" "port")" == "1234" + + test "$(_read_yaml "$file" "url")" == "https://yunohost.org" + test "$(ynh_read_var_in_file "$file" "url")" == "https://yunohost.org" + + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + ! _read_yaml "$file" "nonexistent" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! _read_yaml "$file" "enable" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" +} + + +ynhtest_config_write_yaml() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.yml" + + cat << EOF > $file +# Some comment +foo: +enabled: false +# title: old title +title: Lorem Ipsum +theme: colib'ris +email: root@example.com # This is a comment without quotes +port: 1234 # This is a comment without quotes +url: https://yunohost.org +dict: + ldap_base: ou=users,dc=yunohost,dc=org +EOF + + ynh_write_var_in_file "$file" "foo" "bar" + # cat $dummy_dir/dummy.yml # to debug + ! test "$(_read_yaml "$file" "foo")" == "bar" # writing broke the yaml syntax... "foo:bar" (no space aftr :) + test "$(ynh_read_var_in_file "$file" "foo")" == "bar" + + ynh_write_var_in_file "$file" "enabled" "true" + test "$(_read_yaml "$file" "enabled")" == "True" + test "$(ynh_read_var_in_file "$file" "enabled")" == "true" + + ynh_write_var_in_file "$file" "title" "Foo Bar" + test "$(_read_yaml "$file" "title")" == "Foo Bar" + test "$(ynh_read_var_in_file "$file" "title")" == "Foo Bar" + + ynh_write_var_in_file "$file" "theme" "super-awesome-theme" + test "$(_read_yaml "$file" "theme")" == "super-awesome-theme" + test "$(ynh_read_var_in_file "$file" "theme")" == "super-awesome-theme" + + ynh_write_var_in_file "$file" "email" "sam@domain.tld" + test "$(_read_yaml "$file" "email")" == "sam@domain.tld" + test "$(ynh_read_var_in_file "$file" "email")" == "sam@domain.tld" + + ynh_write_var_in_file "$file" "port" "5678" + test "$(_read_yaml "$file" "port")" == "5678" + test "$(ynh_read_var_in_file "$file" "port")" == "5678" + + ynh_write_var_in_file "$file" "url" "https://domain.tld/foobar" + test "$(_read_yaml "$file" "url")" == "https://domain.tld/foobar" + test "$(ynh_read_var_in_file "$file" "url")" == "https://domain.tld/foobar" + + ynh_write_var_in_file "$file" "ldap_base" "ou=foobar,dc=domain,dc=tld" + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=foobar,dc=domain,dc=tld" + + ! ynh_write_var_in_file "$file" "nonexistent" "foobar" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! ynh_write_var_in_file "$file" "enable" "foobar" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" + test "$(ynh_read_var_in_file "$file" "enabled")" == "true" +} + +######################### +# _ # +# (_) # +# _ ___ ___ _ __ # +# | / __|/ _ \| '_ \ # +# | \__ \ (_) | | | | # +# | |___/\___/|_| |_| # +# _/ | # +# |__/ # +# # +######################### + +_read_json() { + local file="$1" + local key="$2" + python3 -c "import json; print(json.load(open('$file'))['$key'])" +} + +ynhtest_config_read_json() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.json" + + cat << EOF > $file +{ + "foo": null, + "enabled": false, + "title": "Lorem Ipsum", + "theme": "colib'ris", + "email": "root@example.com", + "port": 1234, + "url": "https://yunohost.org", + "dict": { + "ldap_base": "ou=users,dc=yunohost,dc=org" + } +} +EOF + + + test "$(_read_json "$file" "foo")" == "None" + test "$(ynh_read_var_in_file "$file" "foo")" == "null" + + test "$(_read_json "$file" "enabled")" == "False" + test "$(ynh_read_var_in_file "$file" "enabled")" == "false" + + test "$(_read_json "$file" "title")" == "Lorem Ipsum" + test "$(ynh_read_var_in_file "$file" "title")" == "Lorem Ipsum" + + test "$(_read_json "$file" "theme")" == "colib'ris" + test "$(ynh_read_var_in_file "$file" "theme")" == "colib'ris" + + test "$(_read_json "$file" "email")" == "root@example.com" + test "$(ynh_read_var_in_file "$file" "email")" == "root@example.com" + + test "$(_read_json "$file" "port")" == "1234" + test "$(ynh_read_var_in_file "$file" "port")" == "1234" + + test "$(_read_json "$file" "url")" == "https://yunohost.org" + test "$(ynh_read_var_in_file "$file" "url")" == "https://yunohost.org" + + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + ! _read_json "$file" "nonexistent" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! _read_json "$file" "enable" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" +} + + +ynhtest_config_write_json() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.json" + + cat << EOF > $file +{ + "foo": null, + "enabled": false, + "title": "Lorem Ipsum", + "theme": "colib'ris", + "email": "root@example.com", + "port": 1234, + "url": "https://yunohost.org", + "dict": { + "ldap_base": "ou=users,dc=yunohost,dc=org" + } +} +EOF + + ynh_write_var_in_file "$file" "foo" "bar" + cat $file + test "$(_read_json "$file" "foo")" == "bar" + test "$(ynh_read_var_in_file "$file" "foo")" == "bar" + + ynh_write_var_in_file "$file" "enabled" "true" + cat $file + test "$(_read_json "$file" "enabled")" == "true" + test "$(ynh_read_var_in_file "$file" "enabled")" == "true" + + ynh_write_var_in_file "$file" "title" "Foo Bar" + cat $file + test "$(_read_json "$file" "title")" == "Foo Bar" + test "$(ynh_read_var_in_file "$file" "title")" == "Foo Bar" + + ynh_write_var_in_file "$file" "theme" "super-awesome-theme" + cat $file + test "$(_read_json "$file" "theme")" == "super-awesome-theme" + test "$(ynh_read_var_in_file "$file" "theme")" == "super-awesome-theme" + + ynh_write_var_in_file "$file" "email" "sam@domain.tld" + cat $file + test "$(_read_json "$file" "email")" == "sam@domain.tld" + test "$(ynh_read_var_in_file "$file" "email")" == "sam@domain.tld" + + ynh_write_var_in_file "$file" "port" "5678" + test "$(_read_json "$file" "port")" == "5678" + test "$(ynh_read_var_in_file "$file" "port")" == "5678" + + ynh_write_var_in_file "$file" "url" "https://domain.tld/foobar" + test "$(_read_json "$file" "url")" == "https://domain.tld/foobar" + test "$(ynh_read_var_in_file "$file" "url")" == "https://domain.tld/foobar" + + ynh_write_var_in_file "$file" "ldap_base" "ou=foobar,dc=domain,dc=tld" + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=foobar,dc=domain,dc=tld" + + ! ynh_write_var_in_file "$file" "nonexistent" "foobar" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! ynh_write_var_in_file "$file" "enable" "foobar" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" + test "$(ynh_read_var_in_file "$file" "enabled")" == "true" +} + +####################### +# _ # +# | | # +# _ __ | |__ _ __ # +# | '_ \| '_ \| '_ \ # +# | |_) | | | | |_) | # +# | .__/|_| |_| .__/ # +# | | | | # +# |_| |_| # +# # +####################### + +_read_php() { + local file="$1" + local key="$2" + php -r "include '$file'; echo var_export(\$$key);" | sed "s/^'//g" | sed "s/'$//g" +} + +ynhtest_config_read_php() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.php" + + cat << EOF > $file + "ou=users,dc=yunohost,dc=org", + 'ldap_conf' => [] + ]; + \$dict['ldap_conf']['user'] = 'camille'; + const DB_HOST = 'localhost'; +?> +EOF + + test "$(_read_php "$file" "foo")" == "NULL" + test "$(ynh_read_var_in_file "$file" "foo")" == "NULL" + + test "$(_read_php "$file" "enabled")" == "false" + test "$(ynh_read_var_in_file "$file" "enabled")" == "false" + + test "$(_read_php "$file" "title")" == "Lorem Ipsum" + test "$(ynh_read_var_in_file "$file" "title")" == "Lorem Ipsum" + + test "$(_read_php "$file" "theme")" == "colib\\'ris" + test "$(ynh_read_var_in_file "$file" "theme")" == "colib'ris" + + test "$(_read_php "$file" "email")" == "root@example.com" + test "$(ynh_read_var_in_file "$file" "email")" == "root@example.com" + + test "$(_read_php "$file" "port")" == "1234" + test "$(ynh_read_var_in_file "$file" "port")" == "1234" + + test "$(_read_php "$file" "url")" == "https://yunohost.org" + test "$(ynh_read_var_in_file "$file" "url")" == "https://yunohost.org" + + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + test "$(ynh_read_var_in_file "$file" "user")" == "camille" + + test "$(ynh_read_var_in_file "$file" "DB_HOST")" == "localhost" + + ! _read_php "$file" "nonexistent" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! _read_php "$file" "enable" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" +} + + +ynhtest_config_write_php() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.php" + + cat << EOF > $file + "ou=users,dc=yunohost,dc=org", + ]; +?> +EOF + + ynh_write_var_in_file "$file" "foo" "bar" + test "$(_read_php "$file" "foo")" == "bar" + test "$(ynh_read_var_in_file "$file" "foo")" == "bar" + + ynh_write_var_in_file "$file" "enabled" "true" + test "$(_read_php "$file" "enabled")" == "true" + test "$(ynh_read_var_in_file "$file" "enabled")" == "true" + + ynh_write_var_in_file "$file" "title" "Foo Bar" + cat $file + test "$(_read_php "$file" "title")" == "Foo Bar" + test "$(ynh_read_var_in_file "$file" "title")" == "Foo Bar" + + ynh_write_var_in_file "$file" "theme" "super-awesome-theme" + cat $file + test "$(_read_php "$file" "theme")" == "super-awesome-theme" + test "$(ynh_read_var_in_file "$file" "theme")" == "super-awesome-theme" + + ynh_write_var_in_file "$file" "email" "sam@domain.tld" + cat $file + test "$(_read_php "$file" "email")" == "sam@domain.tld" + test "$(ynh_read_var_in_file "$file" "email")" == "sam@domain.tld" + + ynh_write_var_in_file "$file" "port" "5678" + test "$(_read_php "$file" "port")" == "5678" + test "$(ynh_read_var_in_file "$file" "port")" == "5678" + + ynh_write_var_in_file "$file" "url" "https://domain.tld/foobar" + test "$(_read_php "$file" "url")" == "https://domain.tld/foobar" + test "$(ynh_read_var_in_file "$file" "url")" == "https://domain.tld/foobar" + + ynh_write_var_in_file "$file" "ldap_base" "ou=foobar,dc=domain,dc=tld" + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=foobar,dc=domain,dc=tld" + + ! ynh_write_var_in_file "$file" "nonexistent" "foobar" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! ynh_write_var_in_file "$file" "enable" "foobar" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" + test "$(ynh_read_var_in_file "$file" "enabled")" == "true" +} diff --git a/tests/test_helpers.d/ynhtest_network.sh b/tests/test_helpers.d/ynhtest_network.sh new file mode 100644 index 000000000..c1644fc15 --- /dev/null +++ b/tests/test_helpers.d/ynhtest_network.sh @@ -0,0 +1,22 @@ +ynhtest_port_80_aint_available() { + ! ynh_port_available 80 +} + +ynhtest_port_12345_is_available() { + ynh_port_available 12345 +} + +ynhtest_port_12345_is_booked_by_other_app() { + + ynh_port_available 12345 + ynh_port_available 12346 + + mkdir -p /etc/yunohost/apps/block_port/ + echo "port: '12345'" > /etc/yunohost/apps/block_port/settings.yml + ! ynh_port_available 12345 + + echo "other_port: '12346'" > /etc/yunohost/apps/block_port/settings.yml + ! ynh_port_available 12346 + + rm -rf /etc/yunohost/apps/block_port +} diff --git a/tests/test_helpers.d/ynhtest_setup_source.sh b/tests/test_helpers.d/ynhtest_setup_source.sh new file mode 100644 index 000000000..fe61e7401 --- /dev/null +++ b/tests/test_helpers.d/ynhtest_setup_source.sh @@ -0,0 +1,80 @@ +_make_dummy_src() { + if [ ! -e $HTTPSERVER_DIR/dummy.tar.gz ] + then + pushd "$HTTPSERVER_DIR" + mkdir dummy + pushd dummy + echo "Lorem Ipsum" > index.html + echo '{"foo": "bar"}' > conf.json + mkdir assets + echo '.some.css { }' > assets/main.css + echo 'var some="js";' > assets/main.js + popd + tar -czf dummy.tar.gz dummy + popd + fi + echo "SOURCE_URL=http://127.0.0.1:$HTTPSERVER_PORT/dummy.tar.gz" + echo "SOURCE_SUM=$(sha256sum $HTTPSERVER_DIR/dummy.tar.gz | awk '{print $1}')" +} + +ynhtest_setup_source_nominal() { + final_path="$(mktemp -d -p $VAR_WWW)" + _make_dummy_src > ../conf/dummy.src + + ynh_setup_source --dest_dir="$final_path" --source_id="dummy" + + test -e "$final_path" + test -e "$final_path/index.html" +} + +ynhtest_setup_source_nominal_upgrade() { + final_path="$(mktemp -d -p $VAR_WWW)" + _make_dummy_src > ../conf/dummy.src + + ynh_setup_source --dest_dir="$final_path" --source_id="dummy" + + test "$(cat $final_path/index.html)" == "Lorem Ipsum" + + # Except index.html to get overwritten during next ynh_setup_source + echo "IEditedYou!" > $final_path/index.html + test "$(cat $final_path/index.html)" == "IEditedYou!" + + ynh_setup_source --dest_dir="$final_path" --source_id="dummy" + + test "$(cat $final_path/index.html)" == "Lorem Ipsum" +} + + +ynhtest_setup_source_with_keep() { + final_path="$(mktemp -d -p $VAR_WWW)" + _make_dummy_src > ../conf/dummy.src + + echo "IEditedYou!" > $final_path/index.html + echo "IEditedYou!" > $final_path/test.txt + + ynh_setup_source --dest_dir="$final_path" --source_id="dummy" --keep="index.html test.txt" + + test -e "$final_path" + test -e "$final_path/index.html" + test -e "$final_path/test.txt" + test "$(cat $final_path/index.html)" == "IEditedYou!" + test "$(cat $final_path/test.txt)" == "IEditedYou!" +} + +ynhtest_setup_source_with_patch() { + final_path="$(mktemp -d -p $VAR_WWW)" + _make_dummy_src > ../conf/dummy.src + + mkdir -p ../sources/patches + cat > ../sources/patches/dummy-index.html.patch << EOF +--- a/index.html ++++ b/index.html +@@ -1 +1,1 @@ +-Lorem Ipsum ++Lorem Ipsum dolor sit amet +EOF + + ynh_setup_source --dest_dir="$final_path" --source_id="dummy" + + test "$(cat $final_path/index.html)" == "Lorem Ipsum dolor sit amet" +} diff --git a/tests/test_helpers.sh b/tests/test_helpers.sh new file mode 100644 index 000000000..153ce1386 --- /dev/null +++ b/tests/test_helpers.sh @@ -0,0 +1,78 @@ +#!/bin/bash + +readonly NORMAL=$(printf '\033[0m') +readonly BOLD=$(printf '\033[1m') +readonly RED=$(printf '\033[31m') +readonly GREEN=$(printf '\033[32m') +readonly ORANGE=$(printf '\033[33m') + +function log_test() +{ + echo -n "${BOLD}$1${NORMAL} ... " +} + +function log_passed() +{ + echo "${BOLD}${GREEN}✔ Passed${NORMAL}" +} + +function log_failed() +{ + echo "${BOLD}${RED}✘ Failed${NORMAL}" +} + +function cleanup() +{ + [ -n "$HTTPSERVER" ] && kill "$HTTPSERVER" + [ -d "$HTTPSERVER_DIR" ] && rm -rf "$HTTPSERVER_DIR" + [ -d "$VAR_WWW" ] && rm -rf "$VAR_WWW" +} +trap cleanup EXIT SIGINT + +# ========================================================= + +# Dummy http server, to serve archives for ynh_setup_source +HTTPSERVER_DIR=$(mktemp -d) +HTTPSERVER_PORT=1312 +pushd "$HTTPSERVER_DIR" >/dev/null +python3 -m http.server $HTTPSERVER_PORT --bind 127.0.0.1 &>/dev/null & +HTTPSERVER="$!" +popd >/dev/null + +VAR_WWW=$(mktemp -d)/var/www +mkdir -p $VAR_WWW +# ========================================================= + +for TEST_SUITE in $(ls test_helpers.d/*) +do + source $TEST_SUITE +done + +# Hack to list all known function, keep only those starting by ynhtest_ +TESTS=$(declare -F | grep ' ynhtest_' | awk '{print $3}') + +global_result=0 + +for TEST in $TESTS +do + log_test $TEST + cd $(mktemp -d) + (mkdir conf + mkdir scripts + cd scripts + source /usr/share/yunohost/helpers + app=ynhtest + YNH_APP_ID=$app + set -eux + $TEST + ) > ./test.log 2>&1 + + if [[ $? == 0 ]] + then + set +x; log_passed; + else + set +x; echo -e "\n----------"; cat ./test.log; echo -e "----------"; log_failed; global_result=1; + fi +done + +exit $global_result diff --git a/tests/test_i18n_keys.py b/tests/test_i18n_keys.py new file mode 100644 index 000000000..103241085 --- /dev/null +++ b/tests/test_i18n_keys.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- + +import os +import re +import glob +import json +import yaml +import subprocess +import toml + +############################################################################### +# Find used keys in python code # +############################################################################### + + +def find_expected_string_keys(): + + # Try to find : + # m18n.n( "foo" + # YunohostError("foo" + # YunohostValidationError("foo" + # # i18n: foo + p1 = re.compile(r"m18n\.n\(\n*\s*[\"\'](\w+)[\"\']") + p2 = re.compile(r"YunohostError\(\n*\s*[\'\"](\w+)[\'\"]") + p3 = re.compile(r"YunohostValidationError\(\n*\s*[\'\"](\w+)[\'\"]") + p4 = re.compile(r"# i18n: [\'\"]?(\w+)[\'\"]?") + + python_files = glob.glob("src/yunohost/*.py") + python_files.extend(glob.glob("src/yunohost/utils/*.py")) + python_files.extend(glob.glob("src/yunohost/data_migrations/*.py")) + python_files.extend(glob.glob("src/yunohost/authenticators/*.py")) + python_files.extend(glob.glob("data/hooks/diagnosis/*.py")) + python_files.append("bin/yunohost") + + for python_file in python_files: + content = open(python_file).read() + for m in p1.findall(content): + if m.endswith("_"): + continue + yield m + for m in p2.findall(content): + if m.endswith("_"): + continue + yield m + for m in p3.findall(content): + if m.endswith("_"): + continue + yield m + for m in p4.findall(content): + yield m + + # For each diagnosis, try to find strings like "diagnosis_stuff_foo" (c.f. diagnosis summaries) + # Also we expect to have "diagnosis_description_" for each diagnosis + p3 = re.compile(r"[\"\'](diagnosis_[a-z]+_\w+)[\"\']") + for python_file in glob.glob("data/hooks/diagnosis/*.py"): + content = open(python_file).read() + for m in p3.findall(content): + if m.endswith("_"): + # Ignore some name fragments which are actually concatenated with other stuff.. + continue + yield m + yield "diagnosis_description_" + os.path.basename(python_file)[:-3].split("-")[ + -1 + ] + + # For each migration, expect to find "migration_description_" + for path in glob.glob("src/yunohost/data_migrations/*.py"): + if "__init__" in path: + continue + yield "migration_description_" + os.path.basename(path)[:-3] + + # For each default service, expect to find "service_description_" + for service, info in yaml.safe_load( + open("data/templates/yunohost/services.yml") + ).items(): + if info is None: + continue + yield "service_description_" + service + + # For all unit operations, expect to find "log_" + # A unit operation is created either using the @is_unit_operation decorator + # or using OperationLogger( + cmd = "grep -hr '@is_unit_operation' src/yunohost/ -A3 2>/dev/null | grep '^def' | sed -E 's@^def (\\w+)\\(.*@\\1@g'" + for funcname in ( + subprocess.check_output(cmd, shell=True).decode("utf-8").strip().split("\n") + ): + yield "log_" + funcname + + p4 = re.compile(r"OperationLogger\(\n*\s*[\"\'](\w+)[\"\']") + for python_file in python_files: + content = open(python_file).read() + for m in ("log_" + match for match in p4.findall(content)): + yield m + + # Global settings descriptions + # Will be on a line like : ("service.ssh.allow_deprecated_dsa_hostkey", {"type": "bool", ... + p5 = re.compile(r" \(\n*\s*[\"\'](\w[\w\.]+)[\"\'],") + content = open("src/yunohost/settings.py").read() + for m in ( + "global_settings_setting_" + s.replace(".", "_") for s in p5.findall(content) + ): + yield m + + # Keys for the actionmap ... + for category in yaml.safe_load(open("data/actionsmap/yunohost.yml")).values(): + if "actions" not in category.keys(): + continue + for action in category["actions"].values(): + if "arguments" not in action.keys(): + continue + for argument in action["arguments"].values(): + extra = argument.get("extra") + if not extra: + continue + if "password" in extra: + yield extra["password"] + if "ask" in extra: + yield extra["ask"] + if "comment" in extra: + yield extra["comment"] + if "pattern" in extra: + yield extra["pattern"][1] + if "help" in extra: + yield extra["help"] + + # Hardcoded expected keys ... + yield "admin_password" # Not sure that's actually used nowadays... + + for method in ["tar", "copy", "custom"]: + yield "backup_applying_method_%s" % method + yield "backup_method_%s_finished" % method + + registrars = toml.load(open("data/other/registrar_list.toml")) + supported_registrars = ["ovh", "gandi", "godaddy"] + for registrar in supported_registrars: + for key in registrars[registrar].keys(): + yield f"domain_config_{key}" + + domain_config = toml.load(open("data/other/config_domain.toml")) + for panel in domain_config.values(): + if not isinstance(panel, dict): + continue + for section in panel.values(): + if not isinstance(section, dict): + continue + for key, values in section.items(): + if not isinstance(values, dict): + continue + yield f"domain_config_{key}" + + +############################################################################### +# Load en locale json keys # +############################################################################### + + +def keys_defined_for_en(): + return json.loads(open("locales/en.json").read()).keys() + + +############################################################################### +# Compare keys used and keys defined # +############################################################################### + + +expected_string_keys = set(find_expected_string_keys()) +keys_defined = set(keys_defined_for_en()) + + +def test_undefined_i18n_keys(): + undefined_keys = expected_string_keys.difference(keys_defined) + undefined_keys = sorted(undefined_keys) + + if undefined_keys: + raise Exception( + "Those i18n keys should be defined in en.json:\n" + " - " + "\n - ".join(undefined_keys) + ) + + +def test_unused_i18n_keys(): + + unused_keys = keys_defined.difference(expected_string_keys) + unused_keys = sorted(unused_keys) + + if unused_keys: + raise Exception( + "Those i18n keys appears unused:\n" " - " + "\n - ".join(unused_keys) + ) diff --git a/tests/test_translation_format_consistency.py b/tests/test_translation_format_consistency.py new file mode 100644 index 000000000..86d1c3279 --- /dev/null +++ b/tests/test_translation_format_consistency.py @@ -0,0 +1,52 @@ +import re +import json +import glob +import pytest + +# List all locale files (except en.json being the ref) +locale_folder = "locales/" +locale_files = glob.glob(locale_folder + "*.json") +locale_files = [filename.split("/")[-1] for filename in locale_files] +locale_files.remove("en.json") + +reference = json.loads(open(locale_folder + "en.json").read()) + + +def find_inconsistencies(locale_file): + + this_locale = json.loads(open(locale_folder + locale_file).read()) + + # We iterate over all keys/string in en.json + for key, string in reference.items(): + + # Ignore check if there's no translation yet for this key + if key not in this_locale: + continue + + # Then we check that every "{stuff}" (for python's .format()) + # should also be in the translated string, otherwise the .format + # will trigger an exception! + subkeys_in_ref = set(k[0] for k in re.findall(r"{(\w+)(:\w)?}", string)) + subkeys_in_this_locale = set( + k[0] for k in re.findall(r"{(\w+)(:\w)?}", this_locale[key]) + ) + + if any(k not in subkeys_in_ref for k in subkeys_in_this_locale): + yield """\n +========================== +Format inconsistency for string {key} in {locale_file}:" +en.json -> {string} +{locale_file} -> {translated_string} +""".format( + key=key, + string=string.encode("utf-8"), + locale_file=locale_file, + translated_string=this_locale[key].encode("utf-8"), + ) + + +@pytest.mark.parametrize("locale_file", locale_files) +def test_translation_format_consistency(locale_file): + inconsistencies = list(find_inconsistencies(locale_file)) + if inconsistencies: + raise Exception("".join(inconsistencies)) diff --git a/tox.ini b/tox.ini new file mode 100644 index 000000000..e79c70fec --- /dev/null +++ b/tox.ini @@ -0,0 +1,15 @@ +[tox] +envlist = py37-{lint,invalidcode},py37-black-{run,check} + +[testenv] +skip_install=True +deps = + py37-{lint,invalidcode}: flake8 + py37-black-{run,check}: black + py37-mypy: mypy >= 0.900 +commands = + py37-lint: flake8 src doc data tests --ignore E402,E501,E203,W503 --exclude src/yunohost/vendor + py37-invalidcode: flake8 src data --exclude src/yunohost/tests,src/yunohost/vendor --select F + py37-black-check: black --check --diff src doc data tests + py37-black-run: black src doc data tests + py37-mypy: mypy --ignore-missing-import --install-types --non-interactive --follow-imports silent src/yunohost/ --exclude (acme_tiny|data_migrations)