From 68831e9e81a1bd54ce05ec39c9eea1ed250d93ee Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 25 Sep 2021 01:34:45 +0200 Subject: [PATCH] [#196] Added ngrok integration --- CHANGELOG.md | 6 + docs/source/conf.py | 3 + platypush/message/event/ngrok.py | 41 +++++++ platypush/plugins/ngrok/__init__.py | 158 ++++++++++++++++++++++++++ platypush/plugins/ngrok/manifest.yaml | 7 ++ platypush/schemas/ngrok.py | 31 +++++ setup.py | 2 + 7 files changed, 248 insertions(+) create mode 100644 platypush/message/event/ngrok.py create mode 100644 platypush/plugins/ngrok/__init__.py create mode 100644 platypush/plugins/ngrok/manifest.yaml create mode 100644 platypush/schemas/ngrok.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b606efa3f..50bab7ac1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. Given the high speed of development in the first phase, changes are being reported only starting from v0.20.2. +## [Unreleased] + +## Added + +- `ngrok` integration (see #196). + ## [0.22.1] - 2021-09-22 ### Fixed diff --git a/docs/source/conf.py b/docs/source/conf.py index 2a7262c6e9..561691c015 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -280,15 +280,18 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers', 'pysmartthings', 'aiohttp', 'watchdog', + 'pyngrok', ] sys.path.insert(0, os.path.abspath('../..')) + def skip(app, what, name, obj, skip, options): if name == "__init__": return False return skip + def setup(app): app.connect("autodoc-skip-member", skip) diff --git a/platypush/message/event/ngrok.py b/platypush/message/event/ngrok.py new file mode 100644 index 0000000000..b01fc555f7 --- /dev/null +++ b/platypush/message/event/ngrok.py @@ -0,0 +1,41 @@ +from platypush.message.event import Event + + +class NgrokEvent(Event): + """ + ``ngrok`` base event. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class NgrokProcessStartedEvent(Event): + """ + Event triggered when the ``ngrok`` process is started. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class NgrokTunnelStartedEvent(Event): + """ + Event triggered when a tunnel is started. + """ + def __init__(self, *args, name: str, url: str, protocol: str, **kwargs): + super().__init__(*args, name=name, url=url, protocol=protocol, **kwargs) + + +class NgrokTunnelStoppedEvent(Event): + """ + Event triggered when a tunnel is stopped. + """ + def __init__(self, *args, name: str, url: str, protocol: str, **kwargs): + super().__init__(*args, name=name, url=url, protocol=protocol, **kwargs) + + +class NgrokProcessStoppedEvent(Event): + """ + Event triggered when the ngrok process is stopped. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) diff --git a/platypush/plugins/ngrok/__init__.py b/platypush/plugins/ngrok/__init__.py new file mode 100644 index 0000000000..b3ba367c49 --- /dev/null +++ b/platypush/plugins/ngrok/__init__.py @@ -0,0 +1,158 @@ +import os +from typing import Optional, Union, Callable + +from platypush.context import get_bus +from platypush.message.event.ngrok import NgrokProcessStartedEvent, NgrokTunnelStartedEvent, NgrokTunnelStoppedEvent, \ + NgrokProcessStoppedEvent +from platypush.plugins import Plugin, action +from platypush.schemas.ngrok import NgrokTunnelSchema + + +class NgrokPlugin(Plugin): + """ + Plugin to dynamically create and manage network tunnels using `ngrok `_. + + Requires: + + * **pyngrok** (``pip install pyngrok``) + + Triggers: + + * :class:`platypush.message.event.ngrok.NgrokProcessStartedEvent` when the ``ngrok`` process is started. + * :class:`platypush.message.event.ngrok.NgrokProcessStoppedEvent` when the ``ngrok`` process is stopped. + * :class:`platypush.message.event.ngrok.NgrokTunnelStartedEvent` when a tunnel is started. + * :class:`platypush.message.event.ngrok.NgrokTunnelStoppedEvent` when a tunnel is stopped. + + """ + + def __init__(self, auth_token: Optional[str] = None, ngrok_bin: Optional[str] = None, region: Optional[str] = None, + **kwargs): + """ + :param auth_token: Specify the ``ngrok`` auth token, enabling authenticated features (e.g. more concurrent + tunnels, custom subdomains, etc.). + :param ngrok_bin: By default ``pyngrok`` manages its own version of the ``ngrok`` binary, but you can specify + this option if you want to use a different binary installed on the system. + :param region: ISO code of the region/country that should host the ``ngrok`` tunnel (default: ``us``). + """ + from pyngrok import conf, ngrok + super().__init__(**kwargs) + + conf.get_default().log_event_callback = self._get_event_callback() + self._active_tunnels_by_url = {} + + if auth_token: + ngrok.set_auth_token(auth_token) + if ngrok_bin: + conf.get_default().ngrok_path = os.path.expanduser(ngrok_bin) + if region: + conf.get_default().region = region + + @property + def _active_tunnels_by_name(self) -> dict: + return { + tunnel['name']: tunnel + for tunnel in self._active_tunnels_by_url.values() + } + + def _get_event_callback(self) -> Callable: + from pyngrok.process import NgrokLog + + def callback(log: NgrokLog): + if log.msg == 'client session established': + get_bus().post(NgrokProcessStartedEvent()) + elif log.msg == 'started tunnel': + # noinspection PyUnresolvedReferences + tunnel = dict( + name=log.name, + url=log.url, + protocol=log.url.split(':')[0] + ) + + self._active_tunnels_by_url[tunnel['url']] = tunnel + get_bus().post(NgrokTunnelStartedEvent(**tunnel)) + elif ( + log.msg == 'end' and + int(getattr(log, 'status', 0)) == 204 and + getattr(log, 'pg', '').startswith('/api/tunnels') + ): + # noinspection PyUnresolvedReferences + tunnel = log.pg.split('/')[-1] + tunnel = self._active_tunnels_by_name.pop(tunnel, self._active_tunnels_by_url.pop(tunnel, None)) + if tunnel: + get_bus().post(NgrokTunnelStoppedEvent(**tunnel)) + elif log.msg == 'received stop request': + get_bus().post(NgrokProcessStoppedEvent()) + + return callback + + @action + def create_tunnel(self, resource: Union[int, str] = 80, protocol: str = 'tcp', + name: Optional[str] = None, auth: Optional[str] = None, **kwargs) -> dict: + """ + Create an ``ngrok`` tunnel to the specified localhost port/protocol. + + :param resource: This can be any of the following: + + - A TCP or UDP port exposed on localhost. + - A local network address (or ``address:port``) to expose. + - The absolute path (starting with ``file://``) to a local folder - in such case, the specified directory + will be served over HTTP through an ``ngrok`` endpoint (see https://ngrok.com/docs#http-file-urls). + + Default: localhost port 80. + + :param protocol: Network protocol (default: ``tcp``). + :param name: Optional tunnel name. + :param auth: HTTP basic authentication credentials associated with the tunnel, in the format of + ``username:password``. + :param kwargs: Extra arguments supported by the ``ngrok`` tunnel, such as ``hostname``, ``subdomain`` or + ``remote_addr`` - see the `ngrok documentation `_ for a full + list. + :return: .. schema:: ngrok.NgrokTunnelSchema + """ + from pyngrok import ngrok + if isinstance(resource, str) and resource.startswith('file://'): + protocol = None + + tunnel = ngrok.connect(resource, proto=protocol, name=name, auth=auth, **kwargs) + return NgrokTunnelSchema().dump(tunnel) + + @action + def close_tunnel(self, tunnel: str): + """ + Close an ``ngrok`` tunnel. + + :param tunnel: Name or public URL of the tunnel to be closed. + """ + from pyngrok import ngrok + + if tunnel in self._active_tunnels_by_name: + tunnel = self._active_tunnels_by_name[tunnel]['url'] + + assert tunnel in self._active_tunnels_by_url, f'No such tunnel URL or name: {tunnel}' + ngrok.disconnect(tunnel) + + @action + def get_tunnels(self): + """ + Get the list of active ``ngrok`` tunnels. + + :return: .. schema:: ngrok.NgrokTunnelSchema(many=True) + """ + from pyngrok import ngrok + tunnels = ngrok.get_tunnels() + return NgrokTunnelSchema().dump(tunnels, many=True) + + @action + def kill_process(self): + """ + The first created tunnel instance also starts the ``ngrok`` process. + The process will stay alive until the Python interpreter is stopped or this action is invoked. + """ + from pyngrok import ngrok + proc = ngrok.get_ngrok_process() + assert proc and proc.proc, 'The ngrok process is not running' + proc.proc.kill() + get_bus().post(NgrokProcessStoppedEvent()) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/ngrok/manifest.yaml b/platypush/plugins/ngrok/manifest.yaml new file mode 100644 index 0000000000..e8dea9bcef --- /dev/null +++ b/platypush/plugins/ngrok/manifest.yaml @@ -0,0 +1,7 @@ +manifest: + events: {} + install: + pip: + - pyngrok + package: platypush.plugins.ngrok + type: plugin diff --git a/platypush/schemas/ngrok.py b/platypush/schemas/ngrok.py new file mode 100644 index 0000000000..7bd7dc0d53 --- /dev/null +++ b/platypush/schemas/ngrok.py @@ -0,0 +1,31 @@ +from marshmallow import fields +from marshmallow.schema import Schema +from marshmallow.validate import OneOf + + +class NgrokTunnelSchema(Schema): + name = fields.String( + metadata=dict( + description='Tunnel friendly name or auto-generated name', + example='tcp-8080-my-tunnel', + ) + ) + + protocol = fields.String( + allow_none=False, + attribute='proto', + validate=OneOf(['tcp', 'udp', 'http']), + metadata=dict( + description='Tunnel protocol', + example='tcp', + ), + ) + + url = fields.String( + attribute='public_url', + required=True, + metadata=dict( + description='Public URL to the ngrok tunnel', + example='tcp://8.tcp.ngrok.io:12345', + ) + ) diff --git a/setup.py b/setup.py index 648c0c50ef..238b9a3d9a 100755 --- a/setup.py +++ b/setup.py @@ -250,5 +250,7 @@ setup( 'filemonitor': ['watchdog'], # Support for Adafruit PCA9685 PWM controller 'pca9685': ['adafruit-python-shell', 'adafruit-circuitpython-pca9685'], + # Support for ngrok integration + 'ngrok': ['pyngrok'], }, )