1
0

remote_access.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  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: https://{address}
  28. The remote access service is provided by localhost.run (https://localhost.run/).
  29. The remote access address will be reset in every 6 hours and only one
  30. application can enable remote access at the same time, if you use the free tier.
  31. To set up and manage custom domains go to https://admin.localhost.run/
  32. ================================================================================
  33. """
  34. ssh_key_gen_msg = """
  35. ===============================================================================
  36. PyWebIO Application Remote Access Error
  37. You need an SSH key to access the remote access service.
  38. Please follow Gitlab's most excellent howto to generate an SSH key pair:
  39. https://docs.gitlab.com/ee/ssh/
  40. Note that only rsa and ed25519 keys are supported.
  41. ===============================================================================
  42. """
  43. _ssh_process = None # type: Popen
  44. def remote_access_service(local_port=8080, setup_timeout=60, key_path=None, custom_domain=None, need_exist=None):
  45. """
  46. :param local_port: ssh local listen port
  47. :param setup_timeout: If the service can't setup successfully in `setup_timeout` seconds, then exit.
  48. :param key_path: Use a custom ssh key, the default key path is ~/.ssh/id_xxx. Note that only rsa and ed25519 keys are supported.
  49. :param custom_domain: Use a custom domain for your remote access address. This need a subscription to localhost.run
  50. :param callable need_exist: The service will call this function periodicity, when it return True, then exit the service.
  51. """
  52. global _ssh_process
  53. domain_part = '%s:' % custom_domain if custom_domain is not None else ''
  54. key_path_arg = '-i %s' % key_path if key_path is not None else ''
  55. cmd = "ssh %s -oStrictHostKeyChecking=no -R %s80:localhost:%s localhost.run -- --output json" % (
  56. key_path_arg, domain_part, local_port)
  57. args = shlex.split(cmd)
  58. logger.debug('remote access service command: %s', cmd)
  59. _ssh_process = Popen(args, stdout=PIPE, stderr=PIPE)
  60. logger.debug('remote access process pid: %s', _ssh_process.pid)
  61. success = False
  62. def timeout_killer(wait_sec):
  63. time.sleep(wait_sec)
  64. if not success and _ssh_process.poll() is None:
  65. _ssh_process.kill()
  66. threading.Thread(target=timeout_killer, kwargs=dict(wait_sec=setup_timeout), daemon=True).start()
  67. stdout = _ssh_process.stdout.readline().decode('utf8')
  68. connection_info = {}
  69. try:
  70. connection_info = json.loads(stdout)
  71. success = True
  72. except json.decoder.JSONDecodeError:
  73. if not success and _ssh_process.poll() is None:
  74. _ssh_process.kill()
  75. if success:
  76. if connection_info.get('status', 'fail') != 'success':
  77. print("Failed to establish remote access, this is the error message from service provider:",
  78. connection_info.get('message', ''))
  79. else:
  80. print(success_msg.format(address=connection_info['address']))
  81. # wait ssh or parent process exit
  82. while not need_exist() and _ssh_process.poll() is None:
  83. time.sleep(1)
  84. if _ssh_process.poll() is None: # parent process exit, kill ssh process
  85. logger.debug('App process exit, killing ssh process')
  86. _ssh_process.kill()
  87. else: # ssh process exit by itself or by timeout killer
  88. stderr = _ssh_process.stderr.read().decode('utf8')
  89. logger.debug("Stderr from ssh process: %s", stderr)
  90. conn_id = re.search(r'connection id is (.*?),', stderr)
  91. logger.debug('Remote access connection id: %s', conn_id.group(1) if conn_id else '')
  92. try:
  93. ssh_error_msg = stderr.rsplit('**', 1)[-1].rsplit('===', 1)[-1].lower().strip()
  94. except Exception:
  95. ssh_error_msg = stderr
  96. if 'permission denied' in ssh_error_msg:
  97. print(ssh_key_gen_msg)
  98. elif ssh_error_msg:
  99. print(ssh_error_msg)
  100. else:
  101. print('PyWebIO application remote access service exit.')
  102. def start_remote_access_service_(local_port, setup_timeout, ssh_key_path, custom_domain):
  103. ppid = os.getppid()
  104. def need_exist():
  105. # only for unix
  106. return os.getppid() != ppid
  107. try:
  108. remote_access_service(local_port=local_port, setup_timeout=setup_timeout,
  109. key_path=ssh_key_path, custom_domain=custom_domain, need_exist=need_exist)
  110. except KeyboardInterrupt: # ignore KeyboardInterrupt
  111. pass
  112. finally:
  113. if _ssh_process:
  114. logger.debug('Exception occurred, killing ssh process')
  115. _ssh_process.kill()
  116. raise SystemExit
  117. def start_remote_access_service(local_port=8080, setup_timeout=60, ssh_key_path=None, custom_domain=None):
  118. multiprocessing.Process(target=start_remote_access_service_, kwargs=locals()).start()
  119. if __name__ == '__main__':
  120. import argparse
  121. logging.basicConfig(level=logging.DEBUG)
  122. parser = argparse.ArgumentParser(description="localhost.run Remote Access service")
  123. parser.add_argument("--local-port", help="the local port to connect the tunnel to", type=int, default=8080)
  124. parser.add_argument("--custom-domain", help="optionally connect a tunnel to a custom domain", default=None)
  125. parser.add_argument("--key-path", help="custom SSH key path", default=None)
  126. args = parser.parse_args()
  127. start_remote_access_service(local_port=args.local_port, ssh_key_path=args.key_path,
  128. custom_domain=args.custom_domain)
  129. os.wait() # Wait for completion of a child process