Coverage for pyngrok/ngrok.py: 87.70%
187 statements
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-12 13:51 +0000
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-12 13:51 +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 2023, Alex Laird"
17__version__ = "5.2.3"
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/ngrok-agent/api#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/ngrok-agent/api#start-tunnel>`_ 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/secure-tunnels/tunnels/http-tunnels#file-url>`_, defaults to "80".
194 :type addr: str, optional
195 :param proto: A valid `tunnel protocol <https://ngrok.com/docs/ngrok-agent/api#start-tunnel>`_, 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/ngrok-agent/api#start-tunnel>`_
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/ngrok-agent/api#start-tunnel>`_.
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":
258 if "bind_tls" in options:
259 if options.get("bind_tls") is True or options.get("bind_tls") == "true":
260 options["schemes"] = ["https"]
261 elif not options.get("bind_tls") is not False or options.get("bind_tls") == "false":
262 options["schemes"] = ["http"]
263 else:
264 options["schemes"] = ["http", "https"]
266 options.pop("bind_tls")
268 if "auth" in options:
269 auth = options.get("auth")
270 if isinstance(auth, list):
271 options["basic_auth"] = auth
272 else:
273 options["basic_auth"] = [auth]
275 options.pop("auth")
277 api_url = get_ngrok_process(pyngrok_config).api_url
279 logger.debug("Creating tunnel with options: {}".format(options))
281 tunnel = NgrokTunnel(api_request("{}/api/tunnels".format(api_url), method="POST", data=options,
282 timeout=pyngrok_config.request_timeout),
283 pyngrok_config, api_url)
285 if pyngrok_config.ngrok_version == "v2" and proto == "http" and options.get("bind_tls", "both") == "both":
286 tunnel = NgrokTunnel(api_request("{}{}%20%28http%29".format(api_url, tunnel.uri), method="GET",
287 timeout=pyngrok_config.request_timeout),
288 pyngrok_config, api_url)
290 _current_tunnels[tunnel.public_url] = tunnel
292 return tunnel
295def disconnect(public_url, pyngrok_config=None):
296 """
297 Disconnect the ``ngrok`` tunnel for the given URL, if open.
299 :param public_url: The public URL of the tunnel to disconnect.
300 :type public_url: str
301 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
302 overriding :func:`~pyngrok.conf.get_default()`.
303 :type pyngrok_config: PyngrokConfig, optional
304 """
305 if pyngrok_config is None:
306 pyngrok_config = conf.get_default()
308 # If ngrok is not running, there are no tunnels to disconnect
309 if not process.is_process_running(pyngrok_config.ngrok_path):
310 return
312 api_url = get_ngrok_process(pyngrok_config).api_url
314 if public_url not in _current_tunnels:
315 get_tunnels(pyngrok_config)
317 # One more check, if the given URL is still not in the list of tunnels, it is not active
318 if public_url not in _current_tunnels:
319 return
321 tunnel = _current_tunnels[public_url]
323 logger.info("Disconnecting tunnel: {}".format(tunnel.public_url))
325 api_request("{}{}".format(api_url, tunnel.uri), method="DELETE",
326 timeout=pyngrok_config.request_timeout)
328 _current_tunnels.pop(public_url, None)
331def get_tunnels(pyngrok_config=None):
332 """
333 Get a list of active ``ngrok`` tunnels for the given config's ``ngrok_path``.
335 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
336 will first download and install ``ngrok``.
338 If ``ngrok`` is not running, calling this method will first start a process with
339 :class:`~pyngrok.conf.PyngrokConfig`.
341 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
342 overriding :func:`~pyngrok.conf.get_default()`.
343 :type pyngrok_config: PyngrokConfig, optional
344 :return: The active ``ngrok`` tunnels.
345 :rtype: list[NgrokTunnel]
346 """
347 if pyngrok_config is None:
348 pyngrok_config = conf.get_default()
350 api_url = get_ngrok_process(pyngrok_config).api_url
352 _current_tunnels.clear()
353 for tunnel in api_request("{}/api/tunnels".format(api_url), method="GET",
354 timeout=pyngrok_config.request_timeout)["tunnels"]:
355 ngrok_tunnel = NgrokTunnel(tunnel, pyngrok_config, api_url)
356 _current_tunnels[ngrok_tunnel.public_url] = ngrok_tunnel
358 return list(_current_tunnels.values())
361def kill(pyngrok_config=None):
362 """
363 Terminate the ``ngrok`` processes, if running, for the given config's ``ngrok_path``. This method will not
364 block, it will just issue a kill request.
366 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
367 overriding :func:`~pyngrok.conf.get_default()`.
368 :type pyngrok_config: PyngrokConfig, optional
369 """
370 if pyngrok_config is None:
371 pyngrok_config = conf.get_default()
373 process.kill_process(pyngrok_config.ngrok_path)
375 _current_tunnels.clear()
378def get_version(pyngrok_config=None):
379 """
380 Get a tuple with the ``ngrok`` and ``pyngrok`` versions.
382 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
383 overriding :func:`~pyngrok.conf.get_default()`.
384 :type pyngrok_config: PyngrokConfig, optional
385 :return: A tuple of ``(ngrok_version, pyngrok_version)``.
386 :rtype: tuple
387 """
388 if pyngrok_config is None:
389 pyngrok_config = conf.get_default()
391 ngrok_version = process.capture_run_process(pyngrok_config.ngrok_path, ["--version"]).split("version ")[1]
393 return ngrok_version, __version__
396def update(pyngrok_config=None):
397 """
398 Update ``ngrok`` for the given config's ``ngrok_path``, if an update is available.
400 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
401 overriding :func:`~pyngrok.conf.get_default()`.
402 :type pyngrok_config: PyngrokConfig, optional
403 :return: The result from the ``ngrok`` update.
404 :rtype: str
405 """
406 if pyngrok_config is None:
407 pyngrok_config = conf.get_default()
409 return process.capture_run_process(pyngrok_config.ngrok_path, ["update"])
412def api_request(url, method="GET", data=None, params=None, timeout=4):
413 """
414 Invoke an API request to the given URL, returning JSON data from the response.
416 One use for this method is making requests to ``ngrok`` tunnels:
418 .. code-block:: python
420 from pyngrok import ngrok
422 public_url = ngrok.connect()
423 response = ngrok.api_request("{}/some-route".format(public_url),
424 method="POST", data={"foo": "bar"})
426 Another is making requests to the ``ngrok`` API itself:
428 .. code-block:: python
430 from pyngrok import ngrok
432 api_url = ngrok.get_ngrok_process().api_url
433 response = ngrok.api_request("{}/api/requests/http".format(api_url),
434 params={"tunnel_name": "foo"})
436 :param url: The request URL.
437 :type url: str
438 :param method: The HTTP method.
439 :type method: str, optional
440 :param data: The request body.
441 :type data: dict, optional
442 :param params: The URL parameters.
443 :type params: dict, optional
444 :param timeout: The request timeout, in seconds.
445 :type timeout: float, optional
446 :return: The response from the request.
447 :rtype: dict
448 """
449 if params is None:
450 params = []
452 if not url.lower().startswith("http"):
453 raise PyngrokSecurityError("URL must start with \"http\": {}".format(url))
455 data = json.dumps(data).encode("utf-8") if data else None
457 if params:
458 url += "?{}".format(urlencode([(x, params[x]) for x in params]))
460 request = Request(url, method=method.upper())
461 request.add_header("Content-Type", "application/json")
463 logger.debug("Making {} request to {} with data: {}".format(method, url, data))
465 try:
466 response = urlopen(request, data, timeout)
467 response_data = response.read().decode("utf-8")
469 status_code = response.getcode()
470 logger.debug("Response {}: {}".format(status_code, response_data.strip()))
472 if str(status_code)[0] != "2":
473 raise PyngrokNgrokHTTPError("ngrok client API returned {}: {}".format(status_code, response_data), url,
474 status_code, None, request.headers, response_data)
475 elif status_code == HTTPStatus.NO_CONTENT:
476 return None
478 return json.loads(response_data)
479 except socket.timeout:
480 raise PyngrokNgrokURLError("ngrok client exception, URLError: timed out", "timed out")
481 except HTTPError as e:
482 response_data = e.read().decode("utf-8")
484 status_code = e.getcode()
485 logger.debug("Response {}: {}".format(status_code, response_data.strip()))
487 raise PyngrokNgrokHTTPError("ngrok client exception, API returned {}: {}".format(status_code, response_data),
488 e.url,
489 status_code, e.msg, e.hdrs, response_data)
490 except URLError as e:
491 raise PyngrokNgrokURLError("ngrok client exception, URLError: {}".format(e.reason), e.reason)
494def run(args=None, pyngrok_config=None):
495 """
496 Ensure ``ngrok`` is installed at the default path, then call :func:`~pyngrok.process.run_process`.
498 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily
499 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like
500 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.
502 :param args: Arguments to be passed to the ``ngrok`` process.
503 :type args: list[str], optional
504 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
505 overriding :func:`~pyngrok.conf.get_default()`.
506 :type pyngrok_config: PyngrokConfig, optional
507 """
508 if args is None:
509 args = []
510 if pyngrok_config is None:
511 pyngrok_config = conf.get_default()
513 install_ngrok(pyngrok_config)
515 process.run_process(pyngrok_config.ngrok_path, args)
518def main():
519 """
520 Entry point for the package's ``console_scripts``. This initializes a call from the command
521 line and invokes :func:`~pyngrok.ngrok.run`.
523 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily
524 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like
525 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.
526 """
527 run(sys.argv[1:])
529 if len(sys.argv) == 1 or len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") == "help":
530 print("\nPYNGROK VERSION:\n {}".format(__version__))
531 elif len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") in ["v", "version"]:
532 print("pyngrok version {}".format(__version__))
535if __name__ == "__main__":
536 main()