From dcd1d9c6b0d5420bca89cb92792bc440877c1f6e Mon Sep 17 00:00:00 2001 From: ndyer Date: Wed, 21 Dec 2022 12:30:10 -0500 Subject: [PATCH 1/7] implemented checking for network connections in gui --- pypong/networking/client.py | 91 +++++++++++++++++++++++++++++++++++++ pypong/networking/gui.py | 77 +++++++++++++++++++++++++++++-- 2 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 pypong/networking/client.py diff --git a/pypong/networking/client.py b/pypong/networking/client.py new file mode 100644 index 0000000..179e053 --- /dev/null +++ b/pypong/networking/client.py @@ -0,0 +1,91 @@ +""" +Networking client to connect to a server and find server hosts +""" +import ipaddress +import random + +import psutil +import socket + +SERVER_PORT = 29987 +SERVER_BUFFER = 1024 +SCAN_TIMEOUT = 3 + + +class InterfaceInfo: + """ + Class used for storing interface information to make it easier to use + """ + + def __init__(self, interface_name, network): + self.interface_name = interface_name + self.network = network + + def __str__(self): + return 'Interface:{0}, Network:{1}'.format(self.interface_name, self.network) + + def __repr__(self): + return '(Interface:{0}, Network:{1})'.format(self.interface_name, self.network) + + +def request_server_info(server_ip, server_port): + """ + Request server info from a PyPong server given an IP and a port + :param server_ip: Server IP to request info from + :param server_port: Port to connect to server on + :return response given by server (or lack thereof): + """ + request = 'PYPONGREQ;SVRINFO' + + udp_client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp_client_socket.settimeout(SCAN_TIMEOUT + (SCAN_TIMEOUT * random.random())) + try: + udp_client_socket.sendto(str.encode(request, 'UTF-8'), (server_ip, server_port)) + except OSError: + pass + try: + server_response = udp_client_socket.recvfrom(SERVER_BUFFER)[0].decode('UTF-8') + except TimeoutError: + server_response = '{0};TIMEOUT'.format(server_ip) + except UnicodeDecodeError: + server_response = '{0};MANGLED_RESPONSE'.format(server_ip) + + udp_client_socket.close() + return server_response + + +def get_interface_info(): + raw_interface_data = psutil.net_if_addrs() + interface_data = [] + + # Extract needed information from psutil and put it into a InterfaceInfo object + for interface in raw_interface_data: + address = (raw_interface_data.get(interface)[0]).address + cidr_suffix = ipaddress.IPv4Network('{0}/{1}'.format(address, raw_interface_data.get(interface)[0].netmask), + strict=False).prefixlen + if ':' not in address and address != '127.0.0.1': + interface_data.append(InterfaceInfo(interface, '{0}/{1}'.format(address, cidr_suffix))) + return interface_data + + +def get_ips_from_network(network): + network_split = network.split('/') + address = network_split[0] + cidr = int(network_split[1]) + all_addresses = [] + + test_cidr = 32 + while test_cidr >= cidr: + current_addresses = [str(ip) for ip in ipaddress.IPv4Network('{0}/{1}'.format(address, test_cidr), + strict=False)] + for test_address in current_addresses: + if test_address not in all_addresses: + all_addresses.append(test_address) + test_cidr -= 1 + print(test_cidr) + return all_addresses + + +def check_ip(ip_address): + response = request_server_info(ip_address, SERVER_PORT) + return response diff --git a/pypong/networking/gui.py b/pypong/networking/gui.py index 118077c..f13e9cc 100644 --- a/pypong/networking/gui.py +++ b/pypong/networking/gui.py @@ -2,11 +2,15 @@ Starting launcher of the game. This is where you host new games or join other ones. """ import tkinter as tk +import concurrent.futures +import pypong.networking.client as client global running +global listbox def main(): + executor = concurrent.futures.ThreadPoolExecutor(max_workers=512) """ Runs the game launcher. """ @@ -14,7 +18,7 @@ def main(): main_window.title('PyPong Launcher') main_window.resizable(width=False, height=False) - main_window.geometry('500x250') + main_window.geometry('500x300') def quit_launcher(): """ @@ -22,20 +26,77 @@ def main(): """ global running running = False + executor.shutdown(wait=False, cancel_futures=True) - def get_games(): + def check_game(*game_ip): + game_ip = ''.join(game_ip) + result = client.check_ip(game_ip) + return result + + def get_games(network): """ Refresh the games list. """ + results = [] listbox.delete(0, tk.END) - listbox.insert(0, 'Searching for games...') - # Networking code will go here eventually + listbox.insert(0, ' Building IP range...') + main_window.update() + possible_ips = client.get_ips_from_network(network.get().split(' ')[0]) + listbox.delete(0, tk.END) + listbox.insert(0, 'this might take some time...') + listbox.insert(0, 'Refreshing server listing,') + main_window.update() + # possible_ips = ['192.168.12.19'] + futures = [] + + for ip in possible_ips: + if ip == '192.168.12.19': + print(ip) + future = executor.submit(check_game, ip) + futures.append(future) + + game_found = False + + for future in futures: + result = future.result() + main_window.update() + if 'PYPONGRST;SVRINFO' in result: + print(result) + raw_response = result.split(';') + response = [] + for block in raw_response: + print(block) + chunks = block.split(':') + for chunk in chunks: + response.append(chunk) + print(response) + if 'NAME' in response: + session_name = response[response.index('NAME') + 1] + if not game_found: + listbox.delete(0, tk.END) + listbox.insert(0, session_name) + game_found = True + main_window.update() + + if not game_found: + listbox.delete(0, tk.END) + listbox.insert(0, 'No games found!') + + def get_addresses(): + interface_info = client.get_interface_info() + shortened_info = [] + + for interface in interface_info: + shortened_info.append('{0} [{1}]'.format(interface.network, interface.interface_name)) + + return shortened_info # Create the top title label title_label = tk.Label(text='PyPong Launcher', fg='white', bg='#000040', padx=1, pady=20) title_label.pack(side=tk.TOP, fill=tk.BOTH) # Create the list of games + global listbox listbox = tk.Listbox(main_window) listbox.pack(side=tk.LEFT, expand=True, fill=tk.BOTH) # Add scrollbar @@ -49,7 +110,13 @@ def main(): button_frame = tk.Frame(main_window) tk.Button(button_frame, text='Host a Game', height=3, width=30).pack() tk.Button(button_frame, text='Join Selected Game', height=3, width=30).pack() - tk.Button(button_frame, text='Refresh Game List', height=2, width=20, command=get_games).pack() + tk.Button(button_frame, text='Refresh Game List', height=2, width=20, + command=lambda: get_games(selected_network)).pack() + networks = get_addresses() + selected_network = tk.StringVar() + selected_network.set(networks[0]) + interface_menu = tk.OptionMenu(button_frame, selected_network, *networks) + interface_menu.pack() button_frame.pack(side=tk.RIGHT) # Set it so that if the X is pressed the application quits From a5c47f72efd5c86af9c340a8c3ac1acadc48985a Mon Sep 17 00:00:00 2001 From: ndyer Date: Wed, 21 Dec 2022 13:00:27 -0500 Subject: [PATCH 2/7] added docstrings and comments --- pypong/networking/client.py | 35 +++++++++++++++++++--- pypong/networking/gui.py | 59 +++++++++++++++++++++++++------------ 2 files changed, 71 insertions(+), 23 deletions(-) diff --git a/pypong/networking/client.py b/pypong/networking/client.py index 179e053..b882a95 100644 --- a/pypong/networking/client.py +++ b/pypong/networking/client.py @@ -31,9 +31,10 @@ class InterfaceInfo: def request_server_info(server_ip, server_port): """ Request server info from a PyPong server given an IP and a port + :param server_ip: Server IP to request info from :param server_port: Port to connect to server on - :return response given by server (or lack thereof): + :return: response given by server (or lack thereof) """ request = 'PYPONGREQ;SVRINFO' @@ -55,37 +56,63 @@ def request_server_info(server_ip, server_port): def get_interface_info(): + """ + Gets information using psutil about the current machine's interfaces and their networks. This only works for IPv4 + networks and will not return any data in regard to IPv6 links. + + :return: List of InterfaceInfo objects containing information about all interfaces on the current machine excluding + the loopback interface (lo or 127.0.0.1) + """ raw_interface_data = psutil.net_if_addrs() interface_data = [] - # Extract needed information from psutil and put it into a InterfaceInfo object for interface in raw_interface_data: + # Extract needed information from psutil and put it into a InterfaceInfo object address = (raw_interface_data.get(interface)[0]).address cidr_suffix = ipaddress.IPv4Network('{0}/{1}'.format(address, raw_interface_data.get(interface)[0].netmask), strict=False).prefixlen + + # Do not add the loopback interface or IPv6 interfaces if ':' not in address and address != '127.0.0.1': interface_data.append(InterfaceInfo(interface, '{0}/{1}'.format(address, cidr_suffix))) return interface_data def get_ips_from_network(network): + """ + Generate a list of IP addresses given an address in CIDR notation. This works from smallest to largest, meaning that + it builds a list starting at
/32 (aka just the current machine) decrementing the CIDR by 1 until the + correct subnet is reached. + + :param network: + :return: + """ network_split = network.split('/') address = network_split[0] cidr = int(network_split[1]) all_addresses = [] - test_cidr = 32 + test_cidr = 32 # Start with the machine itself while test_cidr >= cidr: + # Generate all addresses inside the current subnet given by 'address/test_cidr' current_addresses = [str(ip) for ip in ipaddress.IPv4Network('{0}/{1}'.format(address, test_cidr), strict=False)] + + # Do not add the address again if it is already in the list of all addresses for test_address in current_addresses: if test_address not in all_addresses: all_addresses.append(test_address) test_cidr -= 1 - print(test_cidr) + return all_addresses def check_ip(ip_address): + """ + Check a single IP address for a running game server + + :param ip_address: IP address to check + :return: response of the server (or timeout if none) + """ response = request_server_info(ip_address, SERVER_PORT) return response diff --git a/pypong/networking/gui.py b/pypong/networking/gui.py index f13e9cc..1f8884a 100644 --- a/pypong/networking/gui.py +++ b/pypong/networking/gui.py @@ -6,14 +6,16 @@ import concurrent.futures import pypong.networking.client as client global running -global listbox def main(): - executor = concurrent.futures.ThreadPoolExecutor(max_workers=512) """ Runs the game launcher. """ + + # Executor used for scanning local network for servers + executor = concurrent.futures.ThreadPoolExecutor(max_workers=512) + main_window = tk.Tk() main_window.title('PyPong Launcher') main_window.resizable(width=False, height=False) @@ -26,32 +28,39 @@ def main(): """ global running running = False + # Stop executor from creating new scanning tasks executor.shutdown(wait=False, cancel_futures=True) - def check_game(*game_ip): - game_ip = ''.join(game_ip) + def check_game(game_ip): + """ + Checks to see if there is a game bein ghosted at a given IP + + :param game_ip: IP address to check for a host + :return: Response from the server (or timeout if nothing found) + """ result = client.check_ip(game_ip) return result def get_games(network): """ - Refresh the games list. + Refreshes the game list. + + :param network: The network to scan for games on """ - results = [] listbox.delete(0, tk.END) listbox.insert(0, ' Building IP range...') main_window.update() + possible_ips = client.get_ips_from_network(network.get().split(' ')[0]) + listbox.delete(0, tk.END) listbox.insert(0, 'this might take some time...') listbox.insert(0, 'Refreshing server listing,') main_window.update() - # possible_ips = ['192.168.12.19'] - futures = [] + futures = [] + # Use the executor to create threads to search for games for ip in possible_ips: - if ip == '192.168.12.19': - print(ip) future = executor.submit(check_game, ip) futures.append(future) @@ -59,30 +68,42 @@ def main(): for future in futures: result = future.result() - main_window.update() - if 'PYPONGRST;SVRINFO' in result: - print(result) - raw_response = result.split(';') + + if 'PYPONGRST;SVRINFO' in result: # Check to make sure response has the right header and type + raw_response = result.split(';') # Split response by block (denoted by semicolons) response = [] + + # Split up each block by chunks (:) + # e.g. the block 'NAME:Nicholas Dyer' -> ['NAME', 'Nicholas Dyer'] for block in raw_response: - print(block) chunks = block.split(':') for chunk in chunks: response.append(chunk) - print(response) + + # Make sure the response has the NAME command if 'NAME' in response: + # Session name is the next index after the command session_name = response[response.index('NAME') + 1] - if not game_found: + if not game_found: # If this is the first game found, clear out the list listbox.delete(0, tk.END) + game_found = True listbox.insert(0, session_name) - game_found = True - main_window.update() + main_window.update() # Update screen while finding games (laggy, but works) if not game_found: listbox.delete(0, tk.END) listbox.insert(0, 'No games found!') def get_addresses(): + """ + Get the current machine's local interfaces and their corresponding addresses in CIDR notation + + :return: Address in CIDR notation along with the interface + + Example: + + ['172.20.126.4/24 [eth0]', 192.168.2.43/16 [wlan0]'] + """ interface_info = client.get_interface_info() shortened_info = [] From 1102e075c0ee27ffd08b98cfd7a93eada23aebe8 Mon Sep 17 00:00:00 2001 From: ndyer Date: Wed, 21 Dec 2022 13:06:04 -0500 Subject: [PATCH 3/7] added packet.py and the Packet class --- pypong/networking/packet.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 pypong/networking/packet.py diff --git a/pypong/networking/packet.py b/pypong/networking/packet.py new file mode 100644 index 0000000..e586691 --- /dev/null +++ b/pypong/networking/packet.py @@ -0,0 +1,36 @@ +""" +Contains the Packet class to help with the transmission, reading, and verification of infermation over the network +""" + + +class Packet: + """ + Class that contains information to be sent over the network. Can self-verify and has methods (will have methods) to + easily encode and decode packet information + """ + VALID_HEADERS = [ + 'PYPONGREQ', + 'PYPONGRST' + ] + VALID_TYPES = [ + 'SVRINFO' + ] + + def __init__(self, header, msg_type, *message): + self.header = header + self.msg_type = msg_type + self.message = [] + for x in message: + self.message.append(x) + + def integrity_check(self): + """ + Verify that the information in the packet is correct and makes sense + + :return: True if packet verifies correctly, False if not + """ + if self.header not in Packet.VALID_HEADERS: + return False + if self.msg_type not in Packet.VALID_TYPES: + return False + return True From b5b247f2a7bc06d9636ea2ed1e56827c82ed9ac1 Mon Sep 17 00:00:00 2001 From: ndyer Date: Wed, 21 Dec 2022 13:08:51 -0500 Subject: [PATCH 4/7] updated networking __init__.py --- pypong/networking/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pypong/networking/__init__.py b/pypong/networking/__init__.py index e69de29..80c304f 100644 --- a/pypong/networking/__init__.py +++ b/pypong/networking/__init__.py @@ -0,0 +1,4 @@ +""" +Contains all networking code and classes to get the game to communicate over UDP, along with a GUI to help connection +go smoothly. +""" \ No newline at end of file From 9ab63930655de72193800faf166be16b2252f079 Mon Sep 17 00:00:00 2001 From: ndyer Date: Wed, 21 Dec 2022 13:20:13 -0500 Subject: [PATCH 5/7] changed host.py to be a super basic server that just responds with an echo --- pypong/networking/host.py | 49 +++++++++++++++------------------------ 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/pypong/networking/host.py b/pypong/networking/host.py index 3ba7be4..bf5acfe 100644 --- a/pypong/networking/host.py +++ b/pypong/networking/host.py @@ -1,36 +1,25 @@ -import psutil +import socket +LOCAL_IP = '127.0.0.1' +SERVER_PORT = 29987 +SERVER_BUFFER = 1024 -class InterfaceInfo: - """ - Class used for storing interface information to make it easier to use - """ - def __init__(self, interface_name, address, subnet_mask): - self.interface_name = interface_name - self.address = address - self.subnet_mask = subnet_mask +udp_server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +udp_server_socket.bind((LOCAL_IP, SERVER_PORT)) - def __str__(self): - return '(Interface:{0}, Address:{1}, Subnet Mask:{2})'.format(self.interface_name, - self.address, self.subnet_mask) +waiting_for_connection = True - def __repr__(self): - return '(Interface:{0}, Address:{1}, Subnet Mask:{2})'.format(self.interface_name, - self.address, self.subnet_mask) +while waiting_for_connection: + received = udp_server_socket.recvfrom(SERVER_BUFFER) + message = received[0] + address = received[1] + decoded_message = message.decode('UTF-8') + if decoded_message.split(';')[0] != 'PYPONGREQ': + message = 'PYPONGRST;ERROR:INVALID HEADER' + udp_server_socket.sendto(message.encode('UTF-8'), address) + else: + message = 'PYPONGRST;ECHO:{0}'.format(decoded_message) + udp_server_socket.sendto(message.encode('UTF-8'), address) -def main(): - raw_interface_data = psutil.net_if_addrs() - interface_data = [] - - # Extract needed information from psutil and put it into a InterfaceInfo object - for interface in raw_interface_data: - address = (raw_interface_data.get(interface)[0]).address - subnet_mask = (raw_interface_data.get(interface)[0]).netmask - interface_data.append(InterfaceInfo(interface, address, subnet_mask)) - - print(interface_data) - - -if __name__ == '__main__': - main() +udp_server_socket.close() From 07e6eec033ca720d2925a85009b1a6a71a411698 Mon Sep 17 00:00:00 2001 From: ndyer Date: Wed, 21 Dec 2022 13:38:33 -0500 Subject: [PATCH 6/7] revised some variables and wording --- pypong/networking/client.py | 3 +++ pypong/networking/gui.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/pypong/networking/client.py b/pypong/networking/client.py index b882a95..0f8e422 100644 --- a/pypong/networking/client.py +++ b/pypong/networking/client.py @@ -39,6 +39,9 @@ def request_server_info(server_ip, server_port): request = 'PYPONGREQ;SVRINFO' udp_client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + # Timeout is set to SCAN_TIMEOUT plus a random number between 0 and SCAN_TIMEOUT so that threads can + # start asynchronously to avoid a lot of requests being sent to machines at once + # Average time is SCAN_TIMEOUT*1.5 udp_client_socket.settimeout(SCAN_TIMEOUT + (SCAN_TIMEOUT * random.random())) try: udp_client_socket.sendto(str.encode(request, 'UTF-8'), (server_ip, server_port)) diff --git a/pypong/networking/gui.py b/pypong/networking/gui.py index 1f8884a..563773c 100644 --- a/pypong/networking/gui.py +++ b/pypong/networking/gui.py @@ -7,6 +7,9 @@ import pypong.networking.client as client global running +# Don't allow any more than this number of threads when looking for games +# Can cripple a network at high numbers +MAX_SCAN_REQUESTS = 64 def main(): """ @@ -14,7 +17,7 @@ def main(): """ # Executor used for scanning local network for servers - executor = concurrent.futures.ThreadPoolExecutor(max_workers=512) + executor = concurrent.futures.ThreadPoolExecutor(max_workers=MAX_SCAN_REQUESTS) main_window = tk.Tk() main_window.title('PyPong Launcher') From 16b263b3e2c4c7ddefed285eab76c4799a7f9f0d Mon Sep 17 00:00:00 2001 From: ndyer Date: Thu, 22 Dec 2022 09:00:39 -0500 Subject: [PATCH 7/7] changed max scan requests to 128 --- pypong/networking/gui.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pypong/networking/gui.py b/pypong/networking/gui.py index 563773c..00ba231 100644 --- a/pypong/networking/gui.py +++ b/pypong/networking/gui.py @@ -8,8 +8,9 @@ import pypong.networking.client as client global running # Don't allow any more than this number of threads when looking for games -# Can cripple a network at high numbers -MAX_SCAN_REQUESTS = 64 +# Can cripple a network at high numbers, 128 is recommended (can scan a /24 network in ~9 seconds on average) +# Higher range subnets will exponentially take more time to finish (/16 would take ~38.4 minutes at 128 req/s) +MAX_SCAN_REQUESTS = 128 def main(): """