From 1eb059931d8be499e4102d0c9692a8cebfbd4041 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sat, 28 Aug 2021 21:02:13 +0200 Subject: [PATCH] Savagely split the dns/registrar stuff in a new dns.py --- data/hooks/diagnosis/12-dnsrecords.py | 3 +- src/yunohost/dns.py | 563 ++++++++++++++++++++++++ src/yunohost/domain.py | 587 ++------------------------ src/yunohost/dyndns.py | 5 +- 4 files changed, 614 insertions(+), 544 deletions(-) create mode 100644 src/yunohost/dns.py diff --git a/data/hooks/diagnosis/12-dnsrecords.py b/data/hooks/diagnosis/12-dnsrecords.py index 4f1f89ef7..727fd2e13 100644 --- a/data/hooks/diagnosis/12-dnsrecords.py +++ b/data/hooks/diagnosis/12-dnsrecords.py @@ -10,7 +10,8 @@ from moulinette.utils.process import check_output from yunohost.utils.dns import dig, YNH_DYNDNS_DOMAINS from yunohost.diagnosis import Diagnoser -from yunohost.domain import domain_list, _build_dns_conf, _get_maindomain +from yunohost.domain import domain_list, _get_maindomain +from yunohost.dns import _build_dns_conf SPECIAL_USE_TLDS = ["local", "localhost", "onion", "test"] diff --git a/src/yunohost/dns.py b/src/yunohost/dns.py new file mode 100644 index 000000000..590d50876 --- /dev/null +++ b/src/yunohost/dns.py @@ -0,0 +1,563 @@ +# -*- 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 + +from lexicon.client import Client +from lexicon.config import ConfigResolver + +from moulinette import m18n, Moulinette +from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import read_yaml, write_to_yaml + +from yunohost.domain import domain_list, _get_domain_settings +from yunohost.app import _parse_args_in_yunohost_format +from yunohost.utils.error import YunohostValidationError +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") + +REGISTRAR_SETTINGS_DIR = "/etc/yunohost/registrars" +REGISTRAR_LIST_PATH = "/usr/share/yunohost/other/registrar_list.yml" + + +def domain_dns_conf(domain): + """ + Generate DNS configuration for a domain + + Keyword argument: + domain -- Domain name + + """ + + if domain not in domain_list()["domains"]: + raise YunohostValidationError("domain_name_unknown", domain=domain) + + dns_conf = _build_dns_conf(domain) + + 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": + # FIXME Update this to point to our "dns push" doc + logger.info(m18n.n("domain_dns_conf_is_just_a_recommendation")) + + return result + + +def _build_dns_conf(base_domain): + """ + 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) + + subdomains = _list_subdomains_of(base_domain) + domains_settings = {domain: _get_domain_settings(domain) + for domain in [base_domain] + subdomains} + + base_dns_zone = domains_settings[base_domain].get("dns_zone") + + 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 # + + # FIXME: shouldn't the basename just be based on the dns_zone setting of this domain ? + basename = domain.replace(f"{base_dns_zone}", "").rstrip(".") or "@" + suffix = f".{basename}" if base_name != "@" else "" + + ttl = settings["ttl"] + + ########################### + # Basic ipv4/ipv6 records # + ########################### + if ipv4: + basic.append([basename, ttl, "A", ipv4]) + + if ipv6: + basic.append([basename, ttl, "AAAA", ipv6]) + # TODO + # elif include_empty_AAAA_if_no_ipv6: + # basic.append(["@", 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 # + ######### + + if ipv4: + extra.append([f"*{suffix}", ttl, "A", ipv4]) + + if ipv6: + extra.append([f"*{suffix}", ttl, "AAAA", ipv6]) + # TODO + # elif include_empty_AAAA_if_no_ipv6: + # extra.append(["*", ttl, "AAAA", None]) + + extra.append([basename, ttl, "CAA", '128 issue "letsencrypt.org"']) + + #################### + # Standard records # + #################### + + records = { + "basic": [ + {"name": name, "ttl": ttl_, "type": type_, "value": value} + for name, ttl_, type_, value in basic + ], + "xmpp": [ + {"name": name, "ttl": ttl_, "type": type_, "value": value} + for name, ttl_, type_, value in xmpp + ], + "mail": [ + {"name": name, "ttl": ttl_, "type": type_, "value": value} + for name, ttl_, type_, value in mail + ], + "extra": [ + {"name": name, "ttl": ttl_, "type": type_, "value": value} + for name, ttl_, type_, value in extra + ], + } + + ################## + # Custom records # + ################## + + # Defined by custom hooks ships in apps for example ... + + hook_results = hook_callback("custom_dns_rules", args=[base_domain]) + for hook_name, results in hook_results.items(): + # + # There can be multiple results per hook name, so results look like + # {'/some/path/to/hook1': + # { 'state': 'succeed', + # 'stdreturn': [{'type': 'SRV', + # 'name': 'stuff.foo.bar.', + # 'value': 'yoloswag', + # 'ttl': 3600}] + # }, + # '/some/path/to/hook2': + # { ... }, + # [...] + # + # Loop over the sub-results + custom_records = [ + v["stdreturn"] for v in results.values() if v and v["stdreturn"] + ] + + records[hook_name] = [] + for record_list in custom_records: + # Check that record_list is indeed a list of dict + # with the required keys + if ( + not isinstance(record_list, list) + or any(not isinstance(record, dict) for record in record_list) + or any( + key not in record + for record in record_list + for key in ["name", "ttl", "type", "value"] + ) + ): + # Display an error, mainly for app packagers trying to implement a hook + logger.warning( + "Ignored custom record from hook '%s' because the data is not a *list* of dict with keys name, ttl, type and value. Raw data : %s" + % (hook_name, record_list) + ) + continue + + records[hook_name].extend(record_list) + + return records + + +def _get_DKIM(domain): + DKIM_file = "/etc/dkim/{domain}.mail.txt".format(domain=domain) + + if not os.path.isfile(DKIM_file): + return (None, None) + + with open(DKIM_file) as f: + dkim_content = f.read() + + # Gotta manage two formats : + # + # Legacy + # ----- + # + # mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " + # "p=" ) + # + # New + # ------ + # + # mail._domainkey IN TXT ( "v=DKIM1; h=sha256; k=rsa; " + # "p=" ) + + is_legacy_format = " h=sha256; " not in dkim_content + + # Legacy DKIM format + if is_legacy_format: + dkim = re.match( + ( + r"^(?P[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+" + r'[^"]*"v=(?P[^";]+);' + r'[\s"]*k=(?P[^";]+);' + r'[\s"]*p=(?P

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

[^";]+)' + ), + dkim_content, + re.M | re.S, + ) + + if not dkim: + return (None, None) + + if is_legacy_format: + return ( + dkim.group("host"), + '"v={v}; k={k}; p={p}"'.format( + v=dkim.group("v"), k=dkim.group("k"), p=dkim.group("p") + ), + ) + else: + return ( + dkim.group("host"), + '"v={v}; h={h}; k={k}; p={p}"'.format( + v=dkim.group("v"), + h=dkim.group("h"), + k=dkim.group("k"), + p=dkim.group("p"), + ), + ) + + +def _get_registrar_settings(dns_zone): + on_disk_settings = {} + filepath = f"{REGISTRAR_SETTINGS_DIR}/{dns_zone}.yml" + if os.path.exists(filepath) and os.path.isfile(filepath): + on_disk_settings = read_yaml(filepath) or {} + + return on_disk_settings + + +def _set_registrar_settings(dns_zone, domain_registrar): + if not os.path.exists(REGISTRAR_SETTINGS_DIR): + os.mkdir(REGISTRAR_SETTINGS_DIR) + filepath = f"{REGISTRAR_SETTINGS_DIR}/{dns_zone}.yml" + write_to_yaml(filepath, domain_registrar) + + +def domain_registrar_info(domain): + + dns_zone = _get_domain_settings(domain)["dns_zone"] + registrar_info = _get_registrar_settings(dns_zone) + if not registrar_info: + raise YunohostValidationError("registrar_is_not_set", dns_zone=dns_zone) + + return registrar_info + + +def domain_registrar_catalog(registrar_name, full): + registrars = read_yaml(REGISTRAR_LIST_PATH) + + if registrar_name: + if registrar_name not in registrars.keys(): + raise YunohostValidationError("domain_registrar_unknown", registrar=registrar_name) + else: + return registrars[registrar_name] + else: + return registrars + + +def domain_registrar_set(domain, registrar, args): + + registrars = read_yaml(REGISTRAR_LIST_PATH) + if registrar not in registrars.keys(): + raise YunohostValidationError("domain_registrar_unknown"i, registrar=registrar) + + parameters = registrars[registrar] + ask_args = [] + for parameter in parameters: + ask_args.append( + { + "name": parameter, + "type": "string", + "example": "", + "default": "", + } + ) + args_dict = ( + {} if not args else dict(urllib.parse.parse_qsl(args, keep_blank_values=True)) + ) + parsed_answer_dict = _parse_args_in_yunohost_format(args_dict, ask_args) + + domain_registrar = {"name": registrar, "options": {}} + for arg_name, arg_value_and_type in parsed_answer_dict.items(): + domain_registrar["options"][arg_name] = arg_value_and_type[0] + + dns_zone = _get_domain_settings(domain)["dns_zone"] + _set_registrar_settings(dns_zone, domain_registrar) + + +def domain_push_config(domain): + """ + Send DNS records to the previously-configured registrar of the domain. + """ + # Generate the records + if domain not in domain_list()["domains"]: + raise YunohostValidationError("domain_name_unknown", domain=domain) + + dns_conf = _build_dns_conf(domain) + + dns_zone = _get_domain_settings(domain)["dns_zone"] + registrar_setting = _get_registrar_settings(dns_zone) + + if not registrar_setting: + # FIXME add locales + raise YunohostValidationError("registrar_is_not_set", domain=domain) + + # Flatten the DNS conf + flatten_dns_conf = [] + for key in dns_conf: + list_of_records = dns_conf[key] + for record in list_of_records: + # 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 + if record["type"] != "CAA": + # Add .domain.tdl to the name entry + record["name"] = "{}.{}".format(record["name"], domain) + flatten_dns_conf.append(record) + + # Construct the base data structure to use lexicon's API. + base_config = { + "provider_name": registrar_setting["name"], + "domain": domain, # domain name + } + base_config[registrar_setting["name"]] = registrar_setting["options"] + + # Get types present in the generated records + types = set() + + for record in flatten_dns_conf: + types.add(record["type"]) + + # Fetch all types present in the generated records + distant_records = {} + + for key in types: + record_config = { + "action": "list", + "type": key, + } + final_lexicon = ( + ConfigResolver() + .with_dict(dict_object=base_config) + .with_dict(dict_object=record_config) + ) + # print('final_lexicon:', final_lexicon); + client = Client(final_lexicon) + distant_records[key] = client.execute() + + for key in types: + for distant_record in distant_records[key]: + logger.debug(f"distant_record: {distant_record}") + for local_record in flatten_dns_conf: + print("local_record:", local_record) + + # Push the records + for record in flatten_dns_conf: + # For each record, first check if one record exists for the same (type, name) couple + it_exists = False + # TODO do not push if local and distant records are exactly the same ? + # is_the_same_record = False + + for distant_record in distant_records[record["type"]]: + if ( + distant_record["type"] == record["type"] + and distant_record["name"] == record["name"] + ): + it_exists = True + # see previous TODO + # if distant_record["ttl"] = ... and distant_record["name"] ... + # is_the_same_record = True + + # Finally, push the new record or update the existing one + record_config = { + "action": "update" + if it_exists + else "create", # create, list, update, delete + "type": record[ + "type" + ], # specify a type for record filtering, case sensitive in some cases. + "name": record["name"], + "content": record["value"], + # FIXME Removed TTL, because it doesn't work with Gandi. + # See https://github.com/AnalogJ/lexicon/issues/726 (similar issue) + # But I think there is another issue with Gandi. Or I'm misusing the API... + # "ttl": record["ttl"], + } + final_lexicon = ( + ConfigResolver() + .with_dict(dict_object=base_config) + .with_dict(dict_object=record_config) + ) + client = Client(final_lexicon) + print("pushed_record:", record_config, "→", end=" ") + results = client.execute() + print("results:", results) + # print("Failed" if results == False else "Ok") + + +# def domain_config_fetch(domain, key, value): diff --git a/src/yunohost/domain.py b/src/yunohost/domain.py index e3e549e20..c18d5f665 100644 --- a/src/yunohost/domain.py +++ b/src/yunohost/domain.py @@ -24,13 +24,6 @@ Manage domains """ import os -import re -import sys -import yaml -import functools - -from lexicon.client import Client -from lexicon.config import ConfigResolver from moulinette import m18n, Moulinette from moulinette.core import MoulinetteError @@ -42,20 +35,15 @@ from yunohost.app import ( _installed_apps, _get_app_settings, _get_conflicting_apps, - _parse_args_in_yunohost_format, ) from yunohost.regenconf import regen_conf, _force_clear_hashes, _process_regen_conf from yunohost.utils.error import YunohostError, YunohostValidationError -from yunohost.utils.network import get_public_ip -from yunohost.utils.dns import get_dns_zone_from_domain from yunohost.log import is_unit_operation from yunohost.hook import hook_callback logger = getActionLogger("yunohost.domain") DOMAIN_SETTINGS_DIR = "/etc/yunohost/domains" -REGISTRAR_SETTINGS_DIR = "/etc/yunohost/registrars" -REGISTRAR_LIST_PATH = "/usr/share/yunohost/other/registrar_list.yml" # Lazy dev caching to avoid re-query ldap every time we need the domain list @@ -331,55 +319,6 @@ def domain_remove(operation_logger, domain, remove_apps=False, force=False): logger.success(m18n.n("domain_deleted")) -def domain_dns_conf(domain): - """ - Generate DNS configuration for a domain - - Keyword argument: - domain -- Domain name - - """ - - if domain not in domain_list()["domains"]: - raise YunohostValidationError("domain_name_unknown", domain=domain) - - dns_conf = _build_dns_conf(domain) - - 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": - # FIXME Update this to point to our "dns push" doc - 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): """ @@ -421,32 +360,6 @@ def domain_main_domain(operation_logger, new_main_domain=None): logger.success(m18n.n("main_domain_changed")) -def domain_cert_status(domain_list, full=False): - import yunohost.certificate - - return yunohost.certificate.certificate_status(domain_list, full) - - -def domain_cert_install( - domain_list, force=False, no_checks=False, self_signed=False, staging=False -): - import yunohost.certificate - - return yunohost.certificate.certificate_install( - domain_list, force, no_checks, self_signed, staging - ) - - -def domain_cert_renew( - domain_list, force=False, no_checks=False, email=False, staging=False -): - import yunohost.certificate - - return yunohost.certificate.certificate_renew( - domain_list, force, no_checks, email, staging - ) - - def domain_url_available(domain, path): """ Check availability of a web path @@ -465,293 +378,8 @@ def _get_maindomain(): return maindomain -def _build_dns_conf(base_domain): - """ - 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) - - subdomains = _list_subdomains_of(base_domain) - domains_settings = {domain: _get_domain_settings(domain) - for domain in [base_domain] + subdomains} - - base_dns_zone = domains_settings[base_domain].get("dns_zone") - - 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 # - - # FIXME: shouldn't the basename just be based on the dns_zone setting of this domain ? - basename = domain.replace(f"{base_dns_zone}", "").rstrip(".") or "@" - suffix = f".{basename}" if base_name != "@" else "" - - ttl = settings["ttl"] - - ########################### - # Basic ipv4/ipv6 records # - ########################### - if ipv4: - basic.append([basename, ttl, "A", ipv4]) - - if ipv6: - basic.append([basename, ttl, "AAAA", ipv6]) - # TODO - # elif include_empty_AAAA_if_no_ipv6: - # basic.append(["@", 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 # - ######### - - if ipv4: - extra.append([f"*{suffix}", ttl, "A", ipv4]) - - if ipv6: - extra.append([f"*{suffix}", ttl, "AAAA", ipv6]) - # TODO - # elif include_empty_AAAA_if_no_ipv6: - # extra.append(["*", ttl, "AAAA", None]) - - extra.append([basename, ttl, "CAA", '128 issue "letsencrypt.org"']) - - #################### - # Standard records # - #################### - - records = { - "basic": [ - {"name": name, "ttl": ttl_, "type": type_, "value": value} - for name, ttl_, type_, value in basic - ], - "xmpp": [ - {"name": name, "ttl": ttl_, "type": type_, "value": value} - for name, ttl_, type_, value in xmpp - ], - "mail": [ - {"name": name, "ttl": ttl_, "type": type_, "value": value} - for name, ttl_, type_, value in mail - ], - "extra": [ - {"name": name, "ttl": ttl_, "type": type_, "value": value} - for name, ttl_, type_, value in extra - ], - } - - ################## - # Custom records # - ################## - - # Defined by custom hooks ships in apps for example ... - - hook_results = hook_callback("custom_dns_rules", args=[base_domain]) - for hook_name, results in hook_results.items(): - # - # There can be multiple results per hook name, so results look like - # {'/some/path/to/hook1': - # { 'state': 'succeed', - # 'stdreturn': [{'type': 'SRV', - # 'name': 'stuff.foo.bar.', - # 'value': 'yoloswag', - # 'ttl': 3600}] - # }, - # '/some/path/to/hook2': - # { ... }, - # [...] - # - # Loop over the sub-results - custom_records = [ - v["stdreturn"] for v in results.values() if v and v["stdreturn"] - ] - - records[hook_name] = [] - for record_list in custom_records: - # Check that record_list is indeed a list of dict - # with the required keys - if ( - not isinstance(record_list, list) - or any(not isinstance(record, dict) for record in record_list) - or any( - key not in record - for record in record_list - for key in ["name", "ttl", "type", "value"] - ) - ): - # Display an error, mainly for app packagers trying to implement a hook - logger.warning( - "Ignored custom record from hook '%s' because the data is not a *list* of dict with keys name, ttl, type and value. Raw data : %s" - % (hook_name, record_list) - ) - continue - - records[hook_name].extend(record_list) - - return records - - -def _get_DKIM(domain): - DKIM_file = "/etc/dkim/{domain}.mail.txt".format(domain=domain) - - if not os.path.isfile(DKIM_file): - return (None, None) - - with open(DKIM_file) as f: - dkim_content = f.read() - - # Gotta manage two formats : - # - # Legacy - # ----- - # - # mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " - # "p=" ) - # - # New - # ------ - # - # mail._domainkey IN TXT ( "v=DKIM1; h=sha256; k=rsa; " - # "p=" ) - - is_legacy_format = " h=sha256; " not in dkim_content - - # Legacy DKIM format - if is_legacy_format: - dkim = re.match( - ( - r"^(?P[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+" - r'[^"]*"v=(?P[^";]+);' - r'[\s"]*k=(?P[^";]+);' - r'[\s"]*p=(?P

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

[^";]+)' - ), - dkim_content, - re.M | re.S, - ) - - if not dkim: - return (None, None) - - if is_legacy_format: - return ( - dkim.group("host"), - '"v={v}; k={k}; p={p}"'.format( - v=dkim.group("v"), k=dkim.group("k"), p=dkim.group("p") - ), - ) - else: - return ( - dkim.group("host"), - '"v={v}; h={h}; k={k}; p={p}"'.format( - v=dkim.group("v"), - h=dkim.group("h"), - k=dkim.group("k"), - p=dkim.group("p"), - ), - ) - - def _default_domain_settings(domain, is_main_domain): + from yunohost.utils.dns import get_dns_zone_from_domain return { "xmpp": is_main_domain, "mail_in": True, @@ -825,10 +453,10 @@ def domain_setting(domain, key, value=None, delete=False): value = int(value) except ValueError: # TODO add locales - raise YunohostError("invalid_number", value_type=type(value)) + raise YunohostValidationError("invalid_number", value_type=type(value)) if value < 0: - raise YunohostError("pattern_positive_number", value_type=type(value)) + raise YunohostValidationError("pattern_positive_number", value_type=type(value)) # Set new value domain_settings[key] = value @@ -870,184 +498,59 @@ def _set_domain_settings(domain, domain_settings): filepath = f"{DOMAIN_SETTINGS_DIR}/{domain}.yml" write_to_yaml(filepath, domain_settings) - -def _get_registrar_settings(dns_zone): - on_disk_settings = {} - filepath = f"{REGISTRAR_SETTINGS_DIR}/{dns_zone}.yml" - if os.path.exists(filepath) and os.path.isfile(filepath): - on_disk_settings = read_yaml(filepath) or {} - - return on_disk_settings +# +# +# Stuff managed in other files +# +# -def _set_registrar_settings(dns_zone): - if not os.path.exists(REGISTRAR_SETTINGS_DIR): - os.mkdir(REGISTRAR_SETTINGS_DIR) - filepath = f"{REGISTRAR_SETTINGS_DIR}/{dns_zone}.yml" - write_to_yaml(filepath, domain_registrar) +def domain_cert_status(domain_list, full=False): + import yunohost.certificate + + return yunohost.certificate.certificate_status(domain_list, full) + + +def domain_cert_install( + domain_list, force=False, no_checks=False, self_signed=False, staging=False +): + import yunohost.certificate + + return yunohost.certificate.certificate_install( + domain_list, force, no_checks, self_signed, staging + ) + + +def domain_cert_renew( + domain_list, force=False, no_checks=False, email=False, staging=False +): + import yunohost.certificate + + return yunohost.certificate.certificate_renew( + domain_list, force, no_checks, email, staging + ) + + +def domain_dns_conf(domain): + import yunohost.dns + return yunohost.dns.domain_dns_conf(domain) def domain_registrar_info(domain): - - dns_zone = _get_domain_settings(domain)["dns_zone"] - registrar_info = _get_registrar_settings(dns_zone) - if not registrar_info: - raise YunohostError("registrar_is_not_set", dns_zone=dns_zone) - - return registrar_info + import yunohost.dns + return yunohost.dns.domain_registrar_info(domain) def domain_registrar_catalog(registrar_name, full): - registrars = read_yaml(REGISTRAR_LIST_PATH) - - if registrar_name: - if registrar_name not in registrars.keys(): - raise YunohostError("domain_registrar_unknown", registrar=registrar_name) - else: - return registrars[registrar_name] - else: - return registrars + import yunohost.dns + return yunohost.dns.domain_registrar_catalog(registrar_name, full) def domain_registrar_set(domain, registrar, args): - - registrars = read_yaml(REGISTRAR_LIST_PATH) - if registrar not in registrars.keys(): - raise YunohostError("domain_registrar_unknown"i, registrar=registrar) - - parameters = registrars[registrar] - ask_args = [] - for parameter in parameters: - ask_args.append( - { - "name": parameter, - "type": "string", - "example": "", - "default": "", - } - ) - args_dict = ( - {} if not args else dict(urllib.parse.parse_qsl(args, keep_blank_values=True)) - ) - parsed_answer_dict = _parse_args_in_yunohost_format(args_dict, ask_args) - - domain_registrar = {"name": registrar, "options": {}} - for arg_name, arg_value_and_type in parsed_answer_dict.items(): - domain_registrar["options"][arg_name] = arg_value_and_type[0] - - dns_zone = _get_domain_settings(domain)["dns_zone"] - _set_registrar_settings(dns_zone, domain_registrar) + import yunohost.dns + return yunohost.dns.domain_registrar_set(domain, registrar, args) def domain_push_config(domain): - """ - Send DNS records to the previously-configured registrar of the domain. - """ - # Generate the records - if domain not in domain_list()["domains"]: - raise YunohostValidationError("domain_name_unknown", domain=domain) - - dns_conf = _build_dns_conf(domain) - - dns_zone = _get_domain_settings(domain)["dns_zone"] - registrar_setting = _get_registrar_settings(dns_zone) - - if not registrar_setting: - # FIXME add locales - raise YunohostValidationError("registrar_is_not_set", domain=domain) - - # Flatten the DNS conf - flatten_dns_conf = [] - for key in dns_conf: - list_of_records = dns_conf[key] - for record in list_of_records: - # 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 - if record["type"] != "CAA": - # Add .domain.tdl to the name entry - record["name"] = "{}.{}".format(record["name"], domain) - flatten_dns_conf.append(record) - - # Construct the base data structure to use lexicon's API. - base_config = { - "provider_name": registrar_setting["name"], - "domain": domain, # domain name - } - base_config[registrar_setting["name"]] = registrar_setting["options"] - - # Get types present in the generated records - types = set() - - for record in flatten_dns_conf: - types.add(record["type"]) - - # Fetch all types present in the generated records - distant_records = {} - - for key in types: - record_config = { - "action": "list", - "type": key, - } - final_lexicon = ( - ConfigResolver() - .with_dict(dict_object=base_config) - .with_dict(dict_object=record_config) - ) - # print('final_lexicon:', final_lexicon); - client = Client(final_lexicon) - distant_records[key] = client.execute() - - for key in types: - for distant_record in distant_records[key]: - logger.debug(f"distant_record: {distant_record}") - for local_record in flatten_dns_conf: - print("local_record:", local_record) - - # Push the records - for record in flatten_dns_conf: - # For each record, first check if one record exists for the same (type, name) couple - it_exists = False - # TODO do not push if local and distant records are exactly the same ? - # is_the_same_record = False - - for distant_record in distant_records[record["type"]]: - if ( - distant_record["type"] == record["type"] - and distant_record["name"] == record["name"] - ): - it_exists = True - # see previous TODO - # if distant_record["ttl"] = ... and distant_record["name"] ... - # is_the_same_record = True - - # Finally, push the new record or update the existing one - record_config = { - "action": "update" - if it_exists - else "create", # create, list, update, delete - "type": record[ - "type" - ], # specify a type for record filtering, case sensitive in some cases. - "name": record["name"], - "content": record["value"], - # FIXME Removed TTL, because it doesn't work with Gandi. - # See https://github.com/AnalogJ/lexicon/issues/726 (similar issue) - # But I think there is another issue with Gandi. Or I'm misusing the API... - # "ttl": record["ttl"], - } - final_lexicon = ( - ConfigResolver() - .with_dict(dict_object=base_config) - .with_dict(dict_object=record_config) - ) - client = Client(final_lexicon) - print("pushed_record:", record_config, "→", end=" ") - results = client.execute() - print("results:", results) - # print("Failed" if results == False else "Ok") - - -# def domain_config_fetch(domain, key, value): + import yunohost.dns + return yunohost.dns.domain_push_config(domain) diff --git a/src/yunohost/dyndns.py b/src/yunohost/dyndns.py index 071e93059..9cb6dc567 100644 --- a/src/yunohost/dyndns.py +++ b/src/yunohost/dyndns.py @@ -37,7 +37,7 @@ 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.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 @@ -225,6 +225,9 @@ def dyndns_update( ipv6 -- IPv6 address to send """ + + from yunohost.dns import _build_dns_conf + # Get old ipv4/v6 old_ipv4, old_ipv6 = (None, None) # (default values)