Coverage for pyngrok/ngrok.py : 90.70%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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 2021, Alex Laird"
17__version__ = "5.0.2"
19logger = logging.getLogger(__name__)
21_current_tunnels = {}
24class NgrokTunnel:
25 """
26 An object containing information about a ``ngrok`` tunnel.
28 :var data: The original tunnel data.
29 :vartype data: dict
30 :var name: The name of the tunnel.
31 :vartype name: str
32 :var proto: A valid `tunnel protocol <https://ngrok.com/docs#tunnel-definitions>`_.
33 :vartype proto: str
34 :var uri: The tunnel URI, a relative path that can be used to make requests to the ``ngrok`` web interface.
35 :vartype uri: str
36 :var public_url: The public ``ngrok`` URL.
37 :vartype public_url: str
38 :var config: The config for the tunnel.
39 :vartype config: dict
40 :var metrics: Metrics for `the tunnel <https://ngrok.com/docs#list-tunnels>`_.
41 :vartype metrics: dict
42 :var pyngrok_config: The ``pyngrok`` configuration to use when interacting with the ``ngrok``.
43 :vartype pyngrok_config: PyngrokConfig
44 :var api_url: The API URL for the ``ngrok`` web interface.
45 :vartype api_url: str
46 """
48 def __init__(self, data, pyngrok_config, api_url):
49 self.data = data
51 self.name = data.get("name")
52 self.proto = data.get("proto")
53 self.uri = data.get("uri")
54 self.public_url = data.get("public_url")
55 self.config = data.get("config", {})
56 self.metrics = data.get("metrics", {})
58 self.pyngrok_config = pyngrok_config
59 self.api_url = api_url
61 def __repr__(self):
62 return "<NgrokTunnel: \"{}\" -> \"{}\">".format(self.public_url, self.config["addr"]) if self.config.get(
63 "addr", None) else "<pending Tunnel>"
65 def __str__(self): # pragma: no cover
66 return "NgrokTunnel: \"{}\" -> \"{}\"".format(self.public_url, self.config["addr"]) if self.config.get(
67 "addr", None) else "<pending Tunnel>"
69 def refresh_metrics(self):
70 """
71 Get the latest metrics for the tunnel and update the ``metrics`` variable.
72 """
73 logger.info("Refreshing metrics for tunnel: {}".format(self.public_url))
75 data = api_request("{}{}".format(self.api_url, self.uri), method="GET",
76 timeout=self.pyngrok_config.request_timeout)
78 if "metrics" not in data:
79 raise PyngrokError("The ngrok API did not return \"metrics\" in the response")
81 self.data["metrics"] = data["metrics"]
82 self.metrics = self.data["metrics"]
85def install_ngrok(pyngrok_config=None):
86 """
87 Download, install, and initialize ``ngrok`` for the given config. If ``ngrok`` and its default
88 config is already installed, calling this method will do nothing.
90 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
91 overriding :func:`~pyngrok.conf.get_default()`.
92 :type pyngrok_config: PyngrokConfig, optional
93 """
94 if pyngrok_config is None:
95 pyngrok_config = conf.get_default()
97 if not os.path.exists(pyngrok_config.ngrok_path):
98 installer.install_ngrok(pyngrok_config.ngrok_path)
100 # If no config_path is set, ngrok will use its default path
101 if pyngrok_config.config_path is not None:
102 config_path = pyngrok_config.config_path
103 else:
104 config_path = conf.DEFAULT_NGROK_CONFIG_PATH
106 # Install the config to the requested path
107 if not os.path.exists(config_path):
108 installer.install_default_config(config_path)
110 # Install the default config, even if we don't need it this time, if it doesn't already exist
111 if conf.DEFAULT_NGROK_CONFIG_PATH != config_path and \
112 not os.path.exists(conf.DEFAULT_NGROK_CONFIG_PATH):
113 installer.install_default_config(conf.DEFAULT_NGROK_CONFIG_PATH)
116def set_auth_token(token, pyngrok_config=None):
117 """
118 Set the ``ngrok`` auth token in the config file, enabling authenticated features (for instance,
119 more concurrent tunnels, custom subdomains, etc.).
121 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
122 will first download and install ``ngrok``.
124 :param token: The auth token to set.
125 :type token: str
126 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
127 overriding :func:`~pyngrok.conf.get_default()`.
128 :type pyngrok_config: PyngrokConfig, optional
129 """
130 if pyngrok_config is None:
131 pyngrok_config = conf.get_default()
133 install_ngrok(pyngrok_config)
135 process.set_auth_token(pyngrok_config, token)
138def get_ngrok_process(pyngrok_config=None):
139 """
140 Get the current ``ngrok`` process for the given config's ``ngrok_path``.
142 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
143 will first download and install ``ngrok``.
145 If ``ngrok`` is not running, calling this method will first start a process with
146 :class:`~pyngrok.conf.PyngrokConfig`.
148 Use :func:`~pyngrok.process.is_process_running` to check if a process is running without also implicitly
149 installing and starting it.
151 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
152 overriding :func:`~pyngrok.conf.get_default()`.
153 :type pyngrok_config: PyngrokConfig, optional
154 :return: The ``ngrok`` process.
155 :rtype: NgrokProcess
156 """
157 if pyngrok_config is None:
158 pyngrok_config = conf.get_default()
160 install_ngrok(pyngrok_config)
162 return process.get_process(pyngrok_config)
165def connect(addr=None, proto=None, name=None, pyngrok_config=None, **options):
166 """
167 Establish a new ``ngrok`` tunnel for the given protocol to the given port, returning an object representing
168 the connected tunnel.
170 If a `tunnel definition in ngrok's config file <https://ngrok.com/docs#tunnel-definitions>`_ matches the given
171 ``name``, it will be loaded and used to start the tunnel. When ``name`` is ``None`` and a "pyngrok-default" tunnel
172 definition exists in ``ngrok``'s config, it will be loaded and use. Any ``kwargs`` passed as ``options`` will
173 override properties from the loaded tunnel definition.
175 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
176 will first download and install ``ngrok``.
178 If ``ngrok`` is not running, calling this method will first start a process with
179 :class:`~pyngrok.conf.PyngrokConfig`.
181 .. note::
183 ``ngrok``'s default behavior for ``http`` when no additional properties are passed is to open *two* tunnels,
184 one ``http`` and one ``https``. This method will return a reference to the ``http`` tunnel in this case. If
185 only a single tunnel is needed, pass ``bind_tls=True``.
187 :param addr: The local port to which the tunnel will forward traffic, or a
188 `local directory or network address <https://ngrok.com/docs#http-file-urls>`_, defaults to "80".
189 :type addr: str, optional
190 :param proto: The protocol to tunnel, defaults to "http".
191 :type proto: str, optional
192 :param name: A friendly name for the tunnel, or the name of a `ngrok tunnel definition <https://ngrok.com/docs#tunnel-definitions>`_
193 to be used.
194 :type name: str, optional
195 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
196 overriding :func:`~pyngrok.conf.get_default()`.
197 :type pyngrok_config: PyngrokConfig, optional
198 :param options: Remaining ``kwargs`` are passed as `configuration for the ngrok
199 tunnel <https://ngrok.com/docs#tunnel-definitions>`_.
200 :type options: dict, optional
201 :return: The created ``ngrok`` tunnel.
202 :rtype: NgrokTunnel
203 """
204 if pyngrok_config is None:
205 pyngrok_config = conf.get_default()
207 if pyngrok_config.config_path is not None:
208 config_path = pyngrok_config.config_path
209 else:
210 config_path = conf.DEFAULT_NGROK_CONFIG_PATH
212 if os.path.exists(config_path):
213 config = installer.get_ngrok_config(config_path)
214 else:
215 config = {}
217 # If a "pyngrok-default" tunnel definition exists in the ngrok config, use that
218 tunnel_definitions = config.get("tunnels", {})
219 if not name and "pyngrok-default" in tunnel_definitions:
220 name = "pyngrok-default"
222 # Use a tunnel definition for the given name, if it exists
223 if name and name in tunnel_definitions:
224 tunnel_definition = tunnel_definitions[name]
226 addr = tunnel_definition.get("addr") if not addr else addr
227 proto = tunnel_definition.get("proto") if not proto else proto
228 # Use the tunnel definition as the base, but override with any passed in options
229 tunnel_definition.update(options)
230 options = tunnel_definition
232 addr = str(addr) if addr else "80"
233 if not proto:
234 proto = "http"
236 if not name:
237 if not addr.startswith("file://"):
238 name = "{}-{}-{}".format(proto, addr, uuid.uuid4())
239 else:
240 name = "{}-file-{}".format(proto, uuid.uuid4())
242 logger.info("Opening tunnel named: {}".format(name))
244 config = {
245 "name": name,
246 "addr": addr,
247 "proto": proto
248 }
249 options.update(config)
251 api_url = get_ngrok_process(pyngrok_config).api_url
253 logger.debug("Creating tunnel with options: {}".format(options))
255 tunnel = NgrokTunnel(api_request("{}/api/tunnels".format(api_url), method="POST", data=options,
256 timeout=pyngrok_config.request_timeout),
257 pyngrok_config, api_url)
259 if proto == "http" and options.get("bind_tls", "both") == "both":
260 tunnel = NgrokTunnel(api_request("{}{}%20%28http%29".format(api_url, tunnel.uri), method="GET",
261 timeout=pyngrok_config.request_timeout),
262 pyngrok_config, api_url)
264 _current_tunnels[tunnel.public_url] = tunnel
266 return tunnel
269def disconnect(public_url, pyngrok_config=None):
270 """
271 Disconnect the ``ngrok`` tunnel for the given URL, if open.
273 :param public_url: The public URL of the tunnel to disconnect.
274 :type public_url: str
275 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
276 overriding :func:`~pyngrok.conf.get_default()`.
277 :type pyngrok_config: PyngrokConfig, optional
278 """
279 if pyngrok_config is None:
280 pyngrok_config = conf.get_default()
282 # If ngrok is not running, there are no tunnels to disconnect
283 if not process.is_process_running(pyngrok_config.ngrok_path):
284 return
286 api_url = get_ngrok_process(pyngrok_config).api_url
288 if public_url not in _current_tunnels:
289 get_tunnels(pyngrok_config)
291 # One more check, if the given URL is still not in the list of tunnels, it is not active
292 if public_url not in _current_tunnels:
293 return
295 tunnel = _current_tunnels[public_url]
297 logger.info("Disconnecting tunnel: {}".format(tunnel.public_url))
299 api_request("{}{}".format(api_url, tunnel.uri), method="DELETE",
300 timeout=pyngrok_config.request_timeout)
302 _current_tunnels.pop(public_url, None)
305def get_tunnels(pyngrok_config=None):
306 """
307 Get a list of active ``ngrok`` tunnels for the given config's ``ngrok_path``.
309 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
310 will first download and install ``ngrok``.
312 If ``ngrok`` is not running, calling this method will first start a process with
313 :class:`~pyngrok.conf.PyngrokConfig`.
315 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
316 overriding :func:`~pyngrok.conf.get_default()`.
317 :type pyngrok_config: PyngrokConfig, optional
318 :return: The active ``ngrok`` tunnels.
319 :rtype: list[NgrokTunnel]
320 """
321 if pyngrok_config is None:
322 pyngrok_config = conf.get_default()
324 api_url = get_ngrok_process(pyngrok_config).api_url
326 _current_tunnels.clear()
327 for tunnel in api_request("{}/api/tunnels".format(api_url), method="GET",
328 timeout=pyngrok_config.request_timeout)["tunnels"]:
329 ngrok_tunnel = NgrokTunnel(tunnel, pyngrok_config, api_url)
330 _current_tunnels[ngrok_tunnel.public_url] = ngrok_tunnel
332 return list(_current_tunnels.values())
335def kill(pyngrok_config=None):
336 """
337 Terminate the ``ngrok`` processes, if running, for the given config's ``ngrok_path``. This method will not
338 block, it will just issue a kill request.
340 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
341 overriding :func:`~pyngrok.conf.get_default()`.
342 :type pyngrok_config: PyngrokConfig, optional
343 """
344 if pyngrok_config is None:
345 pyngrok_config = conf.get_default()
347 process.kill_process(pyngrok_config.ngrok_path)
349 _current_tunnels.clear()
352def get_version(pyngrok_config=None):
353 """
354 Get a tuple with the ``ngrok`` and ``pyngrok`` versions.
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 :return: A tuple of ``(ngrok_version, pyngrok_version)``.
360 :rtype: tuple
361 """
362 if pyngrok_config is None:
363 pyngrok_config = conf.get_default()
365 ngrok_version = process.capture_run_process(pyngrok_config.ngrok_path, ["--version"]).split("version ")[1]
367 return ngrok_version, __version__
370def update(pyngrok_config=None):
371 """
372 Update ``ngrok`` for the given config's ``ngrok_path``, if an update is available.
374 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
375 overriding :func:`~pyngrok.conf.get_default()`.
376 :type pyngrok_config: PyngrokConfig, optional
377 :return: The result from the ``ngrok`` update.
378 :rtype: str
379 """
380 if pyngrok_config is None:
381 pyngrok_config = conf.get_default()
383 return process.capture_run_process(pyngrok_config.ngrok_path, ["update"])
386def api_request(url, method="GET", data=None, params=None, timeout=4):
387 """
388 Invoke an API request to the given URL, returning JSON data from the response.
390 One use for this method is making requests to ``ngrok`` tunnels:
392 .. code-block:: python
394 from pyngrok import ngrok
396 public_url = ngrok.connect()
397 response = ngrok.api_request("{}/some-route".format(public_url),
398 method="POST", data={"foo": "bar"})
400 Another is making requests to the ``ngrok`` API itself:
402 .. code-block:: python
404 from pyngrok import ngrok
406 api_url = ngrok.get_ngrok_process().api_url
407 response = ngrok.api_request("{}/api/requests/http".format(api_url),
408 params={"tunnel_name": "foo"})
410 :param url: The request URL.
411 :type url: str
412 :param method: The HTTP method.
413 :type method: str, optional
414 :param data: The request body.
415 :type data: dict, optional
416 :param params: The URL parameters.
417 :type params: dict, optional
418 :param timeout: The request timeout, in seconds.
419 :type timeout: float, optional
420 :return: The response from the request.
421 :rtype: dict
422 """
423 if params is None:
424 params = []
426 if not url.lower().startswith("http"):
427 raise PyngrokSecurityError("URL must start with \"http\": {}".format(url))
429 data = json.dumps(data).encode("utf-8") if data else None
431 if params:
432 url += "?{}".format(urlencode([(x, params[x]) for x in params]))
434 request = Request(url, method=method.upper())
435 request.add_header("Content-Type", "application/json")
437 logger.debug("Making {} request to {} with data: {}".format(method, url, data))
439 try:
440 response = urlopen(request, data, timeout)
441 response_data = response.read().decode("utf-8")
443 status_code = response.getcode()
444 logger.debug("Response {}: {}".format(status_code, response_data.strip()))
446 if str(status_code)[0] != "2":
447 raise PyngrokNgrokHTTPError("ngrok client API returned {}: {}".format(status_code, response_data), url,
448 status_code, None, request.headers, response_data)
449 elif status_code == HTTPStatus.NO_CONTENT:
450 return None
452 return json.loads(response_data)
453 except socket.timeout:
454 raise PyngrokNgrokURLError("ngrok client exception, URLError: timed out", "timed out")
455 except HTTPError as e:
456 response_data = e.read().decode("utf-8")
458 status_code = e.getcode()
459 logger.debug("Response {}: {}".format(status_code, response_data.strip()))
461 raise PyngrokNgrokHTTPError("ngrok client exception, API returned {}: {}".format(status_code, response_data),
462 e.url,
463 status_code, e.msg, e.hdrs, response_data)
464 except URLError as e:
465 raise PyngrokNgrokURLError("ngrok client exception, URLError: {}".format(e.reason), e.reason)
468def run(args=None, pyngrok_config=None):
469 """
470 Ensure ``ngrok`` is installed at the default path, then call :func:`~pyngrok.process.run_process`.
472 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily
473 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like
474 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.
476 :param args: Arguments to be passed to the ``ngrok`` process.
477 :type args: list[str], optional
478 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
479 overriding :func:`~pyngrok.conf.get_default()`.
480 :type pyngrok_config: PyngrokConfig, optional
481 """
482 if args is None:
483 args = []
484 if pyngrok_config is None:
485 pyngrok_config = conf.get_default()
487 install_ngrok(pyngrok_config)
489 process.run_process(pyngrok_config.ngrok_path, args)
492def main():
493 """
494 Entry point for the package's ``console_scripts``. This initializes a call from the command
495 line and invokes :func:`~pyngrok.ngrok.run`.
497 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily
498 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like
499 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.
500 """
501 run(sys.argv[1:])
503 if len(sys.argv) == 1 or len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") == "help":
504 print("\nPYNGROK VERSION:\n {}".format(__version__))
505 elif len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") in ["v", "version"]:
506 print("pyngrok version {}".format(__version__))
509if __name__ == "__main__":
510 main()