tcp-proxy.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. #!/usr/bin/env python3
  2. import sys
  3. import re
  4. from twisted.internet import defer
  5. from twisted.internet import protocol
  6. from twisted.internet import reactor
  7. from twisted.internet import task
  8. from twisted.python import log
  9. from twisted.python.logfile import DailyLogFile
  10. import pendulum
  11. from subprocess import check_output
  12. from colorama import Fore, Back, Style
  13. # This isn't the best configuration, but it's simple
  14. # and works. Mostly.
  15. try:
  16. from config_dev import *
  17. except ModuleNotFoundError:
  18. from config import *
  19. # Extract the version information from git.
  20. # The match gives us only tags starting with v[0-9]* Using anything else trips up on double digits.
  21. version = check_output(
  22. [
  23. "git",
  24. "describe",
  25. "--abbrev=8",
  26. "--long",
  27. "--tags",
  28. "--dirty",
  29. "--always",
  30. "--match",
  31. "v[0-9]*",
  32. ],
  33. universal_newlines=True,
  34. ).strip()
  35. def merge(color_string):
  36. """ Given a string of colorama ANSI, merge them if you can. """
  37. return color_string.replace("m\x1b[", ";")
  38. # https://en.wikipedia.org/wiki/ANSI_escape_code
  39. # Cleans all ANSI
  40. cleaner = re.compile(r"\x1b\[[0-9;]*[A-Zmh]")
  41. # Looks for ANSI (that should be considered to be a newline)
  42. # This needs to see what is send when something enters / leaves
  43. # the player's current sector. (That doesn't work/isn't
  44. # detected. NNY!) It is "\x1b[K" Erase in Line!
  45. makeNL = re.compile(r"\x1b\[[0-9;]*[JK]")
  46. def treatAsNL(line):
  47. """ Replace any ANSI codes that would be better understood as newlines. """
  48. global makeNL
  49. return makeNL.sub("\n", line)
  50. def cleanANSI(line):
  51. """ Remove all ANSI codes. """
  52. global cleaner
  53. return cleaner.sub("", line)
  54. # return re.sub(r'\x1b\[([0-9,A-Z]{1,2}(;[0-9]{1,2})?(;[0-9]{3})?)?[m|K]?', '', line)
  55. class Observer(object):
  56. def __init__(self):
  57. self.dispatch = {}
  58. def emit(self, signal, message):
  59. """ emit a signal, return True if sent somewhere. """
  60. if signal in self.dispatch:
  61. # something to do
  62. ret = False
  63. for listener in self.dispatch[signal]:
  64. ret = True
  65. reactor.callLater(0, listener, message)
  66. return ret
  67. return False
  68. def connect(self, signal, func):
  69. """ Connect a signal to a given function. """
  70. if not signal in self.dispatch:
  71. self.dispatch[signal] = []
  72. self.dispatch[signal].append(func)
  73. def disconnect(self, signal, func):
  74. """ Disconnect a signal with a certain function. """
  75. if signal in self.dispatch:
  76. self.dispatch[signal].remove(func)
  77. if len(self.dispatch[signal]) == 0:
  78. self.dispatch.pop(signal)
  79. def get_funcs(self, signal):
  80. """ Gives a copy of the dispatch for a given signal. """
  81. if signal in self.dispatch:
  82. return list(self.dispatch[signal])
  83. else:
  84. return []
  85. def set_funcs(self, signal, funcs):
  86. """ Replaces the dispatch for a given signal. """
  87. if signal in self.dispatch:
  88. if len(funcs) == 0:
  89. self.dispatch.pop(signal)
  90. else:
  91. self.dispatch = list(funcs)
  92. else:
  93. if len(funcs) != 0:
  94. self.dispatch = list(funcs)
  95. class MCP(object):
  96. def __init__(self, game):
  97. self.game = game
  98. self.queue_game = None
  99. # we don't have this .. yet!
  100. self.prompt = None
  101. self.observer = None
  102. self.keepalive = None
  103. def finishSetup(self):
  104. if self.queue_game is None:
  105. self.queue_game = self.game.queue_game
  106. if self.observer is None:
  107. self.observer = self.game.observer
  108. self.observer.connect("hotkey", self.activate)
  109. def stayAwake(self):
  110. """ Send a space to the game to keep it alive/don't timeout. """
  111. log.msg("Gameserver, stay awake.")
  112. self.game.queue_player.put(" ")
  113. def activate(self, _):
  114. log.msg("MCP menu called.")
  115. # We want the raw one, not the ANSI cleaned getPrompt.
  116. prompt = self.game.buffer
  117. if not self.prompt is None:
  118. # silly, we're already active
  119. log.msg("I think we're already active. Ignoring request.")
  120. return
  121. # Or will the caller setup/restore the prompt?
  122. self.prompt = prompt
  123. # queue_game = to player
  124. self.displayMenu()
  125. self.observer.connect("player", self.fromPlayer)
  126. # TODO: Add background "keepalive" event so the game doesn't time out on us.
  127. self.keepalive = task.LoopingCall(self.stayAwake)
  128. self.keepalive.start(30)
  129. def displayMenu(self):
  130. nl = "\n\r"
  131. c = merge(Style.BRIGHT + Fore.YELLOW + Back.BLUE)
  132. r = Style.RESET_ALL
  133. c1 = merge(Style.BRIGHT + Fore.BLUE)
  134. c2 = merge(Style.NORMAL + Fore.BLUE)
  135. self.queue_game.put(nl + c + "TradeWars Proxy active." + r + nl)
  136. self.queue_game.put(
  137. " " + c1 + "T" + c2 + " - " + c1 + "Display current Time" + nl
  138. )
  139. self.queue_game.put(" " + c1 + "P" + c2 + " - " + c1 + "Port CIM Report" + nl)
  140. self.queue_game.put(" " + c1 + "X" + c2 + " - " + c1 + "eXit" + nl)
  141. self.queue_game.put(" " + c + "-=>" + r + " ")
  142. def fromPlayer(self, chunk):
  143. """ Data from player (in bytes). """
  144. chunk = chunk.decode("utf-8", "ignore")
  145. nl = "\n\r"
  146. c = merge(Style.BRIGHT + Fore.YELLOW + Back.BLUE)
  147. r = Style.RESET_ALL
  148. c1 = merge(Style.BRIGHT + Fore.BLUE)
  149. c2 = merge(Style.NORMAL + Fore.BLUE)
  150. key = chunk.upper()
  151. if key == "T":
  152. self.queue_game.put(c + key + r + nl)
  153. now = pendulum.now()
  154. log.msg("Time")
  155. self.queue_game.put(
  156. nl + c1 + "It is currently " + now.to_datetime_string() + "." + nl
  157. )
  158. self.displayMenu()
  159. elif key == "P":
  160. log.msg("Port")
  161. self.queue_game.put(c + key + r + nl)
  162. self.queue_game.put(nl + c + "NO, NOT YET!" + r + nl)
  163. self.displayMenu()
  164. elif key == "X":
  165. log.msg('"Quit, return to "normal". (Whatever that means!)')
  166. self.queue_game.put(c + key + r + nl)
  167. self.observer.disconnect("player", self.fromPlayer)
  168. self.queue_game.put(nl + c1 + "Returning to game" + c2 + "..." + r + nl)
  169. self.queue_game.put(self.prompt)
  170. self.prompt = None
  171. self.keepalive.stop()
  172. self.keepalive = None
  173. else:
  174. if key.isprintable():
  175. self.queue_game.put(r + nl)
  176. self.queue_game.put("Excuse me? I don't understand '" + key + "'." + nl)
  177. self.displayMenu()
  178. class Game(protocol.Protocol):
  179. def __init__(self):
  180. self.buffer = ""
  181. self.game = None
  182. self.usergame = (None, None)
  183. self.to_player = True
  184. self.mcp = MCP(self)
  185. def connectionMade(self):
  186. log.msg("Connected to Game Server")
  187. self.queue_player = self.factory.queue_player
  188. self.queue_game = self.factory.queue_game
  189. self.observer = self.factory.observer
  190. self.setPlayerReceived()
  191. self.observer.connect("user-game", self.show_game)
  192. self.mcp.finishSetup()
  193. def show_game(self, game):
  194. self.usergame = game
  195. log.msg("## User-Game:", game)
  196. def setPlayerReceived(self):
  197. """ Get deferred from client queue, callback clientDataReceived. """
  198. self.queue_player.get().addCallback(self.playerDataReceived)
  199. def playerDataReceived(self, chunk):
  200. if chunk is False:
  201. self.queue_player = None
  202. log.msg("Player: disconnected, close connection to game")
  203. # I don't believe I need this if I'm using protocol.Factory
  204. self.factory.continueTrying = False
  205. self.transport.loseConnection()
  206. else:
  207. # Pass received data to the server
  208. if type(chunk) == str:
  209. self.transport.write(chunk.encode())
  210. else:
  211. self.transport.write(chunk)
  212. self.setPlayerReceived()
  213. def lineReceived(self, line):
  214. """ line received from the game. """
  215. if LOG_LINES:
  216. log.msg(">> [{0}]".format(line))
  217. if "TWGS v2.20b" in line and "www.eisonline.com" in line:
  218. self.queue_game.put(
  219. "TWGS Proxy build "
  220. + version
  221. + " is active. \x1b[1;34m~\x1b[0m to activate.\n\r\n\r",
  222. )
  223. if "TradeWars Game Server" in line and "Copyright (C) EIS" in line:
  224. # We are not in a game
  225. if not self.game is None:
  226. # We were in a game.
  227. self.game = None
  228. self.observer.emit("user-game", (self.factory.player.user, self.game))
  229. if "Selection (? for menu): " in line:
  230. game = line[-1]
  231. if game >= "A" and game < "Q":
  232. self.game = game
  233. log.msg("Game: {0}".format(self.game))
  234. self.observer.emit("user-game", (self.factory.player.user, self.game))
  235. self.observer.emit("game-line", line)
  236. def getPrompt(self):
  237. """ Return the current prompt, stripped of ANSI. """
  238. return cleanANSI(self.buffer)
  239. def dataReceived(self, chunk):
  240. """ Data received from the Game.
  241. Remove backspaces.
  242. Treat some ANSI codes as NewLine.
  243. Remove ANSI.
  244. Break into lines.
  245. Trim out carriage returns.
  246. Call lineReceived().
  247. "Optionally" pass data to player.
  248. FUTURE: trigger on prompt. [cleanANSI(buffer)]
  249. """
  250. # Sequence error:
  251. # If I don't put the chunk(I received) to the player.
  252. # anything I display -- lineReceive() put() ... would
  253. # be out of order. (I'd be responding -- before it
  254. # was displayed to the user.)
  255. if self.to_player:
  256. self.queue_game.put(chunk)
  257. self.buffer += chunk.decode("utf-8", "ignore")
  258. # Process any backspaces
  259. while "\x08" in self.buffer:
  260. part = self.buffer.partition("\x08")
  261. self.buffer = part[0][:-1] + part[2]
  262. # Treat some ANSI codes as a newline
  263. self.buffer = treatAsNL(self.buffer)
  264. # Break into lines
  265. while "\n" in self.buffer:
  266. part = self.buffer.partition("\n")
  267. line = part[0].replace("\r", "")
  268. # Clean ANSI codes from line
  269. line = cleanANSI(line)
  270. self.lineReceived(line)
  271. self.buffer = part[2]
  272. self.observer.emit("prompt", self.getPrompt())
  273. def connectionLost(self, why):
  274. log.msg("Game connectionLost because: %s" % why)
  275. self.queue_game.put(False)
  276. self.transport.loseConnection()
  277. class GlueFactory(protocol.ClientFactory):
  278. # class GlueFactory(protocol.Factory):
  279. maxDelay = 10
  280. protocol = Game
  281. def __init__(self, player):
  282. self.player = player
  283. self.queue_player = player.queue_player
  284. self.queue_game = player.queue_game
  285. self.observer = player.observer
  286. def closeIt(self):
  287. log.msg("closeIt")
  288. self.queue_game.put(False)
  289. def getUser(self, user):
  290. log.msg("getUser( %s )" % user)
  291. self.twgs.logUser(user)
  292. # This was needed when I replaced ClientFactory with Factory.
  293. # def clientConnectionLost(self, connector, why):
  294. # log.msg("clientconnectionlost: %s" % why)
  295. # self.queue_client.put(False)
  296. def clientConnectionFailed(self, connector, why):
  297. log.msg("connection to game failed: %s" % why)
  298. self.queue_game.put(b"Sorry! I'm Unable to connect to the game server.\r\n")
  299. # syncterm gets cranky/locks up if we close this here.
  300. # (Because it is still sending rlogin information?)
  301. reactor.callLater(2, self.closeIt)
  302. class Player(protocol.Protocol):
  303. def __init__(self):
  304. self.buffer = ""
  305. self.user = None
  306. self.observer = Observer()
  307. def connectionMade(self):
  308. """ connected, setup queues.
  309. queue_player is data from player.
  310. queue_game is data to player. (possibly from game)
  311. """
  312. self.queue_player = defer.DeferredQueue()
  313. self.queue_game = defer.DeferredQueue()
  314. self.setGameReceived()
  315. # Connect GlueFactory to this Player object.
  316. factory = GlueFactory(self)
  317. # Make connection to the game server
  318. reactor.connectTCP(HOST, PORT, factory, 5)
  319. def setGameReceived(self):
  320. """ Get deferred from client queue, callback clientDataReceived. """
  321. self.queue_game.get().addCallback(self.gameDataReceived)
  322. def gameDataReceived(self, chunk):
  323. """ Data received from the game. """
  324. if chunk is False:
  325. self.transport.loseConnection()
  326. else:
  327. if type(chunk) == bytes:
  328. self.transport.write(chunk)
  329. elif type(chunk) == str:
  330. self.transport.write(chunk.encode())
  331. else:
  332. log.err("gameDataReceived: type (%s) given!", type(chunk))
  333. self.transport.write(chunk)
  334. self.setGameReceived()
  335. def dataReceived(self, chunk):
  336. if self.user is None:
  337. self.buffer += chunk.decode("utf-8", "ignore")
  338. parts = self.buffer.split("\x00")
  339. if len(parts) >= 5:
  340. # rlogin we have the username
  341. self.user = parts[1]
  342. log.msg("User: {0}".format(self.user))
  343. zpos = self.buffer.rindex("\x00")
  344. self.buffer = self.buffer[zpos + 1 :]
  345. # but I don't need the buffer anymore, so:
  346. self.buffer = ""
  347. # Pass user value on to whatever needs it.
  348. self.observer.emit("user", self.user)
  349. # Unfortunately, the ones interested in this don't exist yet.
  350. if not self.observer.emit("player", chunk):
  351. # Was not dispatched. Send to game.
  352. self.queue_player.put(chunk)
  353. if chunk == b"~":
  354. # Selection (? for menu): (the game server menu)
  355. # Enter your choice: (game menu)
  356. # Command [TL=00:00:00]:[1800] (?=Help)? : <- YES!
  357. self.observer.emit("hotkey", None)
  358. def connectionLost(self, why):
  359. log.msg("lost connection %s" % why)
  360. self.queue_player.put(False)
  361. def connectionFailed(self, why):
  362. log.msg("connectionFailed: %s" % why)
  363. if __name__ == "__main__":
  364. if LOGFILE:
  365. log.startLogging(DailyLogFile("proxy.log", "."))
  366. else:
  367. log.startLogging(sys.stdout)
  368. log.msg("This is version: %s" % version)
  369. factory = protocol.Factory()
  370. factory.protocol = Player
  371. reactor.listenTCP(LISTEN_PORT, factory, interface=LISTEN_ON)
  372. reactor.run()