Coverage for pyngrok/ngrok.py: 87.39%

1 import json

2 import logging

3 import os

4 import socket

5 import sys

6 import uuid

7 from http import HTTPStatus

8 from typing import Optional, Any, Dict, List, Tuple

9 from urllib.error import HTTPError, URLError

10 from urllib.parse import urlencode

11 from urllib.request import urlopen, Request

12

13 from pyngrok import process, conf, installer

14 from pyngrok.conf import PyngrokConfig

15 from pyngrok.exception import PyngrokNgrokHTTPError, PyngrokNgrokURLError, PyngrokSecurityError, PyngrokError

16 from pyngrok.installer import get_default_config

17 from pyngrok.process import NgrokProcess

18

19 __author__ = "Alex Laird"

20 __copyright__ = "Copyright 2023, Alex Laird"

21 __version__ = "7.0.5"

22

23 logger = logging.getLogger(__name__)

24

25

26 class NgrokTunnel:

27 """

28 An object containing information about a ``ngrok`` tunnel.

29 """

30

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

41

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", {})

56

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>"

60

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>"

64

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))

70

71 data = api_request("{}{}".format(self.api_url, self.uri), method="GET",

72 timeout=self.pyngrok_config.request_timeout)

73

74 if "metrics" not in data:

75 raise PyngrokError("The ngrok API did not return \"metrics\" in the response")

76

77 self.data["metrics"] = data["metrics"]

78 self.metrics = self.data["metrics"]

79

80

81 _current_tunnels: Dict[str, NgrokTunnel] = {}

82

83

84 def 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.

88

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()

94

95 if not os.path.exists(pyngrok_config.ngrok_path):

96 installer.install_ngrok(pyngrok_config.ngrok_path, ngrok_version=pyngrok_config.ngrok_version)

97

98 config_path = conf.get_config_path(pyngrok_config)

99

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)

103

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)

108

109

110 def 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.).

115

116 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method

117 will first download and install ``ngrok``.

118

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()

125

126 install_ngrok(pyngrok_config)

127

128 process.set_auth_token(pyngrok_config, token)

129

130

131 def get_ngrok_process(pyngrok_config: Optional[PyngrokConfig] = None) -> NgrokProcess:

132 """

133 Get the current ``ngrok`` process for the given config's ``ngrok_path``.

134

135 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method

136 will first download and install ``ngrok``.

137

138 If ``ngrok`` is not running, calling this method will first start a process with

139 :class:`~pyngrok.conf.PyngrokConfig`.

140

141 Use :func:`~pyngrok.process.is_process_running` to check if a process is running without also implicitly

142 installing and starting it.

143

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()

150

151 install_ngrok(pyngrok_config)

152

153 return process.get_process(pyngrok_config)

154

155

156 def _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"]))

164

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))

174

175 edge_response = api_request("https://api.ngrok.com/edges/{}/{}".format(edges_prefix, edge), method="GET",

176 auth=pyngrok_config.api_key)

177

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))

182

183 tunnel.public_url = "{}://{}".format(edges_prefix, edge_response["hostports"][0])

184 tunnel.proto = edges_prefix

185

186

187# When Python <3.9 support is dropped, addr type can be changed to Optional[str|int]

188 def 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.

196

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.

202

203 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method

204 will first download and install ``ngrok``.

205

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`:

208

209 If ``ngrok`` is not running, calling this method will first start a process with

210 :class:`~pyngrok.conf.PyngrokConfig`.

211

212 .. note::

213

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.

217

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.")

232

233 if pyngrok_config is None:

234 pyngrok_config = conf.get_default()

235

236 config_path = conf.get_config_path(pyngrok_config)

237

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)

242

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"

247

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]

251

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.")

254

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

260

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.")

264

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"

269

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())

275

276 logger.info("Opening tunnel named: {}".format(name))

277

278 config = {

279 "name": name,

280 "addr": addr

281 }

282 options.update(config)

283

284 # Only apply proto when "labels" is not defined

285 if "labels" not in options:

286 options["proto"] = proto

287

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"]

297

298 options.pop("bind_tls")

299

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]

306

307 options.pop("auth")

308

309 api_url = get_ngrok_process(pyngrok_config).api_url

310

311 logger.debug("Creating tunnel with options: {}".format(options))

312

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)

316

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)

321

322 _apply_cloud_edge_to_tunnel(tunnel, pyngrok_config)

323

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))

328

329 _current_tunnels[tunnel.public_url] = tunnel

330

331 return tunnel

332

333

334 def disconnect(public_url: str,

335 pyngrok_config: Optional[PyngrokConfig] = None) -> None:

336 """

337 Disconnect the ``ngrok`` tunnel for the given URL, if open.

338

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()

345

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

349

350 api_url = get_ngrok_process(pyngrok_config).api_url

351

352 if public_url not in _current_tunnels:

353 get_tunnels(pyngrok_config)

354

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

358

359 tunnel = _current_tunnels[public_url]

360

361 logger.info("Disconnecting tunnel: {}".format(tunnel.public_url))

362

363 api_request("{}{}".format(api_url, tunnel.uri), method="DELETE",

364 timeout=pyngrok_config.request_timeout)

365

366 _current_tunnels.pop(public_url, None)

367

368

369 def 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``.

372

373 If ``ngrok`` is not installed at :class:`~pyngrok.conf.PyngrokConfig`'s ``ngrok_path``, calling this method

374 will first download and install ``ngrok``.

375

376 If ``ngrok`` is not running, calling this method will first start a process with

377 :class:`~pyngrok.conf.PyngrokConfig`.

378

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()

385

386 api_url = get_ngrok_process(pyngrok_config).api_url

387

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)

393

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))

398

399 _current_tunnels[ngrok_tunnel.public_url] = ngrok_tunnel

400

401 return list(_current_tunnels.values())

402

403

404 def 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.

408

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()

414

415 process.kill_process(pyngrok_config.ngrok_path)

416

417 _current_tunnels.clear()

418

419

420 def get_version(pyngrok_config: Optional[PyngrokConfig] = None) -> Tuple[str, str]:

421 """

422 Get a tuple with the ``ngrok`` and ``pyngrok`` versions.

423

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()

430

431 ngrok_version = process.capture_run_process(pyngrok_config.ngrok_path, ["--version"]).split("version ")[1]

432

433 return ngrok_version, __version__

434

435

436 def update(pyngrok_config: Optional[PyngrokConfig] = None) -> str:

437 """

438 Update ``ngrok`` for the given config's ``ngrok_path``, if an update is available.

439

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()

446

447 return process.capture_run_process(pyngrok_config.ngrok_path, ["update"])

448

449

450 def 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.

458

459 One use for this method is making requests to ``ngrok`` tunnels:

460

461 .. code-block:: python

462

463 from pyngrok import ngrok

464

465 public_url = ngrok.connect()

466 response = ngrok.api_request("{}/some-route".format(public_url),

467 method="POST", data={"foo": "bar"})

468

469 Another is making requests to the ``ngrok`` API itself:

470

471 .. code-block:: python

472

473 from pyngrok import ngrok

474

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"})

478

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 = {}

489

490 if not url.lower().startswith("http"):

491 raise PyngrokSecurityError("URL must start with \"http\": {}".format(url))

492

493 encoded_data = json.dumps(data).encode("utf-8") if data else None

494

495 if params:

496 url += "?{}".format(urlencode([(x, params[x]) for x in params]))

497

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))

503

504 logger.debug("Making {} request to {} with data: {}".format(method, url, data))

505

506 try:

507 response = urlopen(request, encoded_data, timeout)

508 response_data = response.read().decode("utf-8")

509

510 status_code = response.getcode()

511 logger.debug("Response {}: {}".format(status_code, response_data.strip()))

512

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 {}

518

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")

524

525 status_code = e.getcode()

526 logger.debug("Response {}: {}".format(status_code, response_data.strip()))

527

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)

533

534

535 def 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`.

539

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`.

543

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()

552

553 install_ngrok(pyngrok_config)

554

555 process.run_process(pyngrok_config.ngrok_path, args)

556

557

558 def 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`.

562

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:])

568

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__))

573

574

575 if __name__ == "__main__":

576 main()