diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4b606efa3..50bab7ac1 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 2a7262c6e..561691c01 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 000000000..b01fc555f
--- /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 000000000..b3ba367c4
--- /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 000000000..e8dea9bce
--- /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 000000000..7bd7dc0d5
--- /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 648c0c50e..238b9a3d9 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'],
},
)