Coverage for pyngrok/ngrok.py: 87.91%
215 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-19 14:58 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-19 14:58 +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
14from pyngrok.installer import get_default_config
16__author__ = "Alex Laird"
17__copyright__ = "Copyright 2023, Alex Laird"
18__version__ = "6.1.2"
20logger = logging.getLogger(__name__)
22_current_tunnels = {}
25class NgrokTunnel:
26 """
27 An object containing information about a ``ngrok`` tunnel.
29 :var data: The original tunnel data.
30 :vartype data: dict
31 :var name: The name of the tunnel.
32 :vartype name: str
33 :var proto: The protocol of the tunnel.
34 :vartype proto: str
35 :var uri: The tunnel URI, a relative path that can be used to make requests to the ``ngrok`` web interface.
36 :vartype uri: str
37 :var public_url: The public ``ngrok`` URL.
38 :vartype public_url: str
39 :var config: The config for the tunnel.
40 :vartype config: dict
41 :var metrics: Metrics for `the tunnel <https://ngrok.com/docs/ngrok-agent/api#list-tunnels>`_.
42 :vartype metrics: dict
43 :var pyngrok_config: The ``pyngrok`` configuration to use when interacting with the ``ngrok``.
44 :vartype pyngrok_config: PyngrokConfig
45 :var api_url: The API URL for the ``ngrok`` web interface.
46 :vartype api_url: str
47 """
49 def __init__(self, data, pyngrok_config, api_url):
50 self.data = data
52 self.id = data.get("ID", None)
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, ngrok_version=pyngrok_config.ngrok_version)
102 config_path = conf.get_config_path(pyngrok_config)
104 # Install the config to the requested path
105 if not os.path.exists(config_path):
106 installer.install_default_config(config_path, ngrok_version=pyngrok_config.ngrok_version)
108 # Install the default config, even if we don't need it this time, if it doesn't already exist
109 if conf.DEFAULT_NGROK_CONFIG_PATH != config_path and \
110 not os.path.exists(conf.DEFAULT_NGROK_CONFIG_PATH):
111 installer.install_default_config(conf.DEFAULT_NGROK_CONFIG_PATH, ngrok_version=pyngrok_config.ngrok_version)
114def set_auth_token(token, pyngrok_config=None):
115 """
116 Set the ``ngrok`` auth token in the config file, enabling authenticated features (for instance,
117 more concurrent tunnels, custom subdomains, etc.).
119 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
120 will first download and install ``ngrok``.
122 :param token: The auth token to set.
123 :type token: str
124 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
125 overriding :func:`~pyngrok.conf.get_default()`.
126 :type pyngrok_config: PyngrokConfig, optional
127 """
128 if pyngrok_config is None:
129 pyngrok_config = conf.get_default()
131 install_ngrok(pyngrok_config)
133 process.set_auth_token(pyngrok_config, token)
136def get_ngrok_process(pyngrok_config=None):
137 """
138 Get the current ``ngrok`` process for the given config's ``ngrok_path``.
140 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
141 will first download and install ``ngrok``.
143 If ``ngrok`` is not running, calling this method will first start a process with
144 :class:`~pyngrok.conf.PyngrokConfig`.
146 Use :func:`~pyngrok.process.is_process_running` to check if a process is running without also implicitly
147 installing and starting it.
149 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
150 overriding :func:`~pyngrok.conf.get_default()`.
151 :type pyngrok_config: PyngrokConfig, optional
152 :return: The ``ngrok`` process.
153 :rtype: NgrokProcess
154 """
155 if pyngrok_config is None:
156 pyngrok_config = conf.get_default()
158 install_ngrok(pyngrok_config)
160 return process.get_process(pyngrok_config)
163def _apply_cloud_edge_to_tunnel(tunnel, pyngrok_config):
164 if not tunnel.public_url and pyngrok_config.api_key and tunnel.id:
165 tunnel_response = api_request("https://api.ngrok.com/tunnels/{}".format(tunnel.id), method="GET",
166 auth=pyngrok_config.api_key)
167 if "labels" not in tunnel_response or "edge" not in tunnel_response["labels"]:
168 raise PyngrokError(
169 "Tunnel {} does not have \"labels\", use a Tunnel configured on Cloud Edge.".format(tunnel.data["ID"]))
171 edge = tunnel_response["labels"]["edge"]
172 if edge.startswith("edghts_"):
173 edges_prefix = "https"
174 elif edge.startswith("edgtcp"):
175 edges_prefix = "tcp"
176 elif edge.startswith("edgtls"):
177 edges_prefix = "tls"
178 else:
179 raise PyngrokError("Unknown Edge prefix: {}.".format(edge))
181 edge_response = api_request("https://api.ngrok.com/edges/{}/{}".format(edges_prefix, edge), method="GET",
182 auth=pyngrok_config.api_key)
184 if "hostports" not in edge_response or len(edge_response["hostports"]) < 1:
185 raise PyngrokError(
186 "No Endpoint is attached to your Cloud Edge {}, login to the ngrok dashboard to attach an Endpoint to your Edge first.".format(
187 edge))
189 tunnel.public_url = "{}://{}".format(edges_prefix, edge_response["hostports"][0])
190 tunnel.proto = edges_prefix
193def connect(addr=None, proto=None, name=None, pyngrok_config=None, **options):
194 """
195 Establish a new ``ngrok`` tunnel for the given protocol to the given port, returning an object representing
196 the connected tunnel.
198 If a `tunnel definition in ngrok's config file
199 <https://ngrok.com/docs/secure-tunnels/ngrok-agent/reference/config/#tunnel-definitions>`_ matches the given
200 ``name``, it will be loaded and used to start the tunnel. When ``name`` is ``None`` and a "pyngrok-default" tunnel
201 definition exists in ``ngrok``'s config, it will be loaded and use. Any ``kwargs`` passed as ``options`` will
202 override properties from the loaded tunnel definition.
204 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
205 will first download and install ``ngrok``.
207 ``pyngrok`` is compatible with ``ngrok`` v2 and v3, but by default it will install v3. To install v2 instead,
208 set ``ngrok_version`` to "v2" in :class:`~pyngrok.conf.PyngrokConfig`:
210 If ``ngrok`` is not running, calling this method will first start a process with
211 :class:`~pyngrok.conf.PyngrokConfig`.
213 .. note::
215 ``ngrok`` v2's default behavior for ``http`` when no additional properties are passed is to open *two* tunnels,
216 one ``http`` and one ``https``. This method will return a reference to the ``http`` tunnel in this case. If
217 only a single tunnel is needed, pass ``bind_tls=True`` and a reference to the ``https`` tunnel will be returned.
219 :param addr: The local port to which the tunnel will forward traffic, or a
220 `local directory or network address <https://ngrok.com/docs/secure-tunnels/tunnels/http-tunnels#file-url>`_, defaults to "80".
221 :type addr: str, optional
222 :param proto: A valid `tunnel protocol
223 <https://ngrok.com/docs/secure-tunnels/ngrok-agent/reference/config/#tunnel-definitions>`_, defaults to "http".
224 :type proto: str, optional
225 :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>`_
226 to be used.
227 :type name: str, optional
228 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
229 overriding :func:`~pyngrok.conf.get_default()`.
230 :type pyngrok_config: PyngrokConfig, optional
231 :param options: Remaining ``kwargs`` are passed as `configuration for the ngrok
232 tunnel <https://ngrok.com/docs/secure-tunnels/ngrok-agent/reference/config/#tunnel-definitions>`_.
233 :type options: dict, optional
234 :return: The created ``ngrok`` tunnel.
235 :rtype: NgrokTunnel
236 """
237 if "labels" in options:
238 raise PyngrokError("\"labels\" cannot be passed to connect(), define a tunnel definition in the config file.")
240 if pyngrok_config is None:
241 pyngrok_config = conf.get_default()
243 config_path = conf.get_config_path(pyngrok_config)
245 if os.path.exists(config_path):
246 config = installer.get_ngrok_config(config_path, ngrok_version=pyngrok_config.ngrok_version)
247 else:
248 config = get_default_config(pyngrok_config.ngrok_version)
250 tunnel_definitions = config.get("tunnels", {})
251 # If a "pyngrok-default" tunnel definition exists in the ngrok config, use that
252 if not name and "pyngrok-default" in tunnel_definitions:
253 name = "pyngrok-default"
255 # Use a tunnel definition for the given name, if it exists
256 if name and name in tunnel_definitions:
257 tunnel_definition = tunnel_definitions[name]
259 if "labels" in tunnel_definition and "bind_tls" in options:
260 raise PyngrokError("\"bind_tls\" cannot be set when \"labels\" is also on the tunnel definition.")
262 addr = tunnel_definition.get("addr") if not addr else addr
263 proto = tunnel_definition.get("proto") if not proto else proto
264 # Use the tunnel definition as the base, but override with any passed in options
265 tunnel_definition.update(options)
266 options = tunnel_definition
268 if "labels" in options and not pyngrok_config.api_key:
269 raise PyngrokError(
270 "\"PyngrokConfig.api_key\" must be set when \"labels\" is on the tunnel definition.")
272 addr = str(addr) if addr else "80"
273 # Only apply a default proto label if "labels" isn't defined
274 if not proto and "labels" not in options:
275 proto = "http"
277 if not name:
278 if not addr.startswith("file://"):
279 name = "{}-{}-{}".format(proto, addr, uuid.uuid4())
280 else:
281 name = "{}-file-{}".format(proto, uuid.uuid4())
283 logger.info("Opening tunnel named: {}".format(name))
285 config = {
286 "name": name,
287 "addr": addr
288 }
289 options.update(config)
291 # Only apply proto when "labels" is not defined
292 if "labels" not in options:
293 options["proto"] = proto
295 # Upgrade legacy parameters, if present
296 if pyngrok_config.ngrok_version == "v3":
297 if "bind_tls" in options:
298 if options.get("bind_tls") is True or options.get("bind_tls") == "true":
299 options["schemes"] = ["https"]
300 elif not options.get("bind_tls") is not False or options.get("bind_tls") == "false":
301 options["schemes"] = ["http"]
302 else:
303 options["schemes"] = ["http", "https"]
305 options.pop("bind_tls")
307 if "auth" in options:
308 auth = options.get("auth")
309 if isinstance(auth, list):
310 options["basic_auth"] = auth
311 else:
312 options["basic_auth"] = [auth]
314 options.pop("auth")
316 api_url = get_ngrok_process(pyngrok_config).api_url
318 logger.debug("Creating tunnel with options: {}".format(options))
320 tunnel = NgrokTunnel(api_request("{}/api/tunnels".format(api_url), method="POST", data=options,
321 timeout=pyngrok_config.request_timeout),
322 pyngrok_config, api_url)
324 if pyngrok_config.ngrok_version == "v2" and proto == "http" and options.get("bind_tls", "both") == "both":
325 tunnel = NgrokTunnel(api_request("{}{}%20%28http%29".format(api_url, tunnel.uri), method="GET",
326 timeout=pyngrok_config.request_timeout),
327 pyngrok_config, api_url)
329 _apply_cloud_edge_to_tunnel(tunnel, pyngrok_config)
331 _current_tunnels[tunnel.public_url] = tunnel
333 return tunnel
336def disconnect(public_url, pyngrok_config=None):
337 """
338 Disconnect the ``ngrok`` tunnel for the given URL, if open.
340 :param public_url: The public URL of the tunnel to disconnect.
341 :type public_url: str
342 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
343 overriding :func:`~pyngrok.conf.get_default()`.
344 :type pyngrok_config: PyngrokConfig, optional
345 """
346 if pyngrok_config is None:
347 pyngrok_config = conf.get_default()
349 # If ngrok is not running, there are no tunnels to disconnect
350 if not process.is_process_running(pyngrok_config.ngrok_path):
351 return
353 api_url = get_ngrok_process(pyngrok_config).api_url
355 if public_url not in _current_tunnels:
356 get_tunnels(pyngrok_config)
358 # One more check, if the given URL is still not in the list of tunnels, it is not active
359 if public_url not in _current_tunnels:
360 return
362 tunnel = _current_tunnels[public_url]
364 logger.info("Disconnecting tunnel: {}".format(tunnel.public_url))
366 api_request("{}{}".format(api_url, tunnel.uri), method="DELETE",
367 timeout=pyngrok_config.request_timeout)
369 _current_tunnels.pop(public_url, None)
372def get_tunnels(pyngrok_config=None):
373 """
374 Get a list of active ``ngrok`` tunnels for the given config's ``ngrok_path``.
376 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
377 will first download and install ``ngrok``.
379 If ``ngrok`` is not running, calling this method will first start a process with
380 :class:`~pyngrok.conf.PyngrokConfig`.
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: The active ``ngrok`` tunnels.
386 :rtype: list[NgrokTunnel]
387 """
388 if pyngrok_config is None:
389 pyngrok_config = conf.get_default()
391 api_url = get_ngrok_process(pyngrok_config).api_url
393 _current_tunnels.clear()
394 for tunnel in api_request("{}/api/tunnels".format(api_url), method="GET",
395 timeout=pyngrok_config.request_timeout)["tunnels"]:
396 ngrok_tunnel = NgrokTunnel(tunnel, pyngrok_config, api_url)
397 _apply_cloud_edge_to_tunnel(ngrok_tunnel, pyngrok_config)
398 _current_tunnels[ngrok_tunnel.public_url] = ngrok_tunnel
400 return list(_current_tunnels.values())
403def kill(pyngrok_config=None):
404 """
405 Terminate the ``ngrok`` processes, if running, for the given config's ``ngrok_path``. This method will not
406 block, it will just issue a kill request.
408 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
409 overriding :func:`~pyngrok.conf.get_default()`.
410 :type pyngrok_config: PyngrokConfig, optional
411 """
412 if pyngrok_config is None:
413 pyngrok_config = conf.get_default()
415 process.kill_process(pyngrok_config.ngrok_path)
417 _current_tunnels.clear()
420def get_version(pyngrok_config=None):
421 """
422 Get a tuple with the ``ngrok`` and ``pyngrok`` versions.
424 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
425 overriding :func:`~pyngrok.conf.get_default()`.
426 :type pyngrok_config: PyngrokConfig, optional
427 :return: A tuple of ``(ngrok_version, pyngrok_version)``.
428 :rtype: tuple
429 """
430 if pyngrok_config is None:
431 pyngrok_config = conf.get_default()
433 ngrok_version = process.capture_run_process(pyngrok_config.ngrok_path, ["--version"]).split("version ")[1]
435 return ngrok_version, __version__
438def update(pyngrok_config=None):
439 """
440 Update ``ngrok`` for the given config's ``ngrok_path``, if an update is available.
442 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
443 overriding :func:`~pyngrok.conf.get_default()`.
444 :type pyngrok_config: PyngrokConfig, optional
445 :return: The result from the ``ngrok`` update.
446 :rtype: str
447 """
448 if pyngrok_config is None:
449 pyngrok_config = conf.get_default()
451 return process.capture_run_process(pyngrok_config.ngrok_path, ["update"])
454def api_request(url, method="GET", data=None, params=None, timeout=4, auth=None):
455 """
456 Invoke an API request to the given URL, returning JSON data from the response.
458 One use for this method is making requests to ``ngrok`` tunnels:
460 .. code-block:: python
462 from pyngrok import ngrok
464 public_url = ngrok.connect()
465 response = ngrok.api_request("{}/some-route".format(public_url),
466 method="POST", data={"foo": "bar"})
468 Another is making requests to the ``ngrok`` API itself:
470 .. code-block:: python
472 from pyngrok import ngrok
474 api_url = ngrok.get_ngrok_process().api_url
475 response = ngrok.api_request("{}/api/requests/http".format(api_url),
476 params={"tunnel_name": "foo"})
478 :param url: The request URL.
479 :type url: str
480 :param method: The HTTP method.
481 :type method: str, optional
482 :param data: The request body.
483 :type data: dict, optional
484 :param params: The URL parameters.
485 :type params: dict, optional
486 :param timeout: The request timeout, in seconds.
487 :type timeout: float, optional
488 :param auth: Set as Bearer for an Authorization header.
489 :type auth: str, optional
490 :return: The response from the request.
491 :rtype: dict
492 """
493 if params is None:
494 params = {}
496 if not url.lower().startswith("http"):
497 raise PyngrokSecurityError("URL must start with \"http\": {}".format(url))
499 encoded_data = json.dumps(data).encode("utf-8") if data else None
501 if params:
502 url += "?{}".format(urlencode([(x, params[x]) for x in params]))
504 request = Request(url, method=method.upper())
505 request.add_header("Content-Type", "application/json")
506 if auth:
507 request.add_header("Ngrok-Version", "2")
508 request.add_header("Authorization", "Bearer {}".format(auth))
510 logger.debug("Making {} request to {} with data: {}".format(method, url, encoded_data))
512 try:
513 response = urlopen(request, encoded_data, timeout)
514 response_data = response.read().decode("utf-8")
516 status_code = response.getcode()
517 logger.debug("Response {}: {}".format(status_code, response_data.strip()))
519 if str(status_code)[0] != "2":
520 raise PyngrokNgrokHTTPError("ngrok client API returned {}: {}".format(status_code, response_data), url,
521 status_code, None, request.headers, response_data)
522 elif status_code == HTTPStatus.NO_CONTENT:
523 return {}
525 return json.loads(response_data)
526 except socket.timeout:
527 raise PyngrokNgrokURLError("ngrok client exception, URLError: timed out", "timed out")
528 except HTTPError as e:
529 response_data = e.read().decode("utf-8")
531 status_code = e.getcode()
532 logger.debug("Response {}: {}".format(status_code, response_data.strip()))
534 raise PyngrokNgrokHTTPError("ngrok client exception, API returned {}: {}".format(status_code, response_data),
535 e.url,
536 status_code, e.reason, e.headers, response_data)
537 except URLError as e:
538 raise PyngrokNgrokURLError("ngrok client exception, URLError: {}".format(e.reason), e.reason)
541def run(args=None, pyngrok_config=None):
542 """
543 Ensure ``ngrok`` is installed at the default path, then call :func:`~pyngrok.process.run_process`.
545 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily
546 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like
547 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.
549 :param args: Arguments to be passed to the ``ngrok`` process.
550 :type args: list[str], optional
551 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
552 overriding :func:`~pyngrok.conf.get_default()`.
553 :type pyngrok_config: PyngrokConfig, optional
554 """
555 if args is None:
556 args = []
557 if pyngrok_config is None:
558 pyngrok_config = conf.get_default()
560 install_ngrok(pyngrok_config)
562 process.run_process(pyngrok_config.ngrok_path, args)
565def main():
566 """
567 Entry point for the package's ``console_scripts``. This initializes a call from the command
568 line and invokes :func:`~pyngrok.ngrok.run`.
570 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily
571 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like
572 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.
573 """
574 run(sys.argv[1:])
576 if len(sys.argv) == 1 or len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") == "help":
577 print("\nPYNGROK VERSION:\n {}".format(__version__))
578 elif len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") in ["v", "version"]:
579 print("pyngrok version {}".format(__version__))
582if __name__ == "__main__":
583 main()