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
"""
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
# If at least one IP is listed
if addressed:
# Create a ServiceInfo object for each .local domain
for d in local_domains:
for d in config['domains']:
d_domain=d.replace('.local','')
infos.append(
AsyncServiceInfo(
type_="_device-info._tcp.local.",
name=d_domain+f"._device-info._tcp.local.",
type_='_device-info._tcp.local.',
name=d_domain+'._device-info._tcp.local.',
addresses=b_ips,
port=80,
server=d+'.',
)
)
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, store it, and start Service registration
# Create an AsyncZeroconf object, and store registration task
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))
aiozcs[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))

View file

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

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

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

4
debian/postinst vendored
View file

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