Ver código fonte

feat: add remote access support in `start_server()`

wangweimin 4 anos atrás
pai
commit
869ceb09d8

+ 6 - 1
docs/guide.rst

@@ -615,6 +615,9 @@ Server mode and Script mode
 
 In PyWebIO, there are two modes to run PyWebIO applications: running as a script and using `start_server() <pywebio.platform.tornado.start_server>` or `path_deploy() <pywebio.platform.path_deploy>` to run as a web service.
 
+Overview
+^^^^^^^^^^^^^^
+
 **Server mode**
 
 In server mode, PyWebIO will start a web server to continuously provide services. When the user accesses the service address, PyWebIO will open a new session and run PyWebIO application in it.
@@ -639,6 +642,8 @@ Use `start_server() <pywebio.platform.tornado.start_server>` to start a web serv
     start_server([index, task_1, task_2])
 
 
+The `start_server() <pywebio.platform.tornado.start_server>` provide a remote access support, when enabled (by passing `remote_access=True` to `start_server()`), you can get a temporary public network access address for the current application, others can access your application via this address. Using remote access makes it easy to temporarily share the application with others. This service is powered by `localhost.run <https://localhost.run>`_.
+
 Use `path_deploy() <pywebio.platform.path_deploy>` to deploy the PyWebIO applications from a directory.
 The python file under this directory need contain the ``main`` function to be seen as the PyWebIO application.
 You can access the application by using the file path as the URL.
@@ -661,7 +666,7 @@ In Server mode, you can use `pywebio.platform.seo()` to set the `SEO <https://en
 
 .. attention::
 
-    Note that in Server mode, PyWebIO's input and output functions can only be called in the context of task functions. For example, the following code is **not allowed**::
+    Note that in Server mode, PyWebIO's input, output and session functions can only be called in the context of task functions. For example, the following code is **not allowed**::
 
         import pywebio
         from pywebio.input import input

+ 7 - 1
pywebio/platform/aiohttp.py

@@ -8,6 +8,7 @@ from urllib.parse import urlparse
 
 from aiohttp import web
 
+from .remote_access import start_remote_access_service
 from .tornado import open_webbrowser_on_server_started
 from .utils import make_applications, render_page, cdn_validation, deserialize_binary_event
 from ..session import CoroutineBasedSession, ThreadBasedSession, register_session_implement_for_target, Session
@@ -163,7 +164,7 @@ def static_routes(prefix='/'):
 
 
 def start_server(applications, port=0, host='', debug=False,
-                 cdn=True, static_dir=None,
+                 cdn=True, static_dir=None, remote_access=False,
                  allowed_origins=None, check_origin=None,
                  auto_open_webbrowser=False,
                  websocket_settings=None,
@@ -203,4 +204,9 @@ def start_server(applications, port=0, host='', debug=False,
         logging.getLogger("asyncio").setLevel(logging.DEBUG)
 
     print('Listen on %s:%s' % (host, port))
+
+    if remote_access or remote_access == {}:
+        if remote_access is True: remote_access = {}
+        start_remote_access_service(**remote_access, local_port=port)
+
     web.run_app(app, host=host, port=port)

+ 7 - 1
pywebio/platform/django.py

@@ -7,6 +7,7 @@ from django.http import HttpResponse, HttpRequest
 
 from . import utils
 from .httpbased import HttpContext, HttpHandler, run_event_loop
+from .remote_access import start_remote_access_service
 from .utils import make_applications, cdn_validation
 from ..utils import STATIC_PATH, iscoroutinefunction, isgeneratorfunction, get_free_port, parse_file_size
 
@@ -98,7 +99,8 @@ def webio_view(applications, cdn=True,
 urlpatterns = []
 
 
-def start_server(applications, port=8080, host='', cdn=True, static_dir=None,
+def start_server(applications, port=8080, host='', cdn=True,
+                 static_dir=None, remote_access=False,
                  allowed_origins=None, check_origin=None,
                  session_expire_seconds=None,
                  session_cleanup_interval=None,
@@ -177,6 +179,10 @@ def start_server(applications, port=8080, host='', cdn=True, static_dir=None,
     if static_dir is not None:
         urlpatterns.insert(0, path(r'static/<path:path>', serve, {'document_root': static_dir}))
 
+    if remote_access or remote_access == {}:
+        if remote_access is True: remote_access = {}
+        start_remote_access_service(**remote_access, local_port=port)
+
     use_tornado_wsgi = os.environ.get('PYWEBIO_DJANGO_WITH_TORNADO', True)
     app = get_wsgi_application()  # load app
     if use_tornado_wsgi:

+ 10 - 4
pywebio/platform/fastapi.py

@@ -1,16 +1,17 @@
 import asyncio
+import json
 import logging
 from functools import partial
-import json
+
 import uvicorn
 from starlette.applications import Starlette
 from starlette.requests import Request
 from starlette.responses import HTMLResponse
 from starlette.routing import Route, WebSocketRoute, Mount
-
 from starlette.websockets import WebSocket
 from starlette.websockets import WebSocketDisconnect
 
+from .remote_access import start_remote_access_service
 from .tornado import open_webbrowser_on_server_started
 from .utils import make_applications, render_page, cdn_validation, OriginChecker, deserialize_binary_event
 from ..session import CoroutineBasedSession, ThreadBasedSession, register_session_implement_for_target, Session
@@ -136,8 +137,8 @@ def webio_routes(applications, cdn=True, allowed_origins=None, check_origin=None
     return _webio_routes(applications=applications, cdn=cdn, check_origin_func=check_origin_func)
 
 
-def start_server(applications, port=0, host='',
-                 cdn=True, static_dir=None, debug=False,
+def start_server(applications, port=0, host='', cdn=True,
+                 static_dir=None, remote_access=False, debug=False,
                  allowed_origins=None, check_origin=None,
                  auto_open_webbrowser=False,
                  **uvicorn_settings):
@@ -163,6 +164,11 @@ def start_server(applications, port=0, host='',
 
     if port == 0:
         port = get_free_port()
+
+    if remote_access or remote_access == {}:
+        if remote_access is True: remote_access = {}
+        start_remote_access_service(**remote_access, local_port=port)
+
     uvicorn.run(app, host=host, port=port, **uvicorn_settings)
 
 

+ 9 - 2
pywebio/platform/flask.py

@@ -9,6 +9,7 @@ from flask import Flask, request, send_from_directory, Response
 
 from . import utils
 from .httpbased import HttpContext, HttpHandler, run_event_loop
+from .remote_access import start_remote_access_service
 from .utils import make_applications, cdn_validation
 from ..utils import STATIC_PATH, iscoroutinefunction, isgeneratorfunction
 from ..utils import get_free_port, parse_file_size
@@ -97,7 +98,8 @@ def webio_view(applications, cdn=True,
     return view_func
 
 
-def start_server(applications, port=8080, host='', cdn=True, static_dir=None,
+def start_server(applications, port=8080, host='', cdn=True,
+                 static_dir=None, remote_access=False,
                  allowed_origins=None, check_origin=None,
                  session_expire_seconds=None,
                  session_cleanup_interval=None,
@@ -126,7 +128,8 @@ def start_server(applications, port=8080, host='', cdn=True, static_dir=None,
 
     cdn = cdn_validation(cdn, 'warn')
 
-    app = Flask(__name__) if static_dir is None else Flask(__name__, static_url_path="/static", static_folder=static_dir)
+    app = Flask(__name__) if static_dir is None else Flask(__name__, static_url_path="/static",
+                                                           static_folder=static_dir)
     utils.MAX_PAYLOAD_SIZE = app.config['MAX_CONTENT_LENGTH'] = parse_file_size(max_payload_size)
 
     app.add_url_rule('/', 'webio_view', webio_view(
@@ -147,4 +150,8 @@ def start_server(applications, port=8080, host='', cdn=True, static_dir=None,
     if not debug:
         logging.getLogger('werkzeug').setLevel(logging.WARNING)
 
+    if remote_access or remote_access == {}:
+        if remote_access is True: remote_access = {}
+        start_remote_access_service(**remote_access, local_port=port)
+
     app.run(host=host, port=port, debug=debug, threaded=True, **flask_options)

+ 146 - 0
pywebio/platform/remote_access.py

@@ -0,0 +1,146 @@
+"""
+* Implementation of remote access
+Use localhost.run ssh remote port forwarding service by running a ssh subprocess in PyWebIO application.
+
+The stdout of ssh process is the connection info. 
+
+* Strategy
+Wait at most one minute to get stdout, if it gets a normal out, the connection is successfully established. 
+Otherwise report error.
+
+* One Issue
+When the PyWebIO application process exits, the ssh process becomes an orphan process and does not exit.
+
+* Solution.
+Use a child process to create the ssh process, the child process monitors the PyWebIO application process
+to see if it alive, and when the PyWebIO application exit, the child process kills the ssh process and exit.
+"""
+
+import json
+import logging
+import os
+import re
+import shlex
+import threading
+import time
+from subprocess import Popen, PIPE
+
+logger = logging.getLogger(__name__)
+
+success_msg = """
+===============================================================================
+PyWebIO Application Remote Access
+
+Remote access address: https://{address} 
+
+The remote access service is provided by localhost.run(https://localhost.run/).
+The remote access address will expire in 6 hours and only one application can 
+enable remote access at the same time, if you use the free tier.
+
+To set up and manage custom domains go to https://admin.localhost.run/
+
+===============================================================================
+"""
+
+ssh_key_gen_msg = """
+===============================================================================
+PyWebIO Application Remote Access Error
+
+You need an SSH key to access the remote access service.
+Please follow Gitlab's most excellent howto to generate an SSH key pair: https://docs.gitlab.com/ee/ssh/
+Note that only rsa and ed25519 keys are supported.
+===============================================================================
+"""
+
+_ssh_process = None  # type: Popen
+
+
+def remote_access_process(local_port=8080, setup_timeout=60, key_path=None, custom_domain=None):
+    global _ssh_process
+    ppid = os.getppid()
+    assert ppid != 1
+    domain_part = '%s:' % custom_domain if custom_domain is not None else ''
+    key_path_arg = '-i %s' % key_path if key_path is not None else ''
+    cmd = "ssh %s -oStrictHostKeyChecking=no -R %s80:localhost:%s localhost.run -- --output json" % (
+        key_path_arg, domain_part, local_port)
+    args = shlex.split(cmd)
+    logging.debug('remote access service command: %s', cmd)
+
+    _ssh_process = proc = Popen(args, stdout=PIPE, stderr=PIPE)
+    logging.debug('remote access process pid: %s', proc.pid)
+    success = False
+
+    def timeout_killer(wait_sec):
+        time.sleep(wait_sec)
+        if not success and proc.poll() is None:
+            proc.kill()
+
+    threading.Thread(target=timeout_killer, kwargs=dict(wait_sec=setup_timeout), daemon=True).start()
+
+    stdout = proc.stdout.readline().decode('utf8')
+    connection_info = {}
+    try:
+        connection_info = json.loads(stdout)
+        success = True
+    except json.decoder.JSONDecodeError:
+        if not success and proc.poll() is None:
+            proc.kill()
+
+    if success:
+        if connection_info.get('status', 'fail') != 'success':
+            print("Failed to establish remote access, this is the error message from service provider:",
+                  connection_info.get('message', ''))
+        else:
+            print(success_msg.format(address=connection_info['address']))
+
+    # wait ssh or parent process exit
+    while os.getppid() == ppid and proc.poll() is None:
+        time.sleep(1)
+
+    if proc.poll() is None:  # parent process exit, kill ssh process
+        logging.debug('App process exit, killing ssh process')
+        proc.kill()
+    else:  # ssh process exit by itself or by timeout killer
+        stderr = proc.stderr.read().decode('utf8')
+        conn_id = re.search(r'connection id is (.*?),', stderr)
+        logging.debug('Remote access connection id: %s', conn_id.group(1) if conn_id else '')
+        ssh_error_msg = stderr.rsplit('**', 1)[-1].rsplit('===', 1)[-1].lower().strip()
+        if 'permission denied' in ssh_error_msg:
+            print(ssh_key_gen_msg)
+        elif ssh_error_msg:
+            print(ssh_error_msg)
+        else:
+            print('PyWebIO application remote access service exit.')
+
+
+def start_remote_access_service(local_port=8080, setup_timeout=60, ssh_key_path=None, custom_domain=None):
+    pid = os.fork()
+    if pid == 0:  # in child process
+        try:
+            remote_access_process(local_port=local_port, setup_timeout=setup_timeout,
+                                  key_path=ssh_key_path, custom_domain=custom_domain)
+        except KeyboardInterrupt:  # ignore KeyboardInterrupt
+            pass
+        finally:
+            if _ssh_process:
+                logging.debug('Exception occurred, killing ssh process')
+                _ssh_process.kill()
+            raise SystemExit
+
+    else:
+        return pid
+
+
+if __name__ == '__main__':
+    import argparse
+
+    logging.basicConfig(level=logging.DEBUG)
+
+    parser = argparse.ArgumentParser(description="localhost.run Remote Access service")
+    parser.add_argument("--local-port", help="the local port to connect the tunnel to", type=int, default=8080)
+    parser.add_argument("--custom-domain", help="optionally connect a tunnel to a custom domain", default=None)
+    parser.add_argument("--key-path", help="custom SSH key path", default=None)
+    args = parser.parse_args()
+
+    start_remote_access_service(local_port=args.local_port, ssh_key_path=args.key_path, custom_domain=args.custom_domain)
+    os.wait()  # Wait for completion of a child process

+ 19 - 0
pywebio/platform/tornado.py

@@ -17,6 +17,7 @@ from tornado.web import StaticFileHandler
 from tornado.websocket import WebSocketHandler
 
 from . import utils
+from .remote_access import start_remote_access_service
 from .utils import make_applications, render_page, cdn_validation, deserialize_binary_event
 from ..session import CoroutineBasedSession, ThreadBasedSession, ScriptModeSession, \
     register_session_implement_for_target, Session
@@ -277,6 +278,7 @@ def _setup_server(webio_handler, port=0, host='', static_dir=None, max_buffer_si
 
 def start_server(applications, port=0, host='',
                  debug=False, cdn=True, static_dir=None,
+                 remote_access=False,
                  reconnect_timeout=0,
                  allowed_origins=None, check_origin=None,
                  auto_open_webbrowser=False,
@@ -312,6 +314,18 @@ def start_server(applications, port=0, host='',
        The files in this directory can be accessed via ``http://<host>:<port>/static/files``.
        For example, if there is a ``A/B.jpg`` file in ``http_static_dir`` path,
        it can be accessed via ``http://<host>:<port>/static/A/B.jpg``.
+    :param bool/dict remote_access: Whether to enable remote access, when enabled,
+       you can get a temporary public network access address for the current application,
+       others can access your application via this address.
+       Using remote access makes it easy to temporarily share the application with others.
+       The remote access service is provided by `localhost.run <https://localhost.run/>`_.
+       You can use a dict to config remote access service, the following configurations are currently supported:
+
+       - ``ssh_key_path``: Use a custom ssh key, the default key path is ``~/.ssh/id_xxx``. Note that only rsa and ed25519 keys are supported.
+       - ``custom_domain``: Use a custom domain for your remote access address. This need a subscription to localhost.run.
+         See also: `Custom Domains - localhost.run <https://localhost.run/docs/custom-domains/>`_
+
+    :param bool auto_open_webbrowser: Whether or not auto open web browser when server is started (if the operating system allows it) .
     :param int reconnect_timeout: The client can reconnect to server within ``reconnect_timeout`` seconds after an unexpected disconnection.
        If set to 0 (default), once the client disconnects, the server session will be closed.
     :param list allowed_origins: The allowed request source list. (The current server host is always allowed)
@@ -357,6 +371,11 @@ def start_server(applications, port=0, host='',
 
     if auto_open_webbrowser:
         tornado.ioloop.IOLoop.current().spawn_callback(open_webbrowser_on_server_started, host or 'localhost', port)
+
+    if remote_access or remote_access == {}:
+        if remote_access is True: remote_access = {}
+        start_remote_access_service(**remote_access, local_port=port)
+
     tornado.ioloop.IOLoop.current().start()