Merge pull request #1315 from YunoHost/enh-dns-autoconf

Configure automatically the DNS records using lexicon
This commit is contained in:
Alexandre Aubin 2021-09-19 23:00:18 +02:00 committed by GitHub
commit 720ccf52a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 2431 additions and 552 deletions

View file

@ -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

View file

@ -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 #
#############################

View file

@ -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"

View file

@ -1,3 +0,0 @@
# This file is automatically generated
# during Debian's package build by the script
# data/actionsmap/yunohost_completion.py

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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={},

View file

@ -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

View file

@ -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},

View file

@ -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"

View 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

View 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
View file

@ -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
View file

@ -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/

View file

@ -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",

View file

@ -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)

View file

@ -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(

View file

@ -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
View 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()))

View file

@ -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)

View file

@ -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.")

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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:

View 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)

View 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")

View file

@ -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()

View file

@ -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
View 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)

View file

@ -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],

View file

@ -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

View file

@ -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", "")

View file

@ -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}"
###############################################################################