Coverage for pyngrok/ngrok.py: 87.78%
180 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-29 17:05 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-29 17:05 +0000
1import json
2import logging
3import os
4import socket
5import sys
6import uuid
7from http import HTTPStatus
8from urllib.error import HTTPError, URLError
9from urllib.parse import urlencode
10from urllib.request import urlopen, Request
12from pyngrok import process, conf, installer
13from pyngrok.exception import PyngrokNgrokHTTPError, PyngrokNgrokURLError, PyngrokSecurityError, PyngrokError
15__author__ = "Alex Laird"
16__copyright__ = "Copyright 2022, Alex Laird"
17__version__ = "5.2.1"
19from pyngrok.installer import get_default_config
21logger = logging.getLogger(__name__)
23_current_tunnels = {}
26class NgrokTunnel:
27 """
28 An object containing information about a ``ngrok`` tunnel.
30 :var data: The original tunnel data.
31 :vartype data: dict
32 :var name: The name of the tunnel.
33 :vartype name: str
34 :var proto: The protocol of the tunnel.
35 :vartype proto: str
36 :var uri: The tunnel URI, a relative path that can be used to make requests to the ``ngrok`` web interface.
37 :vartype uri: str
38 :var public_url: The public ``ngrok`` URL.
39 :vartype public_url: str
40 :var config: The config for the tunnel.
41 :vartype config: dict
42 :var metrics: Metrics for `the tunnel <https://ngrok.com/docs#list-tunnels>`_.
43 :vartype metrics: dict
44 :var pyngrok_config: The ``pyngrok`` configuration to use when interacting with the ``ngrok``.
45 :vartype pyngrok_config: PyngrokConfig
46 :var api_url: The API URL for the ``ngrok`` web interface.
47 :vartype api_url: str
48 """
50 def __init__(self, data, pyngrok_config, api_url):
51 self.data = data
53 self.name = data.get("name")
54 self.proto = data.get("proto")
55 self.uri = data.get("uri")
56 self.public_url = data.get("public_url")
57 self.config = data.get("config", {})
58 self.metrics = data.get("metrics", {})
60 self.pyngrok_config = pyngrok_config
61 self.api_url = api_url
63 def __repr__(self):
64 return "<NgrokTunnel: \"{}\" -> \"{}\">".format(self.public_url, self.config["addr"]) if self.config.get(
65 "addr", None) else "<pending Tunnel>"
67 def __str__(self): # pragma: no cover
68 return "NgrokTunnel: \"{}\" -> \"{}\"".format(self.public_url, self.config["addr"]) if self.config.get(
69 "addr", None) else "<pending Tunnel>"
71 def refresh_metrics(self):
72 """
73 Get the latest metrics for the tunnel and update the ``metrics`` variable.
74 """
75 logger.info("Refreshing metrics for tunnel: {}".format(self.public_url))
77 data = api_request("{}{}".format(self.api_url, self.uri), method="GET",
78 timeout=self.pyngrok_config.request_timeout)
80 if "metrics" not in data:
81 raise PyngrokError("The ngrok API did not return \"metrics\" in the response")
83 self.data["metrics"] = data["metrics"]
84 self.metrics = self.data["metrics"]
87def install_ngrok(pyngrok_config=None):
88 """
89 Download, install, and initialize ``ngrok`` for the given config. If ``ngrok`` and its default
90 config is already installed, calling this method will do nothing.
92 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
93 overriding :func:`~pyngrok.conf.get_default()`.
94 :type pyngrok_config: PyngrokConfig, optional
95 """
96 if pyngrok_config is None:
97 pyngrok_config = conf.get_default()
99 if not os.path.exists(pyngrok_config.ngrok_path):
100 installer.install_ngrok(pyngrok_config.ngrok_path, pyngrok_config.ngrok_version)
102 # If no config_path is set, ngrok will use its default path
103 if pyngrok_config.config_path is not None:
104 config_path = pyngrok_config.config_path
105 else:
106 config_path = conf.DEFAULT_NGROK_CONFIG_PATH
108 # Install the config to the requested path
109 if not os.path.exists(config_path):
110 installer.install_default_config(config_path, ngrok_version=pyngrok_config.ngrok_version)
112 # Install the default config, even if we don't need it this time, if it doesn't already exist
113 if conf.DEFAULT_NGROK_CONFIG_PATH != config_path and \
114 not os.path.exists(conf.DEFAULT_NGROK_CONFIG_PATH):
115 installer.install_default_config(conf.DEFAULT_NGROK_CONFIG_PATH, ngrok_version=pyngrok_config.ngrok_version)
118def set_auth_token(token, pyngrok_config=None):
119 """
120 Set the ``ngrok`` auth token in the config file, enabling authenticated features (for instance,
121 more concurrent tunnels, custom subdomains, etc.).
123 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
124 will first download and install ``ngrok``.
126 :param token: The auth token to set.
127 :type token: str
128 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
129 overriding :func:`~pyngrok.conf.get_default()`.
130 :type pyngrok_config: PyngrokConfig, optional
131 """
132 if pyngrok_config is None:
133 pyngrok_config = conf.get_default()
135 install_ngrok(pyngrok_config)
137 process.set_auth_token(pyngrok_config, token)
140def get_ngrok_process(pyngrok_config=None):
141 """
142 Get the current ``ngrok`` process for the given config's ``ngrok_path``.
144 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
145 will first download and install ``ngrok``.
147 If ``ngrok`` is not running, calling this method will first start a process with
148 :class:`~pyngrok.conf.PyngrokConfig`.
150 Use :func:`~pyngrok.process.is_process_running` to check if a process is running without also implicitly
151 installing and starting it.
153 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
154 overriding :func:`~pyngrok.conf.get_default()`.
155 :type pyngrok_config: PyngrokConfig, optional
156 :return: The ``ngrok`` process.
157 :rtype: NgrokProcess
158 """
159 if pyngrok_config is None:
160 pyngrok_config = conf.get_default()
162 install_ngrok(pyngrok_config)
164 return process.get_process(pyngrok_config)
167def connect(addr=None, proto=None, name=None, pyngrok_config=None, **options):
168 """
169 Establish a new ``ngrok`` tunnel for the given protocol to the given port, returning an object representing
170 the connected tunnel.
172 If a `tunnel definition in ngrok's config file <https://ngrok.com/docs#tunnel-definitions>`_ matches the given
173 ``name``, it will be loaded and used to start the tunnel. When ``name`` is ``None`` and a "pyngrok-default" tunnel
174 definition exists in ``ngrok``'s config, it will be loaded and use. Any ``kwargs`` passed as ``options`` will
175 override properties from the loaded tunnel definition.
177 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
178 will first download and install ``ngrok``.
180 ``pyngrok`` is compatible with ``ngrok`` v2 and v3, but by default it will install v2. To install v3 instead,
181 set ``ngrok_version`` in :class:`~pyngrok.conf.PyngrokConfig`:
183 If ``ngrok`` is not running, calling this method will first start a process with
184 :class:`~pyngrok.conf.PyngrokConfig`.
186 .. note::
188 ``ngrok`` v2's default behavior for ``http`` when no additional properties are passed is to open *two* tunnels,
189 one ``http`` and one ``https``. This method will return a reference to the ``http`` tunnel in this case. If
190 only a single tunnel is needed, pass ``bind_tls=True`` and a reference to the ``https`` tunnel will be returned.
192 :param addr: The local port to which the tunnel will forward traffic, or a
193 `local directory or network address <https://ngrok.com/docs#http-file-urls>`_, defaults to "80".
194 :type addr: str, optional
195 :param proto: A valid `tunnel protocol <https://ngrok.com/docs#tunnel-definitions>`_, defaults to "http".
196 :type proto: str, optional
197 :param name: A friendly name for the tunnel, or the name of a `ngrok tunnel definition <https://ngrok.com/docs#tunnel-definitions>`_
198 to be used.
199 :type name: str, optional
200 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
201 overriding :func:`~pyngrok.conf.get_default()`.
202 :type pyngrok_config: PyngrokConfig, optional
203 :param options: Remaining ``kwargs`` are passed as `configuration for the ngrok
204 tunnel <https://ngrok.com/docs#tunnel-definitions>`_.
205 :type options: dict, optional
206 :return: The created ``ngrok`` tunnel.
207 :rtype: NgrokTunnel
208 """
209 if pyngrok_config is None:
210 pyngrok_config = conf.get_default()
212 if pyngrok_config.config_path is not None:
213 config_path = pyngrok_config.config_path
214 else:
215 config_path = conf.DEFAULT_NGROK_CONFIG_PATH
217 if os.path.exists(config_path):
218 config = installer.get_ngrok_config(config_path)
219 else:
220 config = get_default_config(pyngrok_config.ngrok_version)
222 # If a "pyngrok-default" tunnel definition exists in the ngrok config, use that
223 tunnel_definitions = config.get("tunnels", {})
224 if not name and "pyngrok-default" in tunnel_definitions:
225 name = "pyngrok-default"
227 # Use a tunnel definition for the given name, if it exists
228 if name and name in tunnel_definitions:
229 tunnel_definition = tunnel_definitions[name]
231 addr = tunnel_definition.get("addr") if not addr else addr
232 proto = tunnel_definition.get("proto") if not proto else proto
233 # Use the tunnel definition as the base, but override with any passed in options
234 tunnel_definition.update(options)
235 options = tunnel_definition
237 addr = str(addr) if addr else "80"
238 if not proto:
239 proto = "http"
241 if not name:
242 if not addr.startswith("file://"):
243 name = "{}-{}-{}".format(proto, addr, uuid.uuid4())
244 else:
245 name = "{}-file-{}".format(proto, uuid.uuid4())
247 logger.info("Opening tunnel named: {}".format(name))
249 config = {
250 "name": name,
251 "addr": addr,
252 "proto": proto
253 }
254 options.update(config)
256 # Upgrade legacy parameters, if present
257 if pyngrok_config.ngrok_version == "v3" and "bind_tls" in options:
258 if options.get("bind_tls") is True or options.get("bind_tls") == "true":
259 options["schemes"] = ["https"]
260 elif not options.get("bind_tls") is not False or options.get("bind_tls") == "false":
261 options["schemes"] = ["http"]
262 else:
263 options["schemes"] = ["http", "https"]
265 options.pop("bind_tls")
267 api_url = get_ngrok_process(pyngrok_config).api_url
269 logger.debug("Creating tunnel with options: {}".format(options))
271 tunnel = NgrokTunnel(api_request("{}/api/tunnels".format(api_url), method="POST", data=options,
272 timeout=pyngrok_config.request_timeout),
273 pyngrok_config, api_url)
275 if pyngrok_config.ngrok_version == "v2" and proto == "http" and options.get("bind_tls", "both") == "both":
276 tunnel = NgrokTunnel(api_request("{}{}%20%28http%29".format(api_url, tunnel.uri), method="GET",
277 timeout=pyngrok_config.request_timeout),
278 pyngrok_config, api_url)
280 _current_tunnels[tunnel.public_url] = tunnel
282 return tunnel
285def disconnect(public_url, pyngrok_config=None):
286 """
287 Disconnect the ``ngrok`` tunnel for the given URL, if open.
289 :param public_url: The public URL of the tunnel to disconnect.
290 :type public_url: str
291 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
292 overriding :func:`~pyngrok.conf.get_default()`.
293 :type pyngrok_config: PyngrokConfig, optional
294 """
295 if pyngrok_config is None:
296 pyngrok_config = conf.get_default()
298 # If ngrok is not running, there are no tunnels to disconnect
299 if not process.is_process_running(pyngrok_config.ngrok_path):
300 return
302 api_url = get_ngrok_process(pyngrok_config).api_url
304 if public_url not in _current_tunnels:
305 get_tunnels(pyngrok_config)
307 # One more check, if the given URL is still not in the list of tunnels, it is not active
308 if public_url not in _current_tunnels:
309 return
311 tunnel = _current_tunnels[public_url]
313 logger.info("Disconnecting tunnel: {}".format(tunnel.public_url))
315 api_request("{}{}".format(api_url, tunnel.uri), method="DELETE",
316 timeout=pyngrok_config.request_timeout)
318 _current_tunnels.pop(public_url, None)
321def get_tunnels(pyngrok_config=None):
322 """
323 Get a list of active ``ngrok`` tunnels for the given config's ``ngrok_path``.
325 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
326 will first download and install ``ngrok``.
328 If ``ngrok`` is not running, calling this method will first start a process with
329 :class:`~pyngrok.conf.PyngrokConfig`.
331 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
332 overriding :func:`~pyngrok.conf.get_default()`.
333 :type pyngrok_config: PyngrokConfig, optional
334 :return: The active ``ngrok`` tunnels.
335 :rtype: list[NgrokTunnel]
336 """
337 if pyngrok_config is None:
338 pyngrok_config = conf.get_default()
340 api_url = get_ngrok_process(pyngrok_config).api_url
342 _current_tunnels.clear()
343 for tunnel in api_request("{}/api/tunnels".format(api_url), method="GET",
344 timeout=pyngrok_config.request_timeout)["tunnels"]:
345 ngrok_tunnel = NgrokTunnel(tunnel, pyngrok_config, api_url)
346 _current_tunnels[ngrok_tunnel.public_url] = ngrok_tunnel
348 return list(_current_tunnels.values())
351def kill(pyngrok_config=None):
352 """
353 Terminate the ``ngrok`` processes, if running, for the given config's ``ngrok_path``. This method will not
354 block, it will just issue a kill request.
356 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
357 overriding :func:`~pyngrok.conf.get_default()`.
358 :type pyngrok_config: PyngrokConfig, optional
359 """
360 if pyngrok_config is None:
361 pyngrok_config = conf.get_default()
363 process.kill_process(pyngrok_config.ngrok_path)
365 _current_tunnels.clear()
368def get_version(pyngrok_config=None):
369 """
370 Get a tuple with the ``ngrok`` and ``pyngrok`` versions.
372 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
373 overriding :func:`~pyngrok.conf.get_default()`.
374 :type pyngrok_config: PyngrokConfig, optional
375 :return: A tuple of ``(ngrok_version, pyngrok_version)``.
376 :rtype: tuple
377 """
378 if pyngrok_config is None:
379 pyngrok_config = conf.get_default()
381 ngrok_version = process.capture_run_process(pyngrok_config.ngrok_path, ["--version"]).split("version ")[1]
383 return ngrok_version, __version__
386def update(pyngrok_config=None):
387 """
388 Update ``ngrok`` for the given config's ``ngrok_path``, if an update is available.
390 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
391 overriding :func:`~pyngrok.conf.get_default()`.
392 :type pyngrok_config: PyngrokConfig, optional
393 :return: The result from the ``ngrok`` update.
394 :rtype: str
395 """
396 if pyngrok_config is None:
397 pyngrok_config = conf.get_default()
399 return process.capture_run_process(pyngrok_config.ngrok_path, ["update"])
402def api_request(url, method="GET", data=None, params=None, timeout=4):
403 """
404 Invoke an API request to the given URL, returning JSON data from the response.
406 One use for this method is making requests to ``ngrok`` tunnels:
408 .. code-block:: python
410 from pyngrok import ngrok
412 public_url = ngrok.connect()
413 response = ngrok.api_request("{}/some-route".format(public_url),
414 method="POST", data={"foo": "bar"})
416 Another is making requests to the ``ngrok`` API itself:
418 .. code-block:: python
420 from pyngrok import ngrok
422 api_url = ngrok.get_ngrok_process().api_url
423 response = ngrok.api_request("{}/api/requests/http".format(api_url),
424 params={"tunnel_name": "foo"})
426 :param url: The request URL.
427 :type url: str
428 :param method: The HTTP method.
429 :type method: str, optional
430 :param data: The request body.
431 :type data: dict, optional
432 :param params: The URL parameters.
433 :type params: dict, optional
434 :param timeout: The request timeout, in seconds.
435 :type timeout: float, optional
436 :return: The response from the request.
437 :rtype: dict
438 """
439 if params is None:
440 params = []
442 if not url.lower().startswith("http"):
443 raise PyngrokSecurityError("URL must start with \"http\": {}".format(url))
445 data = json.dumps(data).encode("utf-8") if data else None
447 if params:
448 url += "?{}".format(urlencode([(x, params[x]) for x in params]))
450 request = Request(url, method=method.upper())
451 request.add_header("Content-Type", "application/json")
453 logger.debug("Making {} request to {} with data: {}".format(method, url, data))
455 try:
456 response = urlopen(request, data, timeout)
457 response_data = response.read().decode("utf-8")
459 status_code = response.getcode()
460 logger.debug("Response {}: {}".format(status_code, response_data.strip()))
462 if str(status_code)[0] != "2":
463 raise PyngrokNgrokHTTPError("ngrok client API returned {}: {}".format(status_code, response_data), url,
464 status_code, None, request.headers, response_data)
465 elif status_code == HTTPStatus.NO_CONTENT:
466 return None
468 return json.loads(response_data)
469 except socket.timeout:
470 raise PyngrokNgrokURLError("ngrok client exception, URLError: timed out", "timed out")
471 except HTTPError as e:
472 response_data = e.read().decode("utf-8")
474 status_code = e.getcode()
475 logger.debug("Response {}: {}".format(status_code, response_data.strip()))
477 raise PyngrokNgrokHTTPError("ngrok client exception, API returned {}: {}".format(status_code, response_data),
478 e.url,
479 status_code, e.msg, e.hdrs, response_data)
480 except URLError as e:
481 raise PyngrokNgrokURLError("ngrok client exception, URLError: {}".format(e.reason), e.reason)
484def run(args=None, pyngrok_config=None):
485 """
486 Ensure ``ngrok`` is installed at the default path, then call :func:`~pyngrok.process.run_process`.
488 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily
489 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like
490 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.
492 :param args: Arguments to be passed to the ``ngrok`` process.
493 :type args: list[str], optional
494 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
495 overriding :func:`~pyngrok.conf.get_default()`.
496 :type pyngrok_config: PyngrokConfig, optional
497 """
498 if args is None:
499 args = []
500 if pyngrok_config is None:
501 pyngrok_config = conf.get_default()
503 install_ngrok(pyngrok_config)
505 process.run_process(pyngrok_config.ngrok_path, args)
508def main():
509 """
510 Entry point for the package's ``console_scripts``. This initializes a call from the command
511 line and invokes :func:`~pyngrok.ngrok.run`.
513 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily
514 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like
515 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.
516 """
517 run(sys.argv[1:])
519 if len(sys.argv) == 1 or len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") == "help":
520 print("\nPYNGROK VERSION:\n {}".format(__version__))
521 elif len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") in ["v", "version"]:
522 print("pyngrok version {}".format(__version__))
525if __name__ == "__main__":
526 main()