remote_access.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. """
  2. * Implementation of remote access
  3. Use localhost.run ssh remote port forwarding service by running a ssh subprocess in PyWebIO application.
  4. The stdout of ssh process is the connection info.
  5. * Strategy
  6. Wait at most one minute to get stdout, if it gets a normal out, the connection is successfully established.
  7. Otherwise report error.
  8. * One Issue
  9. When the PyWebIO application process exits, the ssh process becomes an orphan process and does not exit.
  10. * Solution.
  11. Use a child process to create the ssh process, the child process monitors the PyWebIO application process
  12. to see if it alive, and when the PyWebIO application exit, the child process kills the ssh process and exit.
  13. """
  14. import json
  15. import logging
  16. import multiprocessing
  17. import os
  18. import shlex
  19. import threading
  20. import time
  21. from subprocess import Popen, PIPE
  22. logger = logging.getLogger(__name__)
  23. success_msg = """
  24. ================================================================================
  25. PyWebIO Application Remote Access
  26. Remote access address: {address}
  27. ================================================================================
  28. """
  29. _ssh_process = None # type: Popen
  30. def remote_access_service(local_port=8080, server='app.pywebio.online', server_port=1022, setup_timeout=60,
  31. need_exit=None):
  32. """
  33. :param local_port: ssh local listen port
  34. :param server: ssh server domain
  35. :param server_port: ssh server port
  36. :param setup_timeout: If the service can't setup successfully in `setup_timeout` seconds, then exit.
  37. :param callable need_exit: The service will call this function periodicity, when it return True, then exit the service.
  38. """
  39. global _ssh_process
  40. cmd = "ssh -oStrictHostKeyChecking=no -R 80:localhost:%s -p %s %s -- --output json" % (
  41. local_port, server_port, server)
  42. args = shlex.split(cmd)
  43. logger.debug('remote access service command: %s', cmd)
  44. _ssh_process = Popen(args, stdout=PIPE, stderr=PIPE)
  45. logger.debug('remote access process pid: %s', _ssh_process.pid)
  46. success = False
  47. def timeout_killer(wait_sec):
  48. time.sleep(wait_sec)
  49. if not success and _ssh_process.poll() is None:
  50. _ssh_process.kill()
  51. threading.Thread(target=timeout_killer, kwargs=dict(wait_sec=setup_timeout), daemon=True).start()
  52. stdout = _ssh_process.stdout.readline().decode('utf8')
  53. logger.debug('ssh server stdout: %s', stdout)
  54. connection_info = {}
  55. try:
  56. connection_info = json.loads(stdout)
  57. success = True
  58. except json.decoder.JSONDecodeError:
  59. if not success and _ssh_process.poll() is None:
  60. _ssh_process.kill()
  61. if success:
  62. if connection_info.get('status', 'fail') != 'success':
  63. print("Failed to establish remote access, this is the error message from service provider:",
  64. connection_info.get('message', ''))
  65. else:
  66. print(success_msg.format(address=connection_info['address']))
  67. # wait ssh or parent process exit
  68. while not need_exit() and _ssh_process.poll() is None:
  69. time.sleep(1)
  70. if _ssh_process.poll() is None: # parent process exit, kill ssh process
  71. logger.debug('App process exit, killing ssh process')
  72. _ssh_process.kill()
  73. else: # ssh process exit by itself or by timeout killer
  74. stderr = _ssh_process.stderr.read().decode('utf8')
  75. logger.debug("Stderr from ssh process: %s", stderr)
  76. if stderr:
  77. print(stderr)
  78. else:
  79. print('PyWebIO application remote access service exit.')
  80. def start_remote_access_service_(**kwargs):
  81. ppid = os.getppid()
  82. def need_exit():
  83. # only for unix
  84. return os.getppid() != ppid
  85. try:
  86. remote_access_service(**kwargs, need_exit=need_exit)
  87. except KeyboardInterrupt: # ignore KeyboardInterrupt
  88. pass
  89. finally:
  90. if _ssh_process:
  91. logger.debug('Exception occurred, killing ssh process')
  92. _ssh_process.kill()
  93. raise SystemExit
  94. def start_remote_access_service(**kwargs):
  95. server = os.environ.get('PYWEBIO_REMOTE_ACCESS', 'app.pywebio.online:1022')
  96. if ':' not in server:
  97. server_port = 22
  98. else:
  99. server, server_port = server.split(':', 1)
  100. kwargs.setdefault('server', server)
  101. kwargs.setdefault('server_port', server_port)
  102. multiprocessing.Process(target=start_remote_access_service_, kwargs=kwargs).start()
  103. if __name__ == '__main__':
  104. import argparse
  105. logging.basicConfig(level=logging.DEBUG)
  106. parser = argparse.ArgumentParser(description="localhost.run Remote Access service")
  107. parser.add_argument("--local-port", help="the local port to connect the tunnel to", type=int, default=8080)
  108. parser.add_argument("--server", help="the local port to connect the tunnel to", type=str,
  109. default='app.pywebio.online')
  110. parser.add_argument("--server-port", help="the local port to connect the tunnel to", type=int, default=1022)
  111. args = parser.parse_args()
  112. start_remote_access_service(local_port=args.local_port, server=args.server, server_port=args.server_port)
  113. os.wait() # Wait for completion of a child process