tcp-proxy.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  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. from itertools import cycle
  14. from deprecated import deprecated
  15. from pprint import pformat
  16. # This isn't the best configuration, but it's simple
  17. # and works. Mostly.
  18. try:
  19. from config_dev import *
  20. except ModuleNotFoundError:
  21. from config import *
  22. # Extract the version information from git.
  23. # The match gives us only tags starting with v[0-9]* Using anything else trips up on double digits.
  24. version = check_output(
  25. [
  26. "git",
  27. "describe",
  28. "--abbrev=8",
  29. "--long",
  30. "--tags",
  31. "--dirty",
  32. "--always",
  33. "--match",
  34. "v[0-9]*",
  35. ],
  36. universal_newlines=True,
  37. ).strip()
  38. def merge(color_string):
  39. """ Given a string of colorama ANSI, merge them if you can. """
  40. return color_string.replace("m\x1b[", ";")
  41. # https://en.wikipedia.org/wiki/ANSI_escape_code
  42. # Cleans all ANSI
  43. cleaner = re.compile(r"\x1b\[[0-9;]*[A-Zmh]")
  44. # Looks for ANSI (that should be considered to be a newline)
  45. # This needs to see what is send when something enters / leaves
  46. # the player's current sector. (That doesn't work/isn't
  47. # detected. NNY!) It is "\x1b[K" Erase in Line!
  48. makeNL = re.compile(r"\x1b\[[0-9;]*[JK]")
  49. def treatAsNL(line):
  50. """ Replace any ANSI codes that would be better understood as newlines. """
  51. global makeNL
  52. return makeNL.sub("\n", line)
  53. def cleanANSI(line):
  54. """ Remove all ANSI codes. """
  55. global cleaner
  56. return cleaner.sub("", line)
  57. # return re.sub(r'\x1b\[([0-9,A-Z]{1,2}(;[0-9]{1,2})?(;[0-9]{3})?)?[m|K]?', '', line)
  58. PORT_CLASSES = { 1: 'BBS', 2: 'BSB', 3: 'SBB', 4:'SSB', 5:'SBS', 6:'BSS', 7:'SSS', 8:'BBB'}
  59. CLASSES_PORT = { v: k for k,v in PORT_CLASSES.items() }
  60. from observer import Observer
  61. class PlayerInput(object):
  62. def __init__(self, game):
  63. # I think game gives us access to everything we need
  64. self.game = game
  65. self.observer = self.game.observer
  66. self.save = None
  67. self.deferred = None
  68. self.queue_game = game.queue_game
  69. # default colors, and useful consts
  70. self.c = merge(Style.BRIGHT + Fore.WHITE + Back.BLUE)
  71. self.r = Style.RESET_ALL
  72. self.nl = "\n\r"
  73. self.bsb = "\b \b"
  74. self.keepalive = None
  75. def color(self, c):
  76. self.c = c
  77. def alive(self):
  78. log.msg("PlayerInput.alive()")
  79. self.game.queue_player.put(" ")
  80. def prompt(self, prompt, limit, default=''):
  81. log.msg("PlayerInput({0}, {1}, {2}".format(prompt, limit, default))
  82. self.prompt = prompt
  83. self.limit = limit
  84. self.default = default
  85. self.input = ''
  86. assert(self.save is None)
  87. assert(self.keepalive is None)
  88. # Note: This clears out the server "keep alive"
  89. self.save = self.observer.save()
  90. self.observer.connect('player', self.get_input)
  91. self.keepalive = task.LoopingCall(self.alive)
  92. self.keepalive.start(30)
  93. # We need to "hide" the game output.
  94. # Otherwise it WITH mess up the user input display.
  95. self.to_player = self.game.to_player
  96. self.game.to_player = False
  97. # Display prompt
  98. self.queue_game.put(self.r + self.nl + self.c + prompt)
  99. # Set "Background of prompt"
  100. self.queue_game.put( " " * limit + "\b" * limit)
  101. assert(self.deferred is None)
  102. d = defer.Deferred()
  103. self.deferred = d
  104. log.msg("Return deferred ...", self.deferred)
  105. return d
  106. def get_input(self, chunk):
  107. """ Data from player (in bytes) """
  108. chunk = chunk.decode('utf-8', 'ignore')
  109. for ch in chunk:
  110. if ch == "\b":
  111. if len(self.input) > 0:
  112. self.queue_game.put(self.bsb)
  113. self.input = self.input[0:-1]
  114. else:
  115. self.queue_game.put("\a")
  116. if ch == "\r":
  117. self.queue_game.put(self.r + self.nl)
  118. log.msg("Restore observer dispatch", self.save)
  119. assert(not self.save is None)
  120. self.observer.load(self.save)
  121. self.save = None
  122. log.msg("Disable keepalive")
  123. self.keepalive.stop()
  124. self.keepalive = None
  125. line = self.input
  126. self.input = ''
  127. assert(not self.deferred is None)
  128. log.msg(self.deferred)
  129. self.deferred.callback(line)
  130. # log.msg("callLater in 3 secs defer.callback with line entered...")
  131. # reactor.callLater(2, self.deferred.callback, line)
  132. # Is it possible that setting this to None calleds the callback?
  133. self.deferred = None
  134. if ch.isprintable():
  135. if len(self.input) + 1 <= self.limit:
  136. self.input += ch
  137. self.queue_game.put(ch)
  138. else:
  139. self.queue_game.put("\a")
  140. def output(self, line):
  141. """ A default display of what they just input. """
  142. log.msg("PlayerInput.output({0})".format(line))
  143. self.game.queue_game.put(self.r + self.nl + "[{0}]".format(line) + self.nl)
  144. return line
  145. class ProxyMenu(object):
  146. def __init__(self, game):
  147. self.nl = "\n\r"
  148. self.c = merge(Style.BRIGHT + Fore.YELLOW + Back.BLUE)
  149. self.r = Style.RESET_ALL
  150. self.c1 = merge(Style.BRIGHT + Fore.BLUE)
  151. self.c2 = merge(Style.NORMAL + Fore.BLUE)
  152. self.game = game
  153. self.queue_game = game.queue_game
  154. self.observer = game.observer
  155. # Yes, at this point we would activate
  156. self.prompt = game.buffer
  157. self.save = self.observer.save()
  158. self.observer.connect("player", self.player)
  159. # If we want it, it's here.
  160. self.defer = None
  161. self.keepalive = task.LoopingCall(self.awake)
  162. self.keepalive.start(30)
  163. self.menu()
  164. def __del__(self):
  165. log.msg("ProxyMenu {0} RIP".format(self))
  166. def whenDone(self):
  167. self.defer = defer.Deferred()
  168. # Call this to chain something after we exit.
  169. return self.defer
  170. def menu(self):
  171. self.queue_game.put(self.nl + self.c + "TradeWars Proxy active." + self.r + self.nl)
  172. def menu_item(ch, desc):
  173. self.queue_game.put(" " + self.c1 + ch + self.c2 + " - " + self.c1 + desc + self.nl)
  174. self.queue_game.put(" " + self.c1 + "D" + self.c2 + " - " + self.c1 + "Diagnostics" + self.nl)
  175. menu_item("Q", "Quest")
  176. menu_item("T", "Display current Time")
  177. self.queue_game.put(" " + self.c1 + "P" + self.c2 + " - " + self.c1 + "Port CIM Report" + self.nl)
  178. self.queue_game.put(" " + self.c1 + "S" + self.c2 + " - " + self.c1 + "Scripts" + self.nl)
  179. self.queue_game.put(" " + self.c1 + "X" + self.c2 + " - " + self.c1 + "eXit" + self.nl)
  180. self.queue_game.put(" " + self.c + "-=>" + self.r + " ")
  181. def awake(self):
  182. log.msg("ProxyMenu.awake()")
  183. self.game.queue_player.put(" ")
  184. def player(self, chunk):
  185. """ Data from player (in bytes). """
  186. chunk = chunk.decode("utf-8", 'ignore')
  187. key = chunk.upper()
  188. log.msg("ProxyMenu.player({0})".format(key))
  189. # Weird / long running task / something odd happening here?
  190. self.keepalive.stop()
  191. if key == "T":
  192. self.queue_game.put(self.c + key + self.r + self.nl)
  193. # perform T option
  194. now = pendulum.now()
  195. self.queue_game.put(self.nl + self.c1 + "Current time " + now.to_datetime_string() + self.nl)
  196. elif key == 'Q':
  197. self.queue_game.put(self.c + key + self.r + self.nl)
  198. # This isn't working here, because we don't "stop"...
  199. # we just redisplay the menu and keep going. :(
  200. ask = PlayerInput(self.game)
  201. d = ask.prompt("What is your quest? ", 20)
  202. # d.callback(lambda ignore: ask.prompt("What is your favorite color?", 10))
  203. d.addCallback(ask.output)
  204. # This causes twisted.internet.defer.AlreadyCalledError:
  205. d.addCallback(self.welcome_back)
  206. return
  207. elif key == 'X':
  208. self.queue_game.put(self.c + key + self.r + self.nl)
  209. self.observer.load(self.save)
  210. self.save = None
  211. # It isn't running (NOW), so don't try to stop it.
  212. # self.keepalive.stop()
  213. self.keepalive = None
  214. self.queue_game.put(self.prompt)
  215. self.prompt = None
  216. # Were we asked to do something when we were done here?
  217. if self.defer:
  218. reactor.CallLater(0, self.defer.callback)
  219. # self.defer.callback()
  220. self.defer = None
  221. return
  222. self.keepalive.start(30, True)
  223. self.menu()
  224. def welcome_back(self, _):
  225. log.msg("welcome_back")
  226. self.keepalive.start(30, True)
  227. self.menu()
  228. from mcp import MCP
  229. class Game(protocol.Protocol):
  230. def __init__(self):
  231. self.buffer = ""
  232. self.game = None
  233. self.usergame = (None, None)
  234. self.to_player = True
  235. self.mcp = MCP(self)
  236. def connectionMade(self):
  237. log.msg("Connected to Game Server")
  238. self.queue_player = self.factory.queue_player
  239. self.queue_game = self.factory.queue_game
  240. self.observer = self.factory.observer
  241. self.factory.game = self
  242. self.setPlayerReceived()
  243. self.observer.connect("user-game", self.show_game)
  244. self.mcp.finishSetup()
  245. def show_game(self, game):
  246. self.usergame = game
  247. log.msg("## User-Game:", game)
  248. def setPlayerReceived(self):
  249. """ Get deferred from client queue, callback clientDataReceived. """
  250. self.queue_player.get().addCallback(self.playerDataReceived)
  251. def playerDataReceived(self, chunk):
  252. if chunk is False:
  253. self.queue_player = None
  254. log.msg("Player: disconnected, close connection to game")
  255. # I don't believe I need this if I'm using protocol.Factory
  256. self.factory.continueTrying = False
  257. self.transport.loseConnection()
  258. else:
  259. # Pass received data to the server
  260. if type(chunk) == str:
  261. self.transport.write(chunk.encode())
  262. else:
  263. self.transport.write(chunk)
  264. self.setPlayerReceived()
  265. def lineReceived(self, line):
  266. """ line received from the game. """
  267. if LOG_LINES:
  268. log.msg(">> [{0}]".format(line))
  269. if "TWGS v2.20b" in line and "www.eisonline.com" in line:
  270. self.queue_game.put(
  271. "TWGS Proxy build "
  272. + version
  273. + " is active. \x1b[1;34m~\x1b[0m to activate.\n\r\n\r",
  274. )
  275. if "TradeWars Game Server" in line and "Copyright (C) EIS" in line:
  276. # We are not in a game
  277. if not self.game is None:
  278. # We were in a game.
  279. self.game = None
  280. self.observer.emit("user-game", (self.factory.player.user, self.game))
  281. if "Selection (? for menu): " in line:
  282. game = line[-1]
  283. if game >= "A" and game < "Q":
  284. self.game = game
  285. log.msg("Game: {0}".format(self.game))
  286. self.observer.emit("user-game", (self.factory.player.user, self.game))
  287. self.observer.emit("game-line", line)
  288. def getPrompt(self):
  289. """ Return the current prompt, stripped of ANSI. """
  290. return cleanANSI(self.buffer)
  291. def dataReceived(self, chunk):
  292. """ Data received from the Game.
  293. Remove backspaces.
  294. Treat some ANSI codes as NewLine.
  295. Remove ANSI.
  296. Break into lines.
  297. Trim out carriage returns.
  298. Call lineReceived().
  299. "Optionally" pass data to player.
  300. FUTURE: trigger on prompt. [cleanANSI(buffer)]
  301. """
  302. # Sequence error:
  303. # If I don't put the chunk(I received) to the player.
  304. # anything I display -- lineReceive() put() ... would
  305. # be out of order. (I'd be responding -- before it
  306. # was displayed to the user.)
  307. if self.to_player:
  308. self.queue_game.put(chunk)
  309. self.buffer += chunk.decode("utf-8", "ignore")
  310. # Process any backspaces
  311. while "\b" in self.buffer:
  312. part = self.buffer.partition("\b")
  313. self.buffer = part[0][:-1] + part[2]
  314. # Treat some ANSI codes as a newline
  315. self.buffer = treatAsNL(self.buffer)
  316. # Break into lines
  317. while "\n" in self.buffer:
  318. part = self.buffer.partition("\n")
  319. line = part[0].replace("\r", "")
  320. # Clean ANSI codes from line
  321. line = cleanANSI(line)
  322. self.lineReceived(line)
  323. self.buffer = part[2]
  324. self.observer.emit("prompt", self.getPrompt())
  325. def connectionLost(self, why):
  326. log.msg("Game connectionLost because: %s" % why)
  327. self.observer.emit('close', why)
  328. self.queue_game.put(False)
  329. self.transport.loseConnection()
  330. class GlueFactory(protocol.ClientFactory):
  331. # class GlueFactory(protocol.Factory):
  332. maxDelay = 10
  333. protocol = Game
  334. def __init__(self, player):
  335. self.player = player
  336. self.queue_player = player.queue_player
  337. self.queue_game = player.queue_game
  338. self.observer = player.observer
  339. self.game = None
  340. def closeIt(self):
  341. log.msg("closeIt")
  342. self.queue_game.put(False)
  343. def getUser(self, user):
  344. log.msg("getUser( %s )" % user)
  345. self.twgs.logUser(user)
  346. # This was needed when I replaced ClientFactory with Factory.
  347. # def clientConnectionLost(self, connector, why):
  348. # log.msg("clientconnectionlost: %s" % why)
  349. # self.queue_client.put(False)
  350. def clientConnectionFailed(self, connector, why):
  351. log.msg("connection to game failed: %s" % why)
  352. self.queue_game.put(b"Sorry! I'm Unable to connect to the game server.\r\n")
  353. # syncterm gets cranky/locks up if we close this here.
  354. # (Because it is still sending rlogin information?)
  355. reactor.callLater(2, self.closeIt)
  356. class Player(protocol.Protocol):
  357. def __init__(self):
  358. self.buffer = ""
  359. self.user = None
  360. self.observer = Observer()
  361. self.game = None
  362. self.glue = None
  363. def connectionMade(self):
  364. """ connected, setup queues.
  365. queue_player is data from player.
  366. queue_game is data to player. (possibly from game)
  367. """
  368. self.queue_player = defer.DeferredQueue()
  369. self.queue_game = defer.DeferredQueue()
  370. self.setGameReceived()
  371. # Connect GlueFactory to this Player object.
  372. factory = GlueFactory(self)
  373. self.glue = factory
  374. # Make connection to the game server
  375. reactor.connectTCP(HOST, PORT, factory, 5)
  376. def setGameReceived(self):
  377. """ Get deferred from client queue, callback clientDataReceived. """
  378. self.queue_game.get().addCallback(self.gameDataReceived)
  379. def gameDataReceived(self, chunk):
  380. """ Data received from the game. """
  381. # If we have received game data, it has to be connected.
  382. if self.game is None:
  383. self.game = self.glue.game
  384. if chunk is False:
  385. self.transport.loseConnection()
  386. else:
  387. if type(chunk) == bytes:
  388. self.transport.write(chunk)
  389. elif type(chunk) == str:
  390. self.transport.write(chunk.encode())
  391. else:
  392. log.err("gameDataReceived: type (%s) given!", type(chunk))
  393. self.transport.write(chunk)
  394. self.setGameReceived()
  395. def dataReceived(self, chunk):
  396. if self.user is None:
  397. self.buffer += chunk.decode("utf-8", "ignore")
  398. parts = self.buffer.split("\x00")
  399. if len(parts) >= 5:
  400. # rlogin we have the username
  401. self.user = parts[1]
  402. log.msg("User: {0}".format(self.user))
  403. zpos = self.buffer.rindex("\x00")
  404. self.buffer = self.buffer[zpos + 1 :]
  405. # but I don't need the buffer anymore, so:
  406. self.buffer = ""
  407. # Pass user value on to whatever needs it.
  408. self.observer.emit("user", self.user)
  409. # Unfortunately, the ones interested in this don't exist yet.
  410. if not self.observer.emit("player", chunk):
  411. # Was not dispatched. Send to game.
  412. self.queue_player.put(chunk)
  413. else:
  414. # There's an observer. Don't continue.
  415. return
  416. if chunk == b"~":
  417. if self.game:
  418. prompt = self.game.getPrompt()
  419. if re.match(r"Command \[TL=.* \(\?=Help\)\? :", prompt):
  420. self.observer.emit("hotkey", prompt)
  421. else:
  422. self.observer.emit("notyet", prompt)
  423. # Selection (? for menu): (the game server menu)
  424. # Enter your choice: (game menu)
  425. # Command [TL=00:00:00]:[1800] (?=Help)? : <- YES!
  426. # Computer command [TL=00:00:00]:[613] (?=Help)?
  427. if chunk == b"|":
  428. # how can I tell if this is active or not?
  429. # I no longer see a 'player' observer. GOOD!
  430. # log.msg(pformat(self.observer.dispatch))
  431. # prompt = self.game.getPrompt()
  432. # if re.match(r"Command \[TL=.* \(\?=Help\)\? :", prompt):
  433. menu = ProxyMenu(self.game)
  434. def connectionLost(self, why):
  435. log.msg("lost connection %s" % why)
  436. self.observer.emit('close', why)
  437. self.queue_player.put(False)
  438. def connectionFailed(self, why):
  439. log.msg("connectionFailed: %s" % why)
  440. if __name__ == "__main__":
  441. if LOGFILE:
  442. log.startLogging(DailyLogFile("proxy.log", "."))
  443. else:
  444. log.startLogging(sys.stdout)
  445. log.msg("This is version: %s" % version)
  446. factory = protocol.Factory()
  447. factory.protocol = Player
  448. reactor.listenTCP(LISTEN_PORT, factory, interface=LISTEN_ON)
  449. reactor.run()