From b57b048623d0c336ed7e4b9293cab32ed82324e3 Mon Sep 17 00:00:00 2001 From: jack9603301 Date: Thu, 25 Mar 2021 19:20:49 +0800 Subject: upnpd: T3420: Implement features --- data/configd-include.json | 1 + data/templates/firewall/upnpd.conf.tmpl | 172 +++++++++++++++++++++++++++++ smoketest/scripts/cli/test_service_upnp.py | 71 ++++++++++++ src/conf_mode/service_upnp.py | 155 ++++++++++++++++++++++++++ src/systemd/miniupnpd.service | 13 +++ 5 files changed, 412 insertions(+) create mode 100644 data/templates/firewall/upnpd.conf.tmpl create mode 100755 smoketest/scripts/cli/test_service_upnp.py create mode 100755 src/conf_mode/service_upnp.py create mode 100644 src/systemd/miniupnpd.service diff --git a/data/configd-include.json b/data/configd-include.json index 6893aaa86..6f00b8492 100644 --- a/data/configd-include.json +++ b/data/configd-include.json @@ -53,6 +53,7 @@ "service_mdns-repeater.py", "service_pppoe-server.py", "service_router-advert.py", +"service_upnp.py", "ssh.py", "system-ip.py", "system-ipv6.py", diff --git a/data/templates/firewall/upnpd.conf.tmpl b/data/templates/firewall/upnpd.conf.tmpl new file mode 100644 index 000000000..39cb21373 --- /dev/null +++ b/data/templates/firewall/upnpd.conf.tmpl @@ -0,0 +1,172 @@ +# This is the UPNP configuration file + +# WAN network interface +ext_ifname={{ wan_interface }} +{% if wan_ip is defined %} +# If the WAN interface has several IP addresses, you +# can specify the one to use below +{% for addr in wan_ip %} +ext_ip={{ addr }} +{% endfor %} +{% endif %} + +# LAN network interfaces IPs / networks +{% if listen is defined %} +# There can be multiple listening IPs for SSDP traffic, in that case +# use multiple 'listening_ip=...' lines, one for each network interface. +# It can be IP address or network interface name (ie. "eth0") +# It is mandatory to use the network interface name in order to enable IPv6 +# HTTP is available on all interfaces. +# When MULTIPLE_EXTERNAL_IP is enabled, the external IP +# address associated with the subnet follows. For example: +# listening_ip=192.168.0.1/24 88.22.44.13 +{% for addr in listen %} +{% if addr | is_ipv4 %} +listening_ip={{ addr }} +{% elif addr | is_ipv6 %} +ipv6_listening_ip={{ addr }} +{% else %} +listening_ip={{ addr }} +{% endif %} +{% endfor %} +{% endif %} + +# CAUTION: mixing up WAN and LAN interfaces may introduce security risks! +# Be sure to assign the correct interfaces to LAN and WAN and consider +# implementing UPnP permission rules at the bottom of this configuration file + +# Port for HTTP (descriptions and SOAP) traffic. Set to 0 for autoselect. +#http_port=0 +# Port for HTTPS. Set to 0 for autoselect (default) +#https_port=0 + +# Path to the UNIX socket used to communicate with MiniSSDPd +# If running, MiniSSDPd will manage M-SEARCH answering. +# default is /var/run/minissdpd.sock +#minissdpdsocket=/var/run/minissdpd.sock + +{% if nat_pmp is defined %} +# Enable NAT-PMP support (default is no) +enable_natpmp=yes +{% endif %} + +# Enable UPNP support (default is yes) +enable_upnp=yes + +{% if pcp_lifetime is defined %} +# PCP +# Configure the minimum and maximum lifetime of a port mapping in seconds +# 120s and 86400s (24h) are suggested values from PCP-base +{% if pcp_lifetime.max is defined %} +max_lifetime={{ pcp_lifetime.max }} +{% endif %} +{% if pcp_lifetime.min is defined %} +min_lifetime={{ pcp_lifetime.min }} +{% endif %} +{% endif %} + + +# To enable the next few runtime options, see compile time +# ENABLE_MANUFACTURER_INFO_CONFIGURATION (config.h) + +{% if friendly_name is defined %} +# Name of this service, default is "`uname -s` router" +friendly_name= {{ friendly_name }} +{% endif %} + +# Manufacturer name, default is "`uname -s`" +manufacturer_name=VyOS + +# Manufacturer URL, default is URL of OS vendor +manufacturer_url=https://vyos.io/ + +# Model name, default is "`uname -s` router" +model_name=VyOS Router Model + +# Model description, default is "`uname -s` router" +model_description=Vyos open source enterprise router/firewall operating system + +# Model URL, default is URL of OS vendor +model_url=https://vyos.io/ + +{% if secure_mode is defined %} +# Secure Mode, UPnP clients can only add mappings to their own IP +secure_mode=yes +{% else %} +# Secure Mode, UPnP clients can only add mappings to their own IP +secure_mode=no +{% endif %} + +{% if presentation_url is defined %} +# Default presentation URL is HTTP address on port 80 +# If set to an empty string, no presentationURL element will appear +# in the XML description of the device, which prevents MS Windows +# from displaying an icon in the "Network Connections" panel. +#presentation_url= {{ presentation_url }} +{% endif %} + +# Report system uptime instead of daemon uptime +system_uptime=yes + +# Unused rules cleaning. +# never remove any rule before this threshold for the number +# of redirections is exceeded. default to 20 +clean_ruleset_threshold=10 +# Clean process work interval in seconds. default to 0 (disabled). +# a 600 seconds (10 minutes) interval makes sense +clean_ruleset_interval=600 + +# Anchor name in pf (default is miniupnpd) +anchor=VyOS + +uuid={{ uuid }} + +# Lease file location +lease_file=/config/upnp.leases + +# Daemon's serial and model number when reporting to clients +# (in XML description) +#serial=12345678 +#model_number=1 + +{% if rules is defined %} +# UPnP permission rules +# (allow|deny) (external port range) IP/mask (internal port range) +# A port range is - or if there is only +# one port in the range. +# IP/mask format must be nnn.nnn.nnn.nnn/nn +# It is advised to only allow redirection of port >= 1024 +# and end the rule set with "deny 0-65535 0.0.0.0/0 0-65535" +# The following default ruleset allows specific LAN side IP addresses +# to request only ephemeral ports. It is recommended that users +# modify the IP ranges to match their own internal networks, and +# also consider implementing network-specific restrictions +# CAUTION: failure to enforce any rules may permit insecure requests to be made! +{% for rule, config in rules.items() %} +{% if config.disable is defined %} +{{ config.action}} {{ config.external_port_range }} {{ config.ip }} {{ config.internal_port_range }} +{% endif %} +{% endfor %} +{% endif %} + +{% if stun is defined %} +# WAN interface must have public IP address. Otherwise it is behind NAT +# and port forwarding is impossible. In some cases WAN interface can be +# behind unrestricted NAT 1:1 when all incoming traffic is NAT-ed and +# routed to WAN interfaces without any filtering. In this cases miniupnpd +# needs to know public IP address and it can be learnt by asking external +# server via STUN protocol. Following option enable retrieving external +# public IP address from STUN server and detection of NAT type. You need +# to specify also external STUN server in stun_host option below. +# This option is disabled by default. +ext_perform_stun=yes +# Specify STUN server, either hostname or IP address +# Some public STUN servers: +# stun.stunprotocol.org +# stun.sipgate.net +# stun.xten.com +# stun.l.google.com (on non standard port 19302) +ext_stun_host={{ stun.host }} +# Specify STUN UDP port, by default it is standard port 3478. +ext_stun_port={{ stun.port }} +{% endif %} diff --git a/smoketest/scripts/cli/test_service_upnp.py b/smoketest/scripts/cli/test_service_upnp.py new file mode 100755 index 000000000..9fbbdaff9 --- /dev/null +++ b/smoketest/scripts/cli/test_service_upnp.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import re +import unittest + +from base_vyostest_shim import VyOSUnitTestSHIM + +from vyos.configsession import ConfigSession +from vyos.util import read_file +from vyos.util import process_named_running + +UPNP_CONF = '/run/upnp/miniupnp.conf' +interface = 'eth0' +base_path = ['service', 'upnp'] +address_base = ['interfaces', 'ethernet', interface, 'address'] + +class TestServiceUPnP(VyOSUnitTestSHIM.TestCase): + def tearDown(self): + self.cli_delete(address_base) + self.cli_delete(base_path) + self.cli_commit() + + def test_ipv4_base(self): + self.cli_set(address_base + ['100.64.0.1/24']) + self.cli_set(base_path + ['nat-pmp']) + self.cli_set(base_path + ['wan-interface', interface]) + self.cli_set(base_path + ['listen', interface]) + self.cli_commit() + + config = read_file(UPNP_CONF) + self.assertIn(f'ext_ifname={interface}', config) + self.assertIn(f'listening_ip={interface}', config) + self.assertIn(f'enable_natpmp=yes', config) + self.assertIn(f'enable_upnp=yes', config) + + # Check for running process + self.assertTrue(process_named_running('miniupnpd')) + + def test_ipv6_base(self): + self.cli_set(address_base + ['2001:db8::1/64']) + self.cli_set(base_path + ['nat-pmp']) + self.cli_set(base_path + ['wan-interface', interface]) + self.cli_set(base_path + ['listen', interface]) + self.cli_set(base_path + ['listen', '2001:db8::1']) + self.cli_commit() + + config = read_file(UPNP_CONF) + self.assertIn(f'ext_ifname={interface}', config) + self.assertIn(f'listening_ip={interface}', config) + self.assertIn(f'enable_natpmp=yes', config) + self.assertIn(f'enable_upnp=yes', config) + + # Check for running process + self.assertTrue(process_named_running('miniupnpd')) + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/src/conf_mode/service_upnp.py b/src/conf_mode/service_upnp.py new file mode 100755 index 000000000..8bf6f43b4 --- /dev/null +++ b/src/conf_mode/service_upnp.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os + +from sys import exit +import uuid +import netifaces +from ipaddress import IPv4Network +from ipaddress import IPv6Network + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.configdict import dict_search +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_vrf +from vyos.util import call +from vyos.template import render +from vyos.template import is_ipv4 +from vyos.template import is_ipv6 +from vyos.xml import defaults +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/run/upnp/miniupnp.conf' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'upnp'] + upnpd = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + + if not upnpd: + return None + + if dict_search('rule', upnpd): + default_member_values = defaults(base + ['rule']) + for rule,rule_config in upnpd['rule'].items(): + upnpd['rule'][rule] = dict_merge(default_member_values, upnpd['rule'][rule]) + + uuidgen = uuid.uuid1() + upnpd.update({'uuid': uuidgen}) + + return upnpd + +def get_all_interface_addr(prefix, filter_dev, filter_family): + list_addr = [] + interfaces = netifaces.interfaces() + + for interface in interfaces: + if filter_dev and interface in filter_dev: + continue + addrs = netifaces.ifaddresses(interface) + if netifaces.AF_INET in addrs.keys(): + if netifaces.AF_INET in filter_family: + for addr in addrs[netifaces.AF_INET]: + if prefix: + # we need to manually assemble a list of IPv4 address/prefix + prefix = '/' + \ + str(IPv4Network('0.0.0.0/' + addr['netmask']).prefixlen) + list_addr.append(addr['addr'] + prefix) + else: + list_addr.append(addr['addr']) + if netifaces.AF_INET6 in addrs.keys(): + if netifaces.AF_INET6 in filter_family: + for addr in addrs[netifaces.AF_INET6]: + if prefix: + # we need to manually assemble a list of IPv4 address/prefix + bits = bin(int(addr['netmask'].replace(':', ''), 16)).count('1') + prefix = '/' + str(bits) + list_addr.append(addr['addr'] + prefix) + else: + list_addr.append(addr['addr']) + + return list_addr + +def verify(upnpd): + if not upnpd: + return None + + if 'wan_interface' not in upnpd: + raise ConfigError('To enable UPNP, you must have the "wan-interface" option!') + + if dict_search('rules', upnpd): + for rule,rule_config in upnpd['rule'].items(): + for option in ['external_port_range', 'internal_port_range', 'ip', 'action']: + if option not in rule_config: + raise ConfigError(f'A UPNP rule must have an "{option}" option!') + + if dict_search('stun', upnpd): + for option in ['host', 'port']: + if option not in upnpd['stun']: + raise ConfigError(f'A UPNP stun support must have an "{option}" option!') + + # Check the validity of the IP address + listen_dev = [] + system_addrs_cidr = get_all_interface_addr(True, [], [netifaces.AF_INET, netifaces.AF_INET6]) + system_addrs = get_all_interface_addr(False, [], [netifaces.AF_INET, netifaces.AF_INET6]) + for listen_if_or_addr in upnpd['listen']: + if listen_if_or_addr not in netifaces.interfaces(): + listen_dev.append(listen_if_or_addr) + if (listen_if_or_addr not in system_addrs) and (listen_if_or_addr not in system_addrs_cidr) and (listen_if_or_addr not in netifaces.interfaces()): + if is_ipv4(listen_if_or_addr) and IPv4Network(listen_if_or_addr).is_multicast: + raise ConfigError(f'The address "{listen_if_or_addr}" is an address that is not allowed to listen on. It is not an interface address nor a multicast address!') + if is_ipv6(listen_if_or_addr) and IPv6Network(listen_if_or_addr).is_multicast: + raise ConfigError(f'The address "{listen_if_or_addr}" is an address that is not allowed to listen on. It is not an interface address nor a multicast address!') + + system_listening_dev_addrs_cidr = get_all_interface_addr(True, listen_dev, [netifaces.AF_INET6]) + system_listening_dev_addrs = get_all_interface_addr(False, listen_dev, [netifaces.AF_INET6]) + for listen_if_or_addr in upnpd['listen']: + if listen_if_or_addr not in netifaces.interfaces() and (listen_if_or_addr not in system_listening_dev_addrs_cidr) and (listen_if_or_addr not in system_listening_dev_addrs) and is_ipv6(listen_if_or_addr) and (not IPv6Network(listen_if_or_addr).is_multicast): + raise ConfigError(f'{listen_if_or_addr} must listen on the interface of the network card') + +def generate(upnpd): + if not upnpd: + return None + + if os.path.isfile(config_file): + os.unlink(config_file) + + render(config_file, 'firewall/upnpd.conf.tmpl', upnpd) + +def apply(upnpd): + if not upnpd: + # Stop the UPNP service + call('systemctl stop miniupnpd.service') + else: + # Start the UPNP service + call('systemctl restart miniupnpd.service') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/systemd/miniupnpd.service b/src/systemd/miniupnpd.service new file mode 100644 index 000000000..51cb2eed8 --- /dev/null +++ b/src/systemd/miniupnpd.service @@ -0,0 +1,13 @@ +[Unit] +Description=UPnP service +ConditionPathExists=/run/upnp/miniupnp.conf +After=vyos-router.service +StartLimitIntervalSec=0 + +[Service] +WorkingDirectory=/run/upnp +Type=simple +ExecStart=/usr/sbin/miniupnpd -d -f /run/upnp/miniupnp.conf +PrivateTmp=yes +PIDFile=/run/miniupnpd.pid +Restart=on-failure -- cgit v1.2.3