From 99aacd8b51ade29f5f153cde3808f55706c15bf6 Mon Sep 17 00:00:00 2001 From: tituspijean Date: Sun, 11 Jul 2021 14:23:41 +0000 Subject: [PATCH] Do not rely on Moulinette, integration with Yunohost of mDNS broadcast --- bin/yunomdns | 269 +++++++++++++++++++++------ data/hooks/conf_regen/01-yunohost | 5 +- data/other/yunomdns.service | 13 ++ data/templates/yunohost/mdns.yml | 4 + data/templates/yunohost/services.yml | 2 + debian/install | 1 + debian/postinst | 4 + 7 files changed, 244 insertions(+), 54 deletions(-) create mode 100644 data/other/yunomdns.service create mode 100644 data/templates/yunohost/mdns.yml diff --git a/bin/yunomdns b/bin/yunomdns index 0e249e7b8..795481dfa 100755 --- a/bin/yunomdns +++ b/bin/yunomdns @@ -6,115 +6,278 @@ Pythonic declaration of mDNS .local domains for YunoHost Based off https://github.com/jstasiak/python-zeroconf/blob/master/tests/test_asyncio.py """ +import subprocess import os +import re import sys import argparse +import yaml import asyncio import logging import socket import time -from typing import List - -sys.path.insert(0, "/usr/lib/moulinette/") -from yunohost.domain import domain_list -from yunohost.utils.network import get_network_interfaces -from yunohost.settings import settings_get -from moulinette import m18n -from moulinette.interfaces.cli import get_locale +from typing import List, Dict +from zeroconf import DNSEntry, DNSRecord from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf +# Helper command taken from Moulinette +def check_output(args, stderr=subprocess.STDOUT, shell=True, **kwargs): + """Run command with arguments and return its output as a byte string + Overwrite some of the arguments to capture standard error in the result + and use shell by default before calling subprocess.check_output. + """ + return ( + subprocess.check_output(args, stderr=stderr, shell=shell, **kwargs) + .decode("utf-8") + .strip() + ) -async def register_services(aiozc: AsyncZeroconf, infos: List[AsyncServiceInfo]) -> None: - tasks = [aiozc.async_register_service(info) for info in infos] +# Helper command taken from Moulinette +def _extract_inet(string, skip_netmask=False, skip_loopback=True): + """ + Extract IP addresses (v4 and/or v6) from a string limited to one + address by protocol + + Keyword argument: + string -- String to search in + skip_netmask -- True to skip subnet mask extraction + skip_loopback -- False to include addresses reserved for the + loopback interface + + Returns: + A dict of {protocol: address} with protocol one of 'ipv4' or 'ipv6' + + """ + ip4_pattern = ( + r"((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}" + ) + ip6_pattern = r"(((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)" + ip4_pattern += r"/[0-9]{1,2})" if not skip_netmask else ")" + ip6_pattern += r"/[0-9]{1,3})" if not skip_netmask else ")" + result = {} + + for m in re.finditer(ip4_pattern, string): + addr = m.group(1) + if skip_loopback and addr.startswith("127."): + continue + + # Limit to only one result + result["ipv4"] = addr + break + + for m in re.finditer(ip6_pattern, string): + addr = m.group(1) + if skip_loopback and addr == "::1": + continue + + # Limit to only one result + result["ipv6"] = addr + break + + return result + +# Helper command taken from Moulinette +def get_network_interfaces(): + # Get network devices and their addresses (raw infos from 'ip addr') + devices_raw = {} + output = check_output("ip addr show") + for d in re.split(r"^(?:[0-9]+: )", output, flags=re.MULTILINE): + # Extract device name (1) and its addresses (2) + m = re.match(r"([^\s@]+)(?:@[\S]+)?: (.*)", d, flags=re.DOTALL) + if m: + devices_raw[m.group(1)] = m.group(2) + + # Parse relevant informations for each of them + devices = { + name: _extract_inet(addrs) + for name, addrs in devices_raw.items() + if name != "lo" + } + + return devices + +# async commands +async def register_services(aiozcs: Dict[AsyncZeroconf, List[AsyncServiceInfo]]) -> None: + tasks = [] + for aiozc, infos in aiozcs.items(): + for info in infos: + tasks.append(aiozc.async_register_service(info)) background_tasks = await asyncio.gather(*tasks) await asyncio.gather(*background_tasks) -async def unregister_services(aiozc: AsyncZeroconf, infos: List[AsyncServiceInfo]) -> None: - tasks = [aiozc.async_unregister_service(info) for info in infos] +async def unregister_services(aiozcs: Dict[AsyncZeroconf, List[AsyncServiceInfo]]) -> None: + for aiozc, infos in aiozcs.items(): + for info in infos: + tasks.append(aiozc.async_unregister_service(info)) background_tasks = await asyncio.gather(*tasks) await asyncio.gather(*background_tasks) -async def close_aiozc(aiozc: AsyncZeroconf) -> None: - await aiozc.async_close() +async def close_aiozcs(aiozcs: Dict[AsyncZeroconf, List[AsyncServiceInfo]]) -> None: + tasks = [] + for aiozc in aiozcs: + tasks.append(aiozc.async_close()) + background_tasks = await asyncio.gather(*tasks) + await asyncio.gather(*background_tasks) if __name__ == '__main__': logging.basicConfig(level=logging.DEBUG) - parser = argparse.ArgumentParser() + + ### + # ARGUMENTS + ### + + parser = argparse.ArgumentParser(description=''' + mDNS broadcast for .local domains. + Configuration file: /etc/yunohost/mdns.yml + Subdomains are not supported. + ''') parser.add_argument('--debug', action='store_true') + parser.add_argument('--regen', nargs='?', const='as_stored', choices=['domains', 'interfaces', 'all', 'as_stored'], + help=''' + Regenerates selection into the configuration file then starts mDNS broadcasting. + ''') + parser.add_argument('--set-regen', choices=['domains', 'interfaces', 'all', 'none'], + help=''' + Set which part of the configuration to be regenerated. + Implies --regen as_stored, with newly stored parameter. + ''') + able = parser.add_mutually_exclusive_group() + able.add_argument('--enable', action='store_true', help='Enables mDNS broadcast, and regenerates the configuration') + able.add_argument('--disable', action='store_true') args = parser.parse_args() if args.debug: logging.getLogger('zeroconf').setLevel(logging.DEBUG) - - - local_domains = [ d for d in domain_list()['domains'] if d.endswith('.local') ] - - m18n.load_namespace("yunohost") - m18n.set_locale(get_locale()) - - if settings_get('mdns.interfaces'): - wanted_interfaces = settings_get('mdns.interfaces').split() + logging.getLogger('asyncio').setLevel(logging.DEBUG) else: - wanted_interfaces = [] - print('No interface listed for broadcast.') + logging.getLogger('zeroconf').setLevel(logging.WARNING) + logging.getLogger('asyncio').setLevel(logging.WARNING) - aiozcs = [] + ### + # CONFIG + ### + + with open('/etc/yunohost/mdns.yml', 'r') as f: + config = yaml.load(f) or {} + updated = False + + if args.enable: + config['enabled'] = True + args.regen = 'as_stored' + updated = True + + if args.disable: + config['enabled'] = False + updated = True + + if args.set_regen: + config['regen'] = args.set_regen + args.regen = 'as_stored' + updated = True + + if args.regen: + if args.regen == 'as_stored': + r = config['regen'] + else: + r = args.regen + if r == 'none': + print('Regeneration disabled.') + if r == 'interfaces' or r == 'all': + config['interfaces'] = [ i for i in get_network_interfaces() ] + print('Regenerated interfaces list: ' + str(config['interfaces'])) + if r == 'domains' or r == 'all': + import glob + config['domains'] = [ d.rsplit('/',1)[1][:-2] for d in glob.glob('/etc/nginx/conf.d/*.local.d') ] + print('Regenerated domains list: ' + str(config['domains'])) + updated = True + + if updated: + with open('/etc/yunohost/mdns.yml', 'w') as f: + yaml.safe_dump(config, f, default_flow_style=False) + print('Configuration file updated.') + + ### + # MAIN SCRIPT + ### + + if config['enabled'] is not True: + print('YunomDNS is disabled.') + sys.exit(0) + + if config['interfaces'] is None: + print('No interface listed for broadcast.') + sys.exit(0) + + aiozcs = {} + tasks = [] + loop = asyncio.get_event_loop() interfaces = get_network_interfaces() - for interface in wanted_interfaces: - infos = [] + for interface in config['interfaces']: + infos = [] # List of ServiceInfo objects, to feed Zeroconf ips = [] # Human-readable IPs b_ips = [] # Binary-convered IPs # Parse the IPs and prepare their binary version + addressed = False try: ip = interfaces[interface]['ipv4'].split('/')[0] + if len(ip)>0: addressed = True ips.append(ip) b_ips.append(socket.inet_pton(socket.AF_INET, ip)) except: pass try: ip = interfaces[interface]['ipv6'].split('/')[0] + if len(ip)>0: addressed = True ips.append(ip) b_ips.append(socket.inet_pton(socket.AF_INET6, ip)) except: pass - # Create a ServiceInfo object for each .local domain - for d in local_domains: - d_domain=d.replace('.local','') - infos.append( - AsyncServiceInfo( - type_="_device-info._tcp.local.", - name=d_domain+f"._device-info._tcp.local.", - addresses=b_ips, - port=80, - server=d+'.', + # If at least one IP is listed + if addressed: + # Create a ServiceInfo object for each .local domain + for d in config['domains']: + d_domain=d.replace('.local','') + infos.append( + AsyncServiceInfo( + type_='_device-info._tcp.local.', + name=d_domain+'._device-info._tcp.local.', + addresses=b_ips, + port=80, + server=d+'.', + ) ) - ) - print('Adding '+d+' with addresses '+str(ips)+' on interface '+interface) + infos.append( + AsyncServiceInfo( + type_='_http._tcp.local.', + name=d_domain+'._http._tcp.local.', + addresses=b_ips, + port=80, + server=d+'.', + ) + ) + print('Adding '+d+' with addresses '+str(ips)+' on interface '+interface) + # Create an AsyncZeroconf object, and store registration task + aiozc = AsyncZeroconf(interfaces=ips) + aiozcs[aiozc]=infos - # Create an AsyncZeroconf object, store it, and start Service registration - aiozc = AsyncZeroconf(interfaces=ips) - aiozcs.append(aiozc) - print("Registration on interface "+interface+"...") - loop = asyncio.get_event_loop() - loop.run_until_complete(register_services(aiozc, infos)) + # Run registration + loop.run_until_complete(register_services(aiozcs)) + print("Registration complete. Press Ctrl-c or stop service to exit...") - # We are done looping among the interfaces - print("Registration complete. Press Ctrl-c to exit...") try: while True: - time.sleep(0.1) + time.sleep(1) except KeyboardInterrupt: pass finally: print("Unregistering...") - for aiozc in aiozcs: - loop.run_until_complete(unregister_services(aiozc, infos)) - loop.run_until_complete(close_aiozc(aiozc)) + loop.run_until_complete(unregister_services(aiozcs)) print("Unregistration complete.") + loop.run_until_complete(close_aiozcs(aiozcs)) diff --git a/data/hooks/conf_regen/01-yunohost b/data/hooks/conf_regen/01-yunohost index 3d65d34cd..d160b9e66 100755 --- a/data/hooks/conf_regen/01-yunohost +++ b/data/hooks/conf_regen/01-yunohost @@ -3,6 +3,7 @@ set -e services_path="/etc/yunohost/services.yml" +mdns_path="/etc/yunohost/mdns.yml" do_init_regen() { if [[ $EUID -ne 0 ]]; then @@ -18,9 +19,11 @@ do_init_regen() { [[ -f /etc/yunohost/current_host ]] \ || echo "yunohost.org" > /etc/yunohost/current_host - # copy default services and firewall + # copy default services, mdns, and firewall [[ -f $services_path ]] \ || cp services.yml "$services_path" + [[ -f $mdns_path ]] \ + || cp mdns.yml "$mdns_path" [[ -f /etc/yunohost/firewall.yml ]] \ || cp firewall.yml /etc/yunohost/firewall.yml diff --git a/data/other/yunomdns.service b/data/other/yunomdns.service new file mode 100644 index 000000000..36d938035 --- /dev/null +++ b/data/other/yunomdns.service @@ -0,0 +1,13 @@ +[Unit] +Description=YunoHost mDNS service +After=network.target + +[Service] +User=avahi +Group=avahi +Type=simple +ExecStart=/usr/bin/yunomdns +StandardOutput=syslog + +[Install] +WantedBy=default.target diff --git a/data/templates/yunohost/mdns.yml b/data/templates/yunohost/mdns.yml new file mode 100644 index 000000000..3ed9e792b --- /dev/null +++ b/data/templates/yunohost/mdns.yml @@ -0,0 +1,4 @@ +enabled: True +regen: all +interfaces: +domains: diff --git a/data/templates/yunohost/services.yml b/data/templates/yunohost/services.yml index 7df563c67..b961d274e 100644 --- a/data/templates/yunohost/services.yml +++ b/data/templates/yunohost/services.yml @@ -52,6 +52,8 @@ yunohost-firewall: need_lock: true test_status: iptables -S | grep "^-A INPUT" | grep " --dport" | grep -q ACCEPT category: security +yunomdns: + needs_exposed_ports: [5353] glances: null nsswitch: null ssl: null diff --git a/debian/install b/debian/install index 1691a4849..e30a69a8b 100644 --- a/debian/install +++ b/debian/install @@ -5,6 +5,7 @@ doc/yunohost.8.gz /usr/share/man/man8/ data/actionsmap/* /usr/share/moulinette/actionsmap/ data/hooks/* /usr/share/yunohost/hooks/ data/other/yunoprompt.service /etc/systemd/system/ +data/other/yunomdns.service /etc/systemd/system/ data/other/password/* /usr/share/yunohost/other/password/ data/other/dpkg-origins/yunohost /etc/dpkg/origins data/other/dnsbl_list.yml /usr/share/yunohost/other/ diff --git a/debian/postinst b/debian/postinst index ecae9b258..7590197bd 100644 --- a/debian/postinst +++ b/debian/postinst @@ -38,6 +38,10 @@ do_configure() { # Yunoprompt systemctl enable yunoprompt.service + + # Yunomdns + chown avahi:avahi /etc/yunohost/mdns.yml + systemctl enable yunomdns.service } # summary of how this script can be called: