Coverage for pyngrok/ngrok.py: 87.39%
222 statements
« prev ^ index » next coverage.py v7.4.0, created at 2023-12-30 22:54 +0000
« prev ^ index » next coverage.py v7.4.0, created at 2023-12-30 22:54 +0000
1import json
2import logging
3import os
4import socket
5import sys
6import uuid
7from http import HTTPStatus
8from typing import Optional, Any, Dict, List, Tuple
9from urllib.error import HTTPError, URLError
10from urllib.parse import urlencode
11from urllib.request import urlopen, Request
13from pyngrok import process, conf, installer
14from pyngrok.conf import PyngrokConfig
15from pyngrok.exception import PyngrokNgrokHTTPError, PyngrokNgrokURLError, PyngrokSecurityError, PyngrokError
16from pyngrok.installer import get_default_config
17from pyngrok.process import NgrokProcess
19__author__ = "Alex Laird"
20__copyright__ = "Copyright 2023, Alex Laird"
21__version__ = "7.0.5"
23logger = logging.getLogger(__name__)
26class NgrokTunnel:
27 """
28 An object containing information about a ``ngrok`` tunnel.
29 """
31 def __init__(self,
32 data: Dict[str, Any],
33 pyngrok_config: PyngrokConfig,
34 api_url: Optional[str]) -> None:
35 #: The original tunnel data.
36 self.data: Dict[str, Any] = data
37 #: The ``pyngrok`` configuration to use when interacting with the ``ngrok``.
38 self.pyngrok_config: PyngrokConfig = pyngrok_config
39 #: The API URL for the ``ngrok`` web interface.
40 self.api_url: Optional[str] = api_url
42 #: The ID of the tunnel.
43 self.id: Optional[str] = data.get("ID", None)
44 #: The name of the tunnel.
45 self.name: Optional[str] = data.get("name")
46 #: The protocol of the tunnel.
47 self.proto: Optional[str] = data.get("proto")
48 #: The tunnel URI, a relative path that can be used to make requests to the ``ngrok`` web interface.
49 self.uri: Optional[str] = data.get("uri")
50 #: The public ``ngrok`` URL.
51 self.public_url: Optional[str] = data.get("public_url")
52 #: The config for the tunnel.
53 self.config: Dict[str, Any] = data.get("config", {})
54 #: Metrics for `the tunnel <https://ngrok.com/docs/ngrok-agent/api#list-tunnels>`_.
55 self.metrics: Dict[str, Any] = data.get("metrics", {})
57 def __repr__(self) -> str:
58 return "<NgrokTunnel: \"{}\" -> \"{}\">".format(self.public_url, self.config["addr"]) if self.config.get(
59 "addr", None) else "<pending Tunnel>"
61 def __str__(self) -> str: # pragma: no cover
62 return "NgrokTunnel: \"{}\" -> \"{}\"".format(self.public_url, self.config["addr"]) if self.config.get(
63 "addr", None) else "<pending Tunnel>"
65 def refresh_metrics(self) -> None:
66 """
67 Get the latest metrics for the tunnel and update the ``metrics`` variable.
68 """
69 logger.info("Refreshing metrics for tunnel: {}".format(self.public_url))
71 data = api_request("{}{}".format(self.api_url, self.uri), method="GET",
72 timeout=self.pyngrok_config.request_timeout)
74 if "metrics" not in data:
75 raise PyngrokError("The ngrok API did not return \"metrics\" in the response")
77 self.data["metrics"] = data["metrics"]
78 self.metrics = self.data["metrics"]
81_current_tunnels: Dict[str, NgrokTunnel] = {}
84def install_ngrok(pyngrok_config: Optional[PyngrokConfig] = None) -> None:
85 """
86 Download, install, and initialize ``ngrok`` for the given config. If ``ngrok`` and its default
87 config is already installed, calling this method will do nothing.
89 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
90 overriding :func:`~pyngrok.conf.get_default()`.
91 """
92 if pyngrok_config is None:
93 pyngrok_config = conf.get_default()
95 if not os.path.exists(pyngrok_config.ngrok_path):
96 installer.install_ngrok(pyngrok_config.ngrok_path, ngrok_version=pyngrok_config.ngrok_version)
98 config_path = conf.get_config_path(pyngrok_config)
100 # Install the config to the requested path
101 if not os.path.exists(config_path):
102 installer.install_default_config(config_path, ngrok_version=pyngrok_config.ngrok_version)
104 # Install the default config, even if we don't need it this time, if it doesn't already exist
105 if conf.DEFAULT_NGROK_CONFIG_PATH != config_path and \
106 not os.path.exists(conf.DEFAULT_NGROK_CONFIG_PATH):
107 installer.install_default_config(conf.DEFAULT_NGROK_CONFIG_PATH, ngrok_version=pyngrok_config.ngrok_version)
110def set_auth_token(token: str,
111 pyngrok_config: Optional[PyngrokConfig] = None) -> None:
112 """
113 Set the ``ngrok`` auth token in the config file, enabling authenticated features (for instance,
114 more concurrent tunnels, custom subdomains, etc.).
116 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
117 will first download and install ``ngrok``.
119 :param token: The auth token to set.
120 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
121 overriding :func:`~pyngrok.conf.get_default()`.
122 """
123 if pyngrok_config is None:
124 pyngrok_config = conf.get_default()
126 install_ngrok(pyngrok_config)
128 process.set_auth_token(pyngrok_config, token)
131def get_ngrok_process(pyngrok_config: Optional[PyngrokConfig] = None) -> NgrokProcess:
132 """
133 Get the current ``ngrok`` process for the given config's ``ngrok_path``.
135 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
136 will first download and install ``ngrok``.
138 If ``ngrok`` is not running, calling this method will first start a process with
139 :class:`~pyngrok.conf.PyngrokConfig`.
141 Use :func:`~pyngrok.process.is_process_running` to check if a process is running without also implicitly
142 installing and starting it.
144 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
145 overriding :func:`~pyngrok.conf.get_default()`.
146 :return: The ``ngrok`` process.
147 """
148 if pyngrok_config is None:
149 pyngrok_config = conf.get_default()
151 install_ngrok(pyngrok_config)
153 return process.get_process(pyngrok_config)
156def _apply_cloud_edge_to_tunnel(tunnel: NgrokTunnel,
157 pyngrok_config: PyngrokConfig) -> None:
158 if not tunnel.public_url and pyngrok_config.api_key and tunnel.id:
159 tunnel_response = api_request("https://api.ngrok.com/tunnels/{}".format(tunnel.id), method="GET",
160 auth=pyngrok_config.api_key)
161 if "labels" not in tunnel_response or "edge" not in tunnel_response["labels"]:
162 raise PyngrokError(
163 "Tunnel {} does not have \"labels\", use a Tunnel configured on Cloud Edge.".format(tunnel.data["ID"]))
165 edge = tunnel_response["labels"]["edge"]
166 if edge.startswith("edghts_"):
167 edges_prefix = "https"
168 elif edge.startswith("edgtcp"):
169 edges_prefix = "tcp"
170 elif edge.startswith("edgtls"):
171 edges_prefix = "tls"
172 else:
173 raise PyngrokError("Unknown Edge prefix: {}.".format(edge))
175 edge_response = api_request("https://api.ngrok.com/edges/{}/{}".format(edges_prefix, edge), method="GET",
176 auth=pyngrok_config.api_key)
178 if "hostports" not in edge_response or len(edge_response["hostports"]) < 1:
179 raise PyngrokError(
180 "No Endpoint is attached to your Cloud Edge {}, login to the ngrok dashboard to attach an Endpoint to your Edge first.".format(
181 edge))
183 tunnel.public_url = "{}://{}".format(edges_prefix, edge_response["hostports"][0])
184 tunnel.proto = edges_prefix
187# When Python <3.9 support is dropped, addr type can be changed to Optional[str|int]
188def connect(addr: Optional[str] = None,
189 proto: Optional[str] = None,
190 name: Optional[str] = None,
191 pyngrok_config: Optional[PyngrokConfig] = None,
192 **options: Any) -> NgrokTunnel:
193 """
194 Establish a new ``ngrok`` tunnel for the given protocol to the given port, returning an object representing
195 the connected tunnel.
197 If a `tunnel definition in ngrok's config file
198 <https://ngrok.com/docs/secure-tunnels/ngrok-agent/reference/config/#tunnel-definitions>`_ matches the given
199 ``name``, it will be loaded and used to start the tunnel. When ``name`` is ``None`` and a "pyngrok-default" tunnel
200 definition exists in ``ngrok``'s config, it will be loaded and use. Any ``kwargs`` passed as ``options`` will
201 override properties from the loaded tunnel definition.
203 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
204 will first download and install ``ngrok``.
206 ``pyngrok`` is compatible with ``ngrok`` v2 and v3, but by default it will install v3. To install v2 instead,
207 set ``ngrok_version`` to "v2" in :class:`~pyngrok.conf.PyngrokConfig`:
209 If ``ngrok`` is not running, calling this method will first start a process with
210 :class:`~pyngrok.conf.PyngrokConfig`.
212 .. note::
214 ``ngrok`` v2's default behavior for ``http`` when no additional properties are passed is to open *two* tunnels,
215 one ``http`` and one ``https``. This method will return a reference to the ``http`` tunnel in this case. If
216 only a single tunnel is needed, pass ``bind_tls=True`` and a reference to the ``https`` tunnel will be returned.
218 :param addr: The local port to which the tunnel will forward traffic, or a
219 `local directory or network address <https://ngrok.com/docs/secure-tunnels/tunnels/http-tunnels#file-url>`_, defaults to "80".
220 :param proto: A valid `tunnel protocol
221 <https://ngrok.com/docs/secure-tunnels/ngrok-agent/reference/config/#tunnel-definitions>`_, defaults to "http".
222 :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>`_
223 to be used.
224 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
225 overriding :func:`~pyngrok.conf.get_default()`.
226 :param options: Remaining ``kwargs`` are passed as `configuration for the ngrok
227 tunnel <https://ngrok.com/docs/secure-tunnels/ngrok-agent/reference/config/#tunnel-definitions>`_.
228 :return: The created ``ngrok`` tunnel.
229 """
230 if "labels" in options:
231 raise PyngrokError("\"labels\" cannot be passed to connect(), define a tunnel definition in the config file.")
233 if pyngrok_config is None:
234 pyngrok_config = conf.get_default()
236 config_path = conf.get_config_path(pyngrok_config)
238 if os.path.exists(config_path):
239 config = installer.get_ngrok_config(config_path, ngrok_version=pyngrok_config.ngrok_version)
240 else:
241 config = get_default_config(pyngrok_config.ngrok_version)
243 tunnel_definitions = config.get("tunnels", {})
244 # If a "pyngrok-default" tunnel definition exists in the ngrok config, use that
245 if not name and "pyngrok-default" in tunnel_definitions:
246 name = "pyngrok-default"
248 # Use a tunnel definition for the given name, if it exists
249 if name and name in tunnel_definitions:
250 tunnel_definition = tunnel_definitions[name]
252 if "labels" in tunnel_definition and "bind_tls" in options:
253 raise PyngrokError("\"bind_tls\" cannot be set when \"labels\" is also on the tunnel definition.")
255 addr = tunnel_definition.get("addr") if not addr else addr
256 proto = tunnel_definition.get("proto") if not proto else proto
257 # Use the tunnel definition as the base, but override with any passed in options
258 tunnel_definition.update(options)
259 options = tunnel_definition
261 if "labels" in options and not pyngrok_config.api_key:
262 raise PyngrokError(
263 "\"PyngrokConfig.api_key\" must be set when \"labels\" is on the tunnel definition.")
265 addr = str(addr) if addr else "80"
266 # Only apply a default proto label if "labels" isn't defined
267 if not proto and "labels" not in options:
268 proto = "http"
270 if not name:
271 if not addr.startswith("file://"):
272 name = "{}-{}-{}".format(proto, addr, uuid.uuid4())
273 else:
274 name = "{}-file-{}".format(proto, uuid.uuid4())
276 logger.info("Opening tunnel named: {}".format(name))
278 config = {
279 "name": name,
280 "addr": addr
281 }
282 options.update(config)
284 # Only apply proto when "labels" is not defined
285 if "labels" not in options:
286 options["proto"] = proto
288 # Upgrade legacy parameters, if present
289 if pyngrok_config.ngrok_version == "v3":
290 if "bind_tls" in options:
291 if options.get("bind_tls") is True or options.get("bind_tls") == "true":
292 options["schemes"] = ["https"]
293 elif not options.get("bind_tls") is not False or options.get("bind_tls") == "false":
294 options["schemes"] = ["http"]
295 else:
296 options["schemes"] = ["http", "https"]
298 options.pop("bind_tls")
300 if "auth" in options:
301 auth = options.get("auth")
302 if isinstance(auth, list):
303 options["basic_auth"] = auth
304 else:
305 options["basic_auth"] = [auth]
307 options.pop("auth")
309 api_url = get_ngrok_process(pyngrok_config).api_url
311 logger.debug("Creating tunnel with options: {}".format(options))
313 tunnel = NgrokTunnel(api_request("{}/api/tunnels".format(api_url), method="POST", data=options,
314 timeout=pyngrok_config.request_timeout),
315 pyngrok_config, api_url)
317 if pyngrok_config.ngrok_version == "v2" and proto == "http" and options.get("bind_tls", "both") == "both":
318 tunnel = NgrokTunnel(api_request("{}{}%20%28http%29".format(api_url, tunnel.uri), method="GET",
319 timeout=pyngrok_config.request_timeout),
320 pyngrok_config, api_url)
322 _apply_cloud_edge_to_tunnel(tunnel, pyngrok_config)
324 if tunnel.public_url is None:
325 raise PyngrokError(
326 "\"public_url\" was not populated for tunnel {}, but is required for pyngrok to function.".format(
327 tunnel))
329 _current_tunnels[tunnel.public_url] = tunnel
331 return tunnel
334def disconnect(public_url: str,
335 pyngrok_config: Optional[PyngrokConfig] = None) -> None:
336 """
337 Disconnect the ``ngrok`` tunnel for the given URL, if open.
339 :param public_url: The public URL of the tunnel to disconnect.
340 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
341 overriding :func:`~pyngrok.conf.get_default()`.
342 """
343 if pyngrok_config is None:
344 pyngrok_config = conf.get_default()
346 # If ngrok is not running, there are no tunnels to disconnect
347 if not process.is_process_running(pyngrok_config.ngrok_path):
348 return
350 api_url = get_ngrok_process(pyngrok_config).api_url
352 if public_url not in _current_tunnels:
353 get_tunnels(pyngrok_config)
355 # One more check, if the given URL is still not in the list of tunnels, it is not active
356 if public_url not in _current_tunnels:
357 return
359 tunnel = _current_tunnels[public_url]
361 logger.info("Disconnecting tunnel: {}".format(tunnel.public_url))
363 api_request("{}{}".format(api_url, tunnel.uri), method="DELETE",
364 timeout=pyngrok_config.request_timeout)
366 _current_tunnels.pop(public_url, None)
369def get_tunnels(pyngrok_config: Optional[PyngrokConfig] = None) -> List[NgrokTunnel]:
370 """
371 Get a list of active ``ngrok`` tunnels for the given config's ``ngrok_path``.
373 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method
374 will first download and install ``ngrok``.
376 If ``ngrok`` is not running, calling this method will first start a process with
377 :class:`~pyngrok.conf.PyngrokConfig`.
379 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
380 overriding :func:`~pyngrok.conf.get_default()`.
381 :return: The active ``ngrok`` tunnels.
382 """
383 if pyngrok_config is None:
384 pyngrok_config = conf.get_default()
386 api_url = get_ngrok_process(pyngrok_config).api_url
388 _current_tunnels.clear()
389 for tunnel in api_request("{}/api/tunnels".format(api_url), method="GET",
390 timeout=pyngrok_config.request_timeout)["tunnels"]:
391 ngrok_tunnel = NgrokTunnel(tunnel, pyngrok_config, api_url)
392 _apply_cloud_edge_to_tunnel(ngrok_tunnel, pyngrok_config)
394 if ngrok_tunnel.public_url is None:
395 raise PyngrokError(
396 "\"public_url\" was not populated for tunnel {}, but is required for pyngrok to function.".format(
397 ngrok_tunnel))
399 _current_tunnels[ngrok_tunnel.public_url] = ngrok_tunnel
401 return list(_current_tunnels.values())
404def kill(pyngrok_config: Optional[PyngrokConfig] = None) -> None:
405 """
406 Terminate the ``ngrok`` processes, if running, for the given config's ``ngrok_path``. This method will not
407 block, it will just issue a kill request.
409 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
410 overriding :func:`~pyngrok.conf.get_default()`.
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: Optional[PyngrokConfig] = None) -> Tuple[str, str]:
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 :return: A tuple of ``(ngrok_version, pyngrok_version)``.
427 """
428 if pyngrok_config is None:
429 pyngrok_config = conf.get_default()
431 ngrok_version = process.capture_run_process(pyngrok_config.ngrok_path, ["--version"]).split("version ")[1]
433 return ngrok_version, __version__
436def update(pyngrok_config: Optional[PyngrokConfig] = None) -> str:
437 """
438 Update ``ngrok`` for the given config's ``ngrok_path``, if an update is available.
440 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
441 overriding :func:`~pyngrok.conf.get_default()`.
442 :return: The result from the ``ngrok`` update.
443 """
444 if pyngrok_config is None:
445 pyngrok_config = conf.get_default()
447 return process.capture_run_process(pyngrok_config.ngrok_path, ["update"])
450def api_request(url: str,
451 method: str = "GET",
452 data: Optional[Dict[str, Any]] = None,
453 params: Optional[Dict[str, Any]] = None,
454 timeout: float = 4,
455 auth: Optional[str] = None) -> Dict[str, Any]:
456 """
457 Invoke an API request to the given URL, returning JSON data from the response.
459 One use for this method is making requests to ``ngrok`` tunnels:
461 .. code-block:: python
463 from pyngrok import ngrok
465 public_url = ngrok.connect()
466 response = ngrok.api_request("{}/some-route".format(public_url),
467 method="POST", data={"foo": "bar"})
469 Another is making requests to the ``ngrok`` API itself:
471 .. code-block:: python
473 from pyngrok import ngrok
475 api_url = ngrok.get_ngrok_process().api_url
476 response = ngrok.api_request("{}/api/requests/http".format(api_url),
477 params={"tunnel_name": "foo"})
479 :param url: The request URL.
480 :param method: The HTTP method.
481 :param data: The request body.
482 :param params: The URL parameters.
483 :param timeout: The request timeout, in seconds.
484 :param auth: Set as Bearer for an Authorization header.
485 :return: The response from the request.
486 """
487 if params is None:
488 params = {}
490 if not url.lower().startswith("http"):
491 raise PyngrokSecurityError("URL must start with \"http\": {}".format(url))
493 encoded_data = json.dumps(data).encode("utf-8") if data else None
495 if params:
496 url += "?{}".format(urlencode([(x, params[x]) for x in params]))
498 request = Request(url, method=method.upper())
499 request.add_header("Content-Type", "application/json")
500 if auth:
501 request.add_header("Ngrok-Version", "2")
502 request.add_header("Authorization", "Bearer {}".format(auth))
504 logger.debug("Making {} request to {} with data: {}".format(method, url, data))
506 try:
507 response = urlopen(request, encoded_data, timeout)
508 response_data = response.read().decode("utf-8")
510 status_code = response.getcode()
511 logger.debug("Response {}: {}".format(status_code, response_data.strip()))
513 if str(status_code)[0] != "2":
514 raise PyngrokNgrokHTTPError("ngrok client API returned {}: {}".format(status_code, response_data), url,
515 status_code, None, request.headers, response_data)
516 elif status_code == HTTPStatus.NO_CONTENT:
517 return {}
519 return json.loads(response_data)
520 except socket.timeout:
521 raise PyngrokNgrokURLError("ngrok client exception, URLError: timed out", "timed out")
522 except HTTPError as e:
523 response_data = e.read().decode("utf-8")
525 status_code = e.getcode()
526 logger.debug("Response {}: {}".format(status_code, response_data.strip()))
528 raise PyngrokNgrokHTTPError("ngrok client exception, API returned {}: {}".format(status_code, response_data),
529 e.url,
530 status_code, e.reason, e.headers, response_data)
531 except URLError as e:
532 raise PyngrokNgrokURLError("ngrok client exception, URLError: {}".format(e.reason), e.reason)
535def run(args: Optional[List[str]] = None,
536 pyngrok_config: Optional[PyngrokConfig] = None) -> None:
537 """
538 Ensure ``ngrok`` is installed at the default path, then call :func:`~pyngrok.process.run_process`.
540 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily
541 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like
542 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.
544 :param args: Arguments to be passed to the ``ngrok`` process.
545 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,
546 overriding :func:`~pyngrok.conf.get_default()`.
547 """
548 if args is None:
549 args = []
550 if pyngrok_config is None:
551 pyngrok_config = conf.get_default()
553 install_ngrok(pyngrok_config)
555 process.run_process(pyngrok_config.ngrok_path, args)
558def main() -> None:
559 """
560 Entry point for the package's ``console_scripts``. This initializes a call from the command
561 line and invokes :func:`~pyngrok.ngrok.run`.
563 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily
564 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like
565 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.
566 """
567 run(sys.argv[1:])
569 if len(sys.argv) == 1 or len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") == "help":
570 print("\nPYNGROK VERSION:\n {}".format(__version__))
571 elif len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") in ["v", "version"]:
572 print("pyngrok version {}".format(__version__))
575if __name__ == "__main__":
576 main()