From 729c23733b36f27b1bf49a83e4c83bc9b0783d33 Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Thu, 17 Mar 2022 06:33:53 +0000 Subject: [PATCH 1/6] port to Python 3 and do some minor refactors --- README.md | 6 ++ config_server.py | 27 +++-- dcnow.py | 66 +++++++----- dreampi.py | 243 +++++++++++++++++++++++++-------------------- port_forwarding.py | 16 ++- 5 files changed, 211 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 46ef714..573064b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,8 @@ # dreampi + A daemon that creates a bridge between a Dreamcast's Dial-up Modem, and the Internet via the Pi + +``` +sudo apt install ./arm/*.deb +sudo apt install python3-miniupnpc python3-serial python3-sh python3-iptables +``` \ No newline at end of file diff --git a/config_server.py b/config_server.py index 6a00967..cb38a68 100644 --- a/config_server.py +++ b/config_server.py @@ -1,21 +1,28 @@ +#!/usr/bin/env python3 + +from __future__ import absolute_import + import json import threading import cgi import os +from typing import Dict, List +from http.server import BaseHTTPRequestHandler, HTTPServer -from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer -from dcnow import CONFIGURATION_FILE, scan_mac_address +from dcnow import CONFIGURATION_FILE, hash_mac_address class DreamPiConfigurationService(BaseHTTPRequestHandler): - def _get_post_data(self): - ctype, pdict = cgi.parse_header(self.headers.getheader('content-type')) + def _get_post_data(self) -> Dict[str, List[str]]: + ctype, pdict = cgi.parse_header(self.headers['content-type']) + if ctype == 'multipart/form-data': + pdict = { k: v.encode() for k, v in pdict.items() } postvars = cgi.parse_multipart(self.rfile, pdict) elif ctype == 'application/x-www-form-urlencoded': - length = int(self.headers.getheader('content-length')) - postvars = cgi.parse_qs(self.rfile.read(length), keep_blank_values=1) + length = int(self.headers['content-length']) + postvars = cgi.parse_qs(self.rfile.read(length).decode(), keep_blank_values=True) else: postvars = {} @@ -33,9 +40,9 @@ def do_GET(self): enabled_state = json.loads(f.read())["enabled"] self.wfile.write(json.dumps({ - "mac_address": scan_mac_address(), + "mac_address": hash_mac_address(), "is_enabled": enabled_state - })) + }).encode()) def do_POST(self): @@ -56,9 +63,9 @@ def do_POST(self): f.write(json.dumps({"enabled": enabled_state})) self.wfile.write(json.dumps({ - "mac_address": scan_mac_address(), + "mac_address": hash_mac_address(), "is_enabled": enabled_state - })) + }).encode()) server = None diff --git a/dcnow.py b/dcnow.py index 32a36d2..020ff72 100644 --- a/dcnow.py +++ b/dcnow.py @@ -1,14 +1,17 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 + +from __future__ import absolute_import import threading import os import json import time import logging -import urllib -import urllib2 -import sh - +import logging.handlers +import urllib.request +import urllib.parse +import sh # type: ignore - sh module is dynamic +from typing import List, Optional from hashlib import sha256 from uuid import getnode as get_mac @@ -23,25 +26,26 @@ CONFIGURATION_FILE = os.path.expanduser("~/.dreampi.json") -def scan_mac_address(): +def hash_mac_address(): mac = get_mac() - return sha256(':'.join(("%012X" % mac)[i:i+2] for i in range(0, 12, 2))).hexdigest() + return sha256(':'.join(("%012X" % mac)[i:i+2] for i in range(0, 12, 2)).encode()).hexdigest() class DreamcastNowThread(threading.Thread): - def __init__(self, service): + def __init__(self, service: "DreamcastNowService"): self._service = service self._running = True super(DreamcastNowThread, self).__init__() def run(self): def post_update(): - if not self._service._enabled: + if not self._service.enabled: return - lines = [ x for x in sh.tail("/var/log/syslog", "-n", "10", _iter=True) ] + lines: List[str] = list(sh.tail("/var/log/syslog", "-n", "10", _iter=True)) # type: ignore - sh has dynamic members dns_query = None for line in lines[::-1]: + line: str = line if "CONNECT" in line and "dreampi" in line: # Don't seek back past connection break @@ -50,19 +54,19 @@ def post_update(): # We did a DNS lookup, what was it? remainder = line[line.find("query[A]") + len("query[A]"):].strip() domain = remainder.split(" ", 1)[0].strip() - dns_query = sha256(domain).hexdigest() + dns_query = sha256(domain.encode()).hexdigest() break user_agent = 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT), Dreamcast Now' header = { 'User-Agent' : user_agent } - mac_address = self._service._mac_address + mac_address = self._service.mac_address_hash data = {} if dns_query: data["dns_query"] = dns_query - data = urllib.urlencode(data) - req = urllib2.Request(API_ROOT + UPDATE_END_POINT.format(mac_address=mac_address), data, header) - urllib2.urlopen(req) # Send POST update + data = urllib.parse.urlencode(data).encode() + req = urllib.request.Request(API_ROOT + UPDATE_END_POINT.format(mac_address=mac_address), data, header) + urllib.request.urlopen(req) # Send POST update while self._running: try: @@ -79,17 +83,18 @@ def stop(self): class DreamcastNowService(object): def __init__(self): self._thread = None - self._mac_address = None - self._enabled = True + self._mac_address_hash: Optional[str] = None + self._enabled: bool = True self.reload_settings() logger.setLevel(logging.INFO) - handler = logging.handlers.SysLogHandler(address='/dev/log') - logger.addHandler(handler) + syslog_handler = logging.handlers.SysLogHandler(address='/dev/log') + syslog_handler.setFormatter(logging.Formatter('%(name)s[%(process)d]: %(levelname)s %(message)s')) + logger.addHandler(syslog_handler) - def update_mac_address(self, dreamcast_ip): - self._mac_address = scan_mac_address() - logger.info("MAC address: {}".format(self._mac_address)) + def update_mac_address(self): + self._mac_address_hash = hash_mac_address() + logger.info("MAC address: {}".format(self._mac_address_hash)) def reload_settings(self): settings_file = CONFIGURATION_FILE @@ -99,14 +104,23 @@ def reload_settings(self): content = json.loads(settings.read()) self._enabled = content["enabled"] - def go_online(self, dreamcast_ip): + def go_online(self): if not self._enabled: return - self.update_mac_address(dreamcast_ip) + self.update_mac_address() self._thread = DreamcastNowThread(self) self._thread.start() def go_offline(self): - self._thread.stop() - self._thread = None + if self._thread is not None: + self._thread.stop() + self._thread = None + + @property + def enabled(self): + return self._enabled + + @property + def mac_address_hash(self): + return self._mac_address_hash diff --git a/dreampi.py b/dreampi.py index 53483b1..70e58a4 100755 --- a/dreampi.py +++ b/dreampi.py @@ -1,6 +1,10 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 + +from __future__ import absolute_import +from __future__ import print_function import atexit +from typing import List, Optional, Tuple import serial import socket import os @@ -12,11 +16,11 @@ import sh import signal import re -import config_server -import urllib -import urllib2 import iptc +import urllib.request +import urllib.error +import config_server from dcnow import DreamcastNowService from port_forwarding import PortForwarding @@ -65,38 +69,43 @@ def update_dns_file(): Download a DNS settings file for the DreamPi configuration (avoids forwarding requests to the main DNS server and provides a backup if that ever goes down) """ + # check for a remote configuration try: - response = urllib2.urlopen(DNS_FILE) - # Stop the server - subprocess.check_call("sudo service dnsmasq stop".split()) + response = urllib.request.urlopen(DNS_FILE) + except urllib.error.HTTPError as e: + logging.info(f"Did not find remote DNS config (HTTP code {e.code}); will use upstream") + return + except urllib.error.URLError as e: + logging.exception("Failed to check for remote DNS config") + return + + # Stop the server + subprocess.check_call("sudo service dnsmasq stop".split()) - # Update the configuration + # Update the configuration + try: with open("/etc/dnsmasq.d/dreampi.conf", "w") as f: f.write(response.read()) - - # Start the server again - subprocess.check_call("sudo service dnsmasq start".split()) - except (urllib2.URLError, urllib2.HTTPError, IOError): - logging.exception("Unable to update the DNS file for some reason, will use upstream") - pass - - -afo_patcher = None + except IOError: + logging.exception("Found remote DNS config but failed to apply it locally") + + # Start the server again + subprocess.check_call("sudo service dnsmasq start".split()) def start_afo_patching(): global afo_patcher - def fetch_replacement_ip(): + def fetch_replacement_ip() -> Optional[str]: url = "http://dreamcast.online/afo.txt" try: - return urllib.urlopen(url).read().strip() + return urllib.request.urlopen(url).read().strip().decode() except IOError: return None replacement = fetch_replacement_ip() - if not replacement: + if replacement is None: logger.warning("Not starting AFO patch as couldn't get IP from server") return @@ -111,20 +120,19 @@ def fetch_replacement_ip(): chain.append_rule(rule) - afo_patcher = rule logger.info("AFO routing enabled") + return rule -def stop_afo_patching(): - global afo_patcher - if afo_patcher: +def stop_afo_patching(afo_patcher_rule: iptc.Rule): + if afo_patcher_rule: table = iptc.Table(iptc.Table.NAT) chain = iptc.Chain(table, "PREROUTING") - chain.delete_rule(afo_patcher) + chain.delete_rule(afo_patcher_rule) logger.info("AFO routing disabled") -def start_process(name): +def start_service(name): try: logger.info("Starting {} process - Thanks Jonas Karlsson!".format(name)) with open(os.devnull, 'wb') as devnull: @@ -133,7 +141,7 @@ def start_process(name): logging.warning("Unable to start the {} process".format(name)) -def stop_process(name): +def stop_service(name): try: logger.info("Stopping {} process".format(name)) with open(os.devnull, 'wb') as devnull: @@ -157,7 +165,7 @@ def get_default_iface_name_linux(): def ip_exists(ip, iface): command = ["arp", "-a"] - output = subprocess.check_output(command) + output = subprocess.check_output(command).decode() if ("(%s)" % ip) in output: logger.info("IP existed at %s", ip) return True @@ -181,7 +189,7 @@ def find_next_unused_ip(start): raise Exception("Unable to find a free IP on the network") -def autoconfigure_ppp(device, speed): +def autoconfigure_ppp(device, speed) -> str: """ Every network is different, this function runs on boot and tries to autoconfigure PPP as best it can by detecting the subnet and gateway @@ -190,23 +198,23 @@ def autoconfigure_ppp(device, speed): Returns the IP allocated to the Dreamcast """ - gateway_ip = subprocess.check_output("route -n | grep 'UG[ \t]' | awk '{print $2}'", shell=True) + gateway_ip = subprocess.check_output("route -n | grep 'UG[ \t]' | awk '{print $2}'", shell=True).decode() subnet = gateway_ip.split(".")[:3] - PEERS_TEMPLATE = """ -{device} -{device_speed} -{this_ip}:{dc_ip} -noauth - """.strip() - - OPTIONS_TEMPLATE = """ -debug -ms-dns {} -proxyarp -ktune -noccp - """.strip() + PEERS_TEMPLATE = ( + "{device}\n" + "{device_speed}\n" + "{this_ip}:{dc_ip}\n" + "noauth\n" + ) + + OPTIONS_TEMPLATE = ( + "debug\n" + "ms-dns {this_ip}\n" + "proxyarp\n" + "ktune\n" + "noccp\n" + ) this_ip = find_next_unused_ip(".".join(subnet) + ".100") dreamcast_ip = find_next_unused_ip(this_ip) @@ -218,7 +226,7 @@ def autoconfigure_ppp(device, speed): with open("/etc/ppp/peers/dreamcast", "w") as f: f.write(peers_content) - options_content = OPTIONS_TEMPLATE.format(this_ip) + options_content = OPTIONS_TEMPLATE.format(this_ip=this_ip) with open("/etc/ppp/options", "w") as f: f.write(options_content) @@ -229,7 +237,7 @@ def autoconfigure_ppp(device, speed): ENABLE_SPEED_DETECTION = False # Set this to true if you want to use wvdialconf for device detection -def detect_device_and_speed(): +def detect_device_and_speed() -> Optional[Tuple[str, int]]: MAX_SPEED = 57600 if not ENABLE_SPEED_DETECTION: @@ -241,15 +249,15 @@ def detect_device_and_speed(): command = ["wvdialconf", "/dev/null"] try: - output = subprocess.check_output(command, stderr=subprocess.STDOUT) + output = subprocess.check_output(command, stderr=subprocess.STDOUT).decode() lines = output.split("\n") for line in lines: - match = re.match("(.+)\\:\sSpeed\s(\d+);", line.strip()) + match = re.match(r"(.+):\sSpeed\s(\d+);", line.strip()) if match: - device = match.group(1) - speed = match.group(2) + device: str = match.group(1) + speed = int(match.group(2)) logger.info("Detected device {} with speed {}".format(device, speed)) # Many modems report speeds higher than they can handle so we cap @@ -259,8 +267,9 @@ def detect_device_and_speed(): logger.info("No device detected") except: - logger.exception("Unable to detect modem. Falling back to ttyACM0") - return ("ttyACM0", MAX_SPEED) + logger.exception("Unable to detect modem.") + + return None class Daemon(object): @@ -342,9 +351,9 @@ def run(self): class Modem(object): - def __init__(self, device, speed, send_dial_tone=True): + def __init__(self, device: str, speed: int, send_dial_tone=True): self._device, self._speed = device, speed - self._serial = None + self._serial: Optional[serial.Serial] = None self._sending_tone = False if send_dial_tone: @@ -381,6 +390,7 @@ def connect(self): self._serial = serial.Serial( "/dev/{}".format(self._device), self._speed, timeout=0 ) + return self._serial def disconnect(self): if self._serial and self._serial.isOpen(): @@ -389,18 +399,18 @@ def disconnect(self): logger.info("Serial interface terminated") def reset(self): - self.send_command("ATZ0") # Send reset command - self.send_command("ATE0") # Don't echo our responses + self.send_command(b"ATZ0") # Send reset command + self.send_command(b"ATE0") # Don't echo our responses def start_dial_tone(self): if not self._dial_tone_wav: return self.reset() - self.send_command("AT+FCLASS=8") # Enter voice mode - self.send_command("AT+VLS=1") # Go off-hook - self.send_command("AT+VSM=1,8000") # 8 bit unsigned PCM - self.send_command("AT+VTX") # Voice transmission mode + self.send_command(b"AT+FCLASS=8") # Enter voice mode + self.send_command(b"AT+VLS=1") # Go off-hook + self.send_command(b"AT+VSM=1,8000") # 8 bit unsigned PCM + self.send_command(b"AT+VTX") # Voice transmission mode self._sending_tone = True @@ -413,10 +423,12 @@ def start_dial_tone(self): def stop_dial_tone(self): if not self._sending_tone: return + if self._serial is None: + raise Exception("Not connected") - self._serial.write("\0{}{}\r\n".format(chr(0x10), chr(0x03))) + self._serial.write(b"\x00\x10\x03\r\n") self.send_escape() - self.send_command("ATH0") # Go on-hook + self.send_command(b"ATH0") # Go on-hook self.reset() # Reset the modem self._sending_tone = False @@ -424,27 +436,30 @@ def answer(self): self.reset() # When we send ATA we only want to look for CONNECT. Some modems respond OK then CONNECT # and that messes everything up - self.send_command("ATA", ignore_responses=["OK"]) + self.send_command(b"ATA", ignore_responses=[b"OK"]) time.sleep(5) logger.info("Call answered!") - logger.info(subprocess.check_output(["pon", "dreamcast"])) + logger.info(subprocess.check_output(["pon", "dreamcast"]).decode()) logger.info("Connected") - def send_command(self, command, timeout=60, ignore_responses=None): - ignore_responses = ignore_responses or [] # Things to completely ignore + def send_command(self, command: bytes, timeout=60, ignore_responses: Optional[List[bytes]]=None): + if self._serial is None: + raise Exception("Not connected") + if ignore_responses is None: + ignore_responses = [] - VALID_RESPONSES = ["OK", "ERROR", "CONNECT", "VCON"] + VALID_RESPONSES = [b"OK", b"ERROR", b"CONNECT", b"VCON"] for ignore in ignore_responses: VALID_RESPONSES.remove(ignore) - final_command = "%s\r\n" % command + final_command = b"%b\r\n" % command self._serial.write(final_command) - logger.info(final_command) + logger.info(final_command.decode()) start = datetime.now() - line = "" + line = b"" while True: new_data = self._serial.readline().strip() @@ -454,15 +469,17 @@ def send_command(self, command, timeout=60, ignore_responses=None): line = line + new_data for resp in VALID_RESPONSES: if resp in line: - logger.info(line[line.find(resp):]) + logger.info(line[line.find(resp):].decode()) return # We are done if (datetime.now() - start).total_seconds() > timeout: raise IOError("There was a timeout while waiting for a response from the modem") def send_escape(self): + if self._serial is None: + raise Exception("Not connected") time.sleep(1.0) - self._serial.write("+++") + self._serial.write(b"+++") time.sleep(1.0) def update(self): @@ -472,8 +489,12 @@ def update(self): BUFFER_LENGTH = 1000 TIME_BETWEEN_UPLOADS_MS = (1000.0 / 8000.0) * BUFFER_LENGTH - milliseconds = (now - self._time_since_last_dial_tone).microseconds * 1000 - if not self._time_since_last_dial_tone or milliseconds >= TIME_BETWEEN_UPLOADS_MS: + if self._dial_tone_wav is None: + raise Exception("Dial tone wav not loaded") + if self._serial is None: + raise Exception("Not connected") + + if not self._time_since_last_dial_tone or ((now - (self._time_since_last_dial_tone)).microseconds * 1000) >= TIME_BETWEEN_UPLOADS_MS: byte = self._dial_tone_wav[self._dial_tone_counter:self._dial_tone_counter+BUFFER_LENGTH] self._dial_tone_counter += BUFFER_LENGTH if self._dial_tone_counter >= len(self._dial_tone_wav): @@ -526,15 +547,15 @@ def process(): dreamcast_ip = autoconfigure_ppp(modem.device_name, modem.device_speed) # Get a port forwarding object, now that we know the DC IP. - # port_forwarding = PortForwarding(dreamcast_ip, logger) - - # Disabled until we can figure out a faster way of doing this.. it takes a minute - # on my router which is way too long to wait for the DreamPi to boot - # port_forwarding.forward_all() + if "--enable-port-forwarding" in sys.argv: + port_forwarding = PortForwarding(dreamcast_ip, logger) + port_forwarding.forward_all() + else: + port_forwarding = None mode = "LISTENING" - modem.connect() + modem_serial = modem.connect() if dial_tone_enabled: modem.start_dial_tone() @@ -550,23 +571,27 @@ def process(): if mode == "LISTENING": modem.update() - char = modem._serial.read(1).strip() + char: bytes = modem_serial.read(1) + char = char.strip() if not char: continue if ord(char) == 16: # DLE character try: - char = modem._serial.read(1) - digit = int(char) - logger.info("Heard: %s", digit) - - mode = "ANSWERING" - modem.stop_dial_tone() - time_digit_heard = now - except (TypeError, ValueError): - pass + char = modem_serial.read(1) + if char.isdigit(): + digit = int(char) + logger.info("Heard: %s", digit) + + mode = "ANSWERING" + modem.stop_dial_tone() + time_digit_heard = now + except TypeError as e: + logger.exception(e) elif mode == "ANSWERING": + if time_digit_heard is None: + raise Exception("Impossible code path") if (now - time_digit_heard).total_seconds() > 8.0: time_digit_heard = None modem.answer() @@ -574,10 +599,11 @@ def process(): mode = "CONNECTED" elif mode == "CONNECTED": - dcnow.go_online(dreamcast_ip) + dcnow.go_online() # We start watching /var/log/messages for the hang up message - for line in sh.tail("-f", "/var/log/messages", "-n", "1", _iter=True): + for line in sh.tail("-f", "/var/log/messages", "-n", "1", _iter=True): # type: ignore - sh module is dynamic + line: str = line if "Modem hangup" in line: logger.info("Detected modem hang up, going back to listening") time.sleep(5) # Give the hangup some time @@ -591,8 +617,9 @@ def process(): if dial_tone_enabled: modem.start_dial_tone() - # Temporarily disabled, see above - # port_forwarding.delete_all() + if port_forwarding is not None: + port_forwarding.delete_all() + return 0 @@ -613,6 +640,8 @@ def enable_prom_mode_on_wlan0(): def main(): + afo_patcher_rule = None + try: # Don't do anything until there is an internet connection while not check_internet_connection(): @@ -629,19 +658,20 @@ def main(): restart_dnsmasq() config_server.start() - start_afo_patching() - start_process("dcvoip") - start_process("dcgamespy") - start_process("dc2k2") + afo_patcher_rule = start_afo_patching() + start_service("dcvoip") + start_service("dcgamespy") + start_service("dc2k2") return process() except: logger.exception("Something went wrong...") return 1 finally: - stop_process("dc2k2") - stop_process("dcgamespy") - stop_process("dcvoip") - stop_afo_patching() + stop_service("dc2k2") + stop_service("dcgamespy") + stop_service("dcvoip") + if afo_patcher_rule is not None: + stop_afo_patching(afo_patcher_rule) config_server.stop() logger.info("Dreampi quit successfully") @@ -649,11 +679,12 @@ def main(): if __name__ == '__main__': logger.setLevel(logging.INFO) - handler = logging.handlers.SysLogHandler(address='/dev/log') - logger.addHandler(handler) + syslog_handler = logging.handlers.SysLogHandler(address='/dev/log') + syslog_handler.setFormatter(logging.Formatter('%(name)s[%(process)d]: %(levelname)s %(message)s')) + logger.addHandler(syslog_handler) if len(sys.argv) > 1 and "--no-daemon" in sys.argv: - logger.addHandler(logging.StreamHandler()) + # logger.addHandler(logging.StreamHandler()) sys.exit(main()) daemon = Daemon("/tmp/dreampi.pid", main) @@ -669,5 +700,5 @@ def main(): sys.exit(2) sys.exit(0) else: - print("Usage: %s start|stop|restart" % sys.argv[0]) + print(("Usage: %s start|stop|restart" % sys.argv[0])) sys.exit(2) diff --git a/port_forwarding.py b/port_forwarding.py index 31e0ad2..a7c8af5 100644 --- a/port_forwarding.py +++ b/port_forwarding.py @@ -1,3 +1,9 @@ +#!/usr/bin/env python3 + +from __future__ import absolute_import + +from logging import Logger +from typing import Any import miniupnpc class PortForwarding: @@ -24,14 +30,14 @@ class PortForwarding: (17219, 'TCP', 'Worms World Party'), (37171, 'UDP', 'World Series Baseball 2K2'), (47624, 'TCP', 'PBA Tour Bowling 2001 / Starlancer'), - (range(2300, 2401), 'TCP', 'PBA Tour Bowling 2001 / Starlancer'), - (range(2300, 2401), 'UDP', 'PBA Tour Bowling 2001 / Starlancer') + (list(range(2300, 2401)), 'TCP', 'PBA Tour Bowling 2001 / Starlancer'), + (list(range(2300, 2401)), 'UDP', 'PBA Tour Bowling 2001 / Starlancer') ] - def __init__(self, dc_ip, logger): + def __init__(self, dc_ip: str, logger: Logger): self._dreamcast_ip = dc_ip self._logger = logger - self._upnp = miniupnpc.UPnP() + self._upnp: Any = miniupnpc.UPnP() # type: ignore - this module has no types def forward_all(self): """ @@ -58,7 +64,7 @@ def forward_all(self): except Exception as e: self._logger.warn("Could not create UPnP port mapping for {} ({}/{}): {}".format(game, port, proto, e)) - def delete_all(self): + def delete_all(self) -> bool: """ This method deletes all forwards, if possible. If the process returns an error, we keep trucking. From 87d1fdd0dce4c8682c0a116162f22a2c8aeb5f12 Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Thu, 17 Mar 2022 06:35:00 +0000 Subject: [PATCH 2/6] reformat with black --- .flake8 | 2 - config_server.py | 40 +++++++++-------- dcnow.py | 36 ++++++++++------ dreampi.py | 89 +++++++++++++++++++++----------------- port_forwarding.py | 105 +++++++++++++++++++++++++++++++++------------ 5 files changed, 171 insertions(+), 101 deletions(-) delete mode 100644 .flake8 diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 79a16af..0000000 --- a/.flake8 +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -max-line-length = 120 \ No newline at end of file diff --git a/config_server.py b/config_server.py index cb38a68..70e088b 100644 --- a/config_server.py +++ b/config_server.py @@ -13,16 +13,17 @@ class DreamPiConfigurationService(BaseHTTPRequestHandler): - def _get_post_data(self) -> Dict[str, List[str]]: - ctype, pdict = cgi.parse_header(self.headers['content-type']) + ctype, pdict = cgi.parse_header(self.headers["content-type"]) - if ctype == 'multipart/form-data': - pdict = { k: v.encode() for k, v in pdict.items() } + if ctype == "multipart/form-data": + pdict = {k: v.encode() for k, v in pdict.items()} postvars = cgi.parse_multipart(self.rfile, pdict) - elif ctype == 'application/x-www-form-urlencoded': - length = int(self.headers['content-length']) - postvars = cgi.parse_qs(self.rfile.read(length).decode(), keep_blank_values=True) + elif ctype == "application/x-www-form-urlencoded": + length = int(self.headers["content-length"]) + postvars = cgi.parse_qs( + self.rfile.read(length).decode(), keep_blank_values=True + ) else: postvars = {} @@ -39,11 +40,11 @@ def do_GET(self): with open(CONFIGURATION_FILE, "r") as f: enabled_state = json.loads(f.read())["enabled"] - self.wfile.write(json.dumps({ - "mac_address": hash_mac_address(), - "is_enabled": enabled_state - }).encode()) - + self.wfile.write( + json.dumps( + {"mac_address": hash_mac_address(), "is_enabled": enabled_state} + ).encode() + ) def do_POST(self): enabled_state = True @@ -54,7 +55,7 @@ def do_POST(self): self.end_headers() post_data = self._get_post_data() - if 'disable' in post_data: + if "disable" in post_data: enabled_state = False else: enabled_state = True @@ -62,22 +63,25 @@ def do_POST(self): with open(CONFIGURATION_FILE, "w") as f: f.write(json.dumps({"enabled": enabled_state})) - self.wfile.write(json.dumps({ - "mac_address": hash_mac_address(), - "is_enabled": enabled_state - }).encode()) + self.wfile.write( + json.dumps( + {"mac_address": hash_mac_address(), "is_enabled": enabled_state} + ).encode() + ) server = None thread = None + def start(): global server global thread - server = HTTPServer(('0.0.0.0', 1998), DreamPiConfigurationService) + server = HTTPServer(("0.0.0.0", 1998), DreamPiConfigurationService) thread = threading.Thread(target=server.serve_forever) thread.start() + def stop(): global server global thread diff --git a/dcnow.py b/dcnow.py index 020ff72..8238672 100644 --- a/dcnow.py +++ b/dcnow.py @@ -10,13 +10,13 @@ import logging.handlers import urllib.request import urllib.parse -import sh # type: ignore - sh module is dynamic +import sh # type: ignore - sh module is dynamic from typing import List, Optional from hashlib import sha256 from uuid import getnode as get_mac -logger = logging.getLogger('dcnow') +logger = logging.getLogger("dcnow") API_ROOT = "https://dcnow-2016.appspot.com" UPDATE_END_POINT = "/api/update/{mac_address}/" @@ -28,7 +28,9 @@ def hash_mac_address(): mac = get_mac() - return sha256(':'.join(("%012X" % mac)[i:i+2] for i in range(0, 12, 2)).encode()).hexdigest() + return sha256( + ":".join(("%012X" % mac)[i : i + 2] for i in range(0, 12, 2)).encode() + ).hexdigest() class DreamcastNowThread(threading.Thread): @@ -42,7 +44,9 @@ def post_update(): if not self._service.enabled: return - lines: List[str] = list(sh.tail("/var/log/syslog", "-n", "10", _iter=True)) # type: ignore - sh has dynamic members + lines: List[str] = list( + sh.tail("/var/log/syslog", "-n", "10", _iter=True) + ) # type: ignore - sh has dynamic members dns_query = None for line in lines[::-1]: line: str = line @@ -52,21 +56,25 @@ def post_update(): if "query[A]" in line: # We did a DNS lookup, what was it? - remainder = line[line.find("query[A]") + len("query[A]"):].strip() + remainder = line[line.find("query[A]") + len("query[A]") :].strip() domain = remainder.split(" ", 1)[0].strip() dns_query = sha256(domain.encode()).hexdigest() break - user_agent = 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT), Dreamcast Now' - header = { 'User-Agent' : user_agent } + user_agent = "Mozilla/4.0 (compatible; MSIE 5.5; Windows NT), Dreamcast Now" + header = {"User-Agent": user_agent} mac_address = self._service.mac_address_hash data = {} if dns_query: data["dns_query"] = dns_query data = urllib.parse.urlencode(data).encode() - req = urllib.request.Request(API_ROOT + UPDATE_END_POINT.format(mac_address=mac_address), data, header) - urllib.request.urlopen(req) # Send POST update + req = urllib.request.Request( + API_ROOT + UPDATE_END_POINT.format(mac_address=mac_address), + data, + header, + ) + urllib.request.urlopen(req) # Send POST update while self._running: try: @@ -88,8 +96,10 @@ def __init__(self): self.reload_settings() logger.setLevel(logging.INFO) - syslog_handler = logging.handlers.SysLogHandler(address='/dev/log') - syslog_handler.setFormatter(logging.Formatter('%(name)s[%(process)d]: %(levelname)s %(message)s')) + syslog_handler = logging.handlers.SysLogHandler(address="/dev/log") + syslog_handler.setFormatter( + logging.Formatter("%(name)s[%(process)d]: %(levelname)s %(message)s") + ) logger.addHandler(syslog_handler) def update_mac_address(self): @@ -116,11 +126,11 @@ def go_offline(self): if self._thread is not None: self._thread.stop() self._thread = None - + @property def enabled(self): return self._enabled - + @property def mac_address_hash(self): return self._mac_address_hash diff --git a/dreampi.py b/dreampi.py index 70e58a4..0661045 100755 --- a/dreampi.py +++ b/dreampi.py @@ -30,7 +30,7 @@ DNS_FILE = "https://dreamcast.online/dreampi/dreampi_dns.conf" -logger = logging.getLogger('dreampi') +logger = logging.getLogger("dreampi") def check_internet_connection(): @@ -42,7 +42,7 @@ def check_internet_connection(): "8.8.8.8", # Google DNS "8.8.4.4", "208.67.222.222", # Open DNS - "208.67.220.220" + "208.67.220.220", ] port = 53 @@ -73,12 +73,14 @@ def update_dns_file(): try: response = urllib.request.urlopen(DNS_FILE) except urllib.error.HTTPError as e: - logging.info(f"Did not find remote DNS config (HTTP code {e.code}); will use upstream") + logging.info( + f"Did not find remote DNS config (HTTP code {e.code}); will use upstream" + ) return except urllib.error.URLError as e: logging.exception("Failed to check for remote DNS config") return - + # Stop the server subprocess.check_call("sudo service dnsmasq stop".split()) @@ -88,7 +90,7 @@ def update_dns_file(): f.write(response.read()) except IOError: logging.exception("Found remote DNS config but failed to apply it locally") - + # Start the server again subprocess.check_call("sudo service dnsmasq start".split()) @@ -135,7 +137,7 @@ def stop_afo_patching(afo_patcher_rule: iptc.Rule): def start_service(name): try: logger.info("Starting {} process - Thanks Jonas Karlsson!".format(name)) - with open(os.devnull, 'wb') as devnull: + with open(os.devnull, "wb") as devnull: subprocess.check_call(["sudo", "service", name, "start"], stdout=devnull) except (subprocess.CalledProcessError, IOError): logging.warning("Unable to start the {} process".format(name)) @@ -144,7 +146,7 @@ def start_service(name): def stop_service(name): try: logger.info("Stopping {} process".format(name)) - with open(os.devnull, 'wb') as devnull: + with open(os.devnull, "wb") as devnull: subprocess.check_call(["sudo", "service", name, "stop"], stdout=devnull) except (subprocess.CalledProcessError, IOError): logging.warning("Unable to stop the {} process".format(name)) @@ -155,8 +157,8 @@ def get_default_iface_name_linux(): with open(route) as f: for line in f.readlines(): try: - iface, dest, _, flags, _, _, _, _, _, _, _, = line.strip().split() - if dest != '00000000' or not int(flags, 16) & 2: + iface, dest, _, flags, _, _, _, _, _, _, _, = line.strip().split() + if dest != "00000000" or not int(flags, 16) & 2: continue return iface except: @@ -198,30 +200,23 @@ def autoconfigure_ppp(device, speed) -> str: Returns the IP allocated to the Dreamcast """ - gateway_ip = subprocess.check_output("route -n | grep 'UG[ \t]' | awk '{print $2}'", shell=True).decode() + gateway_ip = subprocess.check_output( + "route -n | grep 'UG[ \t]' | awk '{print $2}'", shell=True + ).decode() subnet = gateway_ip.split(".")[:3] - PEERS_TEMPLATE = ( - "{device}\n" - "{device_speed}\n" - "{this_ip}:{dc_ip}\n" - "noauth\n" - ) + PEERS_TEMPLATE = "{device}\n" "{device_speed}\n" "{this_ip}:{dc_ip}\n" "noauth\n" - OPTIONS_TEMPLATE = ( - "debug\n" - "ms-dns {this_ip}\n" - "proxyarp\n" - "ktune\n" - "noccp\n" - ) + OPTIONS_TEMPLATE = "debug\n" "ms-dns {this_ip}\n" "proxyarp\n" "ktune\n" "noccp\n" this_ip = find_next_unused_ip(".".join(subnet) + ".100") dreamcast_ip = find_next_unused_ip(this_ip) logger.info("Dreamcast IP: {}".format(dreamcast_ip)) - peers_content = PEERS_TEMPLATE.format(device=device, device_speed=speed, this_ip=this_ip, dc_ip=dreamcast_ip) + peers_content = PEERS_TEMPLATE.format( + device=device, device_speed=speed, this_ip=this_ip, dc_ip=dreamcast_ip + ) with open("/etc/ppp/peers/dreamcast", "w") as f: f.write(peers_content) @@ -234,7 +229,9 @@ def autoconfigure_ppp(device, speed) -> str: return dreamcast_ip -ENABLE_SPEED_DETECTION = False # Set this to true if you want to use wvdialconf for device detection +ENABLE_SPEED_DETECTION = ( + False +) # Set this to true if you want to use wvdialconf for device detection def detect_device_and_speed() -> Optional[Tuple[str, int]]: @@ -299,7 +296,7 @@ def daemonize(self): atexit.register(self.delete_pid) pid = str(os.getpid()) - with open(self.pidfile, 'w+') as f: + with open(self.pidfile, "w+") as f: f.write("%s\n" % pid) def delete_pid(self): @@ -307,7 +304,7 @@ def delete_pid(self): def _read_pid_from_pidfile(self): try: - with open(self.pidfile, 'r') as pf: + with open(self.pidfile, "r") as pf: pid = int(pf.read().strip()) except IOError: pid = None @@ -414,9 +411,7 @@ def start_dial_tone(self): self._sending_tone = True - self._time_since_last_dial_tone = ( - datetime.now() - timedelta(seconds=100) - ) + self._time_since_last_dial_tone = datetime.now() - timedelta(seconds=100) self._dial_tone_counter = 0 @@ -442,7 +437,9 @@ def answer(self): logger.info(subprocess.check_output(["pon", "dreamcast"]).decode()) logger.info("Connected") - def send_command(self, command: bytes, timeout=60, ignore_responses: Optional[List[bytes]]=None): + def send_command( + self, command: bytes, timeout=60, ignore_responses: Optional[List[bytes]] = None + ): if self._serial is None: raise Exception("Not connected") if ignore_responses is None: @@ -469,11 +466,13 @@ def send_command(self, command: bytes, timeout=60, ignore_responses: Optional[Li line = line + new_data for resp in VALID_RESPONSES: if resp in line: - logger.info(line[line.find(resp):].decode()) + logger.info(line[line.find(resp) :].decode()) return # We are done if (datetime.now() - start).total_seconds() > timeout: - raise IOError("There was a timeout while waiting for a response from the modem") + raise IOError( + "There was a timeout while waiting for a response from the modem" + ) def send_escape(self): if self._serial is None: @@ -494,8 +493,14 @@ def update(self): if self._serial is None: raise Exception("Not connected") - if not self._time_since_last_dial_tone or ((now - (self._time_since_last_dial_tone)).microseconds * 1000) >= TIME_BETWEEN_UPLOADS_MS: - byte = self._dial_tone_wav[self._dial_tone_counter:self._dial_tone_counter+BUFFER_LENGTH] + if ( + not self._time_since_last_dial_tone + or ((now - (self._time_since_last_dial_tone)).microseconds * 1000) + >= TIME_BETWEEN_UPLOADS_MS + ): + byte = self._dial_tone_wav[ + self._dial_tone_counter : self._dial_tone_counter + BUFFER_LENGTH + ] self._dial_tone_counter += BUFFER_LENGTH if self._dial_tone_counter >= len(self._dial_tone_wav): self._dial_tone_counter = 0 @@ -520,7 +525,7 @@ def process(): dial_tone_enabled = "--disable-dial-tone" not in sys.argv # Make sure pppd isn't running - with open(os.devnull, 'wb') as devnull: + with open(os.devnull, "wb") as devnull: subprocess.call(["sudo", "killall", "pppd"], stderr=devnull) device_and_speed, internet_connected = None, False @@ -602,7 +607,9 @@ def process(): dcnow.go_online() # We start watching /var/log/messages for the hang up message - for line in sh.tail("-f", "/var/log/messages", "-n", "1", _iter=True): # type: ignore - sh module is dynamic + for line in sh.tail( + "-f", "/var/log/messages", "-n", "1", _iter=True + ): # type: ignore - sh module is dynamic line: str = line if "Modem hangup" in line: logger.info("Detected modem hang up, going back to listening") @@ -677,10 +684,12 @@ def main(): logger.info("Dreampi quit successfully") -if __name__ == '__main__': +if __name__ == "__main__": logger.setLevel(logging.INFO) - syslog_handler = logging.handlers.SysLogHandler(address='/dev/log') - syslog_handler.setFormatter(logging.Formatter('%(name)s[%(process)d]: %(levelname)s %(message)s')) + syslog_handler = logging.handlers.SysLogHandler(address="/dev/log") + syslog_handler.setFormatter( + logging.Formatter("%(name)s[%(process)d]: %(levelname)s %(message)s") + ) logger.addHandler(syslog_handler) if len(sys.argv) > 1 and "--no-daemon" in sys.argv: diff --git a/port_forwarding.py b/port_forwarding.py index a7c8af5..ab4ae7f 100644 --- a/port_forwarding.py +++ b/port_forwarding.py @@ -6,6 +6,7 @@ from typing import Any import miniupnpc + class PortForwarding: """ This class is used to forward the ports of all supported Dreamcast games @@ -16,28 +17,28 @@ class PortForwarding: # List of ports and the game they're for PORTS = [ - (1028, 'UDP', 'Planet Ring'), - (1285, 'UDP', 'Planet Ring'), - (3512, 'TCP', 'The Next Tetris: Online Edition'), - (3512, 'UDP', 'The Next Tetris: Online Edition'), - (6001, 'UDP', 'Ooga Booga'), - (6500, 'UDP', 'PBA Tour Bowling 2001 / Starlancer'), - (7648, 'UDP', 'Planet Ring'), - (7980, 'UDP', 'Alien Front Online'), - (9789, 'UDP', 'ChuChu Rocket!'), - (13139, 'UDP', 'PBA Tour Bowling 2001'), - (13713, 'UDP', 'World Series Baseball 2K2'), - (17219, 'TCP', 'Worms World Party'), - (37171, 'UDP', 'World Series Baseball 2K2'), - (47624, 'TCP', 'PBA Tour Bowling 2001 / Starlancer'), - (list(range(2300, 2401)), 'TCP', 'PBA Tour Bowling 2001 / Starlancer'), - (list(range(2300, 2401)), 'UDP', 'PBA Tour Bowling 2001 / Starlancer') + (1028, "UDP", "Planet Ring"), + (1285, "UDP", "Planet Ring"), + (3512, "TCP", "The Next Tetris: Online Edition"), + (3512, "UDP", "The Next Tetris: Online Edition"), + (6001, "UDP", "Ooga Booga"), + (6500, "UDP", "PBA Tour Bowling 2001 / Starlancer"), + (7648, "UDP", "Planet Ring"), + (7980, "UDP", "Alien Front Online"), + (9789, "UDP", "ChuChu Rocket!"), + (13139, "UDP", "PBA Tour Bowling 2001"), + (13713, "UDP", "World Series Baseball 2K2"), + (17219, "TCP", "Worms World Party"), + (37171, "UDP", "World Series Baseball 2K2"), + (47624, "TCP", "PBA Tour Bowling 2001 / Starlancer"), + (list(range(2300, 2401)), "TCP", "PBA Tour Bowling 2001 / Starlancer"), + (list(range(2300, 2401)), "UDP", "PBA Tour Bowling 2001 / Starlancer"), ] def __init__(self, dc_ip: str, logger: Logger): self._dreamcast_ip = dc_ip self._logger = logger - self._upnp: Any = miniupnpc.UPnP() # type: ignore - this module has no types + self._upnp: Any = miniupnpc.UPnP() # type: ignore - this module has no types def forward_all(self): """ @@ -49,20 +50,50 @@ def forward_all(self): port, proto, game = portinfo if isinstance(port, list): - self._logger.info("Trying to create UPnP port mapping for {} ({}-{}/{})".format(game, port[0], port[-1], proto)) + self._logger.info( + "Trying to create UPnP port mapping for {} ({}-{}/{})".format( + game, port[0], port[-1], proto + ) + ) for p in port: try: - self._upnp.addportmapping(p, proto, self._dreamcast_ip, p, "DreamPi: {}".format(game), '') + self._upnp.addportmapping( + p, + proto, + self._dreamcast_ip, + p, + "DreamPi: {}".format(game), + "", + ) except Exception as e: - self._logger.warn("Could not create UPnP port mapping for {} ({}/{}): {}".format(game, p, proto, e)) + self._logger.warn( + "Could not create UPnP port mapping for {} ({}/{}): {}".format( + game, p, proto, e + ) + ) else: - self._logger.info("Trying to create UPnP port mapping for {} ({}/{})".format(game, port, proto)) + self._logger.info( + "Trying to create UPnP port mapping for {} ({}/{})".format( + game, port, proto + ) + ) try: - self._upnp.addportmapping(port, proto, self._dreamcast_ip, port, "DreamPi: {}".format(game), '') + self._upnp.addportmapping( + port, + proto, + self._dreamcast_ip, + port, + "DreamPi: {}".format(game), + "", + ) except Exception as e: - self._logger.warn("Could not create UPnP port mapping for {} ({}/{}): {}".format(game, port, proto, e)) + self._logger.warn( + "Could not create UPnP port mapping for {} ({}/{}): {}".format( + game, port, proto, e + ) + ) def delete_all(self) -> bool: """ @@ -74,26 +105,44 @@ def delete_all(self) -> bool: self._upnp.discover() self._upnp.selectigd() except Exception as e: - self._logger.info("Could not find a UPnP internet gateway device on your network. Not automatically forwarding ports.") + self._logger.info( + "Could not find a UPnP internet gateway device on your network. Not automatically forwarding ports." + ) return False for portinfo in self.PORTS: port, proto, game = portinfo if isinstance(port, list): - self._logger.info("Trying to delete UPnP port mapping for {} ({}-{}/{})".format(game, port[0], port[-1], proto)) + self._logger.info( + "Trying to delete UPnP port mapping for {} ({}-{}/{})".format( + game, port[0], port[-1], proto + ) + ) for p in port: try: self._upnp.deleteportmapping(p, proto) except Exception as e: - self._logger.debug("Could not delete UPnP port mapping for {} ({}/{}): {}".format(game, p, proto, e)) + self._logger.debug( + "Could not delete UPnP port mapping for {} ({}/{}): {}".format( + game, p, proto, e + ) + ) else: - self._logger.info("Trying to delete UPnP port mapping for {} ({}/{})".format(game, port, proto)) + self._logger.info( + "Trying to delete UPnP port mapping for {} ({}/{})".format( + game, port, proto + ) + ) try: self._upnp.deleteportmapping(port, proto) except Exception as e: - self._logger.debug("Could not delete UPnP port mapping for {} ({}/{}): {}".format(game, port, proto, e)) + self._logger.debug( + "Could not delete UPnP port mapping for {} ({}/{}): {}".format( + game, port, proto, e + ) + ) return True From 31a3e9a1cf858b122de178daa5bf8951f4d3a9a5 Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Thu, 17 Mar 2022 06:36:34 +0000 Subject: [PATCH 3/6] fix type ignore comments --- dcnow.py | 6 ++++-- dreampi.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/dcnow.py b/dcnow.py index 8238672..859fca7 100644 --- a/dcnow.py +++ b/dcnow.py @@ -45,8 +45,10 @@ def post_update(): return lines: List[str] = list( - sh.tail("/var/log/syslog", "-n", "10", _iter=True) - ) # type: ignore - sh has dynamic members + sh.tail( # type: ignore - sh has dynamic members + "/var/log/syslog", "-n", "10", _iter=True + ) + ) dns_query = None for line in lines[::-1]: line: str = line diff --git a/dreampi.py b/dreampi.py index 0661045..aa54fbb 100755 --- a/dreampi.py +++ b/dreampi.py @@ -607,9 +607,9 @@ def process(): dcnow.go_online() # We start watching /var/log/messages for the hang up message - for line in sh.tail( + for line in sh.tail( # type: ignore - sh module is dynamic "-f", "/var/log/messages", "-n", "1", _iter=True - ): # type: ignore - sh module is dynamic + ): line: str = line if "Modem hangup" in line: logger.info("Detected modem hang up, going back to listening") From c192a061a652c098574da7ac4aeb38c0586b2598 Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Sun, 25 Dec 2022 15:23:12 -0800 Subject: [PATCH 4/6] Add requirements.txt as requested here: https://github.com/Kazade/dreampi/issues/17#issuecomment-1338120336 --- requirements.txt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..065d368 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pyserial +sh +http-server-base +python-iptables +miniupnpc From ad478865c2ddc11ef675f2f6b6be7dbd6d838865 Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Sun, 25 Dec 2022 16:06:41 -0800 Subject: [PATCH 5/6] venv to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ba74660..71c22a0 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ docs/_build/ # PyBuilder target/ + +# Virtualenv +venv/ From 2903128d61257aef0674de543f60d6a1eb32a0a2 Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Sun, 25 Dec 2022 16:10:20 -0800 Subject: [PATCH 6/6] update README to install dependencies from pip in a virtualenv --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 573064b..f7c3341 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ A daemon that creates a bridge between a Dreamcast's Dial-up Modem, and the Internet via the Pi -``` +```shell sudo apt install ./arm/*.deb -sudo apt install python3-miniupnpc python3-serial python3-sh python3-iptables -``` \ No newline at end of file +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +```