Coverage for pyngrok/ngrok.py: 87.21%
219 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-13 02:08 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-13 02:08 +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__ = "6.1.0"
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.id = data.get("ID", None)
54 self.name = data.get("name")
55 self.proto = data.get("proto")
56 self.uri = data.get("uri")
57 self.public_url = data.get("public_url")
58 self.config = data.get("config", {})
59 self.metrics = data.get("metrics", {})
61 self.pyngrok_config = pyngrok_config
62 self.api_url = api_url
64 def __repr__(self):
65 return "<NgrokTunnel: \"{}\" -> \"{}\">".format(self.public_url, self.config["addr"]) if self.config.get(
66 "addr", None) else "<pending Tunnel>"
68 def __str__(self): # pragma: no cover
69 return "NgrokTunnel: \"{}\" -> \"{}\"".format(self.public_url, self.config["addr"]) if self.config.get(
70 "addr", None) else "<pending Tunnel>"
72 def refresh_metrics(self):
73 """
74 Get the latest metrics for the tunnel and update the ``metrics`` variable.
75 """
76 logger.info("Refreshing metrics for tunnel: {}".format(self.public_url))
78 data = api_request("{}{}".format(self.api_url, self.uri), method="GET",
79 timeout=self.pyngrok_config.request_timeout)
81 if "metrics" not in data:
82 raise PyngrokError("The ngrok API did not return \"metrics\" in the response")
84 self.data["metrics"] = data["metrics"]
85 self.metrics = self.data["metrics"]
88def install_ngrok(pyngrok_config=None):
89 """
90 Download, install, and initialize ``ngrok`` for the given config. If ``ngrok`` and its default
91 config is already installed, calling this method will do nothing.
93 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
94 overriding :func:`~pyngrok.conf.get_default()`.
95 :type pyngrok_config: PyngrokConfig, optional
96 """
97 if pyngrok_config is None:
98 pyngrok_config = conf.get_default()
100 if not os.path.exists(pyngrok_config.ngrok_path):
101 installer.install_ngrok(pyngrok_config.ngrok_path, pyngrok_config.ngrok_version)
103 # If no config_path is set, ngrok will use its default path
104 if pyngrok_config.config_path is not None:
105 config_path = pyngrok_config.config_path
106 else:
107 config_path = conf.DEFAULT_NGROK_CONFIG_PATH
109 # Install the config to the requested path
110 if not os.path.exists(config_path):
111 installer.install_default_config(config_path, ngrok_version=pyngrok_config.ngrok_version)
113 # Install the default config, even if we don't need it this time, if it doesn't already exist
114 if conf.DEFAULT_NGROK_CONFIG_PATH != config_path and \
115 not os.path.exists(conf.DEFAULT_NGROK_CONFIG_PATH):
116 installer.install_default_config(conf.DEFAULT_NGROK_CONFIG_PATH, ngrok_version=pyngrok_config.ngrok_version)
119def set_auth_token(token, pyngrok_config=None):
120 """
121 Set the ``ngrok`` auth token in the config file, enabling authenticated features (for instance,
122 more concurrent tunnels, custom subdomains, etc.).
124 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
125 will first download and install ``ngrok``.
127 :param token: The auth token to set.
128 :type token: str
129 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
130 overriding :func:`~pyngrok.conf.get_default()`.
131 :type pyngrok_config: PyngrokConfig, optional
132 """
133 if pyngrok_config is None:
134 pyngrok_config = conf.get_default()
136 install_ngrok(pyngrok_config)
138 process.set_auth_token(pyngrok_config, token)
141def get_ngrok_process(pyngrok_config=None):
142 """
143 Get the current ``ngrok`` process for the given config's ``ngrok_path``.
145 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
146 will first download and install ``ngrok``.
148 If ``ngrok`` is not running, calling this method will first start a process with
149 :class:`~pyngrok.conf.PyngrokConfig`.
151 Use :func:`~pyngrok.process.is_process_running` to check if a process is running without also implicitly
152 installing and starting it.
154 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
155 overriding :func:`~pyngrok.conf.get_default()`.
156 :type pyngrok_config: PyngrokConfig, optional
157 :return: The ``ngrok`` process.
158 :rtype: NgrokProcess
159 """
160 if pyngrok_config is None:
161 pyngrok_config = conf.get_default()
163 install_ngrok(pyngrok_config)
165 return process.get_process(pyngrok_config)
168def _apply_cloud_edge_to_tunnel(tunnel, pyngrok_config):
169 if not tunnel.public_url and pyngrok_config.api_key and tunnel.id:
170 tunnel_response = api_request("https://api.ngrok.com/tunnels/{}".format(tunnel.id), method="GET",
171 auth=pyngrok_config.api_key)
172 if "labels" not in tunnel_response or "edge" not in tunnel_response["labels"]:
173 raise PyngrokError(
174 "Tunnel {} does not have \"labels\", use a Tunnel configured on Cloud Edge.".format(tunnel.data["ID"]))
176 edge = tunnel_response["labels"]["edge"]
177 if edge.startswith("edghts_"):
178 edges_prefix = "https"
179 elif edge.startswith("edgtcp"):
180 edges_prefix = "tcp"
181 elif edge.startswith("edgtls"):
182 edges_prefix = "tls"
183 else:
184 raise PyngrokError("Unknown Edge prefix: {}.".format(edge))
186 edge_response = api_request("https://api.ngrok.com/edges/{}/{}".format(edges_prefix, edge), method="GET",
187 auth=pyngrok_config.api_key)
189 if "hostports" not in edge_response or len(edge_response["hostports"]) < 1:
190 raise PyngrokError(
191 "No Endpoint is attached to your Cloud Edge {}, login to the ngrok dashboard to attach an Endpoint to your Edge first.".format(edge))
193 tunnel.public_url = "{}://{}".format(edges_prefix, edge_response["hostports"][0])
194 tunnel.proto = edges_prefix
197def connect(addr=None, proto=None, name=None, pyngrok_config=None, **options):
198 """
199 Establish a new ``ngrok`` tunnel for the given protocol to the given port, returning an object representing
200 the connected tunnel.
202 If a `tunnel definition in ngrok's config file
203 <https://ngrok.com/docs/secure-tunnels/ngrok-agent/reference/config/#tunnel-definitions>`_ matches the given
204 ``name``, it will be loaded and used to start the tunnel. When ``name`` is ``None`` and a "pyngrok-default" tunnel
205 definition exists in ``ngrok``'s config, it will be loaded and use. Any ``kwargs`` passed as ``options`` will
206 override properties from the loaded tunnel definition.
208 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
209 will first download and install ``ngrok``.
211 ``pyngrok`` is compatible with ``ngrok`` v2 and v3, but by default it will install v3. To install v2 instead,
212 set ``ngrok_version`` in :class:`~pyngrok.conf.PyngrokConfig`:
214 If ``ngrok`` is not running, calling this method will first start a process with
215 :class:`~pyngrok.conf.PyngrokConfig`.
217 .. note::
219 ``ngrok`` v2's default behavior for ``http`` when no additional properties are passed is to open *two* tunnels,
220 one ``http`` and one ``https``. This method will return a reference to the ``http`` tunnel in this case. If
221 only a single tunnel is needed, pass ``bind_tls=True`` and a reference to the ``https`` tunnel will be returned.
223 :param addr: The local port to which the tunnel will forward traffic, or a
224 `local directory or network address <https://ngrok.com/docs/secure-tunnels/tunnels/http-tunnels#file-url>`_, defaults to "80".
225 :type addr: str, optional
226 :param proto: A valid `tunnel protocol
227 <https://ngrok.com/docs/secure-tunnels/ngrok-agent/reference/config/#tunnel-definitions>`_, defaults to "http".
228 :type proto: str, optional
229 :param name: A friendly name for the tunnel, or the name of a `ngrok tunnel definition <https://ngrok.com/docs/secure-tunnels/ngrok-agent/reference/config/#tunnel-definitions>`_
230 to be used.
231 :type name: str, optional
232 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
233 overriding :func:`~pyngrok.conf.get_default()`.
234 :type pyngrok_config: PyngrokConfig, optional
235 :param options: Remaining ``kwargs`` are passed as `configuration for the ngrok
236 tunnel <https://ngrok.com/docs/secure-tunnels/ngrok-agent/reference/config/#tunnel-definitions>`_.
237 :type options: dict, optional
238 :return: The created ``ngrok`` tunnel.
239 :rtype: NgrokTunnel
240 """
241 if "labels" in options:
242 raise PyngrokError("\"labels\" cannot be passed to connect(), define a tunnel definition in the config file.")
244 if pyngrok_config is None:
245 pyngrok_config = conf.get_default()
247 if pyngrok_config.config_path is not None:
248 config_path = pyngrok_config.config_path
249 else:
250 config_path = conf.DEFAULT_NGROK_CONFIG_PATH
252 if os.path.exists(config_path):
253 config = installer.get_ngrok_config(config_path)
254 else:
255 config = get_default_config(pyngrok_config.ngrok_version)
257 # If a "pyngrok-default" tunnel definition exists in the ngrok config, use that
258 tunnel_definitions = config.get("tunnels", {})
259 if not name and "pyngrok-default" in tunnel_definitions:
260 name = "pyngrok-default"
262 # Use a tunnel definition for the given name, if it exists
263 if name and name in tunnel_definitions:
264 tunnel_definition = tunnel_definitions[name]
266 addr = tunnel_definition.get("addr") if not addr else addr
267 proto = tunnel_definition.get("proto") if not proto else proto
268 # Use the tunnel definition as the base, but override with any passed in options
269 tunnel_definition.update(options)
270 options = tunnel_definition
272 if "labels" in options and not pyngrok_config.api_key:
273 raise PyngrokError(
274 "\"PyngrokConfig.api_key\" must be set when \"labels\" is on the tunnel definition.")
276 addr = str(addr) if addr else "80"
277 # Only apply a default proto label if "labels" isn't defined
278 if not proto and "labels" not in options:
279 proto = "http"
281 if not name:
282 if not addr.startswith("file://"):
283 name = "{}-{}-{}".format(proto, addr, uuid.uuid4())
284 else:
285 name = "{}-file-{}".format(proto, uuid.uuid4())
287 logger.info("Opening tunnel named: {}".format(name))
289 config = {
290 "name": name,
291 "addr": addr
292 }
293 options.update(config)
295 # Only apply proto when "labels" is not defined
296 if "labels" not in options:
297 options["proto"] = proto
299 if "labels" in options and "bind_tls" in options:
300 raise PyngrokError("\"bind_tls\" cannot be set when \"labels\" is also on the tunnel definition.")
302 # Upgrade legacy parameters, if present
303 if pyngrok_config.ngrok_version == "v3":
304 if "bind_tls" in options:
305 if options.get("bind_tls") is True or options.get("bind_tls") == "true":
306 options["schemes"] = ["https"]
307 elif not options.get("bind_tls") is not False or options.get("bind_tls") == "false":
308 options["schemes"] = ["http"]
309 else:
310 options["schemes"] = ["http", "https"]
312 options.pop("bind_tls")
314 if "auth" in options:
315 auth = options.get("auth")
316 if isinstance(auth, list):
317 options["basic_auth"] = auth
318 else:
319 options["basic_auth"] = [auth]
321 options.pop("auth")
323 api_url = get_ngrok_process(pyngrok_config).api_url
325 logger.debug("Creating tunnel with options: {}".format(options))
327 tunnel = NgrokTunnel(api_request("{}/api/tunnels".format(api_url), method="POST", data=options,
328 timeout=pyngrok_config.request_timeout),
329 pyngrok_config, api_url)
331 if pyngrok_config.ngrok_version == "v2" and proto == "http" and options.get("bind_tls", "both") == "both":
332 tunnel = NgrokTunnel(api_request("{}{}%20%28http%29".format(api_url, tunnel.uri), method="GET",
333 timeout=pyngrok_config.request_timeout),
334 pyngrok_config, api_url)
336 _apply_cloud_edge_to_tunnel(tunnel, pyngrok_config)
338 _current_tunnels[tunnel.public_url] = tunnel
340 return tunnel
343def disconnect(public_url, pyngrok_config=None):
344 """
345 Disconnect the ``ngrok`` tunnel for the given URL, if open.
347 :param public_url: The public URL of the tunnel to disconnect.
348 :type public_url: str
349 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
350 overriding :func:`~pyngrok.conf.get_default()`.
351 :type pyngrok_config: PyngrokConfig, optional
352 """
353 if pyngrok_config is None:
354 pyngrok_config = conf.get_default()
356 # If ngrok is not running, there are no tunnels to disconnect
357 if not process.is_process_running(pyngrok_config.ngrok_path):
358 return
360 api_url = get_ngrok_process(pyngrok_config).api_url
362 if public_url not in _current_tunnels:
363 get_tunnels(pyngrok_config)
365 # One more check, if the given URL is still not in the list of tunnels, it is not active
366 if public_url not in _current_tunnels:
367 return
369 tunnel = _current_tunnels[public_url]
371 logger.info("Disconnecting tunnel: {}".format(tunnel.public_url))
373 api_request("{}{}".format(api_url, tunnel.uri), method="DELETE",
374 timeout=pyngrok_config.request_timeout)
376 _current_tunnels.pop(public_url, None)
379def get_tunnels(pyngrok_config=None):
380 """
381 Get a list of active ``ngrok`` tunnels for the given config's ``ngrok_path``.
383 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
384 will first download and install ``ngrok``.
386 If ``ngrok`` is not running, calling this method will first start a process with
387 :class:`~pyngrok.conf.PyngrokConfig`.
389 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
390 overriding :func:`~pyngrok.conf.get_default()`.
391 :type pyngrok_config: PyngrokConfig, optional
392 :return: The active ``ngrok`` tunnels.
393 :rtype: list[NgrokTunnel]
394 """
395 if pyngrok_config is None:
396 pyngrok_config = conf.get_default()
398 api_url = get_ngrok_process(pyngrok_config).api_url
400 _current_tunnels.clear()
401 for tunnel in api_request("{}/api/tunnels".format(api_url), method="GET",
402 timeout=pyngrok_config.request_timeout)["tunnels"]:
403 ngrok_tunnel = NgrokTunnel(tunnel, pyngrok_config, api_url)
404 _apply_cloud_edge_to_tunnel(ngrok_tunnel, pyngrok_config)
405 _current_tunnels[ngrok_tunnel.public_url] = ngrok_tunnel
407 return list(_current_tunnels.values())
410def kill(pyngrok_config=None):
411 """
412 Terminate the ``ngrok`` processes, if running, for the given config's ``ngrok_path``. This method will not
413 block, it will just issue a kill request.
415 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
416 overriding :func:`~pyngrok.conf.get_default()`.
417 :type pyngrok_config: PyngrokConfig, optional
418 """
419 if pyngrok_config is None:
420 pyngrok_config = conf.get_default()
422 process.kill_process(pyngrok_config.ngrok_path)
424 _current_tunnels.clear()
427def get_version(pyngrok_config=None):
428 """
429 Get a tuple with the ``ngrok`` and ``pyngrok`` versions.
431 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
432 overriding :func:`~pyngrok.conf.get_default()`.
433 :type pyngrok_config: PyngrokConfig, optional
434 :return: A tuple of ``(ngrok_version, pyngrok_version)``.
435 :rtype: tuple
436 """
437 if pyngrok_config is None:
438 pyngrok_config = conf.get_default()
440 ngrok_version = process.capture_run_process(pyngrok_config.ngrok_path, ["--version"]).split("version ")[1]
442 return ngrok_version, __version__
445def update(pyngrok_config=None):
446 """
447 Update ``ngrok`` for the given config's ``ngrok_path``, if an update is available.
449 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
450 overriding :func:`~pyngrok.conf.get_default()`.
451 :type pyngrok_config: PyngrokConfig, optional
452 :return: The result from the ``ngrok`` update.
453 :rtype: str
454 """
455 if pyngrok_config is None:
456 pyngrok_config = conf.get_default()
458 return process.capture_run_process(pyngrok_config.ngrok_path, ["update"])
461def api_request(url, method="GET", data=None, params=None, timeout=4, auth=None):
462 """
463 Invoke an API request to the given URL, returning JSON data from the response.
465 One use for this method is making requests to ``ngrok`` tunnels:
467 .. code-block:: python
469 from pyngrok import ngrok
471 public_url = ngrok.connect()
472 response = ngrok.api_request("{}/some-route".format(public_url),
473 method="POST", data={"foo": "bar"})
475 Another is making requests to the ``ngrok`` API itself:
477 .. code-block:: python
479 from pyngrok import ngrok
481 api_url = ngrok.get_ngrok_process().api_url
482 response = ngrok.api_request("{}/api/requests/http".format(api_url),
483 params={"tunnel_name": "foo"})
485 :param url: The request URL.
486 :type url: str
487 :param method: The HTTP method.
488 :type method: str, optional
489 :param data: The request body.
490 :type data: dict, optional
491 :param params: The URL parameters.
492 :type params: dict, optional
493 :param timeout: The request timeout, in seconds.
494 :type timeout: float, optional
495 :param auth: Set as Bearer for an Authorization header.
496 :type auth: str, optional
497 :return: The response from the request.
498 :rtype: dict
499 """
500 if params is None:
501 params = []
503 if not url.lower().startswith("http"):
504 raise PyngrokSecurityError("URL must start with \"http\": {}".format(url))
506 data = json.dumps(data).encode("utf-8") if data else None
508 if params:
509 url += "?{}".format(urlencode([(x, params[x]) for x in params]))
511 request = Request(url, method=method.upper())
512 request.add_header("Content-Type", "application/json")
513 if auth:
514 request.add_header("Ngrok-Version", "2")
515 request.add_header("Authorization", "Bearer {}".format(auth))
517 logger.debug("Making {} request to {} with data: {}".format(method, url, data))
519 try:
520 response = urlopen(request, data, timeout)
521 response_data = response.read().decode("utf-8")
523 status_code = response.getcode()
524 logger.debug("Response {}: {}".format(status_code, response_data.strip()))
526 if str(status_code)[0] != "2":
527 raise PyngrokNgrokHTTPError("ngrok client API returned {}: {}".format(status_code, response_data), url,
528 status_code, None, request.headers, response_data)
529 elif status_code == HTTPStatus.NO_CONTENT:
530 return None
532 return json.loads(response_data)
533 except socket.timeout:
534 raise PyngrokNgrokURLError("ngrok client exception, URLError: timed out", "timed out")
535 except HTTPError as e:
536 response_data = e.read().decode("utf-8")
538 status_code = e.getcode()
539 logger.debug("Response {}: {}".format(status_code, response_data.strip()))
541 raise PyngrokNgrokHTTPError("ngrok client exception, API returned {}: {}".format(status_code, response_data),
542 e.url,
543 status_code, e.msg, e.hdrs, response_data)
544 except URLError as e:
545 raise PyngrokNgrokURLError("ngrok client exception, URLError: {}".format(e.reason), e.reason)
548def run(args=None, pyngrok_config=None):
549 """
550 Ensure ``ngrok`` is installed at the default path, then call :func:`~pyngrok.process.run_process`.
552 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily
553 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like
554 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.
556 :param args: Arguments to be passed to the ``ngrok`` process.
557 :type args: list[str], optional
558 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
559 overriding :func:`~pyngrok.conf.get_default()`.
560 :type pyngrok_config: PyngrokConfig, optional
561 """
562 if args is None:
563 args = []
564 if pyngrok_config is None:
565 pyngrok_config = conf.get_default()
567 install_ngrok(pyngrok_config)
569 process.run_process(pyngrok_config.ngrok_path, args)
572def main():
573 """
574 Entry point for the package's ``console_scripts``. This initializes a call from the command
575 line and invokes :func:`~pyngrok.ngrok.run`.
577 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily
578 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like
579 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.
580 """
581 run(sys.argv[1:])
583 if len(sys.argv) == 1 or len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") == "help":
584 print("\nPYNGROK VERSION:\n {}".format(__version__))
585 elif len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") in ["v", "version"]:
586 print("pyngrok version {}".format(__version__))
589if __name__ == "__main__":
590 main()