tcp-proxy.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  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.internet.task import coiterate
  9. from twisted.python import log
  10. from twisted.python.logfile import DailyLogFile
  11. import pendulum
  12. from subprocess import check_output
  13. import os
  14. from colorama import Fore, Back, Style
  15. from pprint import pformat
  16. import yaml
  17. def config_load(filename):
  18. global config
  19. with open(filename, "r") as fp:
  20. config = yaml.safe_load(fp)
  21. if os.path.exists("config_dev.yaml"):
  22. config_load("config_dev.yaml")
  23. else:
  24. config_load("config.yaml")
  25. # Extract the version information from git.
  26. # The match gives us only tags starting with v[0-9]* Using anything else trips up on double digits.
  27. version = check_output(
  28. [
  29. "git",
  30. "describe",
  31. "--abbrev=8",
  32. "--long",
  33. "--tags",
  34. "--dirty",
  35. "--always",
  36. "--match",
  37. "v[0-9]*",
  38. ],
  39. universal_newlines=True,
  40. ).strip()
  41. def merge(color_string):
  42. """ Given a string of colorama ANSI, merge them if you can. """
  43. return color_string.replace("m\x1b[", ";")
  44. # https://en.wikipedia.org/wiki/ANSI_escape_code
  45. # Cleans all ANSI
  46. cleaner = re.compile(r"\x1b\[[0-9;]*[A-Zmh]")
  47. # Looks for ANSI (that should be considered to be a newline)
  48. # This needs to see what is send when something enters / leaves
  49. # the player's current sector. (That doesn't work/isn't
  50. # detected. NNY!) It is "\x1b[K" Erase in Line!
  51. makeNL = re.compile(r"\x1b\[[0-9;]*[JK]")
  52. def treatAsNL(line):
  53. """ Replace any ANSI codes that would be better understood as newlines. """
  54. global makeNL
  55. return makeNL.sub("\n", line)
  56. def cleanANSI(line):
  57. """ Remove all ANSI codes. """
  58. global cleaner
  59. return cleaner.sub("", line)
  60. # return re.sub(r'\x1b\[([0-9,A-Z]{1,2}(;[0-9]{1,2})?(;[0-9]{3})?)?[m|K]?', '', line)
  61. from observer import Observer
  62. from flexible import PlayerInput, ProxyMenu
  63. from galaxy import GameData
  64. from flexible import PORT_CLASSES, CLASSES_PORT
  65. class Game(protocol.Protocol):
  66. def __init__(self):
  67. self.buffer = ""
  68. self.game = None
  69. self.usergame = (None, None)
  70. self.gamedata = None
  71. self.to_player = True
  72. self.linestate = ""
  73. def connectionMade(self):
  74. log.msg("Connected to Game Server")
  75. self.queue_player = self.factory.queue_player
  76. self.queue_game = self.factory.queue_game
  77. self.observer = self.factory.observer
  78. self.factory.game = self
  79. self.setPlayerReceived()
  80. self.observer.connect("user-game", self.show_game)
  81. def show_game(self, game):
  82. self.usergame = game
  83. log.msg("## User-Game:", game)
  84. if game[1] is None:
  85. if self.gamedata is not None:
  86. # start the save
  87. coiterate(self.gamedata.save())
  88. self.gamedata = None
  89. if hasattr(self, "portdata"):
  90. log.msg("Clearing out old portdata.")
  91. self.portdata = {}
  92. if hasattr(self, "warpdata"):
  93. log.msg("Clearing out old warpdata.")
  94. self.warpdata = {}
  95. else:
  96. # Load the game data (if any)
  97. self.gamedata = GameData(game)
  98. coiterate(self.gamedata.load())
  99. def setPlayerReceived(self):
  100. """ Get deferred from client queue, callback clientDataReceived. """
  101. self.queue_player.get().addCallback(self.playerDataReceived)
  102. def playerDataReceived(self, chunk):
  103. if chunk is False:
  104. self.queue_player = None
  105. log.msg("Player: disconnected, close connection to game")
  106. # I don't believe I need this if I'm using protocol.Factory
  107. self.factory.continueTrying = False
  108. self.transport.loseConnection()
  109. else:
  110. # Pass received data to the server
  111. if type(chunk) == str:
  112. self.transport.write(chunk.encode())
  113. log.msg(">> [{0}]".format(chunk))
  114. else:
  115. self.transport.write(chunk)
  116. log.msg(">> [{0}]".format(chunk.decode("utf-8", "ignore")))
  117. self.setPlayerReceived()
  118. def warpline(self, line):
  119. log.msg("warp:", line)
  120. # 1 > 3 > 5 > 77 > 999
  121. last_sector = self.lastwarp
  122. line = line.replace("(", "").replace(")", "").replace(">", "").strip()
  123. for s in line.split():
  124. # Ok, this should be all of the warps.
  125. sector = int(s)
  126. if last_sector > 0:
  127. self.gamedata.warp_to(last_sector, sector)
  128. last_sector = sector
  129. self.lastwarp = sector
  130. def cimline(self, line):
  131. log.msg(self.linestate, ":", line)
  132. if line[-1] == "%":
  133. self.linestate = "portcim"
  134. if self.linestate == "warpcim":
  135. # warps
  136. work = line.strip()
  137. if work != "":
  138. parts = re.split(r"(?<=\d)\s", work)
  139. parts = [int(x) for x in parts]
  140. sector = parts.pop(0)
  141. self.gamedata.warp_to(sector, *parts)
  142. elif self.linestate == "portcim":
  143. # ports
  144. work = line.replace("%", "")
  145. parts = re.parts = re.split(r"(?<=\d)\s", work)
  146. if len(parts) == 8:
  147. sector = int(parts[0].strip())
  148. data = dict()
  149. def portBS(info):
  150. if info[0] == "-":
  151. bs = "B"
  152. else:
  153. bs = "S"
  154. return (bs, int(info[1:].strip()))
  155. data["fuel"] = dict()
  156. data["fuel"]["sale"], data["fuel"]["units"] = portBS(parts[1])
  157. data["fuel"]["pct"] = int(parts[2].strip())
  158. data["org"] = dict()
  159. data["org"]["sale"], data["org"]["units"] = portBS(parts[3])
  160. data["org"]["pct"] = int(parts[4].strip())
  161. data["equ"] = dict()
  162. data["equ"]["sale"], data["equ"]["units"] = portBS(parts[5])
  163. data["equ"]["pct"] = int(parts[6].strip())
  164. # Store what this port is buying/selling
  165. data["port"] = (
  166. data["fuel"]["sale"] + data["org"]["sale"] + data["equ"]["sale"]
  167. )
  168. # Convert BBS/SBB to Class number 1-8
  169. data["class"] = CLASSES_PORT[data["port"]]
  170. self.gamedata.set_port(sector, data)
  171. else:
  172. self.linestate = "cim"
  173. def sectorline(self, line):
  174. log.msg("sector:", self.current_sector, ':', line)
  175. if line.startswith('Beacon : '):
  176. pass # get beacon text
  177. elif line.startswith('Ports : '):
  178. # Ports : Ballista, Class 1 (BBS)
  179. self.sector_state = 'port'
  180. if '<=-DANGER-=>' in line:
  181. # Port is destroyed
  182. if sector in self.gamedate.ports:
  183. del self.gamedata.ports[sector]
  184. elif '(StarDock)' not in line:
  185. _, _, class_port = line.partition(', Class ')
  186. c, port = class_port.split(' ')
  187. c = int(c)
  188. port = port.replace('(', '').replace(')', '')
  189. data = { 'port': port, 'class': c }
  190. self.gamedata.set_port(self.current_sector, data)
  191. elif line.startswith('Planets : '):
  192. # Planets : (O) Flipper
  193. self.sector_state = 'planet'
  194. elif line.startswith('Traders : '):
  195. self.sector_state = 'trader'
  196. elif line.startswith('Ships : '):
  197. self.sector_state = 'ship'
  198. elif line.startswith('Fighters: '):
  199. self.sector_state = 'fighter'
  200. elif line.startswith('NavHaz : '):
  201. pass
  202. elif line.startswith('Mines : '):
  203. self.sector_state = 'mine'
  204. elif line.startswith(' '):
  205. # continues
  206. if self.sector_state == 'mines':
  207. pass
  208. if self.sector_state == 'planet':
  209. pass
  210. if self.sector_state == 'trader':
  211. pass
  212. if self.sector_state == 'ship':
  213. pass
  214. elif len(line) > 8 and line[8] == ':':
  215. self.sector_state = 'normal'
  216. elif line.startswith('Warps to Sector(s) :'):
  217. # Warps to Sector(s) : 5468
  218. _, _, work = line.partition(':')
  219. work = work.strip().replace('(', '').replace(')', '').replace(' - ', ' ')
  220. parts = [ int(x) for x in work.split(' ')]
  221. log.msg("Sectorline warps", parts)
  222. self.gamedata.warp_to(self.current_sector, *parts)
  223. self.sector_state = 'normal'
  224. self.linestate = ''
  225. def lineReceived(self, line):
  226. """ line received from the game. """
  227. if "log_lines" in config and config["log_lines"]:
  228. log.msg("<< [{0}]".format(line))
  229. if "TradeWars Game Server" in line and "Copyright (C) EIS" in line:
  230. # We are not in a game
  231. if not self.game is None:
  232. # We were in a game.
  233. self.game = None
  234. self.observer.emit("user-game", (self.factory.player.user, self.game))
  235. elif "Selection (? for menu): " in line:
  236. game = line[-1]
  237. if game >= "A" and game < "Q":
  238. self.game = game
  239. log.msg("Game: {0}".format(self.game))
  240. self.observer.emit("user-game", (self.factory.player.user, self.game))
  241. # Process.pas parse line
  242. if line.startswith("The shortest path (") or line.startswith(" TO > "):
  243. self.linestate = "warpline"
  244. self.lastwarp = 0
  245. elif self.linestate == "warpline":
  246. if line == "":
  247. self.linestate = ""
  248. else:
  249. self.warpline(line)
  250. elif self.linestate == "portcim" or self.linestate == "warpcim":
  251. if line == ": ENDINTERROG":
  252. self.linestate = ""
  253. elif line == ": ":
  254. self.linestate = "cim"
  255. elif line == "":
  256. self.linestate = ""
  257. else:
  258. if len(line) > 2:
  259. self.cimline(line)
  260. elif self.linestate == "cim":
  261. if line == ": ENDINTERROG" or line == "":
  262. self.linestate = ""
  263. elif len(line) > 2:
  264. if line.rstrip()[-1] == "%":
  265. self.linestate = "portcim"
  266. else:
  267. self.linestate = "warpcim"
  268. self.cimline(line)
  269. elif line.startswith(": "):
  270. self.linestate = "cim"
  271. elif line.startswith("Sector : "):
  272. # Sector : 2565 in uncharted space.
  273. self.linestate = "sector"
  274. work = line.strip()
  275. parts = re.split(r"\s+", work)
  276. self.current_sector = int(parts[2])
  277. elif self.linestate == "sector":
  278. self.sectorline(line)
  279. self.observer.emit("game-line", line)
  280. def getPrompt(self):
  281. """ Return the current prompt, stripped of ANSI. """
  282. return cleanANSI(self.buffer)
  283. def dataReceived(self, chunk):
  284. """ Data received from the Game.
  285. Remove backspaces.
  286. Treat some ANSI codes as NewLine.
  287. Remove ANSI.
  288. Break into lines.
  289. Trim out carriage returns.
  290. Call lineReceived().
  291. "Optionally" pass data to player.
  292. FUTURE: trigger on prompt. [cleanANSI(buffer)]
  293. """
  294. # Store the text into the buffer before we inject into it.
  295. self.buffer += chunk.decode("utf-8", "ignore")
  296. # log.msg("data: [{0}]".format(repr(chunk)))
  297. if b"TWGS v2.20b" in chunk and b"www.eisonline.com" in chunk:
  298. # Ok, we have a possible target.
  299. target = b"www.eisonline.com\n\r"
  300. pos = chunk.find(target)
  301. if pos != -1:
  302. # Found it! Inject!
  303. message = (
  304. "TWGS Proxy build " + version + ". ~ to activate in game.\n\r"
  305. )
  306. chunk = (
  307. chunk[0 : pos + len(target)]
  308. + message.encode()
  309. + chunk[pos + len(target) :]
  310. )
  311. # Sequence error:
  312. # If I don't put the chunk(I received) to the player.
  313. # anything I display -- lineReceive() put() ... would
  314. # be out of order. (I'd be responding -- before it
  315. # was displayed to the user.)
  316. if self.to_player:
  317. self.queue_game.put(chunk)
  318. # self.buffer += chunk.decode("utf-8", "ignore")
  319. #
  320. # Begin processing the buffer
  321. #
  322. # Process any backspaces
  323. while "\b" in self.buffer:
  324. part = self.buffer.partition("\b")
  325. self.buffer = part[0][:-1] + part[2]
  326. # Treat some ANSI codes as a newline
  327. self.buffer = treatAsNL(self.buffer)
  328. # Break into lines
  329. while "\n" in self.buffer:
  330. part = self.buffer.partition("\n")
  331. line = part[0].replace("\r", "")
  332. # Clean ANSI codes from line
  333. line = cleanANSI(line)
  334. self.lineReceived(line)
  335. self.buffer = part[2]
  336. self.observer.emit("prompt", self.getPrompt())
  337. def connectionLost(self, why):
  338. log.msg("Game connectionLost because: %s" % why)
  339. self.observer.emit("close", why)
  340. self.queue_game.put(False)
  341. self.transport.loseConnection()
  342. class GlueFactory(protocol.ClientFactory):
  343. # class GlueFactory(protocol.Factory):
  344. maxDelay = 10
  345. protocol = Game
  346. def __init__(self, player):
  347. self.player = player
  348. self.queue_player = player.queue_player
  349. self.queue_game = player.queue_game
  350. self.observer = player.observer
  351. self.game = None
  352. def closeIt(self):
  353. log.msg("closeIt")
  354. self.queue_game.put(False)
  355. def getUser(self, user):
  356. log.msg("getUser( %s )" % user)
  357. self.twgs.logUser(user)
  358. # This was needed when I replaced ClientFactory with Factory.
  359. # def clientConnectionLost(self, connector, why):
  360. # log.msg("clientconnectionlost: %s" % why)
  361. # self.queue_client.put(False)
  362. def clientConnectionFailed(self, connector, why):
  363. log.msg("connection to game failed: %s" % why)
  364. self.queue_game.put(b"Sorry! I'm Unable to connect to the game server.\r\n")
  365. # syncterm gets cranky/locks up if we close this here.
  366. # (Because it is still sending rlogin information?)
  367. reactor.callLater(2, self.closeIt)
  368. class Player(protocol.Protocol):
  369. def __init__(self):
  370. self.buffer = ""
  371. self.user = None
  372. self.observer = Observer()
  373. self.game = None
  374. self.glue = None
  375. def connectionMade(self):
  376. """ connected, setup queues.
  377. queue_player is data from player.
  378. queue_game is data to player. (possibly from game)
  379. """
  380. self.queue_player = defer.DeferredQueue()
  381. self.queue_game = defer.DeferredQueue()
  382. self.setGameReceived()
  383. # Connect GlueFactory to this Player object.
  384. factory = GlueFactory(self)
  385. self.glue = factory
  386. # Make connection to the game server
  387. reactor.connectTCP(config["host"], config["port"], factory, 5)
  388. def setGameReceived(self):
  389. """ Get deferred from client queue, callback clientDataReceived. """
  390. self.queue_game.get().addCallback(self.gameDataReceived)
  391. def gameDataReceived(self, chunk):
  392. """ Data received from the game. """
  393. # If we have received game data, it has to be connected.
  394. if self.game is None:
  395. self.game = self.glue.game
  396. if chunk is False:
  397. self.transport.loseConnection()
  398. else:
  399. if type(chunk) == bytes:
  400. self.transport.write(chunk)
  401. elif type(chunk) == str:
  402. self.transport.write(chunk.encode())
  403. else:
  404. log.err("gameDataReceived: type (%s) given!".format(type(chunk)))
  405. self.transport.write(chunk)
  406. self.setGameReceived()
  407. def dataReceived(self, chunk):
  408. if self.user is None:
  409. self.buffer += chunk.decode("utf-8", "ignore")
  410. parts = self.buffer.split("\x00")
  411. if len(parts) >= 5:
  412. # rlogin we have the username
  413. self.user = parts[1]
  414. log.msg("User: {0}".format(self.user))
  415. zpos = self.buffer.rindex("\x00")
  416. self.buffer = self.buffer[zpos + 1 :]
  417. # but I don't need the buffer anymore, so:
  418. self.buffer = ""
  419. # Pass user value on to whatever needs it.
  420. self.observer.emit("user", self.user)
  421. # Unfortunately, the ones interested in this don't exist yet.
  422. if not self.observer.emit("player", chunk):
  423. # Was not dispatched. Send to game.
  424. self.queue_player.put(chunk)
  425. else:
  426. # There's an observer. Don't continue.
  427. return
  428. if chunk == b"~":
  429. prompt = self.game.getPrompt()
  430. # Selection (? for menu): (the game server menu)
  431. # Enter your choice: (game menu)
  432. # Command [TL=00:00:00]:[1800] (?=Help)? : <- YES!
  433. # Computer command [TL=00:00:00]:[613] (?=Help)?
  434. # (and others I've yet to see...)
  435. if re.match(r"Command \[TL=.* \(\?=Help\)\? :", prompt):
  436. menu = ProxyMenu(self.game)
  437. else:
  438. nl = "\n\r"
  439. r = Style.RESET_ALL
  440. log.msg("NNY!")
  441. prompt = self.game.buffer
  442. self.queue_game.put(
  443. r
  444. + nl
  445. + Style.BRIGHT
  446. + "Proxy:"
  447. + Style.RESET_ALL
  448. + " I can't activate at this time."
  449. + nl
  450. )
  451. self.queue_game.put(prompt)
  452. self.queue_player.put("\a")
  453. # self.observer.emit("notyet", prompt)
  454. def connectionLost(self, why):
  455. log.msg("lost connection %s" % why)
  456. self.observer.emit("close", why)
  457. self.queue_player.put(False)
  458. def connectionFailed(self, why):
  459. log.msg("connectionFailed: %s" % why)
  460. if __name__ == "__main__":
  461. if "logfile" in config and config["logfile"]:
  462. log.startLogging(DailyLogFile("proxy.log", "."))
  463. else:
  464. log.startLogging(sys.stdout)
  465. log.msg("This is version: %s" % version)
  466. factory = protocol.Factory()
  467. factory.protocol = Player
  468. reactor.listenTCP(config["listen_port"], factory, interface=config["listen_on"])
  469. reactor.run()
  470. else:
  471. # I can't seem to get twistd -y tcp-proxy.py
  472. # to work. Missing imports?
  473. application = service.Application("TradeWarsGameServer-Proxy")
  474. factory = protocol.Factory()
  475. factory.protocol = Player
  476. internet.TCPServer(config["listen_port"], factory).setServiceParent(application)