mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
Savagely split the dns/registrar stuff in a new dns.py
This commit is contained in:
parent
0ec1516f6e
commit
1eb059931d
4 changed files with 614 additions and 544 deletions
|
@ -10,7 +10,8 @@ from moulinette.utils.process import check_output
|
||||||
|
|
||||||
from yunohost.utils.dns import dig, YNH_DYNDNS_DOMAINS
|
from yunohost.utils.dns import dig, YNH_DYNDNS_DOMAINS
|
||||||
from yunohost.diagnosis import Diagnoser
|
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"]
|
SPECIAL_USE_TLDS = ["local", "localhost", "onion", "test"]
|
||||||
|
|
||||||
|
|
563
src/yunohost/dns.py
Normal file
563
src/yunohost/dns.py
Normal file
|
@ -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=<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_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):
|
|
@ -24,13 +24,6 @@
|
||||||
Manage domains
|
Manage domains
|
||||||
"""
|
"""
|
||||||
import os
|
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 import m18n, Moulinette
|
||||||
from moulinette.core import MoulinetteError
|
from moulinette.core import MoulinetteError
|
||||||
|
@ -42,20 +35,15 @@ from yunohost.app import (
|
||||||
_installed_apps,
|
_installed_apps,
|
||||||
_get_app_settings,
|
_get_app_settings,
|
||||||
_get_conflicting_apps,
|
_get_conflicting_apps,
|
||||||
_parse_args_in_yunohost_format,
|
|
||||||
)
|
)
|
||||||
from yunohost.regenconf import regen_conf, _force_clear_hashes, _process_regen_conf
|
from yunohost.regenconf import regen_conf, _force_clear_hashes, _process_regen_conf
|
||||||
from yunohost.utils.error import YunohostError, YunohostValidationError
|
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.log import is_unit_operation
|
||||||
from yunohost.hook import hook_callback
|
from yunohost.hook import hook_callback
|
||||||
|
|
||||||
logger = getActionLogger("yunohost.domain")
|
logger = getActionLogger("yunohost.domain")
|
||||||
|
|
||||||
DOMAIN_SETTINGS_DIR = "/etc/yunohost/domains"
|
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
|
# 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"))
|
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()
|
@is_unit_operation()
|
||||||
def domain_main_domain(operation_logger, new_main_domain=None):
|
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"))
|
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):
|
def domain_url_available(domain, path):
|
||||||
"""
|
"""
|
||||||
Check availability of a web path
|
Check availability of a web path
|
||||||
|
@ -465,293 +378,8 @@ def _get_maindomain():
|
||||||
return 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=<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 _default_domain_settings(domain, is_main_domain):
|
def _default_domain_settings(domain, is_main_domain):
|
||||||
|
from yunohost.utils.dns import get_dns_zone_from_domain
|
||||||
return {
|
return {
|
||||||
"xmpp": is_main_domain,
|
"xmpp": is_main_domain,
|
||||||
"mail_in": True,
|
"mail_in": True,
|
||||||
|
@ -825,10 +453,10 @@ def domain_setting(domain, key, value=None, delete=False):
|
||||||
value = int(value)
|
value = int(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# TODO add locales
|
# TODO add locales
|
||||||
raise YunohostError("invalid_number", value_type=type(value))
|
raise YunohostValidationError("invalid_number", value_type=type(value))
|
||||||
|
|
||||||
if value < 0:
|
if value < 0:
|
||||||
raise YunohostError("pattern_positive_number", value_type=type(value))
|
raise YunohostValidationError("pattern_positive_number", value_type=type(value))
|
||||||
|
|
||||||
# Set new value
|
# Set new value
|
||||||
domain_settings[key] = value
|
domain_settings[key] = value
|
||||||
|
@ -870,184 +498,59 @@ def _set_domain_settings(domain, domain_settings):
|
||||||
filepath = f"{DOMAIN_SETTINGS_DIR}/{domain}.yml"
|
filepath = f"{DOMAIN_SETTINGS_DIR}/{domain}.yml"
|
||||||
write_to_yaml(filepath, domain_settings)
|
write_to_yaml(filepath, domain_settings)
|
||||||
|
|
||||||
|
#
|
||||||
def _get_registrar_settings(dns_zone):
|
#
|
||||||
on_disk_settings = {}
|
# Stuff managed in other files
|
||||||
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):
|
def domain_cert_status(domain_list, full=False):
|
||||||
if not os.path.exists(REGISTRAR_SETTINGS_DIR):
|
import yunohost.certificate
|
||||||
os.mkdir(REGISTRAR_SETTINGS_DIR)
|
|
||||||
filepath = f"{REGISTRAR_SETTINGS_DIR}/{dns_zone}.yml"
|
return yunohost.certificate.certificate_status(domain_list, full)
|
||||||
write_to_yaml(filepath, domain_registrar)
|
|
||||||
|
|
||||||
|
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):
|
def domain_registrar_info(domain):
|
||||||
|
import yunohost.dns
|
||||||
dns_zone = _get_domain_settings(domain)["dns_zone"]
|
return yunohost.dns.domain_registrar_info(domain)
|
||||||
registrar_info = _get_registrar_settings(dns_zone)
|
|
||||||
if not registrar_info:
|
|
||||||
raise YunohostError("registrar_is_not_set", dns_zone=dns_zone)
|
|
||||||
|
|
||||||
return registrar_info
|
|
||||||
|
|
||||||
|
|
||||||
def domain_registrar_catalog(registrar_name, full):
|
def domain_registrar_catalog(registrar_name, full):
|
||||||
registrars = read_yaml(REGISTRAR_LIST_PATH)
|
import yunohost.dns
|
||||||
|
return yunohost.dns.domain_registrar_catalog(registrar_name, full)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def domain_registrar_set(domain, registrar, args):
|
def domain_registrar_set(domain, registrar, args):
|
||||||
|
import yunohost.dns
|
||||||
registrars = read_yaml(REGISTRAR_LIST_PATH)
|
return yunohost.dns.domain_registrar_set(domain, registrar, args)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def domain_push_config(domain):
|
def domain_push_config(domain):
|
||||||
"""
|
import yunohost.dns
|
||||||
Send DNS records to the previously-configured registrar of the domain.
|
return yunohost.dns.domain_push_config(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):
|
|
||||||
|
|
|
@ -37,7 +37,7 @@ from moulinette.utils.filesystem import write_to_file, read_file
|
||||||
from moulinette.utils.network import download_json
|
from moulinette.utils.network import download_json
|
||||||
|
|
||||||
from yunohost.utils.error import YunohostError, YunohostValidationError
|
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.network import get_public_ip
|
||||||
from yunohost.utils.dns import dig
|
from yunohost.utils.dns import dig
|
||||||
from yunohost.log import is_unit_operation
|
from yunohost.log import is_unit_operation
|
||||||
|
@ -225,6 +225,9 @@ def dyndns_update(
|
||||||
ipv6 -- IPv6 address to send
|
ipv6 -- IPv6 address to send
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from yunohost.dns import _build_dns_conf
|
||||||
|
|
||||||
# Get old ipv4/v6
|
# Get old ipv4/v6
|
||||||
|
|
||||||
old_ipv4, old_ipv6 = (None, None) # (default values)
|
old_ipv4, old_ipv6 = (None, None) # (default values)
|
||||||
|
|
Loading…
Add table
Reference in a new issue