From 4b4f90a57323ae4cf3e5ab88b61d12a30144fce4 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Sun, 24 May 2026 15:57:57 +0200 Subject: [PATCH 1/7] Improve ipv6 detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, ipv6 detection relied on the IP address. It didn’t work for domain names. The new implementation uses `socket.getaddrinfo()` to determine the address family. --- wakeonlan/__init__.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/wakeonlan/__init__.py b/wakeonlan/__init__.py index 5fd0b24..9ddecd4 100755 --- a/wakeonlan/__init__.py +++ b/wakeonlan/__init__.py @@ -5,7 +5,6 @@ """ import argparse -import ipaddress import socket import typing @@ -83,9 +82,12 @@ def send_magic_packet( packets = [create_magic_packet(mac) for mac in macs] if address_family is None: - address_family = ( - socket.AF_INET6 if _is_ipv6_address(ip_address) else socket.AF_INET - ) + for family, *_ in socket.getaddrinfo(ip_address, port): + if family == socket.AF_INET6 or family == socket.AF_INET: + address_family = family + break + else: + address_family = socket.AF_INET with socket.socket(address_family, socket.SOCK_DGRAM) as sock: if interface is not None: @@ -96,13 +98,6 @@ def send_magic_packet( sock.send(packet) -def _is_ipv6_address(ip_address: str) -> bool: - try: - return isinstance(ipaddress.ip_address(ip_address), ipaddress.IPv6Address) - except ValueError: - return False - - def main(argv: typing.Optional[typing.List[str]] = None) -> None: """ Run wake on lan as a CLI application. From bcc9fd7b8fd70c24597eb95e68eafe0a7ad5c4b6 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Sun, 7 Jun 2026 20:53:43 +0200 Subject: [PATCH 2/7] Use the result from getaddrinfo --- wakeonlan/__init__.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/wakeonlan/__init__.py b/wakeonlan/__init__.py index 9ddecd4..f7f7a61 100755 --- a/wakeonlan/__init__.py +++ b/wakeonlan/__init__.py @@ -81,19 +81,15 @@ def send_magic_packet( """ packets = [create_magic_packet(mac) for mac in macs] - if address_family is None: - for family, *_ in socket.getaddrinfo(ip_address, port): - if family == socket.AF_INET6 or family == socket.AF_INET: - address_family = family - break - else: - address_family = socket.AF_INET - - with socket.socket(address_family, socket.SOCK_DGRAM) as sock: + family, type, proto, canonname, addr = socket.getaddrinfo( + ip_address, port, type=socket.SOCK_DGRAM, proto=socket.IPPROTO_UDP + )[0] + + with socket.socket(family, type, proto) as sock: if interface is not None: sock.bind((interface, 0)) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.connect((ip_address, port)) + sock.connect(addr) for packet in packets: sock.send(packet) From ab02852d713694dbbb758749e92c4d2b02a0204d Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Sun, 7 Jun 2026 21:02:33 +0200 Subject: [PATCH 3/7] Try every option returned by socket.getaddrinfo --- wakeonlan/__init__.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/wakeonlan/__init__.py b/wakeonlan/__init__.py index f7f7a61..3695d70 100755 --- a/wakeonlan/__init__.py +++ b/wakeonlan/__init__.py @@ -81,17 +81,25 @@ def send_magic_packet( """ packets = [create_magic_packet(mac) for mac in macs] - family, type, proto, canonname, addr = socket.getaddrinfo( + error: typing.Optional[socket.gaierror] = None + for family, type, proto, canonname, addr in socket.getaddrinfo( ip_address, port, type=socket.SOCK_DGRAM, proto=socket.IPPROTO_UDP - )[0] - - with socket.socket(family, type, proto) as sock: - if interface is not None: - sock.bind((interface, 0)) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.connect(addr) - for packet in packets: - sock.send(packet) + ): + try: + with socket.socket(family, type, proto) as sock: + if interface is not None: + sock.bind((interface, 0)) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.connect(addr) + for packet in packets: + sock.send(packet) + break + except socket.gaierror as e: + if not error: + error = e + continue + if error: + raise error def main(argv: typing.Optional[typing.List[str]] = None) -> None: From 6966f3b0c2b4ae9ad72e74fc8486cef8eed81f69 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Sun, 7 Jun 2026 21:07:47 +0200 Subject: [PATCH 4/7] Handle empty address infos --- wakeonlan/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/wakeonlan/__init__.py b/wakeonlan/__init__.py index 3695d70..3c52088 100755 --- a/wakeonlan/__init__.py +++ b/wakeonlan/__init__.py @@ -81,10 +81,15 @@ def send_magic_packet( """ packets = [create_magic_packet(mac) for mac in macs] - error: typing.Optional[socket.gaierror] = None - for family, type, proto, canonname, addr in socket.getaddrinfo( + address_infos = socket.getaddrinfo( ip_address, port, type=socket.SOCK_DGRAM, proto=socket.IPPROTO_UDP - ): + ) + + if not address_infos: + raise Exception(f'Could not resolve {ip_address}') + + error: typing.Optional[socket.gaierror] = None + for family, type, proto, canonname, addr in address_infos: try: with socket.socket(family, type, proto) as sock: if interface is not None: From 1a48cd560999daafe3712a6246e3c2c1e3c1a334 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Wed, 10 Jun 2026 19:35:32 +0200 Subject: [PATCH 5/7] Refactor socket creation Socket creation happens in a new public function named `create_socket`. The preferred address family can be specified again. The `proto` argument for `getaddrinfo()` is redundant and has been removed. Also, if the connection now fails for some reason, the last error is raised instead of the first. --- wakeonlan/__init__.py | 85 ++++++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 26 deletions(-) diff --git a/wakeonlan/__init__.py b/wakeonlan/__init__.py index 3c52088..8c8a666 100755 --- a/wakeonlan/__init__.py +++ b/wakeonlan/__init__.py @@ -52,12 +52,61 @@ def create_magic_packet(macaddress: str) -> bytes: return bytes.fromhex('F' * 12 + macaddress * 16 + secureon) +def create_socket( + *, + ip_address: str = BROADCAST_IP, + port: int = DEFAULT_PORT, + interface: typing.Optional[str] = None, + address_family: socket.AddressFamily = socket.AF_UNSPEC, +) -> socket.socket: + """ + Create a socket that’s suitable for sending magic packets. + + Args: + ip_address: The hostname to connect to. + port: The port to connect to. + interface: The IP address of the network adapter to use. + address_family: The address family to send the magic packet to. + Use this to force the use of IPv4 or IPv6. The default is + to auto detect. + + Returns: + A socket you can use for sending magic packets. + + """ + # This is based on the example for a connection that supports both IPv4 + # and IPv6 in https://docs.python.org/3/library/socket.html#example + # This also matches the getaddrinfo man page, which states applications + # should try using the addresses in order. + # https://man7.org/linux/man-pages/man3/getaddrinfo.3.html + address_infos = socket.getaddrinfo( + ip_address, port, address_family, socket.SOCK_DGRAM + ) + sock: typing.Optional[socket.socket] = None + for index, (family, type, proto, canonname, addr) in enumerate(address_infos, 1): + try: + sock = socket.socket(family, type, proto) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + if interface: + sock.bind((interface, 0)) + sock.connect(addr) + break + except OSError: + if sock: + sock.close() + sock = None + if index == len(address_infos): + raise + assert sock, 'sock should be defined at this point' + return sock + + def send_magic_packet( *macs: str, ip_address: str = BROADCAST_IP, port: int = DEFAULT_PORT, interface: typing.Optional[str] = None, - address_family: typing.Optional[socket.AddressFamily] = None, + address_family: socket.AddressFamily = socket.AF_UNSPEC, ) -> None: """ Wake up computers having any of the given mac addresses. @@ -81,30 +130,14 @@ def send_magic_packet( """ packets = [create_magic_packet(mac) for mac in macs] - address_infos = socket.getaddrinfo( - ip_address, port, type=socket.SOCK_DGRAM, proto=socket.IPPROTO_UDP - ) - - if not address_infos: - raise Exception(f'Could not resolve {ip_address}') - - error: typing.Optional[socket.gaierror] = None - for family, type, proto, canonname, addr in address_infos: - try: - with socket.socket(family, type, proto) as sock: - if interface is not None: - sock.bind((interface, 0)) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.connect(addr) - for packet in packets: - sock.send(packet) - break - except socket.gaierror as e: - if not error: - error = e - continue - if error: - raise error + with create_socket( + ip_address=ip_address, + port=port, + interface=interface, + address_family=address_family, + ) as sock: + for packet in packets: + sock.send(packet) def main(argv: typing.Optional[typing.List[str]] = None) -> None: @@ -152,7 +185,7 @@ def main(argv: typing.Optional[typing.List[str]] = None) -> None: ip_address=args.ip, port=args.port, interface=args.interface, - address_family=socket.AF_INET6 if args.ipv6 else None, + address_family=socket.AF_INET6 if args.ipv6 else socket.AF_UNSPEC, ) From 4aed6aa5a6481570db6a468be437734ed38f2c4c Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Wed, 10 Jun 2026 19:47:05 +0200 Subject: [PATCH 6/7] Fix broken test mock calls --- test_wakeonlan.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test_wakeonlan.py b/test_wakeonlan.py index d152f7b..5aa5d75 100644 --- a/test_wakeonlan.py +++ b/test_wakeonlan.py @@ -550,14 +550,14 @@ def test_main(self, send_magic_packet: mock.Mock) -> None: ip_address='host.example', port=1337, interface=None, - address_family=None, + address_family=socket.AF_UNSPEC, ), mock.call( '00:11:22:33:44:55', ip_address='host.example', port=1337, interface='192.168.0.2', - address_family=None, + address_family=socket.AF_UNSPEC, ), mock.call( '00:11:22:33:44:55', From b2453c7cd3f146eb9a7c8365f43517988191e509 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Fri, 12 Jun 2026 14:19:49 +0200 Subject: [PATCH 7/7] Add tests for create_socket --- test_wakeonlan.py | 80 ++++++++++++++++++++++++++++++++++++++++++- wakeonlan/__init__.py | 4 +-- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/test_wakeonlan.py b/test_wakeonlan.py index 4c422ec..d158b5b 100644 --- a/test_wakeonlan.py +++ b/test_wakeonlan.py @@ -7,7 +7,7 @@ import unittest from unittest import mock -from wakeonlan import create_magic_packet, main, send_magic_packet +from wakeonlan import create_magic_packet, create_socket, main, send_magic_packet class TestCreateMagicPacket(unittest.TestCase): @@ -253,6 +253,84 @@ def test_invalid_secureon(self) -> None: create_magic_packet('01:23:45:67:89:ab/invalid') +class TestCreateSocket(unittest.TestCase): + """ + Test :func:`create_socket`. + + """ + + def test_ipv4_broadcast(self) -> None: + """ + Test if IPv4 works. + + """ + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server: + server.bind(('', 1234)) + with create_socket(port=1234) as client: + client.send(b'Hello server!') + data, addr = server.recvfrom(1024) + self.assertEqual(data, b'Hello server!') + self.assertEqual(addr[0], socket.gethostbyname(socket.gethostname())) + + def test_ipv6_broadcast(self) -> None: + """ + Test if IPv6 works. + + """ + with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as server: + server.bind(('', 1234)) + with create_socket(port=1234) as client: + client.send(b'Hello server!') + data, addr = server.recvfrom(1024) + self.assertEqual(data, b'Hello server!') + self.assertEqual( + addr[0], f'::ffff:{socket.gethostbyname(socket.gethostname())}' + ) + + def test_interface(self) -> None: + """ + Test if IPv4 works. + + """ + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server: + server.bind(('', 1234)) + with create_socket(interface='127.0.0.1', port=1234) as client: + client.send(b'Hello server!') + data, addr = server.recvfrom(1024) + self.assertEqual(data, b'Hello server!') + self.assertEqual(addr[0], '127.0.0.1') + + def test_explicit_ipv4(self) -> None: + """ + Test if explicit IPv4 works. + + """ + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server: + server.bind(('', 1234)) + with create_socket( + ip_address='localhost', port=1234, address_family=socket.AF_INET + ) as client: + client.send(b'Hello server!') + data, addr = server.recvfrom(1024) + self.assertEqual(data, b'Hello server!') + self.assertEqual(addr[0], '127.0.0.1') + + def test_explicit_ipv6(self) -> None: + """ + Test if explicit IPv4 works. + + """ + with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as server: + server.bind(('', 1234)) + with create_socket( + ip_address='localhost', port=1234, address_family=socket.AF_INET6 + ) as client: + client.send(b'Hello server!') + data, addr = server.recvfrom(1024) + self.assertEqual(data, b'Hello server!') + self.assertEqual(addr[0], '::1') + + class TestSendMagicPacket(unittest.TestCase): """ Test :ref:`send_magic_packet`. diff --git a/wakeonlan/__init__.py b/wakeonlan/__init__.py index ef38300..5155e60 100755 --- a/wakeonlan/__init__.py +++ b/wakeonlan/__init__.py @@ -83,14 +83,14 @@ def create_socket( ) sock: socket.socket | None = None for index, (family, type, proto, canonname, addr) in enumerate(address_infos, 1): - try: + try: # pragma: nocover sock = socket.socket(family, type, proto) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) if interface: sock.bind((interface, 0)) sock.connect(addr) break - except OSError: + except OSError: # pragma: nocover if sock: sock.close() sock = None