From 2212a438b234f34f32e08efef2f841ba55a3b6a0 Mon Sep 17 00:00:00 2001 From: sskaje Date: Tue, 31 Dec 2024 10:44:01 +0800 Subject: wireguard: T4930: allow peers via FQDN * set interfaces wireguard wgXX peer YY hostname --- python/vyos/ifconfig/control.py | 4 +- python/vyos/ifconfig/wireguard.py | 196 ++++++++++++++++++++++++++++++-------- 2 files changed, 159 insertions(+), 41 deletions(-) (limited to 'python') 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) -- cgit v1.2.3 From f01c4d0173bb49bfd5bd4f1ef5675cc8c597595a Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 18 Jan 2025 23:06:37 +0100 Subject: wireguard: T4930: add mnemonic for WIREGUARD_REKEY_AFTER_TIME WireGuard performs a handshake every WIREGUARD_REKEY_AFTER_TIME if data is being transmitted between the peers. If no data is transmitted, the handshake will not be initiated unless new data begins to flow. Each handshake generates a new session key, and the key is rotated at least every 120 seconds or upon data transmission after a prolonged silence. --- python/vyos/utils/kernel.py | 4 ++++ src/services/vyos-domain-resolver | 38 +++++++++++++++++--------------------- 2 files changed, 21 insertions(+), 21 deletions(-) (limited to 'python') diff --git a/python/vyos/utils/kernel.py b/python/vyos/utils/kernel.py index 847f80108..05eac8a6a 100644 --- a/python/vyos/utils/kernel.py +++ b/python/vyos/utils/kernel.py @@ -15,6 +15,10 @@ import os +# A list of used Kernel constants +# https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/drivers/net/wireguard/messages.h?h=linux-6.6.y#n45 +WIREGUARD_REKEY_AFTER_TIME = 120 + def check_kmod(k_mod): """ Common utility function to load required kernel modules on demand """ from vyos import ConfigError diff --git a/src/services/vyos-domain-resolver b/src/services/vyos-domain-resolver index 6eab7e7e5..a4b0869fa 100755 --- a/src/services/vyos-domain-resolver +++ b/src/services/vyos-domain-resolver @@ -22,12 +22,13 @@ from vyos.configdict import dict_merge from vyos.configquery import ConfigTreeQuery from vyos.firewall import fqdn_config_parse from vyos.firewall import fqdn_resolve +from vyos.ifconfig import WireGuardIf from vyos.utils.commit import commit_in_progress from vyos.utils.dict import dict_search_args +from vyos.utils.kernel import WIREGUARD_REKEY_AFTER_TIME 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 @@ -175,50 +176,45 @@ def update_fqdn(config, node): def update_interfaces(config, node): if node == 'interfaces': - wireguard_interfaces = dict_search_args(config, 'wireguard') + wg_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 = {} + peer_public_keys = {} # for each wireguard interfaces - for interface, wireguard in wireguard_interfaces.items(): - check_wireguard_peer_public_keys[interface] = [] + for interface, wireguard in wg_interfaces.items(): + 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_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(): + for (interface, check_peer_public_keys) in 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() + # WireGuard performs a handshake every WIREGUARD_REKEY_AFTER_TIME + # if data is being transmitted between the peers. If no data is + # transmitted, the handshake will not be initiated unless new + # data begins to flow. Each handshake generates a new session + # key, and the key is rotated at least every 120 seconds or + # upon data transmission after a prolonged silence. 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 + or (now_time - handshake_time > 3*WIREGUARD_REKEY_AFTER_TIME) ): intf.operational.reset_peer(public_key=public_key) - - print(f'Wireguard: reset {interface} peer {public_key}') + print(f'WireGuard: reset {interface} peer {public_key}') if __name__ == '__main__': - logger.info(f'VyOS domain resolver') + logger.info('VyOS domain resolver') count = 1 while commit_in_progress(): -- cgit v1.2.3 From 20f0deb28d3d88537171f869234520ceb4f67f01 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sun, 19 Jan 2025 00:12:03 +0100 Subject: wireguard: T4930: use get_config_dict() rather then individual config queries Extend ConfigTreeQuery().get_config_dict() with arguments to read in default CLI values, too. This removes the need for hardcoded default values at multiple places like: if max_dns_retry is None: max_dns_retry = 3 in this case. --- python/vyos/configquery.py | 9 +++-- python/vyos/ifconfig/wireguard.py | 73 ++++++++++++++------------------------- 2 files changed, 32 insertions(+), 50 deletions(-) (limited to 'python') diff --git a/python/vyos/configquery.py b/python/vyos/configquery.py index 5d6ca9be9..4c4ead0a3 100644 --- a/python/vyos/configquery.py +++ b/python/vyos/configquery.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors +# Copyright 2021-2025 VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -120,11 +120,14 @@ class ConfigTreeQuery(GenericConfigQuery): def get_config_dict(self, path=[], effective=False, key_mangling=None, get_first_key=False, no_multi_convert=False, - no_tag_node_value_mangle=False): + no_tag_node_value_mangle=False, with_defaults=False, + with_recursive_defaults=False): return self.config.get_config_dict(path, effective=effective, key_mangling=key_mangling, get_first_key=get_first_key, no_multi_convert=no_multi_convert, - no_tag_node_value_mangle=no_tag_node_value_mangle) + no_tag_node_value_mangle=no_tag_node_value_mangle, + with_defaults=with_defaults, + with_recursive_defaults=with_recursive_defaults) class VbashOpRun(GenericOpRun): def __init__(self): diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index 9d0bd1a6c..f5217aecb 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors +# Copyright 2019-2025 VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -22,6 +22,7 @@ from tempfile import NamedTemporaryFile from hurry.filesize import size from hurry.filesize import alternative +from vyos.configquery import ConfigTreeQuery from vyos.ifconfig import Interface from vyos.ifconfig import Operational from vyos.template import is_ipv6 @@ -181,53 +182,40 @@ class WireGuardOperational(Operational): 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 + tmp = c.get_config_dict(['interfaces', 'wireguard', self.ifname], + effective=True, get_first_key=True, + key_mangling=('-', '_'), with_defaults=True) 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'] - ) + for peer, peer_config in tmp['peer'].items(): + peer_public_key = peer_config['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 ('address' not in peer_config and 'host_name' not in peer_config) or 'port' not in peer_config: if peer_name is not None: - print(f'Peer {peer_name} endpoint not set') + print(f'WireGuard interface "{self.ifname}" peer "{peer_name}" address/host-name unset!') continue + # As we work with an effective config, a port CLI node is always + # available when an address/host-name is defined on the CLI + port = peer_config['port'] + # address has higher priority than host-name - if address: + if 'address' in peer_config: + address = peer_config['address'] new_endpoint = f'{address}:{port}' else: + host_name = peer_config['host_name'] new_endpoint = f'{host_name}:{port}' - if c.exists( - ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'disable'] - ): + if 'disable' in peer_config: + print(f'WireGuard interface "{self.ifname}" peer "{peer_name}" disabled!') continue cmd = f'wg set {self.ifname} peer {peer_public_key} endpoint {new_endpoint}' try: - if ( - peer_public_key in current_peers + 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 ): @@ -235,14 +223,10 @@ class WireGuardOperational(Operational): 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(message, end='') + + self._cmd(cmd, env={'WG_ENDPOINT_RESOLUTION_RETRIES': + tmp['max_dns_retry']}) print('done') except: print(f'Error\nPlease try to run command manually:\n{cmd}\n') @@ -272,15 +256,12 @@ class WireGuardIf(Interface): get_config_dict(). It's main intention is to consolidate the scattered interface setup code and provide a single point of entry when workin on any interface.""" - tmp_file = NamedTemporaryFile('w') tmp_file.write(config['private_key']) tmp_file.flush() # Wireguard base command is identical for every peer - base_cmd = 'wg set ' + config['ifname'] - max_dns_retry = config['max_dns_retry'] if 'max_dns_retry' in config else 3 - + base_cmd = f'wg set {self.ifname}' interface_cmd = base_cmd if 'port' in config: interface_cmd += ' listen-port {port}' @@ -347,10 +328,8 @@ class WireGuardIf(Interface): 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)}, - ) + self._cmd(cmd.format(**peer_config), env={ + 'WG_ENDPOINT_RESOLUTION_RETRIES': config['max_dns_retry']}) except: # todo: logging pass -- cgit v1.2.3 From 98414a69f0018915ac999f51975618dd5fbe817d Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sun, 19 Jan 2025 00:14:25 +0100 Subject: wireguard: T4930: drop unused WireGuardOperational().show_interface() method Method is not referenced in the code base, remove dead code. --- python/vyos/ifconfig/wireguard.py | 78 --------------------------------------- 1 file changed, 78 deletions(-) (limited to 'python') diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index f5217aecb..341fd32ff 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -82,84 +82,6 @@ class WireGuardOperational(Operational): } return output - def show_interface(self): - from vyos.config import Config - - c = Config() - - wgdump = self._dump().get(self.config['ifname'], None) - - 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' - answer += ' listening port: {}\n'.format(wgdump['listen_port']) - answer += '\n' - - for peer in c.list_effective_nodes(['peer']): - if wgdump['peers']: - pubkey = c.return_effective_value(['peer', peer, 'public-key']) - if pubkey in wgdump['peers']: - wgpeer = wgdump['peers'][pubkey] - - answer += ' peer: {}\n'.format(peer) - answer += ' public key: {}\n'.format(pubkey) - - """ figure out if the tunnel is recently active or not """ - status = 'inactive' - if wgpeer['latest_handshake'] is None: - """ no handshake ever """ - status = 'inactive' - else: - if int(wgpeer['latest_handshake']) > 0: - delta = timedelta( - seconds=int(time.time() - wgpeer['latest_handshake']) - ) - answer += ' latest handshake: {}\n'.format(delta) - if time.time() - int(wgpeer['latest_handshake']) < (60 * 5): - """ Five minutes and the tunnel is still active """ - status = 'active' - else: - """ it's been longer than 5 minutes """ - status = 'inactive' - elif int(wgpeer['latest_handshake']) == 0: - """ no handshake ever """ - status = 'inactive' - answer += ' status: {}\n'.format(status) - - if wgpeer['endpoint'] is not None: - answer += ' endpoint: {}\n'.format(wgpeer['endpoint']) - - if wgpeer['allowed_ips'] is not None: - answer += ' allowed ips: {}\n'.format( - ','.join(wgpeer['allowed_ips']).replace(',', ', ') - ) - - if wgpeer['transfer_rx'] > 0 or wgpeer['transfer_tx'] > 0: - rx_size = size(wgpeer['transfer_rx'], system=alternative) - tx_size = size(wgpeer['transfer_tx'], system=alternative) - answer += ' transfer: {} received, {} sent\n'.format( - rx_size, tx_size - ) - - if wgpeer['persistent_keepalive'] is not None: - answer += ' persistent keepalive: every {} seconds\n'.format( - wgpeer['persistent_keepalive'] - ) - answer += '\n' - return answer - def get_latest_handshakes(self): """Get latest handshake time for each peer""" output = {} -- cgit v1.2.3