mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
commit
4101e0e041
3 changed files with 120 additions and 129 deletions
235
bin/yunomdns
235
bin/yunomdns
|
@ -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)
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue