diff --git a/README.md b/README.md index 83a39dc..5a9f83d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ information needed for example to display the name of the server on node-maps. systemctl restart respondd systemctl status respondd +Note that you might need to transition from the old, commandline argument based config method to +the new config file based method when upgrading from an older version + ### Alfred Open the Alfred port UDP 16962 in your firewall. Add _announce.sh_ to your @@ -57,38 +60,48 @@ and B.A.T.M.A.N. advanced interfaces. Please *don't* open the port globally, as it can be used for traffic amplification attacks. You also might want to ratelimit it on the allowed interfaces for the same reason. -#### commandline options +#### Commandline options Those are all available options (`respondd --help`): ``` +usage: respondd.py -h - respondd.py [-p ] [-g ] [-i [%]] [-i [%] ..] [-d ] [-b [:] [-n ] ..] + respondd.py [-f ] [-d ] optional arguments: - -h, --help show this help message and exit - -p port number to listen on (default 1001) - -g - link-local multicast group (default ff02::2:1001), set - to emtpy string to disable - -s - site-local multicast group (default ff05::2:1001), set - to empty string to disable - -i listening interface (default bat0), may be specified - multiple times - -d data provider directory (default: $PWD/providers) - -b batman-adv interface to answer for (default: bat0). - Specify once per domain - -m mesh ipv4 address - -n (default) domain code for system/domain_code - -c - domain_code.json path (if info is not in file, - fallback to -n's value) - - -This is a possible configuration for a site with a single domain: - - `respondd.py -d /opt/mesh-announce/providers -i -i -b -m -n ` + -h, --help show this help message and exit + -f config file to use (default: $PWD/respondd.conf) + -d data provider directory (default: $PWD/providers) + +``` + +#### Configuration + +Configuration is done via a ini-style config file. A possible config for a setup with a single batman domain in outlined in `respondd.conf.example`. +The following is a more complete breakdown of the settings required: +``` +# Default settings +[Defaults] +# Listen port, defaults to 1001 +Port: 1001 +# Default multicast listen addresses +MulticastLinkAddress: ff02::2:1001 +MulticastSiteAddress: ff05::2:1001 +# Default domain to use +DefaultDomain: +# Default domain type +DomainType: batadv + +# A domain +[] +# Batman interface, mandatory +BatmanInterface: +# Other listen interfaces +Interfaces: , +# IPv4 gateway option for ddhcpd +IPv4Gateway: +``` * ``: interfacename of mesh-bridge (for example br-ffXX) * ``: interfacename of fastd or tuneldigger (for example ffXX-mvpn) @@ -97,25 +110,94 @@ This is a possible configuration for a site with a single domain: you can get the ip with `ip a s dev br-ffXX|grep inet|head -n1|cut -d" " -f 6|sed 's|/.*||g'` * ``: The internal domain_code, identical with the gluon domain_name -The ipv4 address can be requested for example by +The can be requested for example by [ddhcpd](https://github.com/TobleMiner/gluon-sargon/blob/feature-respondd-gateway-update/ddhcpd/files/usr/sbin/ddhcpd-gateway-update#L3) via `gluon-neighbour-info -p 1001 -d ff02::1 -i bat0 -r gateway` This will request all json objects for all gateways. The json object for the -gateway can then be selected by the known macadress. The ip4 is stored in +gateway can then be selected by the known macadress. The IPv4 address is stored in `node_id.address.ipv4`. Configuration for a multi-domain site (domains 'one', 'two' and 'three') might look like this: - `respondd.py -d /opt/mesh-announce/providers -i meshvpn-one -i br-one -i bat-one -b bat-one -i meshvpn-two -i br-two -i bat-two -b bat-two -i meshvpn-three -i br-three -i bat-three -b bat-three` +``` +# Default settings +[Defaults] +# Listen port, defaults to 1001 +Port: 1001 +# Default multicast listen addresses +MulticastLinkAddress: ff02::2:1001 +MulticastSiteAddress: ff05::2:1001 +# Default domain type +DomainType: batadv +# IPv4 gateway option for ddhcpd +IPv4Gateway: 10.42.0.1 + +# First domain +[one] +# Batman interface, mandatory for batman domains +BatmanInterface: bat-one +# Other listen interfaces +Interfaces: br-one, mvpn-one + +# Second domain +[two] +# Batman interface, mandatory for batman domains +BatmanInterface: bat-two +# Other listen interfaces +Interfaces: br-two, mvpn-two + +# Third domain +[three] +# Batman interface, mandatory for datman domains +BatmanInterface: bat-three +# Other listen interfaces +Interfaces: br-three, mvpn-three +``` In a more complex configuration involving the distributed DHCP deamon ddhcpd you might want to advertise different ipv4 gateways depending on the domain the query came from. This can be realized by adding gateway address overrides to the corresponding batman interfaces: - `respondd.py -d /opt/mesh-announce/providers -i meshvpn-one -i br-one -i bat-one -b bat-one:10.42.1.1 -i meshvpn-two -i br-two -i bat-two -b bat-two:10.42.2.1 -i meshvpn-three -i br-three -i bat-three -b bat-three:10.42.3.1` - +``` +# Default settings +[Defaults] +# Listen port, defaults to 1001 +Port: 1001 +# Default multicast listen addresses +MulticastLinkAddress: ff02::2:1001 +MulticastSiteAddress: ff05::2:1001 +# Default domain type +DomainType: batadv + +# First domain +[one] +# Batman interface, mandatory for batman domains +BatmanInterface: bat-one +# Other listen interfaces +Interfaces: br-one, mvpn-one +# IPv4 gateway option for ddhcpd +IPv4Gateway: 10.42.0.1 + +# Second domain +[two] +# Batman interface, mandatory for batman domains +BatmanInterface: bat-two +# Other listen interfaces +Interfaces: br-two, mvpn-two +# IPv4 gateway option for ddhcpd +IPv4Gateway: 10.42.8.1 + +# Third domain +[three] +# Batman interface, mandatory for datman domains +BatmanInterface: bat-three +# Other listen interfaces +Interfaces: br-three, mvpn-three +# IPv4 gateway option for ddhcpd +IPv4Gateway: 10.42.16.1 +``` ### Debugging diff --git a/config.py b/config.py new file mode 100644 index 0000000..fc8b59c --- /dev/null +++ b/config.py @@ -0,0 +1,97 @@ +from configparser import ConfigParser + +class GlobalOptions(): + ''' Container class for global options + ''' + def __init__(self, port, default_domain): + self.port = port + self.default_domain = default_domain + +class DomainOptions(): + ''' Base container class for per domain options + ''' + @classmethod + def from_parser(cls, section, parser, globals): + ''' Builds a DomainOptions object from a config section + Handles domain type specific options automatically + ''' + from domain import DomainType + + domain_type = parser.get(section, 'DomainType', fallback='simple') + # Get DomainOptions subclass for type and instantiate + return DomainType.get(domain_type.lower()).options(section, parser, globals) + + def __init__(self, name, parser, globals): + ''' Initialize common options + ''' + from domain import Domain + + self.name = name + self.interfaces = list(map(str.strip, parser.get(name, 'Interfaces', fallback='').split(','))) + self.mcast_link = parser.get(name, 'MulticastLinkAddress', fallback='ff02::2:1001') + self.mcast_site = parser.get(name, 'MulticastSiteAddress', fallback='ff05::2:1001') + self.ipv4_gateway = parser.get(name, 'IPv4Gateway', fallback=None) + self.domain_type = Domain + +class BatmanDomainOptions(DomainOptions): + ''' Container for batman specific options + ''' + def __init__(self, name, parser, globals): + ''' Initialize common and batman-specific options + ''' + from domain import BatadvDomain + + # Parse common options + super().__init__(name, parser, globals) + # Parse batman specific options + self.batman_iface = parser.get(name, 'BatmanInterface', fallback='bat-' + name) + self.domain_type = BatadvDomain + +class Config(): + ''' Represents a parsed config file + ''' + @classmethod + def from_file(cls, fname): + ''' Load config from file + ''' + parser = ConfigParser(empty_lines_in_values=False, default_section='Defaults') + with open(fname) as file: + parser.read_file(file) + return cls(parser) + + def __init__(self, parser): + ''' load config from a config parser + ''' + self._initialize_global_options(parser) + self.domains = { } + for domain in parser.sections(): + self._initialize_domain_options(parser, domain) + if not self.globals.default_domain: + self.globals.default_domain = self.domains[domain] + + def _initialize_global_options(self, parser): + ''' Set all global options + ''' + self.globals = GlobalOptions( + parser.getint('Defaults', 'Port', fallback=1001), + parser.get('Defaults', 'DefaultDomain', fallback=None) + ) + + def _initialize_domain_options(self, parser, domain): + ''' Populate options for domain from config parser + ''' + self.domains[domain] = DomainOptions.from_parser(domain, parser, self.globals) + + def get_domain_names(self): + ''' Get list of all domain names listed in the config + ''' + return self.domains.keys() + + def get_port(self): + return self.globals.port + + def get_default_domain(self): + return self.globals.default_domain + + def get_domain_config(self, domain): + return self.domains[domain] diff --git a/domain.py b/domain.py new file mode 100644 index 0000000..bc9158c --- /dev/null +++ b/domain.py @@ -0,0 +1,108 @@ +from config import BatmanDomainOptions, DomainOptions + +class Domain(): + ''' Abstract container object for a freifunk domain + ''' + def __init__(self, config): + self.config = config + + def get_name(self): + return self.config.name + + def get_ipv4_gateway(self): + return self.config.ipv4_gateway + + def get_multicast_address_link(self): + return self.config.mcast_link + + def get_multicast_address_site(self): + return self.config.mcast_site + + def get_interfaces(self): + ''' Returns list off all interfaces respondd queries are + expected to arrive on + ''' + return self.config.interfaces + + def get_provider_args(self): + ''' Returns dict of parameters respondd queries are + expected to arrive on + ''' + return { + 'domain_code': self.get_name(), + 'mesh_ipv4': self.get_ipv4_gateway() + } + +class BatadvDomain(Domain): + ''' Container object for a batman freifunk domain + ''' + def __init__(self, config): + super().__init__(config) + + def get_interfaces(self): + return super().get_interfaces() + [self.get_batman_interface()] + + def get_batman_interface(self): + return self.config.batman_iface + + def get_provider_args(self): + args = super().get_provider_args() + args.update({ 'batadv_dev': self.get_batman_interface() }) + return args + +class DomainRegistry(): + ''' Simple singleton based registry for freifunk domains + ''' + instance = None + @classmethod + def get_instance(cls): + if not cls.instance: + cls.instance = cls() + return cls.instance + + def __init__(self): + self.domain_by_iface = { } + self.default_domain = None + + def add_domain(self, dom): + for iface in dom.get_interfaces(): + self.domain_by_iface[iface] = dom + + def get_domain_by_interface(self, iface): + if iface in self.domain_by_iface: + return self.domain_by_iface[iface] + return None + + def get_interfaces(self): + ''' Get all domain interfaces known to this registry + ''' + return self.domain_by_iface.keys() + + def get_default_domain(self): + return self.default_domain + + def set_default_domain(self, dom): + self.default_domain = dom + +class DomainType(): + ''' Domain type, links domain type to its options + ''' + @staticmethod + def get(name): + if not name in domain_types: + raise Exception("Unknown domain type") + return domain_types[name] + + def __init__(self, name, options, domain_type): + self.name = name + self.options = options + self.domain_type = domain_type + +# List of domain types, key is used as domain type in config +# Use only lower case keys, domain type from config is converted to lower +# case during parsing +domain_types = { + 'simple': DomainType('simple', DomainOptions, Domain), + 'batadv': DomainType('batadv', BatmanDomainOptions, BatadvDomain), +} + diff --git a/providers/nodeinfo/system/domain_code.py b/providers/nodeinfo/system/domain_code.py index 28b2181..31074ae 100644 --- a/providers/nodeinfo/system/domain_code.py +++ b/providers/nodeinfo/system/domain_code.py @@ -2,10 +2,7 @@ class Source(providers.DataSource): def required_args(self): - return ['batadv_dev', 'domain_code', 'known_codes'] + return ['domain_code'] - def call(self, batadv_dev, domain_code, known_codes): - try: - return known_codes[batadv_dev] - except KeyError: - return domain_code + def call(self, domain_code): + return domain_code diff --git a/respondd.conf.example b/respondd.conf.example new file mode 100644 index 0000000..b30f146 --- /dev/null +++ b/respondd.conf.example @@ -0,0 +1,47 @@ +# Default settings +[Defaults] +# Listen port +# optional, default: 1001 +Port: 1001 +# Default link local listen addresses +# optional, default: ff02::2:1001 +MulticastLinkAddress: ff02::2:1001 +# Default site local listen addresses +# optional, default: ff05::2:1001 +MulticastSiteAddress: ff05::2:1001 +# Default domain to use +# optional, if specified incoming requests that can not be mapped to a domain +# are mapped to this domain +DefaultDomain: ffki +# Default domain type +# optional, default: simple +# supported domain types are: simple, batadv +DomainType: batadv +# Default ddhcpd IPv4 gateway address +# optional +IPv4Gateway: 10.116.128.8 + +# A domain +# User your own domain name here +[ffki] +# This is a batman domain +# optional, default: @Defaults.DomainType +# supported domain types are: simple, batadv +DomainType: batadv +# Link local listen addresses +# optional, default: @Defaults.MulticastLinkAddress +MulticastLinkAddress: ff02::2:1001 +# Site local listen addresses +# optional, default: @Defautls.MulticastSiteAddress +MulticastSiteAddress: ff05::2:1001 +# Batman interface +# only for batadv domains, defaults to bat- +BatmanInterface: bat-ffki +# Other listen interfaces +# optional, specify comma separated list of listen/multicast interfaces for this domain here +Interfaces: mvpn-ffki +# IPv4 gateway option for ddhcpd +# optional, default: @IPv4Gateway +IPv4Gateway: 10.116.128.8 + +# An arbitrary number of further domains may follow here diff --git a/respondd.py b/respondd.py index e27ef05..265bfef 100755 --- a/respondd.py +++ b/respondd.py @@ -9,10 +9,12 @@ import os from zlib import compress +from config import Config +from domain import BatadvDomain, DomainRegistry from providers import get_providers import util -def get_handler(providers, batadv_ifaces, batadv_mesh_ipv4_overrides, env): +def get_handler(providers): class ResponddUDPHandler(socketserver.BaseRequestHandler): def multi_request(self, providernames, local_env): ret = {} @@ -30,21 +32,21 @@ def handle(self): ifindex = self.request[2] response = None - # Find batman interface the query belongs to - batadv_dev = util.ifindex_to_batiface(ifindex, batadv_ifaces) - if batadv_dev == None: - return + iface = util.ifindex_to_iface(ifindex) - # Clone global environment and populate with interface-specific data - local_env = dict(env) - local_env['batadv_dev'] = batadv_dev - if batadv_dev in batadv_mesh_ipv4_overrides: - local_env['mesh_ipv4'] = batadv_mesh_ipv4_overrides[batadv_dev] + domain = DomainRegistry.get_instance().get_domain_by_interface(iface) + if not domain: + # Try default domain, ignore request if not configured + domain = DomainRegistry.get_instance().get_default_domain() + if not domain: + return + + provider_env = domain.get_provider_args() if data.startswith("GET "): - response = self.multi_request(data.split(" ")[1:], local_env) + response = self.multi_request(data.split(" ")[1:], provider_env) else: - answer = providers[data].call(local_env) + answer = providers[data].call(provider_env) if answer: response = str.encode(json.dumps(answer)) @@ -56,63 +58,29 @@ def handle(self): if __name__ == "__main__": parser = argparse.ArgumentParser(usage=""" %(prog)s -h - %(prog)s [-p ] [-g ] [-i [%%]] [-i [%%] ..] [-d ] [-b [:][:] [-n ] [-c ] ..]""") - parser.add_argument('-p', dest='port', - default=1001, type=int, metavar='', - help='port number to listen on (default 1001)') - parser.add_argument('-g', dest='link_group', - default='ff02::2:1001', metavar='', - help='link-local multicast group (default ff02::2:1001), set to emtpy string to disable') - parser.add_argument('-s', dest='site_group', - default='ff05::2:1001', metavar='', - help='site-local multicast group (default ff05::2:1001), set to empty string to disable') - parser.add_argument('-i', dest='mcast_ifaces', - action='append', default=[ 'bat0' ], metavar='', - help='listening interface (default bat0), may be specified multiple times') + %(prog)s [-f ] [-d ]""") + parser.add_argument('-f', dest='config', + default='./respondd.conf', metavar='', + help='config file to use (default: $PWD/respondd.conf)') parser.add_argument('-d', dest='directory', default='./providers', metavar='', help='data provider directory (default: $PWD/providers)') - parser.add_argument('-b', dest='batadv_ifaces', - action='append', default=[ 'bat0' ], metavar='', - help='batman-adv interface to answer for (default: bat0). Specify once per domain') - parser.add_argument('-m', dest='mesh_ipv4', - metavar='', - help='mesh ipv4 address') - parser.add_argument('-n', dest='domain_code', metavar='', - help='(default) domain code for system/domain_code') - parser.add_argument('-c', dest='domain_code_file', metavar='', - help='domain_code.json path (if info is not in file, fallback to -n\'s value)') args = parser.parse_args() - # Read domain-codes from file - known_codes = util.read_domainfile(args.domain_code_file) - - # Extract batman interfaces from commandline parameters - # and overwrite domain-codes from file with commandline arguments - batadv_mesh_ipv4_overrides = { } - batadv_ifaces = [ ] - for ifspec in args.batadv_ifaces: - iface, *left_over = ifspec.split(':') - batadv_ifaces.append(iface) - try: - # if left_over list is not empty, there is at least an override address - possible_override = left_over.pop(0) - # this clause is necessary in case one does not specify an ipv4 override, but a domain-code - if '' != possible_override: - batadv_mesh_ipv4_overrides[iface] = possible_override - # if left_over list is not empty, there is a domain_code - known_codes[iface] = left_over.pop(0) - except IndexError: - continue - - global_handler_env = { 'domain_code': args.domain_code, 'known_codes': known_codes, 'mesh_ipv4': args.mesh_ipv4 } + config = Config.from_file(args.config) + for domname in config.get_domain_names(): + domcfg = config.get_domain_config(domname) + domain = domcfg.domain_type(domcfg) + DomainRegistry.get_instance().add_domain(domain) + if domname == config.get_default_domain(): + DomainRegistry.get_instance().set_default_domain(domain) metasocketserver.MetadataUDPServer.address_family = socket.AF_INET6 metasocketserver.MetadataUDPServer.allow_reuse_address = True server = metasocketserver.MetadataUDPServer( - ("", args.port), - get_handler(get_providers(args.directory), batadv_ifaces, batadv_mesh_ipv4_overrides, global_handler_env) + ("", config.get_port()), + get_handler(get_providers(args.directory)) ) server.daemon_threads = True @@ -125,22 +93,12 @@ def join_group(mcast_group, if_index=0): mreq ) - # Extract multicast interfaces from commandline parameters - mcast_iface_groups = { } - for ifspec in args.mcast_ifaces: - iface, *groups = reversed(ifspec.split('%')) - # Populate with default link and site mcast groups if entry not yet created - if not iface in mcast_iface_groups: - mcast_iface_groups[iface] = [ group for group in [ args.link_group, args.site_group ] if len(group) > 0 ] - # Append group specified on commndline - mcast_iface_groups[iface] += groups - for (if_index, if_name) in socket.if_nameindex(): # Check if daemon should listen on interface - if if_name in mcast_iface_groups: - groups = mcast_iface_groups[if_name] + if if_name in DomainRegistry.get_instance().get_interfaces(): + dom = DomainRegistry.get_instance().get_domain_by_interface(if_name) # Join all multicast groups specified for this interface - for group in groups: - join_group(group, if_index) + join_group(dom.get_multicast_address_link(), if_index) + join_group(dom.get_multicast_address_site(), if_index) server.serve_forever() diff --git a/util.py b/util.py index a8e386c..e35e721 100644 --- a/util.py +++ b/util.py @@ -35,7 +35,6 @@ def ifindex_to_iface(if_index): return iface return None - def iface_match_recursive(iface, candidates): """Check if iface has any connection with an interface from candidates through master/slave relationship and return the name of the first @@ -73,17 +72,3 @@ def ifindex_to_batiface(if_index, batman_ifaces): if iface in batman_ifaces or iface == None: return iface return iface_match_recursive(iface, batman_ifaces) - -def read_domainfile(dcf_path): - """Read a json file which holds all currently known assignments of - bat interfaces to domains as a dictionary within a dict and below its key 'domaincodes' - and return it as python dict. - Return an empty dict, if the given path was None. - """ - if dcf_path is None: - return {} - with open(dcf_path, "r") as dc_file: - try: - return json.load(dc_file)["domaincodes"] - except KeyError: - return {}