remote_access.py 5.1 KB

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