#!/usr/bin/env python #dreampi.py_version=202307212004 # from __future__ import absolute_import # from __future__ import print_function import atexit # from typing import List, Optional, Tuple import serial import socket import os import logging import logging.handlers import sys import time import subprocess import sh import signal import re import config_server import iptc import select import requests from dcnow import DreamcastNowService from port_forwarding import PortForwarding from datetime import datetime, timedelta def updater(): if os.path.isfile("/boot/noautoupdates.txt") == True: logger.info("Dreampi script auto updates are disabled") return netlink_script_url = "https://raw.githubusercontent.com/eaudunord/Netlink/latest/tunnel/netlink.py" dreampi_script_url = "https://raw.githubusercontent.com/eaudunord/dreampi/latest/dreampi.py" xband_script_url = "https://raw.githubusercontent.com/eaudunord/Netlink/latest/tunnel/xband.py" checkScripts = [netlink_script_url,xband_script_url,dreampi_script_url] restartFlag = False for script in checkScripts: url = script try: r=requests.get(url, stream = True) r.raise_for_status() for line in r.iter_lines(): if b'_version' in line: upstream_version = str(line.decode().split('version=')[1]).strip() break local_script = "/home/pi/dreampi/"+script.split("/")[-1] if os.path.isfile(local_script) == False: local_version = None else: with open(local_script,'rb') as f: for line in f: if b'_version' in line: local_version = str(line.decode().split('version=')[1]).strip() break if upstream_version == local_version: logger.info('%s Up To Date' % local_script) else: r = requests.get(url) r.raise_for_status() with open(local_script,'wb') as f: f.write(r.content) logger.info('%s Updated' % local_script) if local_script == "dreampi.py": os.system("sudo chmod +x dreampi.py") restartFlag = True except requests.exceptions.HTTPError: logger.info("Couldn't check updates for: %s" % local_script) continue except requests.exceptions.SSLError: logger.info("SSL error while checking for updates. System time may need to be synced") return if restartFlag: logger.info('Updated. Rebooting') os.system("sudo reboot") DNS_FILE = "https://dreamcast.online/dreampi/dreampi_dns.conf" logger = logging.getLogger("dreampi") def check_internet_connection(): """ Returns True if there's a connection """ IP_ADDRESS_LIST = [ "1.1.1.1", # Cloudflare "1.0.0.1", "8.8.8.8", # Google DNS "8.8.4.4", "208.67.222.222", # Open DNS "208.67.220.220", ] port = 53 timeout = 3 for host in IP_ADDRESS_LIST: try: socket.setdefaulttimeout(timeout) socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port)) return True except socket.error: pass else: logger.exception("No internet connection") return False def restart_dnsmasq(): subprocess.call("sudo service dnsmasq restart".split()) 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 = requests.get(DNS_FILE) response.raise_for_status() except requests.exceptions.HTTPError: logging.info( "Did not find remote DNS confi; will use upstream" ) return # Stop the server subprocess.check_call("sudo service dnsmasq stop".split()) # Update the configuration try: with open("/etc/dnsmasq.d/dreampi.conf", "w") as f: 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()) afo_patcher = None def start_afo_patching(): global afo_patcher def fetch_replacement_ip(): url = "http://dreamcast.online/afo.txt" try: r = requests.get(url) r.raise_for_status() afo_IP = r.text.strip() return afo_IP except requests.exceptions.HTTPError: return None replacement = fetch_replacement_ip() if replacement is None: logger.warning("Not starting AFO patch as couldn't get IP from server") return table = iptc.Table(iptc.Table.NAT) chain = iptc.Chain(table, "PREROUTING") rule = iptc.Rule() rule.protocol = "tcp" rule.dst = "63.251.242.131" rule.create_target("DNAT") rule.target.to_destination = replacement chain.append_rule(rule) logger.info("AFO routing enabled") return rule def stop_afo_patching(afo_patcher_rule): if afo_patcher_rule: table = iptc.Table(iptc.Table.NAT) chain = iptc.Chain(table, "PREROUTING") chain.delete_rule(afo_patcher_rule) logger.info("AFO routing disabled") def start_service(name): try: logger.info("Starting {} process - Thanks Jonas Karlsson!".format(name)) 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)) def stop_service(name): try: logger.info("Stopping {} process".format(name)) 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)) def get_default_iface_name_linux(): route = "/proc/net/route" 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: continue return iface except: continue def ip_exists(ip, iface): command = ["arp", "-a", "-i", iface] output = subprocess.check_output(command).decode() if ("(%s)" % ip) in output: logger.info("IP existed at %s", ip) return True else: logger.info("Free IP at %s", ip) return False def find_next_unused_ip(start): interface = get_default_iface_name_linux() parts = [int(x) for x in start.split(".")] current_check = parts[-1] - 1 while current_check: test_ip = ".".join([str(x) for x in parts[:3] + [current_check]]) if not ip_exists(test_ip, interface): return test_ip current_check -= 1 raise Exception("Unable to find a free IP on the network") def autoconfigure_ppp(device, speed): """ Every network is different, this function runs on boot and tries to autoconfigure PPP as best it can by detecting the subnet and gateway we're running on. Returns the IP allocated to the Dreamcast """ 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" 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 ) with open("/etc/ppp/peers/dreamcast", "w") as f: f.write(peers_content) options_content = OPTIONS_TEMPLATE.format(this_ip=this_ip) with open("/etc/ppp/options", "w") as f: f.write(options_content) return dreamcast_ip ENABLE_SPEED_DETECTION = ( False ) # Set this to true if you want to use wvdialconf for device detection def detect_device_and_speed(): MAX_SPEED = 115200 if not ENABLE_SPEED_DETECTION: # By default we don't detect the speed or device as it's flakey in later # Pi kernels. But it might be necessary for some people so that functionality # can be enabled by setting the flag above to True return ("/dev/ttyACM0", MAX_SPEED) command = ["wvdialconf", "/dev/null"] try: output = subprocess.check_output(command, stderr=subprocess.STDOUT).decode() lines = output.split("\n") for line in lines: match = re.match(r"(.+):\sSpeed\s(\d+);", line.strip()) if match: device = 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 # to 56k return device, min(speed, MAX_SPEED) else: logger.info("No device detected") except: logger.exception("Unable to detect modem. Falling back to ttyACM0") return ("/dev/ttyACM0", MAX_SPEED) class Daemon(object): def __init__(self, pidfile, process): self.pidfile = pidfile self.process = process def daemonize(self): try: pid = os.fork() if pid > 0: sys.exit(0) except OSError: sys.exit(1) os.chdir("/") os.setsid() os.umask(0) try: pid = os.fork() if pid > 0: sys.exit(0) except OSError: sys.exit(1) atexit.register(self.delete_pid) pid = str(os.getpid()) with open(self.pidfile, "w+") as f: f.write("%s\n" % pid) def delete_pid(self): os.remove(self.pidfile) def _read_pid_from_pidfile(self): try: with open(self.pidfile, "r") as pf: pid = int(pf.read().strip()) except IOError: pid = None return pid def start(self): pid = self._read_pid_from_pidfile() if pid: logger.info("Daemon already running, exiting") sys.exit(1) logger.info("Starting daemon") self.daemonize() self.run() def stop(self): pid = self._read_pid_from_pidfile() if not pid: logger.info("pidfile doesn't exist, deamon must not be running") return try: while True: os.kill(pid, signal.SIGTERM) time.sleep(0.1) except OSError: if os.path.exists(self.pidfile): os.remove(self.pidfile) else: sys.exit(1) def restart(self): self.stop() self.start() def run(self): self.process() class Modem(object): def __init__(self, device, speed, send_dial_tone=True): self._device, self._speed = device, speed self._serial = None self._sending_tone = False if send_dial_tone: self._dial_tone_wav = self._read_dial_tone() else: self._dial_tone_wav = None self._time_since_last_dial_tone = None self._dial_tone_counter = 0 @property def device_speed(self): return self._speed @property def device_name(self): return self._device def _read_dial_tone(self): this_dir = os.path.dirname(os.path.abspath(os.path.realpath(__file__))) dial_tone_wav = os.path.join(this_dir, "dial-tone.wav") with open(dial_tone_wav, "rb") as f: dial_tone = f.read() # Read the entire wav file dial_tone = dial_tone[44:] # Strip the header (44 bytes) return dial_tone def connect(self): if self._serial: self.disconnect() logger.info("Opening serial interface to {}".format(self._device)) self._serial = serial.Serial( self._device, self._speed, timeout=0 ) return self._serial def connect_netlink(self,speed = 115200, timeout = 0.01, rtscts = False): #non-blocking if self._serial: self.disconnect() logger.info("Opening serial interface to {}".format(self._device)) self._serial = serial.Serial( self._device, speed, timeout=timeout, rtscts = rtscts ) def disconnect(self): if self._serial and self._serial.isOpen(): self._serial.flush() self._serial.close() self._serial = None logger.info("Serial interface terminated") def reset(self): while True: try: self.send_command("ATZ0",timeout=3) # Send reset command time.sleep(1) self.send_command("AT&F0") self.send_command("ATE0W2") # Don't echo our responses return except IOError: self.shake_it_off() # modem isn't responding. Try a harder reset def start_dial_tone(self): if not self._dial_tone_wav: return i = 0 while i < 3: try: self.reset() 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 logger.info("") break except IOError: time.sleep(0.5) i+=1 pass self._sending_tone = True self._time_since_last_dial_tone = datetime.now() - timedelta(seconds=100) self._dial_tone_counter = 0 def stop_dial_tone(self): if not self._sending_tone: return if self._serial is None: raise Exception("Not connected") self._serial.write(b"\x00\x10\x03\r\n") self.send_escape() self.send_command(b"ATH0") # Go on-hook self.reset() # Reset the modem self._sending_tone = False 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(b"ATA", ignore_responses=[b"OK"]) time.sleep(5) logger.info("Call answered!") logger.info(subprocess.check_output(["pon", "dreamcast"]).decode()) logger.info("Connected") def netlink_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(b"ATA", ignore_responses=[b"OK"]) # time.sleep(5) logger.info("Call answered!") logger.info("Connected") def query_modem(self, command, timeout=3, response = "OK"): #this function assumes we're being passed a non-blocking modem if isinstance(command, bytes): final_command = command + b'\r\n' else: final_command = ("%s\r\n" % command).encode() self._serial.write(final_command) logger.info(final_command.decode()) start = time.time() line = b"" while True: new_data = self._serial.readline().strip() if not new_data: #non-blocking modem will end up here when timeout reached, try until this function's timeout is reached. if time.time() - start < timeout: continue raise IOError() line = line + new_data if response.encode() in line: if response != "OK": logger.info(line.decode()) return # Valid response def send_command( self, command, timeout=60, ignore_responses = None ): if self._serial is None: raise Exception("Not connected") if ignore_responses is None: ignore_responses = [] VALID_RESPONSES = [b"OK", b"ERROR", b"CONNECT", b"VCON"] for ignore in ignore_responses: VALID_RESPONSES.remove(ignore) if isinstance(command, bytes): final_command = command + b'\r\n' else: final_command = ("%s\r\n" % command).encode() self._serial.write(final_command) logger.info('Command: %s' % final_command.decode()) start = time.time() line = b"" while True: new_data = self._serial.readline().strip() if not new_data: if time.time() - start < timeout: continue raise IOError("There was a timeout while waiting for a response from the modem") line = line + new_data for resp in VALID_RESPONSES: if resp in line: if resp != b"OK": logger.info('Response: %s' % line.decode()) if resp == b"ERROR": raise IOError("Command returned an error") # logger.info(line[line.find(resp) :].decode()) return # We are done def send_escape(self): if self._serial is None: raise Exception("Not connected") time.sleep(1.0) self._serial.write(b"+++") time.sleep(1.0) def shake_it_off(self): #sometimes the modem gets stuck in data mode for i in range(3): self._serial.write(b'+') time.sleep(0.2) time.sleep(4) self.send_command('ATH0') #make sure we're on hook logger.info("Shook it off") def update(self): now = datetime.now() if self._sending_tone: # Keep sending dial tone BUFFER_LENGTH = 1000 TIME_BETWEEN_UPLOADS_MS = (1000.0 / 8000.0) * BUFFER_LENGTH 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): self._dial_tone_counter = 0 self._serial.write(byte) self._time_since_last_dial_tone = now class GracefulKiller(object): def __init__(self): self.kill_now = False signal.signal(signal.SIGINT, self.exit_gracefully) signal.signal(signal.SIGTERM, self.exit_gracefully) def exit_gracefully(self, signum, frame): logging.warning("Received signal: %s", signum) self.kill_now = True def do_netlink(side,dial_string,modem,saturn=True): # ser = serial.Serial(device_and_speed[0], device_and_speed[1], timeout=0.005) state, opponent = netlink.netlink_setup(side,dial_string,modem) if state == "failed": for i in range(3): modem._serial.write(b'+') time.sleep(0.2) time.sleep(4) modem.send_command(b'ATH0') return if saturn == False: netlink.kddi_exchange(side,state,opponent,ser=modem._serial) else: netlink.netlink_exchange(side,state,opponent,ser=modem._serial) def process(): xbandnums = ["18002071194","19209492263","0120717360","0355703001"] xbandMatching = False xbandTimer = None xbandInit = False openXband = False killer = GracefulKiller() dial_tone_enabled = "--disable-dial-tone" not in sys.argv # Make sure pppd isn't running with open(os.devnull, "wb") as devnull: subprocess.call(["sudo", "killall", "pppd"], stderr=devnull) device_and_speed, internet_connected = None, False # Startup checks, make sure that we don't do anything until # we have a modem and internet connection while True: logger.info("Detecting connection and modem...") internet_connected = check_internet_connection() device_and_speed = detect_device_and_speed() if internet_connected and device_and_speed: logger.info("Internet connected and device found!") break elif not internet_connected: logger.warn("Unable to detect an internet connection. Waiting...") elif not device_and_speed: logger.warn("Unable to find a modem device. Waiting...") time.sleep(5) modem = Modem(device_and_speed[0], device_and_speed[1], dial_tone_enabled) dreamcast_ip = autoconfigure_ppp(modem.device_name, modem.device_speed) # Get a port forwarding object, now that we know the DC IP. 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() if dial_tone_enabled: modem.start_dial_tone() time_digit_heard = None global saturn saturn = True dcnow = DreamcastNowService() while True: if killer.kill_now: break now = datetime.now() if mode == "LISTENING": if xbandMatching == True: if xbandInit == False: xband.xbandInit() xbandInit = True if time.time() - xbandTimer > 900: #Listen for incoming connections for 15 minutes xbandMatching = False xband.closeXband() openXband = False continue if openXband == False: xband.openXband() openXband = True xbandResult,opponent = xband.xbandListen(modem) if xbandResult == "connected": xband.netlink_exchange("waiting","connected",opponent,ser=modem._serial) logger.info("Xband Disconnected") mode = "LISTENING" modem.connect() modem.start_dial_tone() xbandMatching = False xband.closeXband() openXband = False modem.update() char = modem._serial.read(1) char = char.strip() if not char: continue if ord(char) == 16: # DLE character try: parsed = netlink.digit_parser(modem) if parsed == "nada": pass elif isinstance(parsed,dict): client = parsed['client'] dial_string = parsed['dial_string'] side = parsed['side'] logger.info("Heard: %s" % dial_string) if dial_string in xbandnums: logger.info("Calling Xband server") client = "xband" mode = "XBAND ANSWERING" elif dial_string == "00": side = "waiting" client = "direct_dial" saturn = False elif dial_string[0:3] == "859": try: kddi_opponent = dial_string kddi_lookup = "https://dial.redreamcast.net/?phoneNumber=%s" % kddi_opponent response = requests.get(kddi_lookup) response.raise_for_status() ip = response.text if len(ip) == 0: pass else: dial_string = ip logger.info(dial_string) saturn = False side = "calling" client = "direct_dial" time.sleep(7) except requests.exceptions.HTTPError: pass elif len(dial_string.split('*')) == 5 and dial_string.split('*')[-1] == "1": oppIP = '.'.join(dial_string.split('*')[0:4]) client = "xband" mode = "NETLINK ANSWERING" side = "calling" if client == "direct_dial": mode = "NETLINK ANSWERING" elif client == "xband": pass else: mode = "ANSWERING" modem.stop_dial_tone() time_digit_heard = now except (TypeError, ValueError): pass elif mode == "XBAND ANSWERING": # print("xband answering") if (now - time_digit_heard).total_seconds() > 8.0: time_digit_heard = None modem.query_modem("ATA", timeout=60, response = "CONNECT") xband.xbandServer(modem) mode = "LISTENING" modem.connect() modem.start_dial_tone() xbandMatching = True xbandTimer = time.time() 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() modem.disconnect() mode = "CONNECTED" elif mode == "NETLINK ANSWERING": if (now - time_digit_heard).total_seconds() > 8.0: time_digit_heard = None try: if client == "xband": xband.init_xband(modem) result = xband.ringPhone(oppIP,modem) if result == "hangup": mode = "LISTENING" modem.connect() modem.start_dial_tone() else: mode = "NETLINK_CONNECTED" else: modem.connect_netlink(speed=57600,timeout=0.01,rtscts = True) #non-blocking version modem.query_modem(b"AT%E0\V1") if saturn: modem.query_modem(b'AT%C0\N3') modem.query_modem(b'AT+MS=V32b,1,14400,14400,14400,14400') modem.query_modem(b"ATA", timeout=120, response = "CONNECT") mode = "NETLINK_CONNECTED" except IOError: modem.connect() mode = "LISTENING" modem.start_dial_tone() elif mode == "CONNECTED": dcnow.go_online(dreamcast_ip) for line in sh.tail("-f", "/var/log/messages", "-n", "1", _iter=True): if "pppd" in line and "Exit" in line:#wait for pppd to execute the ip-down script logger.info("Detected modem hang up, going back to listening") break dcnow.go_offline() #changed dcnow to wait 15 seconds for event instead of sleeping. Should be faster. mode = "LISTENING" # modem = Modem(device_and_speed[0], device_and_speed[1], dial_tone_enabled) modem.connect() if dial_tone_enabled: modem.start_dial_tone() elif mode == "NETLINK_CONNECTED": if client == "xband": xband.netlink_exchange("calling","connected",oppIP,ser=modem._serial) else: do_netlink(side,dial_string,modem,saturn=saturn) logger.info("Netlink Disconnected") mode = "LISTENING" modem.connect() modem.start_dial_tone() if port_forwarding is not None: port_forwarding.delete_all() return 0 def enable_prom_mode_on_wlan0(): """ The Pi wifi firmware seems broken, we can only get it to work by enabling promiscuous mode. This is a hack, we just enable it for wlan0 and ignore errors """ try: subprocess.check_call("sudo ifconfig wlan0 promisc".split()) logging.info("Promiscuous mode set on wlan0") except subprocess.CalledProcessError: logging.info("Attempted to set promiscuous mode on wlan0 but was unsuccessful") logging.info("Probably no wifi connected, or using a different device name") def main(): afo_patcher_rule = None try: # Don't do anything until there is an internet connection while not check_internet_connection(): logger.info("Waiting for internet connection...") time.sleep(3) #try auto updates updater() global xband global netlink try: import xband as xband import netlink as netlink except ImportError: logger.info("couldn't import xband or netlink modules") # Try to update the DNS configuration update_dns_file() # Hack around dodgy Raspberry Pi things enable_prom_mode_on_wlan0() # Just make sure everything is fine restart_dnsmasq() config_server.start() 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_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") 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") ) logger.addHandler(syslog_handler) if len(sys.argv) > 1 and "--no-daemon" in sys.argv: # logger.addHandler(logging.StreamHandler()) sys.exit(main()) daemon = Daemon("/tmp/dreampi.pid", main) if len(sys.argv) == 2: if sys.argv[1] == "start": daemon.start() elif sys.argv[1] == "stop": daemon.stop() elif sys.argv[1] == "restart": daemon.restart() else: sys.exit(2) sys.exit(0) else: print(("Usage: %s start|stop|restart" % sys.argv[0])) sys.exit(2)