diff options
author | sskaje <sskaje@gmail.com> | 2024-12-31 10:44:01 +0800 |
---|---|---|
committer | Christian Breunig <christian@breunig.cc> | 2025-01-19 00:17:12 +0100 |
commit | 2212a438b234f34f32e08efef2f841ba55a3b6a0 (patch) | |
tree | 47528dafb6733efb134a5ceee51e52118be7896f | |
parent | 4d3e976271e30d70c8b2660d869a220de98d8c59 (diff) | |
download | vyos-1x-2212a438b234f34f32e08efef2f841ba55a3b6a0.tar.gz vyos-1x-2212a438b234f34f32e08efef2f841ba55a3b6a0.zip |
wireguard: T4930: allow peers via FQDN
* set interfaces wireguard wgXX peer YY hostname <fqdn>
-rw-r--r-- | interface-definitions/interfaces_wireguard.xml.in | 25 | ||||
-rw-r--r-- | op-mode-definitions/reset-wireguard.xml.in | 34 | ||||
-rw-r--r-- | python/vyos/ifconfig/control.py | 4 | ||||
-rw-r--r-- | python/vyos/ifconfig/wireguard.py | 196 | ||||
-rwxr-xr-x | src/conf_mode/interfaces_wireguard.py | 40 | ||||
-rwxr-xr-x | src/conf_mode/nat.py | 8 | ||||
-rwxr-xr-x | src/op_mode/reset_wireguard.py | 55 | ||||
-rwxr-xr-x | src/services/vyos-domain-resolver | 48 |
8 files changed, 360 insertions, 50 deletions
diff --git a/interface-definitions/interfaces_wireguard.xml.in b/interface-definitions/interfaces_wireguard.xml.in index ce49de038..4f8b6c751 100644 --- a/interface-definitions/interfaces_wireguard.xml.in +++ b/interface-definitions/interfaces_wireguard.xml.in @@ -40,6 +40,19 @@ </properties> <defaultValue>0</defaultValue> </leafNode> + <leafNode name="max-dns-retry"> + <properties> + <help>DNS retries when resolve fails</help> + <valueHelp> + <format>u32:1-15</format> + <description>Maximum number of retries</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-15"/> + </constraint> + </properties> + <defaultValue>3</defaultValue> + </leafNode> <leafNode name="private-key"> <properties> <help>Base64 encoded private key</help> @@ -104,6 +117,18 @@ </constraint> </properties> </leafNode> + <leafNode name="host-name"> + <properties> + <help>Hostname of tunnel endpoint</help> + <valueHelp> + <format>hostname</format> + <description>FQDN of WireGuard endpoint</description> + </valueHelp> + <constraint> + <validator name="fqdn"/> + </constraint> + </properties> + </leafNode> #include <include/port-number.xml.i> <leafNode name="persistent-keepalive"> <properties> diff --git a/op-mode-definitions/reset-wireguard.xml.in b/op-mode-definitions/reset-wireguard.xml.in new file mode 100644 index 000000000..c2243f519 --- /dev/null +++ b/op-mode-definitions/reset-wireguard.xml.in @@ -0,0 +1,34 @@ +<?xml version="1.0"?> +<interfaceDefinition> + <node name="reset"> + <children> + <node name="wireguard"> + <properties> + <help>Reset WireGuard Peers</help> + </properties> + <children> + <tagNode name="interface"> + <properties> + <help>WireGuard interface name</help> + <completionHelp> + <path>interfaces wireguard</path> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/reset_wireguard.py reset_peer --interface="$4"</command> + <children> + <tagNode name="peer"> + <properties> + <help>WireGuard peer name</help> + <completionHelp> + <path>interfaces wireguard ${COMP_WORDS[3]} peer</path> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/reset_wireguard.py reset_peer --interface="$4" --peer="$6"</command> + </tagNode> + </children> + </tagNode> + </children> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/python/vyos/ifconfig/control.py b/python/vyos/ifconfig/control.py index 7402da55a..a886c1b9e 100644 --- a/python/vyos/ifconfig/control.py +++ b/python/vyos/ifconfig/control.py @@ -48,7 +48,7 @@ class Control(Section): def _popen(self, command): return popen(command, self.debug) - def _cmd(self, command): + def _cmd(self, command, env=None): import re if 'netns' in self.config: # This command must be executed from default netns 'ip link set dev X netns X' @@ -61,7 +61,7 @@ class Control(Section): command = command else: command = f'ip netns exec {self.config["netns"]} {command}' - return cmd(command, self.debug) + return cmd(command, self.debug, env=env) def _get_command(self, config, name): """ diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index 519012625..9d0bd1a6c 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -25,6 +25,7 @@ from hurry.filesize import alternative from vyos.ifconfig import Interface from vyos.ifconfig import Operational from vyos.template import is_ipv6 +from vyos.template import is_ipv4 class WireGuardOperational(Operational): def _dump(self): @@ -90,12 +91,15 @@ class WireGuardOperational(Operational): c.set_level(['interfaces', 'wireguard', self.config['ifname']]) description = c.return_effective_value(['description']) ips = c.return_effective_values(['address']) + hostnames = c.return_effective_values(['host-name']) answer = 'interface: {}\n'.format(self.config['ifname']) if description: answer += ' description: {}\n'.format(description) if ips: answer += ' address: {}\n'.format(', '.join(ips)) + if hostnames: + answer += ' hostname: {}\n'.format(', '.join(hostnames)) answer += ' public key: {}\n'.format(wgdump['public_key']) answer += ' private key: (hidden)\n' @@ -155,6 +159,94 @@ class WireGuardOperational(Operational): answer += '\n' return answer + def get_latest_handshakes(self): + """Get latest handshake time for each peer""" + output = {} + + # Dump wireguard last handshake + tmp = self._cmd(f'wg show {self.ifname} latest-handshakes') + # Output: + # PUBLIC-KEY= 1732812147 + for line in tmp.split('\n'): + if not line: + # Skip empty lines and last line + continue + items = line.split('\t') + + if len(items) != 2: + continue + + output[items[0]] = int(items[1]) + + return output + + def reset_peer(self, peer_name=None, public_key=None): + from vyos.configquery import ConfigTreeQuery + + c = ConfigTreeQuery() + + max_dns_retry = c.value( + ['interfaces', 'wireguard', self.ifname, 'max-dns-retry'] + ) + if max_dns_retry is None: + max_dns_retry = 3 + + current_peers = self._dump().get(self.ifname, {}).get('peers', {}) + + for peer in c.list_nodes(['interfaces', 'wireguard', self.ifname, 'peer']): + peer_public_key = c.value( + ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'public-key'] + ) + if peer_name is None or peer == peer_name or public_key == peer_public_key: + address = c.value( + ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'address'] + ) + host_name = c.value( + ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'host-name'] + ) + port = c.value( + ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'port'] + ) + + if (not address and not host_name) or not port: + if peer_name is not None: + print(f'Peer {peer_name} endpoint not set') + continue + + # address has higher priority than host-name + if address: + new_endpoint = f'{address}:{port}' + else: + new_endpoint = f'{host_name}:{port}' + + if c.exists( + ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'disable'] + ): + continue + + cmd = f'wg set {self.ifname} peer {peer_public_key} endpoint {new_endpoint}' + try: + if ( + peer_public_key in current_peers + and 'endpoint' in current_peers[peer_public_key] + and current_peers[peer_public_key]['endpoint'] is not None + ): + current_endpoint = current_peers[peer_public_key]['endpoint'] + message = f'Resetting {self.ifname} peer {peer_public_key} from {current_endpoint} endpoint to {new_endpoint} ... ' + else: + message = f'Resetting {self.ifname} peer {peer_public_key} endpoint to {new_endpoint} ... ' + print( + message, + end='', + ) + + self._cmd( + cmd, env={'WG_ENDPOINT_RESOLUTION_RETRIES': str(max_dns_retry)} + ) + print('done') + except: + print(f'Error\nPlease try to run command manually:\n{cmd}\n') + @Interface.register class WireGuardIf(Interface): @@ -186,16 +278,23 @@ class WireGuardIf(Interface): tmp_file.flush() # Wireguard base command is identical for every peer - base_cmd = 'wg set {ifname}' + base_cmd = 'wg set ' + config['ifname'] + max_dns_retry = config['max_dns_retry'] if 'max_dns_retry' in config else 3 + + interface_cmd = base_cmd if 'port' in config: - base_cmd += ' listen-port {port}' + interface_cmd += ' listen-port {port}' if 'fwmark' in config: - base_cmd += ' fwmark {fwmark}' + interface_cmd += ' fwmark {fwmark}' - base_cmd += f' private-key {tmp_file.name}' - base_cmd = base_cmd.format(**config) + interface_cmd += f' private-key {tmp_file.name}' + interface_cmd = interface_cmd.format(**config) # T6490: execute command to ensure interface configured - self._cmd(base_cmd) + self._cmd(interface_cmd) + + # If no PSK is given remove it by using /dev/null - passing keys via + # the shell (usually bash) is considered insecure, thus we use a file + no_psk_file = '/dev/null' if 'peer' in config: for peer, peer_config in config['peer'].items(): @@ -203,43 +302,62 @@ class WireGuardIf(Interface): # marked as disabled - also active sessions are terminated as # the public key was already removed when entering this method! if 'disable' in peer_config: + # remove peer if disabled, no error report even if peer not exists + cmd = base_cmd + ' peer {public_key} remove' + self._cmd(cmd.format(**peer_config)) continue - # start of with a fresh 'wg' command - cmd = base_cmd + ' peer {public_key}' - - # If no PSK is given remove it by using /dev/null - passing keys via - # the shell (usually bash) is considered insecure, thus we use a file - no_psk_file = '/dev/null' psk_file = no_psk_file - if 'preshared_key' in peer_config: - psk_file = '/tmp/tmp.wireguard.psk' - with open(psk_file, 'w') as f: - f.write(peer_config['preshared_key']) - cmd += f' preshared-key {psk_file}' - - # Persistent keepalive is optional - if 'persistent_keepalive' in peer_config: - cmd += ' persistent-keepalive {persistent_keepalive}' - - # Multiple allowed-ip ranges can be defined - ensure we are always - # dealing with a list - if isinstance(peer_config['allowed_ips'], str): - peer_config['allowed_ips'] = [peer_config['allowed_ips']] - cmd += ' allowed-ips ' + ','.join(peer_config['allowed_ips']) - - # Endpoint configuration is optional - if {'address', 'port'} <= set(peer_config): - if is_ipv6(peer_config['address']): - cmd += ' endpoint [{address}]:{port}' - else: - cmd += ' endpoint {address}:{port}' - - self._cmd(cmd.format(**peer_config)) - # PSK key file is not required to be stored persistently as its backed by CLI - if psk_file != no_psk_file and os.path.exists(psk_file): - os.remove(psk_file) + # start of with a fresh 'wg' command + peer_cmd = base_cmd + ' peer {public_key}' + + try: + cmd = peer_cmd + + if 'preshared_key' in peer_config: + psk_file = '/tmp/tmp.wireguard.psk' + with open(psk_file, 'w') as f: + f.write(peer_config['preshared_key']) + cmd += f' preshared-key {psk_file}' + + # Persistent keepalive is optional + if 'persistent_keepalive' in peer_config: + cmd += ' persistent-keepalive {persistent_keepalive}' + + # Multiple allowed-ip ranges can be defined - ensure we are always + # dealing with a list + if isinstance(peer_config['allowed_ips'], str): + peer_config['allowed_ips'] = [peer_config['allowed_ips']] + cmd += ' allowed-ips ' + ','.join(peer_config['allowed_ips']) + + self._cmd(cmd.format(**peer_config)) + + cmd = peer_cmd + + # Ensure peer is created even if dns not working + if {'address', 'port'} <= set(peer_config): + if is_ipv6(peer_config['address']): + cmd += ' endpoint [{address}]:{port}' + elif is_ipv4(peer_config['address']): + cmd += ' endpoint {address}:{port}' + else: + # don't set endpoint if address uses domain name + continue + elif {'host_name', 'port'} <= set(peer_config): + cmd += ' endpoint {host_name}:{port}' + + self._cmd( + cmd.format(**peer_config), + env={'WG_ENDPOINT_RESOLUTION_RETRIES': str(max_dns_retry)}, + ) + except: + # todo: logging + pass + finally: + # PSK key file is not required to be stored persistently as its backed by CLI + if psk_file != no_psk_file and os.path.exists(psk_file): + os.remove(psk_file) # call base class super().update(config) diff --git a/src/conf_mode/interfaces_wireguard.py b/src/conf_mode/interfaces_wireguard.py index b6fd6b0b2..1dbaa9d4e 100755 --- a/src/conf_mode/interfaces_wireguard.py +++ b/src/conf_mode/interfaces_wireguard.py @@ -29,11 +29,12 @@ from vyos.ifconfig import WireGuardIf from vyos.utils.kernel import check_kmod from vyos.utils.network import check_port_availability from vyos.utils.network import is_wireguard_key_pair +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag +from pathlib import Path airbag.enable() - def get_config(config=None): """ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the @@ -54,6 +55,12 @@ def get_config(config=None): if is_node_changed(conf, base + [ifname, 'peer']): wireguard.update({'rebuild_required': {}}) + wireguard['peers_need_resolve'] = [] + if 'peer' in wireguard: + for peer, peer_config in wireguard['peer'].items(): + if 'disable' not in peer_config and 'host_name' in peer_config: + wireguard['peers_need_resolve'].append(peer) + return wireguard def verify(wireguard): @@ -82,16 +89,15 @@ def verify(wireguard): for tmp in wireguard['peer']: peer = wireguard['peer'][tmp] + if 'host_name' in peer and 'address' in peer: + raise ConfigError('"host-name" and "address" are mutually exclusive') + if 'allowed_ips' not in peer: raise ConfigError(f'Wireguard allowed-ips required for peer "{tmp}"!') if 'public_key' not in peer: raise ConfigError(f'Wireguard public-key required for peer "{tmp}"!') - if ('address' in peer and 'port' not in peer) or ('port' in peer and 'address' not in peer): - raise ConfigError('Both Wireguard port and address must be defined ' - f'for peer "{tmp}" if either one of them is set!') - if peer['public_key'] in public_keys: raise ConfigError(f'Duplicate public-key defined on peer "{tmp}"') @@ -99,6 +105,13 @@ def verify(wireguard): if is_wireguard_key_pair(wireguard['private_key'], peer['public_key']): raise ConfigError(f'Peer "{tmp}" has the same public key as the interface "{wireguard["ifname"]}"') + if 'port' not in peer: + if 'host_name' in peer or 'address' in peer: + raise ConfigError(f'Missing "host-name" or "address" on peer "{tmp}"') + else: + if 'host_name' not in peer and 'address' not in peer: + raise ConfigError(f'Missing "host-name" and "address" on peer "{tmp}"') + public_keys.append(peer['public_key']) def generate(wireguard): @@ -122,6 +135,23 @@ def apply(wireguard): wg = WireGuardIf(**wireguard) wg.update(wireguard) + domain_resolver_usage = '/run/use-vyos-domain-resolver-interfaces-wireguard-' + wireguard['ifname'] + + ## DOMAIN RESOLVER + domain_action = 'restart' + if 'peers_need_resolve' in wireguard and len(wireguard['peers_need_resolve']) > 0 and 'disable' not in wireguard: + from vyos.utils.file import write_file + + text = f'# Automatically generated by interfaces_wireguard.py\nThis file indicates that vyos-domain-resolver service is used by the interfaces_wireguard.\n' + text += "intefaces:\n" + "".join([f" - {peer}\n" for peer in wireguard['peers_need_resolve']]) + Path(domain_resolver_usage).write_text(text) + write_file(domain_resolver_usage, text) + else: + Path(domain_resolver_usage).unlink(missing_ok=True) + if not Path('/run').glob('use-vyos-domain-resolver*'): + domain_action = 'stop' + call(f'systemctl {domain_action} vyos-domain-resolver.service') + return None if __name__ == '__main__': diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 98b2f3f29..504b3e82a 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -17,6 +17,7 @@ import os from sys import exit +from pathlib import Path from vyos.base import Warning from vyos.config import Config @@ -43,7 +44,6 @@ k_mod = ['nft_nat', 'nft_chain_nat'] nftables_nat_config = '/run/nftables_nat.conf' nftables_static_nat_conf = '/run/nftables_static-nat-rules.nft' domain_resolver_usage = '/run/use-vyos-domain-resolver-nat' -domain_resolver_usage_firewall = '/run/use-vyos-domain-resolver-firewall' valid_groups = [ 'address_group', @@ -265,9 +265,9 @@ def apply(nat): text = f'# Automatically generated by nat.py\nThis file indicates that vyos-domain-resolver service is used by nat.\n' write_file(domain_resolver_usage, text) elif os.path.exists(domain_resolver_usage): - os.unlink(domain_resolver_usage) - if not os.path.exists(domain_resolver_usage_firewall): - # Firewall not using domain resolver + Path(domain_resolver_usage).unlink(missing_ok=True) + + if not Path('/run').glob('use-vyos-domain-resolver*'): domain_action = 'stop' call(f'systemctl {domain_action} vyos-domain-resolver.service') diff --git a/src/op_mode/reset_wireguard.py b/src/op_mode/reset_wireguard.py new file mode 100755 index 000000000..1fcfb31b5 --- /dev/null +++ b/src/op_mode/reset_wireguard.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 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 <http://www.gnu.org/licenses/>. + +import sys +import typing + +import vyos.opmode + +from vyos.ifconfig import WireGuardIf +from vyos.configquery import ConfigTreeQuery + + +def _verify(func): + """Decorator checks if WireGuard interface config exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + interface = kwargs.get('interface') + if not config.exists(['interfaces', 'wireguard', interface]): + unconf_message = f'WireGuard interface {interface} is not configured' + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + return func(*args, **kwargs) + + return _wrapper + + +@_verify +def reset_peer(interface: str, peer: typing.Optional[str] = None): + intf = WireGuardIf(interface, create=False, debug=False) + return intf.operational.reset_peer(peer) + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/services/vyos-domain-resolver b/src/services/vyos-domain-resolver index bc74a05d1..6eab7e7e5 100755 --- a/src/services/vyos-domain-resolver +++ b/src/services/vyos-domain-resolver @@ -27,12 +27,14 @@ from vyos.utils.dict import dict_search_args from vyos.utils.process import cmd from vyos.utils.process import run from vyos.xml_ref import get_defaults +from vyos.template import is_ip base = ['firewall'] timeout = 300 cache = False base_firewall = ['firewall'] base_nat = ['nat'] +base_interfaces = ['interfaces'] domain_state = {} @@ -171,6 +173,50 @@ def update_fqdn(config, node): logger.info(f'Updated {count} sets in {node} - result: {code}') +def update_interfaces(config, node): + if node == 'interfaces': + wireguard_interfaces = dict_search_args(config, 'wireguard') + + # WireGuard redo handshake usually every 180 seconds, but not documented officially. + # If peer with domain name in its endpoint didn't get handshake for over 300 seconds, + # we do re-resolv and reset its endpoint from config tree. + handshake_threshold = 300 + + from vyos.ifconfig import WireGuardIf + + check_wireguard_peer_public_keys = {} + # for each wireguard interfaces + for interface, wireguard in wireguard_interfaces.items(): + check_wireguard_peer_public_keys[interface] = [] + for peer, peer_config in wireguard['peer'].items(): + # check peer if peer host-name or address is set + if 'host-name' in peer_config or 'address' in peer_config: + # check latest handshake + check_wireguard_peer_public_keys[interface].append( + peer_config['public_key'] + ) + + now_time = time.time() + for ( + interface, + check_peer_public_keys + ) in check_wireguard_peer_public_keys.items(): + if len(check_peer_public_keys) == 0: + continue + + intf = WireGuardIf(interface, create=False, debug=False) + handshakes = intf.operational.get_latest_handshakes() + + for public_key, handshake_time in handshakes.items(): + if public_key in check_peer_public_keys and ( + handshake_time == 0 + or now_time - handshake_time > handshake_threshold + ): + intf.operational.reset_peer(public_key=public_key) + + print(f'Wireguard: reset {interface} peer {public_key}') + + if __name__ == '__main__': logger.info(f'VyOS domain resolver') @@ -184,10 +230,12 @@ if __name__ == '__main__': conf = ConfigTreeQuery() firewall = get_config(conf, base_firewall) nat = get_config(conf, base_nat) + interfaces = get_config(conf, base_interfaces) logger.info(f'interval: {timeout}s - cache: {cache}') while True: update_fqdn(firewall, 'firewall') update_fqdn(nat, 'nat') + update_interfaces(interfaces, 'interfaces') time.sleep(timeout) |