Do not rely on Moulinette, integration with Yunohost of mDNS broadcast

This commit is contained in:
tituspijean 2021-07-11 14:23:41 +00:00
parent 9e93efa895
commit 99aacd8b51
7 changed files with 244 additions and 54 deletions

View file

@ -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 Based off https://github.com/jstasiak/python-zeroconf/blob/master/tests/test_asyncio.py
""" """
import subprocess
import os import os
import re
import sys import sys
import argparse import argparse
import yaml
import asyncio import asyncio
import logging import logging
import socket import socket
import time import time
from typing import List from typing import List, Dict
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 zeroconf import DNSEntry, DNSRecord
from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf 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: # Helper command taken from Moulinette
tasks = [aiozc.async_register_service(info) for info in infos] 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) background_tasks = await asyncio.gather(*tasks)
await asyncio.gather(*background_tasks) await asyncio.gather(*background_tasks)
async def unregister_services(aiozc: AsyncZeroconf, infos: List[AsyncServiceInfo]) -> None: async def unregister_services(aiozcs: Dict[AsyncZeroconf, List[AsyncServiceInfo]]) -> None:
tasks = [aiozc.async_unregister_service(info) for info in infos] for aiozc, infos in aiozcs.items():
for info in infos:
tasks.append(aiozc.async_unregister_service(info))
background_tasks = await asyncio.gather(*tasks) background_tasks = await asyncio.gather(*tasks)
await asyncio.gather(*background_tasks) await asyncio.gather(*background_tasks)
async def close_aiozc(aiozc: AsyncZeroconf) -> None: async def close_aiozcs(aiozcs: Dict[AsyncZeroconf, List[AsyncServiceInfo]]) -> None:
await aiozc.async_close() tasks = []
for aiozc in aiozcs:
tasks.append(aiozc.async_close())
background_tasks = await asyncio.gather(*tasks)
await asyncio.gather(*background_tasks)
if __name__ == '__main__': if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG) 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('--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() args = parser.parse_args()
if args.debug: if args.debug:
logging.getLogger('zeroconf').setLevel(logging.DEBUG) logging.getLogger('zeroconf').setLevel(logging.DEBUG)
logging.getLogger('asyncio').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()
else: else:
wanted_interfaces = [] logging.getLogger('zeroconf').setLevel(logging.WARNING)
print('No interface listed for broadcast.') 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() interfaces = get_network_interfaces()
for interface in wanted_interfaces: for interface in config['interfaces']:
infos = [] infos = [] # List of ServiceInfo objects, to feed Zeroconf
ips = [] # Human-readable IPs ips = [] # Human-readable IPs
b_ips = [] # Binary-convered IPs b_ips = [] # Binary-convered IPs
# Parse the IPs and prepare their binary version # Parse the IPs and prepare their binary version
addressed = False
try: try:
ip = interfaces[interface]['ipv4'].split('/')[0] ip = interfaces[interface]['ipv4'].split('/')[0]
if len(ip)>0: addressed = True
ips.append(ip) ips.append(ip)
b_ips.append(socket.inet_pton(socket.AF_INET, ip)) b_ips.append(socket.inet_pton(socket.AF_INET, ip))
except: except:
pass pass
try: try:
ip = interfaces[interface]['ipv6'].split('/')[0] ip = interfaces[interface]['ipv6'].split('/')[0]
if len(ip)>0: addressed = True
ips.append(ip) ips.append(ip)
b_ips.append(socket.inet_pton(socket.AF_INET6, ip)) b_ips.append(socket.inet_pton(socket.AF_INET6, ip))
except: except:
pass pass
# Create a ServiceInfo object for each .local domain # If at least one IP is listed
for d in local_domains: if addressed:
d_domain=d.replace('.local','') # Create a ServiceInfo object for each .local domain
infos.append( for d in config['domains']:
AsyncServiceInfo( d_domain=d.replace('.local','')
type_="_device-info._tcp.local.", infos.append(
name=d_domain+f"._device-info._tcp.local.", AsyncServiceInfo(
addresses=b_ips, type_='_device-info._tcp.local.',
port=80, name=d_domain+'._device-info._tcp.local.',
server=d+'.', addresses=b_ips,
port=80,
server=d+'.',
)
) )
) infos.append(
print('Adding '+d+' with addresses '+str(ips)+' on interface '+interface) 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 # Run registration
aiozc = AsyncZeroconf(interfaces=ips) loop.run_until_complete(register_services(aiozcs))
aiozcs.append(aiozc) print("Registration complete. Press Ctrl-c or stop service to exit...")
print("Registration on interface "+interface+"...")
loop = asyncio.get_event_loop()
loop.run_until_complete(register_services(aiozc, infos))
# We are done looping among the interfaces
print("Registration complete. Press Ctrl-c to exit...")
try: try:
while True: while True:
time.sleep(0.1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: finally:
print("Unregistering...") print("Unregistering...")
for aiozc in aiozcs: loop.run_until_complete(unregister_services(aiozcs))
loop.run_until_complete(unregister_services(aiozc, infos))
loop.run_until_complete(close_aiozc(aiozc))
print("Unregistration complete.") print("Unregistration complete.")
loop.run_until_complete(close_aiozcs(aiozcs))

View file

@ -3,6 +3,7 @@
set -e set -e
services_path="/etc/yunohost/services.yml" services_path="/etc/yunohost/services.yml"
mdns_path="/etc/yunohost/mdns.yml"
do_init_regen() { do_init_regen() {
if [[ $EUID -ne 0 ]]; then if [[ $EUID -ne 0 ]]; then
@ -18,9 +19,11 @@ do_init_regen() {
[[ -f /etc/yunohost/current_host ]] \ [[ -f /etc/yunohost/current_host ]] \
|| echo "yunohost.org" > /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 ]] \ [[ -f $services_path ]] \
|| cp services.yml "$services_path" || cp services.yml "$services_path"
[[ -f $mdns_path ]] \
|| cp mdns.yml "$mdns_path"
[[ -f /etc/yunohost/firewall.yml ]] \ [[ -f /etc/yunohost/firewall.yml ]] \
|| cp firewall.yml /etc/yunohost/firewall.yml || cp firewall.yml /etc/yunohost/firewall.yml

View file

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

View file

@ -0,0 +1,4 @@
enabled: True
regen: all
interfaces:
domains:

View file

@ -52,6 +52,8 @@ yunohost-firewall:
need_lock: true need_lock: true
test_status: iptables -S | grep "^-A INPUT" | grep " --dport" | grep -q ACCEPT test_status: iptables -S | grep "^-A INPUT" | grep " --dport" | grep -q ACCEPT
category: security category: security
yunomdns:
needs_exposed_ports: [5353]
glances: null glances: null
nsswitch: null nsswitch: null
ssl: null ssl: null

1
debian/install vendored
View file

@ -5,6 +5,7 @@ doc/yunohost.8.gz /usr/share/man/man8/
data/actionsmap/* /usr/share/moulinette/actionsmap/ data/actionsmap/* /usr/share/moulinette/actionsmap/
data/hooks/* /usr/share/yunohost/hooks/ data/hooks/* /usr/share/yunohost/hooks/
data/other/yunoprompt.service /etc/systemd/system/ data/other/yunoprompt.service /etc/systemd/system/
data/other/yunomdns.service /etc/systemd/system/
data/other/password/* /usr/share/yunohost/other/password/ data/other/password/* /usr/share/yunohost/other/password/
data/other/dpkg-origins/yunohost /etc/dpkg/origins data/other/dpkg-origins/yunohost /etc/dpkg/origins
data/other/dnsbl_list.yml /usr/share/yunohost/other/ data/other/dnsbl_list.yml /usr/share/yunohost/other/

4
debian/postinst vendored
View file

@ -38,6 +38,10 @@ do_configure() {
# Yunoprompt # Yunoprompt
systemctl enable yunoprompt.service systemctl enable yunoprompt.service
# Yunomdns
chown avahi:avahi /etc/yunohost/mdns.yml
systemctl enable yunomdns.service
} }
# summary of how this script can be called: # summary of how this script can be called: