mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
647 lines
21 KiB
Python
647 lines
21 KiB
Python
# -*- 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
|
|
from typing import Dict, Any
|
|
|
|
from moulinette import m18n, Moulinette
|
|
from moulinette.core import MoulinetteError
|
|
from moulinette.utils.log import getActionLogger
|
|
from moulinette.utils.filesystem import write_to_file, read_yaml, write_to_yaml, rm
|
|
|
|
from yunohost.app import (
|
|
app_ssowatconf,
|
|
_installed_apps,
|
|
_get_app_settings,
|
|
_get_conflicting_apps,
|
|
)
|
|
from yunohost.regenconf import regen_conf, _force_clear_hashes, _process_regen_conf
|
|
from yunohost.utils.config import ConfigPanel, Question
|
|
from yunohost.utils.error import YunohostError, YunohostValidationError
|
|
from yunohost.utils.dns import is_yunohost_dyndns_domain
|
|
from yunohost.log import is_unit_operation
|
|
|
|
logger = getActionLogger("yunohost.domain")
|
|
|
|
DOMAIN_CONFIG_PATH = "/usr/share/yunohost/config_domain.toml"
|
|
DOMAIN_SETTINGS_DIR = "/etc/yunohost/domains"
|
|
|
|
# Lazy dev caching to avoid re-query ldap every time we need the domain list
|
|
domain_list_cache: Dict[str, Any] = {}
|
|
|
|
|
|
def domain_list(exclude_subdomains=False, auto_push=False, full=False):
|
|
"""
|
|
List domains
|
|
|
|
Keyword argument:
|
|
exclude_subdomains -- Filter out domains that are subdomains of other declared domains
|
|
|
|
"""
|
|
global domain_list_cache
|
|
if not (exclude_subdomains or full) and domain_list_cache:
|
|
return domain_list_cache
|
|
|
|
from yunohost.utils.ldap import _get_ldap_interface
|
|
|
|
ldap = _get_ldap_interface()
|
|
result = [
|
|
entry["virtualdomain"][0]
|
|
for entry in ldap.search("ou=domains", "virtualdomain=*", ["virtualdomain"])
|
|
]
|
|
|
|
result_list = []
|
|
for domain in result:
|
|
if exclude_subdomains:
|
|
parent_domain = domain.split(".", 1)[1]
|
|
if parent_domain in result:
|
|
continue
|
|
if auto_push and not domain_config_get(domain, key="dns.zone.autopush"):
|
|
continue
|
|
|
|
result_list.append(domain)
|
|
|
|
def cmp_domain(domain):
|
|
# Keep the main part of the domain and the extension together
|
|
# eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
|
|
domain = domain.split(".")
|
|
domain[-1] = domain[-2] + domain.pop()
|
|
domain = list(reversed(domain))
|
|
return domain
|
|
|
|
result_list = sorted(result_list, key=cmp_domain)
|
|
|
|
if full:
|
|
for i in range(len(result_list)):
|
|
domain = result_list[i]
|
|
dyndns = is_yunohost_dyndns_domain(domain) and len(domain.split(".")) == 3
|
|
result_list[i] = {'name': domain, 'isdyndns': dyndns}
|
|
|
|
result = {"domains": result_list, "main": _get_maindomain()}
|
|
|
|
# Cache answer only if not using exclude_subdomains or full
|
|
if not (full or exclude_subdomains):
|
|
domain_list_cache = result
|
|
|
|
return result
|
|
|
|
|
|
def _assert_domain_exists(domain):
|
|
if domain not in domain_list()["domains"]:
|
|
raise YunohostValidationError("domain_unknown", domain=domain)
|
|
|
|
|
|
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 _get_parent_domain_of(domain):
|
|
|
|
_assert_domain_exists(domain)
|
|
|
|
if "." not in domain:
|
|
return domain
|
|
|
|
parent_domain = domain.split(".", 1)[-1]
|
|
if parent_domain not in domain_list()["domains"]:
|
|
return domain # Domain is its own parent
|
|
|
|
else:
|
|
return _get_parent_domain_of(parent_domain)
|
|
|
|
|
|
@is_unit_operation(exclude=["dyndns_password_recovery"])
|
|
def domain_add(operation_logger, domain, dyndns_password_recovery=None, no_subscribe=False):
|
|
"""
|
|
Create a custom domain
|
|
|
|
Keyword argument:
|
|
domain -- Domain name to add
|
|
dyndns -- Subscribe to DynDNS
|
|
dyndns_password_recovery -- Password used to later unsubscribe from DynDNS
|
|
no_unsubscribe -- If we want to just add the DynDNS domain to the list, without subscribing
|
|
"""
|
|
from yunohost.hook import hook_callback
|
|
from yunohost.app import app_ssowatconf
|
|
from yunohost.utils.ldap import _get_ldap_interface
|
|
from yunohost.utils.password import assert_password_is_strong_enough
|
|
from yunohost.certificate import _certificate_install_selfsigned
|
|
|
|
if dyndns_password_recovery != 0 and dyndns_password_recovery is not None:
|
|
operation_logger.data_to_redact.append(dyndns_password_recovery)
|
|
|
|
if domain.startswith("xmpp-upload."):
|
|
raise YunohostValidationError("domain_cannot_add_xmpp_upload")
|
|
|
|
ldap = _get_ldap_interface()
|
|
|
|
try:
|
|
ldap.validate_uniqueness({"virtualdomain": domain})
|
|
except MoulinetteError:
|
|
raise YunohostValidationError("domain_exists")
|
|
|
|
# Lower domain to avoid some edge cases issues
|
|
# See: https://forum.yunohost.org/t/invalid-domain-causes-diagnosis-web-to-fail-fr-on-demand/11765
|
|
domain = domain.lower()
|
|
|
|
# Non-latin characters (e.g. café.com => xn--caf-dma.com)
|
|
domain = domain.encode("idna").decode("utf-8")
|
|
|
|
# Detect if this is a DynDNS domain ( and not a subdomain of a DynDNS domain )
|
|
dyndns = is_yunohost_dyndns_domain(domain) and len(domain.split(".")) == 3
|
|
if dyndns:
|
|
if not no_subscribe and not dyndns_password_recovery:
|
|
if Moulinette.interface.type == "api":
|
|
raise YunohostValidationError("domain_dyndns_missing_password")
|
|
else:
|
|
dyndns_password_recovery = Moulinette.prompt(
|
|
m18n.n("ask_dyndns_recovery_password"), is_password=True, confirm=True
|
|
)
|
|
|
|
# Ensure sufficiently complex password
|
|
assert_password_is_strong_enough("admin", dyndns_password_recovery)
|
|
|
|
if ((dyndns_password_recovery is None) == (no_subscribe is False)):
|
|
raise YunohostValidationError("domain_dyndns_instruction_unclear")
|
|
|
|
from yunohost.dyndns import is_subscribing_allowed
|
|
|
|
# Do not allow to subscribe to multiple dyndns domains...
|
|
if not is_subscribing_allowed():
|
|
raise YunohostValidationError("domain_dyndns_already_subscribed")
|
|
|
|
operation_logger.start()
|
|
if not dyndns and (dyndns_password_recovery is not None or no_subscribe):
|
|
logger.warning("This domain is not a DynDNS one, no need for the --dyndns-password-recovery or --no-subscribe option")
|
|
|
|
if dyndns and not no_subscribe:
|
|
# Actually subscribe
|
|
domain_dyndns_subscribe(domain=domain, password=dyndns_password_recovery)
|
|
|
|
_certificate_install_selfsigned([domain], True)
|
|
|
|
try:
|
|
attr_dict = {
|
|
"objectClass": ["mailDomain", "top"],
|
|
"virtualdomain": domain,
|
|
}
|
|
|
|
try:
|
|
ldap.add(f"virtualdomain={domain},ou=domains", 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"):
|
|
# Sometime we have weird issues with the regenconf where some files
|
|
# appears as manually modified even though they weren't touched ...
|
|
# There are a few ideas why this happens (like backup/restore nginx
|
|
# conf ... which we shouldnt do ...). This in turns creates funky
|
|
# situation where the regenconf may refuse to re-create the conf
|
|
# (when re-creating a domain..)
|
|
# So here we force-clear the has out of the regenconf if it exists.
|
|
# This is a pretty ad hoc solution and only applied to nginx
|
|
# because it's one of the major service, but in the long term we
|
|
# should identify the root of this bug...
|
|
_force_clear_hashes([f"/etc/nginx/conf.d/{domain}.conf"])
|
|
regen_conf(
|
|
names=["nginx", "metronome", "dnsmasq", "postfix", "rspamd", "mdns"]
|
|
)
|
|
app_ssowatconf()
|
|
|
|
except Exception as e:
|
|
# Force domain removal silently
|
|
try:
|
|
domain_remove(domain, force=True)
|
|
except Exception:
|
|
pass
|
|
raise e
|
|
|
|
hook_callback("post_domain_add", args=[domain])
|
|
|
|
logger.success(m18n.n("domain_created"))
|
|
|
|
|
|
@is_unit_operation(exclude=["dyndns_password_recovery"])
|
|
def domain_remove(operation_logger, domain, remove_apps=False, force=False, dyndns_password_recovery=None, no_unsubscribe=False):
|
|
"""
|
|
Delete domains
|
|
|
|
Keyword argument:
|
|
domain -- Domain to delete
|
|
remove_apps -- Remove applications installed on the domain
|
|
force -- Force the domain removal and don't not ask confirmation to
|
|
remove apps if remove_apps is specified
|
|
dyndns_password_recovery -- Recovery password used at the creation of the DynDNS domain
|
|
no_unsubscribe -- If we just remove the DynDNS domain, without unsubscribing
|
|
"""
|
|
from yunohost.hook import hook_callback
|
|
from yunohost.app import app_ssowatconf, app_info, app_remove
|
|
from yunohost.utils.ldap import _get_ldap_interface
|
|
|
|
if dyndns_password_recovery != 0 and dyndns_password_recovery is not None:
|
|
operation_logger.data_to_redact.append(dyndns_password_recovery)
|
|
|
|
# the 'force' here is related to the exception happening in domain_add ...
|
|
# we don't want to check the domain exists because the ldap add may have
|
|
# failed
|
|
if not force:
|
|
_assert_domain_exists(domain)
|
|
|
|
# Check domain is not the main domain
|
|
if domain == _get_maindomain():
|
|
other_domains = domain_list()["domains"]
|
|
other_domains.remove(domain)
|
|
|
|
if other_domains:
|
|
raise YunohostValidationError(
|
|
"domain_cannot_remove_main",
|
|
domain=domain,
|
|
other_domains="\n * " + ("\n * ".join(other_domains)),
|
|
)
|
|
else:
|
|
raise YunohostValidationError(
|
|
"domain_cannot_remove_main_add_new_one", domain=domain
|
|
)
|
|
|
|
# Check if apps are installed on the domain
|
|
apps_on_that_domain = []
|
|
|
|
for app in _installed_apps():
|
|
settings = _get_app_settings(app)
|
|
label = app_info(app)["name"]
|
|
if settings.get("domain") == domain:
|
|
apps_on_that_domain.append(
|
|
(
|
|
app,
|
|
f" - {app} \"{label}\" on https://{domain}{settings['path']}"
|
|
if "path" in settings
|
|
else app,
|
|
)
|
|
)
|
|
|
|
if apps_on_that_domain:
|
|
if remove_apps:
|
|
if Moulinette.interface.type == "cli" and not force:
|
|
answer = Moulinette.prompt(
|
|
m18n.n(
|
|
"domain_remove_confirm_apps_removal",
|
|
apps="\n".join([x[1] for x in apps_on_that_domain]),
|
|
answers="y/N",
|
|
),
|
|
color="yellow",
|
|
)
|
|
if answer.upper() != "Y":
|
|
raise YunohostError("aborting")
|
|
|
|
for app, _ in apps_on_that_domain:
|
|
app_remove(app)
|
|
else:
|
|
raise YunohostValidationError(
|
|
"domain_uninstall_app_first",
|
|
apps="\n".join([x[1] for x in apps_on_that_domain]),
|
|
)
|
|
|
|
# Detect if this is a DynDNS domain ( and not a subdomain of a DynDNS domain )
|
|
dyndns = is_yunohost_dyndns_domain(domain) and len(domain.split(".")) == 3
|
|
if dyndns:
|
|
if ((dyndns_password_recovery is None) == (no_unsubscribe is False)):
|
|
raise YunohostValidationError("domain_dyndns_instruction_unclear_unsubscribe")
|
|
|
|
operation_logger.start()
|
|
|
|
if not dyndns and ((dyndns_password_recovery is not None) or (no_unsubscribe is not False)):
|
|
logger.warning("This domain is not a DynDNS one, no need for the --dyndns_password_recovery or --no-unsubscribe option")
|
|
|
|
ldap = _get_ldap_interface()
|
|
try:
|
|
ldap.remove("virtualdomain=" + domain + ",ou=domains")
|
|
except Exception as e:
|
|
raise YunohostError("domain_deletion_failed", domain=domain, error=e)
|
|
finally:
|
|
global domain_list_cache
|
|
domain_list_cache = {}
|
|
|
|
stuff_to_delete = [
|
|
f"/etc/yunohost/certs/{domain}",
|
|
f"/etc/yunohost/dyndns/K{domain}.+*",
|
|
f"{DOMAIN_SETTINGS_DIR}/{domain}.yml",
|
|
]
|
|
|
|
for stuff in stuff_to_delete:
|
|
rm(stuff, force=True, recursive=True)
|
|
|
|
# Sometime we have weird issues with the regenconf where some files
|
|
# appears as manually modified even though they weren't touched ...
|
|
# There are a few ideas why this happens (like backup/restore nginx
|
|
# conf ... which we shouldnt do ...). This in turns creates funky
|
|
# situation where the regenconf may refuse to re-create the conf
|
|
# (when re-creating a domain..)
|
|
#
|
|
# So here we force-clear the has out of the regenconf if it exists.
|
|
# This is a pretty ad hoc solution and only applied to nginx
|
|
# because it's one of the major service, but in the long term we
|
|
# should identify the root of this bug...
|
|
_force_clear_hashes([f"/etc/nginx/conf.d/{domain}.conf"])
|
|
# And in addition we even force-delete the file Otherwise, if the file was
|
|
# manually modified, it may not get removed by the regenconf which leads to
|
|
# catastrophic consequences of nginx breaking because it can't load the
|
|
# cert file which disappeared etc..
|
|
if os.path.exists(f"/etc/nginx/conf.d/{domain}.conf"):
|
|
_process_regen_conf(
|
|
f"/etc/nginx/conf.d/{domain}.conf", new_conf=None, save=True
|
|
)
|
|
|
|
regen_conf(names=["nginx", "metronome", "dnsmasq", "postfix", "rspamd", "mdns"])
|
|
app_ssowatconf()
|
|
|
|
hook_callback("post_domain_remove", args=[domain])
|
|
|
|
# If a password is provided, delete the DynDNS record
|
|
if dyndns and not no_unsubscribe:
|
|
# Actually unsubscribe
|
|
domain_dyndns_unsubscribe(domain=domain, password=dyndns_password_recovery)
|
|
|
|
logger.success(m18n.n("domain_deleted"))
|
|
|
|
|
|
def domain_dyndns_subscribe(**kwargs):
|
|
"""
|
|
Subscribe to a DynDNS domain
|
|
"""
|
|
from yunohost.dyndns import dyndns_subscribe
|
|
|
|
dyndns_subscribe(**kwargs)
|
|
|
|
|
|
def domain_dyndns_unsubscribe(**kwargs):
|
|
"""
|
|
Unsubscribe from a DynDNS domain
|
|
"""
|
|
from yunohost.dyndns import dyndns_unsubscribe
|
|
|
|
dyndns_unsubscribe(**kwargs)
|
|
|
|
|
|
def domain_dyndns_list():
|
|
"""
|
|
Returns all currently subscribed DynDNS domains
|
|
"""
|
|
from yunohost.dyndns import dyndns_list
|
|
|
|
return dyndns_list()
|
|
|
|
|
|
def domain_dyndns_update(**kwargs):
|
|
"""
|
|
Update a DynDNS domain
|
|
"""
|
|
from yunohost.dyndns import dyndns_update
|
|
|
|
dyndns_update(**kwargs)
|
|
|
|
|
|
@is_unit_operation()
|
|
def domain_main_domain(operation_logger, new_main_domain=None):
|
|
"""
|
|
Check the current main domain, or change it
|
|
|
|
Keyword argument:
|
|
new_main_domain -- The new domain to be set as the main domain
|
|
|
|
"""
|
|
from yunohost.tools import _set_hostname
|
|
|
|
# If no new domain specified, we return the current main domain
|
|
if not new_main_domain:
|
|
return {"current_main_domain": _get_maindomain()}
|
|
|
|
# Check domain exists
|
|
_assert_domain_exists(new_main_domain)
|
|
|
|
operation_logger.related_to.append(("domain", new_main_domain))
|
|
operation_logger.start()
|
|
|
|
# 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(str(e), exc_info=1)
|
|
raise YunohostError("main_domain_change_failed")
|
|
|
|
# Generate SSOwat configuration file
|
|
app_ssowatconf()
|
|
|
|
# Regen configurations
|
|
if os.path.exists("/etc/yunohost/installed"):
|
|
regen_conf()
|
|
|
|
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):
|
|
entity_type = "domain"
|
|
save_path_tpl = f"{DOMAIN_SETTINGS_DIR}/{{entity}}.yml"
|
|
save_mode = "diff"
|
|
|
|
def _apply(self):
|
|
if (
|
|
"default_app" in self.future_values
|
|
and self.future_values["default_app"] != self.values["default_app"]
|
|
):
|
|
from yunohost.app import app_ssowatconf, app_map
|
|
|
|
if "/" in app_map(raw=True)[self.entity]:
|
|
raise YunohostValidationError(
|
|
"app_make_default_location_already_used",
|
|
app=self.future_values["default_app"],
|
|
domain=self.entity,
|
|
other_app=app_map(raw=True)[self.entity]["/"]["id"],
|
|
)
|
|
|
|
super()._apply()
|
|
|
|
# Reload ssowat if default app changed
|
|
if (
|
|
"default_app" in self.future_values
|
|
and self.future_values["default_app"] != self.values["default_app"]
|
|
):
|
|
app_ssowatconf()
|
|
|
|
def _get_toml(self):
|
|
|
|
toml = super()._get_toml()
|
|
|
|
toml["feature"]["xmpp"]["xmpp"]["default"] = (
|
|
1 if self.entity == _get_maindomain() else 0
|
|
)
|
|
|
|
# Optimize wether or not to load the DNS section,
|
|
# e.g. we don't want to trigger the whole _get_registary_config_section
|
|
# when just getting the current value from the feature section
|
|
filter_key = self.filter_key.split(".") if self.filter_key != "" else []
|
|
if not filter_key or filter_key[0] == "dns":
|
|
from yunohost.dns import _get_registrar_config_section
|
|
|
|
toml["dns"]["registrar"] = _get_registrar_config_section(self.entity)
|
|
|
|
# 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 ...
|
|
filter_key = self.filter_key.split(".") if self.filter_key != "" else []
|
|
if not filter_key or filter_key[0] == "dns":
|
|
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):
|
|
from yunohost.certificate import certificate_status
|
|
|
|
return certificate_status(domain_list, full)
|
|
|
|
|
|
def domain_cert_install(domain_list, force=False, no_checks=False, self_signed=False):
|
|
from yunohost.certificate import certificate_install
|
|
|
|
return certificate_install(domain_list, force, no_checks, self_signed)
|
|
|
|
|
|
def domain_cert_renew(domain_list, force=False, no_checks=False, email=False):
|
|
from yunohost.certificate import certificate_renew
|
|
|
|
return certificate_renew(domain_list, force, no_checks, email)
|
|
|
|
|
|
def domain_dns_conf(domain):
|
|
return domain_dns_suggest(domain)
|
|
|
|
|
|
def domain_dns_suggest(domain):
|
|
from yunohost.dns import domain_dns_suggest
|
|
|
|
return domain_dns_suggest(domain)
|
|
|
|
|
|
def domain_dns_push(domains, dry_run=None, force=None, purge=None, auto=False):
|
|
from yunohost.dns import domain_dns_push
|
|
|
|
return domain_dns_push(domains, dry_run=dry_run, force=force, purge=purge, auto=auto)
|