Merge pull request #1335 from YunoHost/fix-mdns

Fix yunomdns
This commit is contained in:
Alexandre Aubin 2021-09-29 20:37:19 +02:00 committed by GitHub
commit 4101e0e041
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 120 additions and 129 deletions

View file

@ -4,160 +4,152 @@
Pythonic declaration of mDNS .local domains for YunoHost Pythonic declaration of mDNS .local domains for YunoHost
""" """
import subprocess
import re
import sys import sys
import yaml import yaml
import socket
from time import sleep from time import sleep
from typing import List, Dict from typing import List, Dict
from zeroconf import Zeroconf, ServiceInfo import ifaddr
from ipaddress import ip_address
from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser
# Helper command taken from Moulinette
def check_output(args, stderr=subprocess.STDOUT, shell=True, **kwargs): def get_network_local_interfaces() -> Dict[str, Dict[str, List[str]]]:
"""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 ( Returns interfaces with their associated local IPs
subprocess.check_output(args, stderr=stderr, shell=shell, **kwargs)
.decode("utf-8")
.strip()
)
# 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: interfaces = {
string -- String to search in adapter.name: {
skip_netmask -- True to skip subnet mask extraction "ipv4": [ip.ip for ip in adapter.ips if ip.is_IPv4 and ip_address(ip.ip).is_private],
skip_loopback -- False to include addresses reserved for the "ipv6": [ip.ip[0] for ip in adapter.ips if ip.is_IPv6 and ip_address(ip.ip[0]).is_private and not ip_address(ip.ip[0]).is_link_local],
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 --brief a").split("\n")
for line in output:
line = line.split()
iname = line[0]
ips = ' '.join(line[2:])
devices_raw[iname] = ips
# Parse relevant informations for each of them
devices = {
name: _extract_inet(addrs)
for name, addrs in devices_raw.items()
if name != "lo"
} }
for adapter in ifaddr.get_adapters()
if adapter.name != "lo"
}
return interfaces
return devices
if __name__ == '__main__': # Listener class, to detect duplicates on the network
# Stores the list of servers in its list property
class Listener:
def __init__(self):
self.list = []
def remove_service(self, zeroconf, type, name):
info = zeroconf.get_service_info(type, name)
self.list.remove(info.server)
def update_service(self, zeroconf, type, name):
pass
def add_service(self, zeroconf, type, name):
info = zeroconf.get_service_info(type, name)
self.list.append(info.server[:-1])
def main() -> bool:
### ###
# CONFIG # CONFIG
### ###
with open('/etc/yunohost/mdns.yml', 'r') as f: with open("/etc/yunohost/mdns.yml", "r") as f:
config = yaml.safe_load(f) or {} config = yaml.safe_load(f) or {}
updated = False
required_fields = ["interfaces", "domains"] required_fields = ["domains"]
missing_fields = [field for field in required_fields if field not in config] missing_fields = [field for field in required_fields if field not in config]
interfaces = get_network_local_interfaces()
if missing_fields: if missing_fields:
print("The fields %s are required" % ', '.join(missing_fields)) print(f"The fields {missing_fields} are required in mdns.yml")
return False
if config['interfaces'] is None: if "interfaces" not in config:
print('No interface listed for broadcast.') config["interfaces"] = [interface
sys.exit(0) for interface, local_ips in interfaces.items()
if local_ips["ipv4"]]
if 'yunohost.local' not in config['domains']: if "ban_interfaces" in config:
config['domains'].append('yunohost.local') config["interfaces"] = [interface
for interface in config["interfaces"]
if interface not in config["ban_interfaces"]]
zcs = {} # Let's discover currently published .local domains accross the network
interfaces = get_network_interfaces() zc = Zeroconf()
for interface in config['interfaces']: listener = Listener()
infos = [] # List of ServiceInfo objects, to feed Zeroconf browser = ServiceBrowser(zc, "_device-info._tcp.local.", listener)
ips = [] # Human-readable IPs sleep(2)
b_ips = [] # Binary-convered IPs browser.cancel()
zc.close()
ipv4 = interfaces[interface]['ipv4'].split('/')[0] # Always attempt to publish yunohost.local
if ipv4: if "yunohost.local" not in config["domains"]:
ips.append(ipv4) config["domains"].append("yunohost.local")
b_ips.append(socket.inet_pton(socket.AF_INET, ipv4))
ipv6 = interfaces[interface]['ipv6'].split('/')[0] def find_domain_not_already_published(domain):
if ipv6:
ips.append(ipv6) # Try domain.local ... but if it's already published by another entity,
b_ips.append(socket.inet_pton(socket.AF_INET6, ipv6)) # try domain-2.local, domain-3.local, ...
i = 1
domain_i = domain
while domain_i in listener.list:
print(f"Uh oh, {domain_i} already exists on the network...")
i += 1
domain_i = domain.replace(".local", f"-{i}.local")
return domain_i
config['domains'] = [find_domain_not_already_published(domain) for domain in config['domains']]
zcs: Dict[Zeroconf, List[ServiceInfo]] = {}
for interface in config["interfaces"]:
if interface not in interfaces:
print(f"Interface {interface} listed in config file is not present on system.")
continue
# Only broadcast IPv4 because IPv6 is buggy ... because we ain't using python3-ifaddr >= 0.1.7
# Buster only ships 0.1.6
# Bullseye ships 0.1.7
# To be re-enabled once we're on bullseye...
# ips: List[str] = interfaces[interface]["ipv4"] + interfaces[interface]["ipv6"]
ips: List[str] = interfaces[interface]["ipv4"]
# If at least one IP is listed # If at least one IP is listed
if ips: if not ips:
continue
# Create a Zeroconf object, and store the ServiceInfos # Create a Zeroconf object, and store the ServiceInfos
zc = Zeroconf(interfaces=ips) zc = Zeroconf(interfaces=ips) # type: ignore
zcs[zc]=[] zcs[zc] = []
for d in config['domains']:
d_domain=d.replace('.local','') for d in config["domains"]:
if '.' in d_domain: d_domain = d.replace(".local", "")
print(d_domain+'.local: subdomains are not supported.') if "." in d_domain:
else: print(f"{d_domain}.local: subdomains are not supported.")
continue
# Create a ServiceInfo object for each .local domain # Create a ServiceInfo object for each .local domain
zcs[zc].append(ServiceInfo( zcs[zc].append(
type_='_device-info._tcp.local.', ServiceInfo(
name=interface+': '+d_domain+'._device-info._tcp.local.', type_="_device-info._tcp.local.",
addresses=b_ips, name=f"{interface}: {d_domain}._device-info._tcp.local.",
parsed_addresses=ips,
port=80, port=80,
server=d+'.', server=f"{d}.",
)) )
print('Adding '+d+' with addresses '+str(ips)+' on interface '+interface) )
print(f"Adding {d} with addresses {ips} on interface {interface}")
# Run registration # Run registration
print("Registering...") print("Registering...")
for zc, infos in zcs.items(): for zc, infos in zcs.items():
for info in infos: for info in infos:
zc.register_service(info) zc.register_service(info, allow_name_change=True, cooperating_responders=True)
try: try:
print("Registered. Press Ctrl+C or stop service to stop.") print("Registered. Press Ctrl+C or stop service to stop.")
@ -168,6 +160,11 @@ if __name__ == '__main__':
finally: finally:
print("Unregistering...") print("Unregistering...")
for zc, infos in zcs.items(): for zc, infos in zcs.items():
for info in infos: zc.unregister_all_services()
zc.unregister_service(info)
zc.close() zc.close()
return True
if __name__ == "__main__":
sys.exit(0 if main() else 1)

View file

@ -12,13 +12,6 @@ _generate_config() {
[[ "$domain" =~ ^[^.]+\.local$ ]] || continue [[ "$domain" =~ ^[^.]+\.local$ ]] || continue
echo " - $domain" echo " - $domain"
done done
echo "interfaces:"
local_network_interfaces="$(ip --brief a | grep ' 10\.\| 192\.168\.' | awk '{print $1}')"
for interface in $local_network_interfaces
do
echo " - $interface"
done
} }
do_init_regen() { do_init_regen() {

View file

@ -6,6 +6,7 @@ After=network.target
User=mdns User=mdns
Group=mdns Group=mdns
Type=simple Type=simple
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/yunomdns ExecStart=/usr/bin/yunomdns
StandardOutput=syslog StandardOutput=syslog