mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
Merge pull request #1315 from YunoHost/enh-dns-autoconf
Configure automatically the DNS records using lexicon
This commit is contained in:
commit
720ccf52a6
35 changed files with 2431 additions and 552 deletions
|
@ -85,11 +85,27 @@ test-helpers:
|
|||
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:
|
||||
- cd src/yunohost
|
||||
- python3 -m pytest tests/test_apps.py
|
||||
- python3 -m pytest src/yunohost/tests/test_apps.py
|
||||
only:
|
||||
changes:
|
||||
- src/yunohost/app.py
|
||||
|
@ -97,8 +113,7 @@ test-apps:
|
|||
test-appscatalog:
|
||||
extends: .test-stage
|
||||
script:
|
||||
- cd src/yunohost
|
||||
- python3 -m pytest tests/test_appscatalog.py
|
||||
- python3 -m pytest src/yunohost/tests/test_appscatalog.py
|
||||
only:
|
||||
changes:
|
||||
- src/yunohost/app.py
|
||||
|
@ -106,8 +121,7 @@ test-appscatalog:
|
|||
test-appurl:
|
||||
extends: .test-stage
|
||||
script:
|
||||
- cd src/yunohost
|
||||
- python3 -m pytest tests/test_appurl.py
|
||||
- python3 -m pytest src/yunohost/tests/test_appurl.py
|
||||
only:
|
||||
changes:
|
||||
- src/yunohost/app.py
|
||||
|
@ -115,8 +129,7 @@ test-appurl:
|
|||
test-questions:
|
||||
extends: .test-stage
|
||||
script:
|
||||
- cd src/yunohost
|
||||
- python3 -m pytest tests/test_questions.py
|
||||
- python3 -m pytest src/yunohost/tests/test_questions.py
|
||||
only:
|
||||
changes:
|
||||
- src/yunohost/utils/config.py
|
||||
|
@ -124,8 +137,7 @@ test-questions:
|
|||
test-app-config:
|
||||
extends: .test-stage
|
||||
script:
|
||||
- cd src/yunohost
|
||||
- python3 -m pytest tests/test_app_config.py
|
||||
- python3 -m pytest src/yunohost/tests/test_app_config.py
|
||||
only:
|
||||
changes:
|
||||
- src/yunohost/app.py
|
||||
|
@ -134,8 +146,7 @@ test-app-config:
|
|||
test-changeurl:
|
||||
extends: .test-stage
|
||||
script:
|
||||
- cd src/yunohost
|
||||
- python3 -m pytest tests/test_changeurl.py
|
||||
- python3 -m pytest src/yunohost/tests/test_changeurl.py
|
||||
only:
|
||||
changes:
|
||||
- src/yunohost/app.py
|
||||
|
@ -143,8 +154,7 @@ test-changeurl:
|
|||
test-backuprestore:
|
||||
extends: .test-stage
|
||||
script:
|
||||
- cd src/yunohost
|
||||
- python3 -m pytest tests/test_backuprestore.py
|
||||
- python3 -m pytest src/yunohost/tests/test_backuprestore.py
|
||||
only:
|
||||
changes:
|
||||
- src/yunohost/backup.py
|
||||
|
@ -152,8 +162,7 @@ test-backuprestore:
|
|||
test-permission:
|
||||
extends: .test-stage
|
||||
script:
|
||||
- cd src/yunohost
|
||||
- python3 -m pytest tests/test_permission.py
|
||||
- python3 -m pytest src/yunohost/tests/test_permission.py
|
||||
only:
|
||||
changes:
|
||||
- src/yunohost/permission.py
|
||||
|
@ -161,8 +170,7 @@ test-permission:
|
|||
test-settings:
|
||||
extends: .test-stage
|
||||
script:
|
||||
- cd src/yunohost
|
||||
- python3 -m pytest tests/test_settings.py
|
||||
- python3 -m pytest src/yunohost/tests/test_settings.py
|
||||
only:
|
||||
changes:
|
||||
- src/yunohost/settings.py
|
||||
|
@ -170,8 +178,7 @@ test-settings:
|
|||
test-user-group:
|
||||
extends: .test-stage
|
||||
script:
|
||||
- cd src/yunohost
|
||||
- python3 -m pytest tests/test_user-group.py
|
||||
- python3 -m pytest src/yunohost/tests/test_user-group.py
|
||||
only:
|
||||
changes:
|
||||
- src/yunohost/user.py
|
||||
|
@ -179,8 +186,7 @@ test-user-group:
|
|||
test-regenconf:
|
||||
extends: .test-stage
|
||||
script:
|
||||
- cd src/yunohost
|
||||
- python3 -m pytest tests/test_regenconf.py
|
||||
- python3 -m pytest src/yunohost/tests/test_regenconf.py
|
||||
only:
|
||||
changes:
|
||||
- src/yunohost/regenconf.py
|
||||
|
@ -188,8 +194,7 @@ test-regenconf:
|
|||
test-service:
|
||||
extends: .test-stage
|
||||
script:
|
||||
- cd src/yunohost
|
||||
- python3 -m pytest tests/test_service.py
|
||||
- python3 -m pytest src/yunohost/tests/test_service.py
|
||||
only:
|
||||
changes:
|
||||
- src/yunohost/service.py
|
||||
|
@ -197,8 +202,7 @@ test-service:
|
|||
test-ldapauth:
|
||||
extends: .test-stage
|
||||
script:
|
||||
- cd src/yunohost
|
||||
- python3 -m pytest tests/test_ldapauth.py
|
||||
- python3 -m pytest src/yunohost/tests/test_ldapauth.py
|
||||
only:
|
||||
changes:
|
||||
- src/yunohost/authenticators/*.py
|
||||
|
|
|
@ -479,21 +479,17 @@ domain:
|
|||
help: Do not ask confirmation to remove apps
|
||||
action: store_true
|
||||
|
||||
|
||||
### domain_dns_conf()
|
||||
dns-conf:
|
||||
deprecated: true
|
||||
action_help: Generate sample DNS configuration for a domain
|
||||
api: GET /domains/<domain>/dns
|
||||
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
|
||||
|
@ -511,8 +507,8 @@ domain:
|
|||
|
||||
### certificate_status()
|
||||
cert-status:
|
||||
deprecated: true
|
||||
action_help: List status of current certificates (all by default).
|
||||
api: GET /domains/<domain_list>/cert
|
||||
arguments:
|
||||
domain_list:
|
||||
help: Domains to check
|
||||
|
@ -523,8 +519,8 @@ domain:
|
|||
|
||||
### certificate_install()
|
||||
cert-install:
|
||||
deprecated: true
|
||||
action_help: Install Let's Encrypt certificates for given domains (all by default).
|
||||
api: PUT /domains/<domain_list>/cert
|
||||
arguments:
|
||||
domain_list:
|
||||
help: Domains for which to install the certificates
|
||||
|
@ -544,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: PUT /domains/<domain_list>/cert/renew
|
||||
arguments:
|
||||
domain_list:
|
||||
help: Domains for which to renew the certificates
|
||||
|
@ -575,6 +571,141 @@ domain:
|
|||
path:
|
||||
help: The path to check (e.g. /coffee)
|
||||
|
||||
subcategories:
|
||||
|
||||
config:
|
||||
subcategory_help: Domain settings
|
||||
actions:
|
||||
|
||||
### domain_config_get()
|
||||
get:
|
||||
action_help: Display a domain configuration
|
||||
api: GET /domains/<domain>/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/<domain>/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/<domain>/dns
|
||||
- GET /domains/<domain>/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/<domain>/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/<domain_list>/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/<domain_list>/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/<domain_list>/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
|
||||
|
||||
|
||||
#############################
|
||||
# App #
|
||||
#############################
|
||||
|
|
|
@ -13,6 +13,7 @@ import yaml
|
|||
|
||||
THIS_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
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"
|
||||
|
||||
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
# This file is automatically generated
|
||||
# during Debian's package build by the script
|
||||
# data/actionsmap/yunohost_completion.py
|
|
@ -12,6 +12,7 @@ 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"
|
||||
|
|
|
@ -35,6 +35,10 @@ do_init_regen() {
|
|||
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
|
||||
|
@ -82,7 +86,7 @@ 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
|
||||
yunohost domain cert renew --email
|
||||
EOF
|
||||
|
||||
# If we subscribed to a dyndns domain, add the corresponding cron
|
||||
|
@ -179,6 +183,8 @@ do_post_regen() {
|
|||
[ ! -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)
|
||||
|
@ -187,6 +193,7 @@ do_post_regen() {
|
|||
# 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
|
||||
|
|
|
@ -65,7 +65,7 @@ do_pre_regen() {
|
|||
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)
|
||||
cert_status=$(yunohost domain cert status --json)
|
||||
|
||||
# add domain conf files
|
||||
for domain in $YNH_DOMAINS; do
|
||||
|
|
|
@ -8,11 +8,11 @@ from publicsuffix import PublicSuffixList
|
|||
|
||||
from moulinette.utils.process import check_output
|
||||
|
||||
from yunohost.utils.network import dig
|
||||
from yunohost.utils.dns import dig, YNH_DYNDNS_DOMAINS
|
||||
from yunohost.diagnosis import Diagnoser
|
||||
from yunohost.domain import domain_list, _build_dns_conf, _get_maindomain
|
||||
from yunohost.domain import domain_list, _get_maindomain
|
||||
from yunohost.dns import _build_dns_conf, _get_dns_zone_for_domain
|
||||
|
||||
YNH_DYNDNS_DOMAINS = ["nohost.me", "noho.st", "ynh.fr"]
|
||||
SPECIAL_USE_TLDS = ["local", "localhost", "onion", "test"]
|
||||
|
||||
|
||||
|
@ -26,17 +26,15 @@ class DNSRecordsDiagnoser(Diagnoser):
|
|||
|
||||
main_domain = _get_maindomain()
|
||||
|
||||
all_domains = domain_list()["domains"]
|
||||
all_domains = domain_list(exclude_subdomains=True)["domains"]
|
||||
for domain in all_domains:
|
||||
self.logger_debug("Diagnosing DNS conf for %s" % domain)
|
||||
is_subdomain = domain.split(".", 1)[1] in all_domains
|
||||
is_specialusedomain = any(
|
||||
domain.endswith("." + tld) for tld in SPECIAL_USE_TLDS
|
||||
)
|
||||
for report in self.check_domain(
|
||||
domain,
|
||||
domain == main_domain,
|
||||
is_subdomain=is_subdomain,
|
||||
is_specialusedomain=is_specialusedomain,
|
||||
):
|
||||
yield report
|
||||
|
@ -55,16 +53,16 @@ class DNSRecordsDiagnoser(Diagnoser):
|
|||
for report in self.check_expiration_date(domains_from_registrar):
|
||||
yield report
|
||||
|
||||
def check_domain(self, domain, is_main_domain, is_subdomain, is_specialusedomain):
|
||||
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"]
|
||||
# For subdomains, we only diagnosis A and AAAA records
|
||||
if is_subdomain:
|
||||
categories = ["basic"]
|
||||
|
||||
if is_specialusedomain:
|
||||
categories = []
|
||||
|
@ -82,8 +80,16 @@ class DNSRecordsDiagnoser(Diagnoser):
|
|||
results = {}
|
||||
|
||||
for r in records:
|
||||
|
||||
id_ = r["type"] + ":" + r["name"]
|
||||
r["current"] = self.get_current_record(domain, r["name"], r["type"])
|
||||
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 + "."
|
||||
|
||||
|
@ -106,7 +112,7 @@ class DNSRecordsDiagnoser(Diagnoser):
|
|||
# A bad or missing A record is critical ...
|
||||
# And so is a wrong AAAA record
|
||||
# (However, a missing AAAA record is acceptable)
|
||||
if results["A:@"] != "OK" or results["AAAA:@"] == "WRONG":
|
||||
if results[f"A:{basename}"] != "OK" or results[f"AAAA:{basename}"] == "WRONG":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
@ -139,10 +145,9 @@ class DNSRecordsDiagnoser(Diagnoser):
|
|||
|
||||
yield output
|
||||
|
||||
def get_current_record(self, domain, name, type_):
|
||||
def get_current_record(self, fqdn, type_):
|
||||
|
||||
query = "%s.%s" % (name, domain) if name != "@" else domain
|
||||
success, answers = dig(query, type_, resolvers="force_external")
|
||||
success, answers = dig(fqdn, type_, resolvers="force_external")
|
||||
|
||||
if success != "ok":
|
||||
return None
|
||||
|
@ -170,7 +175,7 @@ class DNSRecordsDiagnoser(Diagnoser):
|
|||
)
|
||||
|
||||
# For SPF, ignore parts starting by ip4: or ip6:
|
||||
if r["name"] == "@":
|
||||
if 'v=spf1' in r["value"]:
|
||||
current = {
|
||||
part
|
||||
for part in current
|
||||
|
@ -189,7 +194,6 @@ class DNSRecordsDiagnoser(Diagnoser):
|
|||
"""
|
||||
Alert if expiration date of a domain is soon
|
||||
"""
|
||||
|
||||
details = {"not_found": [], "error": [], "warning": [], "success": []}
|
||||
|
||||
for domain in domains:
|
||||
|
@ -199,6 +203,7 @@ class DNSRecordsDiagnoser(Diagnoser):
|
|||
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),
|
||||
|
@ -233,6 +238,12 @@ class DNSRecordsDiagnoser(Diagnoser):
|
|||
# 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={},
|
||||
|
|
|
@ -121,6 +121,10 @@ class WebDiagnoser(Diagnoser):
|
|||
|
||||
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
|
||||
|
|
|
@ -12,7 +12,7 @@ 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.network import dig
|
||||
from yunohost.utils.dns import dig
|
||||
|
||||
DEFAULT_DNS_BLACKLIST = "/usr/share/yunohost/other/dnsbl_list.yml"
|
||||
|
||||
|
@ -35,11 +35,11 @@ class MailDiagnoser(Diagnoser):
|
|||
# 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",
|
||||
"check_ehlo",
|
||||
"check_fcrdns",
|
||||
"check_blacklist",
|
||||
"check_queue",
|
||||
"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)
|
||||
|
@ -102,6 +102,10 @@ class MailDiagnoser(Diagnoser):
|
|||
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},
|
||||
|
|
|
@ -2,6 +2,7 @@ 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"
|
||||
|
|
55
data/other/config_domain.toml
Normal file
55
data/other/config_domain.toml
Normal file
|
@ -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
|
649
data/other/registrar_list.toml
Normal file
649
data/other/registrar_list.toml
Normal file
|
@ -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
|
||||
|
2
debian/control
vendored
2
debian/control
vendored
|
@ -14,7 +14,7 @@ Depends: ${python3:Depends}, ${misc:Depends}
|
|||
, 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-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
|
||||
|
|
2
debian/install
vendored
2
debian/install
vendored
|
@ -8,6 +8,8 @@ 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/dnsbl_list.yml /usr/share/yunohost/other/
|
||||
data/other/config_domain.toml /usr/share/yunohost/other/
|
||||
data/other/registrar_list.toml /usr/share/yunohost/other/
|
||||
data/other/ffdhe2048.pem /usr/share/yunohost/other/
|
||||
data/other/* /usr/share/yunohost/yunohost-config/moulinette/
|
||||
data/templates/* /usr/share/yunohost/templates/
|
||||
|
|
|
@ -317,6 +317,34 @@
|
|||
"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...",
|
||||
|
@ -403,6 +431,7 @@
|
|||
"iptables_unavailable": "You cannot play with iptables here. You are either in a container or your kernel does not support it",
|
||||
"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",
|
||||
|
@ -417,8 +446,10 @@
|
|||
"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_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",
|
||||
|
@ -526,7 +557,6 @@
|
|||
"pattern_password": "Must be at least 3 characters long",
|
||||
"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",
|
||||
"permission_already_allowed": "Group '{group}' already has permission '{permission}' enabled",
|
||||
"permission_already_disallowed": "Group '{group}' already has permission '{permission}' disabled",
|
||||
|
|
|
@ -234,6 +234,9 @@ def app_info(app, full=False):
|
|||
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")
|
||||
|
@ -819,6 +822,10 @@ def app_install(
|
|||
if confirm is None or force or Moulinette.interface.type == "api":
|
||||
return
|
||||
|
||||
# i18n: confirm_app_install_warning
|
||||
# i18n: confirm_app_install_danger
|
||||
# i18n: confirm_app_install_thirdparty
|
||||
|
||||
if confirm in ["danger", "thirdparty"]:
|
||||
answer = Moulinette.prompt(
|
||||
m18n.n("confirm_app_install_" + confirm, answers="Yes, I understand"),
|
||||
|
@ -1259,7 +1266,7 @@ 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"]
|
||||
|
@ -1267,9 +1274,10 @@ def app_makedefault(operation_logger, app, domain=None):
|
|||
|
||||
if domain is None:
|
||||
domain = app_domain
|
||||
operation_logger.related_to.append(("domain", domain))
|
||||
elif domain not in domain_list()["domains"]:
|
||||
raise YunohostValidationError("domain_name_unknown", domain=domain)
|
||||
|
||||
_assert_domain_exists(domain)
|
||||
|
||||
operation_logger.related_to.append(("domain", domain))
|
||||
|
||||
if "/" in app_map(raw=True)[domain]:
|
||||
raise YunohostValidationError(
|
||||
|
@ -2496,13 +2504,12 @@ def _get_conflicting_apps(domain, path, ignore_app=None):
|
|||
ignore_app -- An optional app id to ignore (c.f. the change_url usecase)
|
||||
"""
|
||||
|
||||
from yunohost.domain import domain_list
|
||||
from yunohost.domain import _assert_domain_exists
|
||||
|
||||
domain, path = _normalize_domain_path(domain, path)
|
||||
|
||||
# Abort if domain is unknown
|
||||
if domain not in domain_list()["domains"]:
|
||||
raise YunohostValidationError("domain_name_unknown", domain=domain)
|
||||
_assert_domain_exists(domain)
|
||||
|
||||
# Fetch apps map
|
||||
apps_map = app_map(raw=True)
|
||||
|
|
|
@ -44,6 +44,7 @@ from moulinette.utils.log import getActionLogger
|
|||
from moulinette.utils.filesystem import read_file, mkdir, write_to_yaml, read_yaml
|
||||
from moulinette.utils.process import check_output
|
||||
|
||||
from yunohost.domain import domain_list_cache
|
||||
from yunohost.app import (
|
||||
app_info,
|
||||
_is_installed,
|
||||
|
@ -1284,6 +1285,8 @@ class RestoreManager:
|
|||
else:
|
||||
operation_logger.success()
|
||||
|
||||
domain_list_cache = {}
|
||||
|
||||
regen_conf()
|
||||
|
||||
_tools_migrations_run_after_system_restore(
|
||||
|
|
|
@ -86,11 +86,8 @@ def certificate_status(domain_list, full=False):
|
|||
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 YunohostValidationError("domain_name_unknown", domain=domain)
|
||||
yunohost.domain._assert_domain_exists(domain)
|
||||
|
||||
certificates = {}
|
||||
|
||||
|
@ -267,9 +264,7 @@ def _certificate_install_letsencrypt(
|
|||
# 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 YunohostValidationError("domain_name_unknown", domain=domain)
|
||||
yunohost.domain._assert_domain_exists(domain)
|
||||
|
||||
# Is it self-signed?
|
||||
status = _get_status(domain)
|
||||
|
@ -368,9 +363,8 @@ def certificate_renew(
|
|||
else:
|
||||
for domain in domain_list:
|
||||
|
||||
# Is it in Yunohost dmomain list?
|
||||
if domain not in yunohost.domain.domain_list()["domains"]:
|
||||
raise YunohostValidationError("domain_name_unknown", domain=domain)
|
||||
# Is it in Yunohost domain list?
|
||||
yunohost.domain._assert_domain_exists(domain)
|
||||
|
||||
status = _get_status(domain)
|
||||
|
||||
|
|
923
src/yunohost/dns.py
Normal file
923
src/yunohost/dns.py
Normal file
|
@ -0,0 +1,923 @@
|
|||
# -*- 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=<theDKIMpublicKey>" )
|
||||
#
|
||||
# New
|
||||
# ------
|
||||
#
|
||||
# mail._domainkey IN TXT ( "v=DKIM1; h=sha256; k=rsa; "
|
||||
# "p=<theDKIMpublicKey>" )
|
||||
|
||||
is_legacy_format = " h=sha256; " not in dkim_content
|
||||
|
||||
# Legacy DKIM format
|
||||
if is_legacy_format:
|
||||
dkim = re.match(
|
||||
(
|
||||
r"^(?P<host>[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+"
|
||||
r'[^"]*"v=(?P<v>[^";]+);'
|
||||
r'[\s"]*k=(?P<k>[^";]+);'
|
||||
r'[\s"]*p=(?P<p>[^";]+)'
|
||||
),
|
||||
dkim_content,
|
||||
re.M | re.S,
|
||||
)
|
||||
else:
|
||||
dkim = re.match(
|
||||
(
|
||||
r"^(?P<host>[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+"
|
||||
r'[^"]*"v=(?P<v>[^";]+);'
|
||||
r'[\s"]*h=(?P<h>[^";]+);'
|
||||
r'[\s"]*k=(?P<k>[^";]+);'
|
||||
r'[\s"]*p=(?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()))
|
|
@ -24,13 +24,11 @@
|
|||
Manage domains
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
from moulinette import m18n, Moulinette
|
||||
from moulinette.core import MoulinetteError
|
||||
from yunohost.utils.error import YunohostError, YunohostValidationError
|
||||
from moulinette.utils.log import getActionLogger
|
||||
from moulinette.utils.filesystem import write_to_file
|
||||
from moulinette.utils.filesystem import write_to_file, read_yaml, write_to_yaml
|
||||
|
||||
from yunohost.app import (
|
||||
app_ssowatconf,
|
||||
|
@ -38,13 +36,21 @@ from yunohost.app import (
|
|||
_get_app_settings,
|
||||
_get_conflicting_apps,
|
||||
)
|
||||
from yunohost.regenconf import regen_conf, _force_clear_hashes, _process_regen_conf
|
||||
from yunohost.utils.network import get_public_ip
|
||||
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")
|
||||
|
||||
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 = {}
|
||||
|
||||
|
||||
def domain_list(exclude_subdomains=False):
|
||||
"""
|
||||
|
@ -54,6 +60,10 @@ def domain_list(exclude_subdomains=False):
|
|||
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()
|
||||
|
@ -83,7 +93,17 @@ def domain_list(exclude_subdomains=False):
|
|||
|
||||
result_list = sorted(result_list, key=cmp_domain)
|
||||
|
||||
return {"domains": result_list, "main": _get_maindomain()}
|
||||
# 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()
|
||||
|
@ -152,6 +172,9 @@ def domain_add(operation_logger, domain, dyndns=False):
|
|||
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"):
|
||||
|
@ -203,8 +226,8 @@ def domain_remove(operation_logger, domain, remove_apps=False, force=False):
|
|||
# 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 and domain not in domain_list()["domains"]:
|
||||
raise YunohostValidationError("domain_name_unknown", domain=domain)
|
||||
if not force:
|
||||
_assert_domain_exists(domain)
|
||||
|
||||
# Check domain is not the main domain
|
||||
if domain == _get_maindomain():
|
||||
|
@ -268,11 +291,18 @@ def domain_remove(operation_logger, domain, remove_apps=False, force=False):
|
|||
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 = {}
|
||||
|
||||
os.system("rm -rf /etc/yunohost/certs/%s" % domain)
|
||||
stuff_to_delete = [
|
||||
f"/etc/yunohost/certs/{domain}",
|
||||
f"/etc/yunohost/dyndns/K{domain}.+*",
|
||||
f"{DOMAIN_SETTINGS_DIR}/{domain}.yml",
|
||||
]
|
||||
|
||||
# Delete dyndns keys for this domain (if any)
|
||||
os.system("rm -rf /etc/yunohost/dyndns/K%s.+*" % domain)
|
||||
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 ...
|
||||
|
@ -303,57 +333,6 @@ def domain_remove(operation_logger, domain, remove_apps=False, force=False):
|
|||
logger.success(m18n.n("domain_deleted"))
|
||||
|
||||
|
||||
def domain_dns_conf(domain, ttl=None):
|
||||
"""
|
||||
Generate DNS configuration for a domain
|
||||
|
||||
Keyword argument:
|
||||
domain -- Domain name
|
||||
ttl -- Time to live
|
||||
|
||||
"""
|
||||
|
||||
if domain not in domain_list()["domains"]:
|
||||
raise YunohostValidationError("domain_name_unknown", domain=domain)
|
||||
|
||||
ttl = 3600 if ttl is None else ttl
|
||||
|
||||
dns_conf = _build_dns_conf(domain, ttl)
|
||||
|
||||
result = ""
|
||||
|
||||
result += "; Basic ipv4/ipv6 records"
|
||||
for record in dns_conf["basic"]:
|
||||
result += "\n{name} {ttl} IN {type} {value}".format(**record)
|
||||
|
||||
result += "\n\n"
|
||||
result += "; XMPP"
|
||||
for record in dns_conf["xmpp"]:
|
||||
result += "\n{name} {ttl} IN {type} {value}".format(**record)
|
||||
|
||||
result += "\n\n"
|
||||
result += "; Mail"
|
||||
for record in dns_conf["mail"]:
|
||||
result += "\n{name} {ttl} IN {type} {value}".format(**record)
|
||||
result += "\n\n"
|
||||
|
||||
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":
|
||||
logger.info(m18n.n("domain_dns_conf_is_just_a_recommendation"))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@is_unit_operation()
|
||||
def domain_main_domain(operation_logger, new_main_domain=None):
|
||||
"""
|
||||
|
@ -370,8 +349,7 @@ def domain_main_domain(operation_logger, new_main_domain=None):
|
|||
return {"current_main_domain": _get_maindomain()}
|
||||
|
||||
# Check domain exists
|
||||
if new_main_domain not in domain_list()["domains"]:
|
||||
raise YunohostValidationError("domain_name_unknown", domain=new_main_domain)
|
||||
_assert_domain_exists(new_main_domain)
|
||||
|
||||
operation_logger.related_to.append(("domain", new_main_domain))
|
||||
operation_logger.start()
|
||||
|
@ -379,7 +357,8 @@ def domain_main_domain(operation_logger, new_main_domain=None):
|
|||
# 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)
|
||||
|
@ -395,6 +374,111 @@ def domain_main_domain(operation_logger, new_main_domain=None):
|
|||
logger.success(m18n.n("main_domain_changed"))
|
||||
|
||||
|
||||
def domain_url_available(domain, path):
|
||||
"""
|
||||
Check availability of a web path
|
||||
|
||||
Keyword argument:
|
||||
domain -- The domain for the web path (e.g. your.domain.tld)
|
||||
path -- The path to check (e.g. /coffee)
|
||||
"""
|
||||
|
||||
return len(_get_conflicting_apps(domain, path)) == 0
|
||||
|
||||
|
||||
def _get_maindomain():
|
||||
with open("/etc/yunohost/current_host", "r") as f:
|
||||
maindomain = f.readline().rstrip()
|
||||
return maindomain
|
||||
|
||||
|
||||
def domain_config_get(domain, key='', full=False, export=False):
|
||||
"""
|
||||
Display a domain configuration
|
||||
"""
|
||||
|
||||
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:
|
||||
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"
|
||||
)
|
||||
|
||||
def _get_toml(self):
|
||||
from yunohost.dns import _get_registrar_config_section
|
||||
|
||||
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 {}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
@ -421,268 +505,15 @@ def domain_cert_renew(
|
|||
)
|
||||
|
||||
|
||||
def domain_url_available(domain, path):
|
||||
"""
|
||||
Check availability of a web path
|
||||
|
||||
Keyword argument:
|
||||
domain -- The domain for the web path (e.g. your.domain.tld)
|
||||
path -- The path to check (e.g. /coffee)
|
||||
"""
|
||||
|
||||
return len(_get_conflicting_apps(domain, path)) == 0
|
||||
def domain_dns_conf(domain):
|
||||
return domain_dns_suggest(domain)
|
||||
|
||||
|
||||
def _get_maindomain():
|
||||
with open("/etc/yunohost/current_host", "r") as f:
|
||||
maindomain = f.readline().rstrip()
|
||||
return maindomain
|
||||
def domain_dns_suggest(domain):
|
||||
import yunohost.dns
|
||||
return yunohost.dns.domain_dns_suggest(domain)
|
||||
|
||||
|
||||
def _build_dns_conf(domain, ttl=3600, 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
|
||||
|
||||
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}
|
||||
],
|
||||
}
|
||||
"""
|
||||
|
||||
ipv4 = get_public_ip()
|
||||
ipv6 = get_public_ip(6)
|
||||
|
||||
###########################
|
||||
# Basic ipv4/ipv6 records #
|
||||
###########################
|
||||
|
||||
basic = []
|
||||
if ipv4:
|
||||
basic.append(["@", ttl, "A", ipv4])
|
||||
|
||||
if ipv6:
|
||||
basic.append(["@", ttl, "AAAA", ipv6])
|
||||
elif include_empty_AAAA_if_no_ipv6:
|
||||
basic.append(["@", ttl, "AAAA", None])
|
||||
|
||||
#########
|
||||
# Email #
|
||||
#########
|
||||
|
||||
mail = [
|
||||
["@", ttl, "MX", "10 %s." % domain],
|
||||
["@", ttl, "TXT", '"v=spf1 a mx -all"'],
|
||||
]
|
||||
|
||||
# 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"'],
|
||||
]
|
||||
|
||||
########
|
||||
# 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", "@"],
|
||||
["xmpp-upload", ttl, "CNAME", "@"],
|
||||
]
|
||||
|
||||
#########
|
||||
# Extra #
|
||||
#########
|
||||
|
||||
extra = []
|
||||
|
||||
if ipv4:
|
||||
extra.append(["*", ttl, "A", ipv4])
|
||||
|
||||
if ipv6:
|
||||
extra.append(["*", ttl, "AAAA", ipv6])
|
||||
elif include_empty_AAAA_if_no_ipv6:
|
||||
extra.append(["*", ttl, "AAAA", None])
|
||||
|
||||
extra.append(["@", 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=[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=<theDKIMpublicKey>" )
|
||||
#
|
||||
# New
|
||||
# ------
|
||||
#
|
||||
# mail._domainkey IN TXT ( "v=DKIM1; h=sha256; k=rsa; "
|
||||
# "p=<theDKIMpublicKey>" )
|
||||
|
||||
is_legacy_format = " h=sha256; " not in dkim_content
|
||||
|
||||
# Legacy DKIM format
|
||||
if is_legacy_format:
|
||||
dkim = re.match(
|
||||
(
|
||||
r"^(?P<host>[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+"
|
||||
r'[^"]*"v=(?P<v>[^";]+);'
|
||||
r'[\s"]*k=(?P<k>[^";]+);'
|
||||
r'[\s"]*p=(?P<p>[^";]+)'
|
||||
),
|
||||
dkim_content,
|
||||
re.M | re.S,
|
||||
)
|
||||
else:
|
||||
dkim = re.match(
|
||||
(
|
||||
r"^(?P<host>[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+"
|
||||
r'[^"]*"v=(?P<v>[^";]+);'
|
||||
r'[\s"]*h=(?P<h>[^";]+);'
|
||||
r'[\s"]*k=(?P<k>[^";]+);'
|
||||
r'[\s"]*p=(?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 domain_dns_push(domain, dry_run, force, purge):
|
||||
import yunohost.dns
|
||||
return yunohost.dns.domain_dns_push(domain, dry_run, force, purge)
|
||||
|
|
|
@ -37,8 +37,9 @@ from moulinette.utils.filesystem import write_to_file, read_file
|
|||
from moulinette.utils.network import download_json
|
||||
|
||||
from yunohost.utils.error import YunohostError, YunohostValidationError
|
||||
from yunohost.domain import _get_maindomain, _build_dns_conf
|
||||
from yunohost.utils.network import get_public_ip, dig
|
||||
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
|
||||
|
||||
|
@ -224,9 +225,8 @@ def dyndns_update(
|
|||
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:
|
||||
|
@ -307,6 +307,10 @@ def dyndns_update(
|
|||
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 (not force and not dry_run) and (old_ipv4 == ipv4 and old_ipv6 == ipv6):
|
||||
logger.info("No updated needed.")
|
||||
|
|
|
@ -864,11 +864,9 @@ def _validate_and_sanitize_permission_url(url, app_base_path, app):
|
|||
re:^/api/.*|/scripts/api.js$
|
||||
"""
|
||||
|
||||
from yunohost.domain import domain_list
|
||||
from yunohost.domain import _assert_domain_exists
|
||||
from yunohost.app import _assert_no_conflicting_apps
|
||||
|
||||
domains = domain_list()["domains"]
|
||||
|
||||
#
|
||||
# Regexes
|
||||
#
|
||||
|
@ -900,8 +898,8 @@ def _validate_and_sanitize_permission_url(url, app_base_path, app):
|
|||
domain, path = url[3:].split("/", 1)
|
||||
path = "/" + path
|
||||
|
||||
if domain.replace("%", "").replace("\\", "") not in domains:
|
||||
raise YunohostValidationError("domain_name_unknown", domain=domain)
|
||||
domain_with_no_regex = domain.replace("%", "").replace("\\", "")
|
||||
_assert_domain_exists(domain_with_no_regex)
|
||||
|
||||
validate_regex(path)
|
||||
|
||||
|
@ -935,8 +933,7 @@ def _validate_and_sanitize_permission_url(url, app_base_path, app):
|
|||
domain, path = split_domain_path(url)
|
||||
sanitized_url = domain + path
|
||||
|
||||
if domain not in domains:
|
||||
raise YunohostValidationError("domain_name_unknown", domain=domain)
|
||||
_assert_domain_exists(domain)
|
||||
|
||||
_assert_no_conflicting_apps(domain, path, ignore_app=app)
|
||||
|
||||
|
|
|
@ -18,6 +18,9 @@ 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
|
||||
|
||||
|
@ -30,9 +33,11 @@ def is_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", "on", "yes", "false", "off", "no"]:
|
||||
return True, str(value).lower() in ["true", "on", "yes"]
|
||||
if str(value).lower() in TRUE + FALSE:
|
||||
return True, str(value).lower() in TRUE
|
||||
else:
|
||||
return False, None
|
||||
else:
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
import os
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
import moulinette
|
||||
from moulinette import m18n, Moulinette
|
||||
from yunohost.utils.error import YunohostError
|
||||
from contextlib import contextmanager
|
||||
|
||||
sys.path.append("..")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def clone_test_app(request):
|
||||
|
@ -77,6 +74,7 @@ moulinette.core.Moulinette18n.n = new_m18nn
|
|||
|
||||
def pytest_cmdline_main(config):
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/usr/lib/moulinette/")
|
||||
import yunohost
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import pytest
|
|||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from mock import patch
|
||||
|
||||
from .conftest import message, raiseYunohostError, get_test_apps_dir
|
||||
|
||||
|
@ -77,7 +78,8 @@ def setup_function(function):
|
|||
if "with_permission_app_installed" in markers:
|
||||
assert not app_is_installed("permissions_app")
|
||||
user_create("alice", "Alice", "White", maindomain, "test123Ynh")
|
||||
install_app("permissions_app_ynh", "/urlpermissionapp" "&admin=alice")
|
||||
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:
|
||||
|
|
71
src/yunohost/tests/test_dns.py
Normal file
71
src/yunohost/tests/test_dns.py
Normal file
|
@ -0,0 +1,71 @@
|
|||
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.test") == "yolo.test"
|
||||
assert _get_dns_zone_for_domain("foo.yolo.test") == "test"
|
||||
|
||||
|
||||
# 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.test")["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)
|
122
src/yunohost/tests/test_domains.py
Normal file
122
src/yunohost/tests/test_domains.py
Normal file
|
@ -0,0 +1,122 @@
|
|||
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")
|
|
@ -142,7 +142,7 @@ def user_create(
|
|||
from_import=False,
|
||||
):
|
||||
|
||||
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
|
||||
|
@ -176,8 +176,7 @@ def user_create(
|
|||
domain = maindomain
|
||||
|
||||
# Check that the domain exists
|
||||
if domain not in domain_list()["domains"]:
|
||||
raise YunohostValidationError("domain_name_unknown", domain=domain)
|
||||
_assert_domain_exists(domain)
|
||||
|
||||
mail = username + "@" + domain
|
||||
ldap = _get_ldap_interface()
|
||||
|
|
|
@ -53,8 +53,8 @@ class ConfigPanel:
|
|||
self.values = {}
|
||||
self.new_values = {}
|
||||
|
||||
def get(self, key="", mode="classic"):
|
||||
self.filter_key = key or ""
|
||||
def get(self, key='', mode='classic'):
|
||||
self.filter_key = key or ''
|
||||
|
||||
# Read config panel toml
|
||||
self._get_config_panel()
|
||||
|
@ -66,10 +66,6 @@ class ConfigPanel:
|
|||
self._load_current_values()
|
||||
self._hydrate()
|
||||
|
||||
# Format result in full mode
|
||||
if mode == "full":
|
||||
return self.config
|
||||
|
||||
# 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]
|
||||
|
@ -82,13 +78,19 @@ class ConfigPanel:
|
|||
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:
|
||||
if "ask" in option:
|
||||
result[key] = {"ask": _value_for_locale(option["ask"])}
|
||||
elif "i18n" in self.config:
|
||||
result[key] = {
|
||||
"ask": m18n.n(self.config["i18n"] + "_" + option["id"])
|
||||
}
|
||||
result[key] = {"ask": ask}
|
||||
if "current_value" in option:
|
||||
question_class = ARGUMENTS_TYPE_PARSERS[
|
||||
option.get("type", "string")
|
||||
|
@ -97,7 +99,10 @@ class ConfigPanel:
|
|||
option["current_value"], option
|
||||
)
|
||||
|
||||
return result
|
||||
if mode == "full":
|
||||
return self.config
|
||||
else:
|
||||
return result
|
||||
|
||||
def set(
|
||||
self, key=None, value=None, args=None, args_file=None, operation_logger=None
|
||||
|
@ -179,7 +184,9 @@ class ConfigPanel:
|
|||
)
|
||||
|
||||
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
|
||||
|
@ -348,6 +355,11 @@ class ConfigPanel:
|
|||
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:
|
||||
|
@ -409,7 +421,7 @@ class ConfigPanel:
|
|||
}
|
||||
|
||||
# Save the settings to the .yaml file
|
||||
write_to_yaml(self.save_path, self.new_values)
|
||||
write_to_yaml(self.save_path, values_to_save)
|
||||
|
||||
def _reload_services(self):
|
||||
|
||||
|
@ -736,6 +748,7 @@ class BooleanQuestion(Question):
|
|||
|
||||
@staticmethod
|
||||
def humanize(value, option={}):
|
||||
|
||||
yes = option.get("yes", 1)
|
||||
no = option.get("no", 0)
|
||||
value = str(value).lower()
|
||||
|
@ -1010,6 +1023,7 @@ class FileQuestion(Question):
|
|||
while os.path.exists(file_path):
|
||||
file_path = os.path.normpath(upload_dir + "/" + filename + (".%d" % i))
|
||||
i += 1
|
||||
|
||||
content = self.value["content"]
|
||||
|
||||
write_to_file(file_path, b64decode(content), file_mode="wb")
|
||||
|
|
94
src/yunohost/utils/dns.py
Normal file
94
src/yunohost/utils/dns.py
Normal file
|
@ -0,0 +1,94 @@
|
|||
# -*- 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 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_ = []
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -101,7 +101,8 @@ class LDAPInterface:
|
|||
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'"
|
||||
"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
|
||||
|
@ -289,7 +290,7 @@ class LDAPInterface:
|
|||
attr_found[0],
|
||||
attr_found[1],
|
||||
)
|
||||
raise MoulinetteError(
|
||||
raise YunohostError(
|
||||
"ldap_attribute_already_exists",
|
||||
attribute=attr_found[0],
|
||||
value=attr_found[1],
|
||||
|
|
|
@ -22,7 +22,6 @@ import os
|
|||
import re
|
||||
import logging
|
||||
import time
|
||||
import dns.resolver
|
||||
|
||||
from moulinette.utils.filesystem import read_file, write_to_file
|
||||
from moulinette.utils.network import download_text
|
||||
|
@ -124,76 +123,6 @@ def get_gateway():
|
|||
return addr.popitem()[1] if len(addr) == 1 else None
|
||||
|
||||
|
||||
# Lazy dev caching to avoid re-reading the file multiple time when calling
|
||||
# dig() often during same yunohost operation
|
||||
external_resolvers_ = []
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _extract_inet(string, skip_netmask=False, skip_loopback=True):
|
||||
"""
|
||||
Extract IP addresses (v4 and/or v6) from a string limited to one
|
||||
|
|
|
@ -111,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", "")
|
||||
|
|
|
@ -6,14 +6,7 @@ import glob
|
|||
import json
|
||||
import yaml
|
||||
import subprocess
|
||||
|
||||
ignore = [
|
||||
"password_too_simple_",
|
||||
"password_listed",
|
||||
"backup_method_",
|
||||
"backup_applying_method_",
|
||||
"confirm_app_install_",
|
||||
]
|
||||
import toml
|
||||
|
||||
###############################################################################
|
||||
# Find used keys in python code #
|
||||
|
@ -137,33 +130,23 @@ def find_expected_string_keys():
|
|||
yield "backup_applying_method_%s" % method
|
||||
yield "backup_method_%s_finished" % method
|
||||
|
||||
for level in ["danger", "thirdparty", "warning"]:
|
||||
yield "confirm_app_install_%s" % level
|
||||
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}"
|
||||
|
||||
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
|
||||
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}"
|
||||
|
||||
|
||||
###############################################################################
|
||||
|
|
Loading…
Add table
Reference in a new issue