Coverage for pyngrok/ngrok.py: 87.21%

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 urllib.error import HTTPError, URLError

9 from urllib.parse import urlencode

10 from urllib.request import urlopen, Request

11

12 from pyngrok import process, conf, installer

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

14

15 __author__ = "Alex Laird"

16 __copyright__ = "Copyright 2023, Alex Laird"

17 __version__ = "6.1.0"

18

19 from pyngrok.installer import get_default_config

20

21 logger = logging.getLogger(__name__)

22

23 _current_tunnels = {}

24

25

26 class NgrokTunnel:

27 """

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

29

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

49

50 def __init__(self, data, pyngrok_config, api_url):

51 self.data = data

52

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

60

61 self.pyngrok_config = pyngrok_config

62 self.api_url = api_url

63

64 def __repr__(self):

65 return "<NgrokTunnel: \"{}\" -> \"{}\">".format(self.public_url, self.config["addr"]) if self.config.get(

66 "addr", None) else "<pending Tunnel>"

67

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

71

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

77

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

79 timeout=self.pyngrok_config.request_timeout)

80

81 if "metrics" not in data:

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

83

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

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

86

87

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

92

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

99

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

101 installer.install_ngrok(pyngrok_config.ngrok_path, pyngrok_config.ngrok_version)

102

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

108

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)

112

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)

117

118

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

123

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

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

126

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

135

136 install_ngrok(pyngrok_config)

137

138 process.set_auth_token(pyngrok_config, token)

139

140

141 def get_ngrok_process(pyngrok_config=None):

142 """

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

144

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

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

147

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

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

150

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

152 installing and starting it.

153

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

162

163 install_ngrok(pyngrok_config)

164

165 return process.get_process(pyngrok_config)

166

167

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

175

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

185

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

187 auth=pyngrok_config.api_key)

188

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

192

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

194 tunnel.proto = edges_prefix

195

196

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

201

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.

207

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

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

210

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

213

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

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

216

217 .. note::

218

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.

222

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

243

244 if pyngrok_config is None:

245 pyngrok_config = conf.get_default()

246

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

251

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)

256

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"

261

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]

265

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

271

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

275

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"

280

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

286

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

288

289 config = {

290 "name": name,

291 "addr": addr

292 }

293 options.update(config)

294

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

296 if "labels" not in options:

297 options["proto"] = proto

298

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

301

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

311

312 options.pop("bind_tls")

313

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]

320

321 options.pop("auth")

322

323 api_url = get_ngrok_process(pyngrok_config).api_url

324

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

326

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)

330

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)

335

336 _apply_cloud_edge_to_tunnel(tunnel, pyngrok_config)

337

338 _current_tunnels[tunnel.public_url] = tunnel

339

340 return tunnel

341

342

343 def disconnect(public_url, pyngrok_config=None):

344 """

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

346

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

355

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

359

360 api_url = get_ngrok_process(pyngrok_config).api_url

361

362 if public_url not in _current_tunnels:

363 get_tunnels(pyngrok_config)

364

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

368

369 tunnel = _current_tunnels[public_url]

370

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

372

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

374 timeout=pyngrok_config.request_timeout)

375

376 _current_tunnels.pop(public_url, None)

377

378

379 def get_tunnels(pyngrok_config=None):

380 """

381 Get a list of active ``ngrok`` tunnels for the given config's ``ngrok_path``.

382

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

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

385

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

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

388

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

397

398 api_url = get_ngrok_process(pyngrok_config).api_url

399

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

406

407 return list(_current_tunnels.values())

408

409

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

414

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

421

422 process.kill_process(pyngrok_config.ngrok_path)

423

424 _current_tunnels.clear()

425

426

427 def get_version(pyngrok_config=None):

428 """

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

430

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

439

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

441

442 return ngrok_version, __version__

443

444

445 def update(pyngrok_config=None):

446 """

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

448

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

457

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

459

460

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

464

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

466

467 .. code-block:: python

468

469 from pyngrok import ngrok

470

471 public_url = ngrok.connect()

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

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

474

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

476

477 .. code-block:: python

478

479 from pyngrok import ngrok

480

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

484

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 = []

502

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

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

505

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

507

508 if params:

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

510

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

516

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

518

519 try:

520 response = urlopen(request, data, timeout)

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

522

523 status_code = response.getcode()

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

525

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

531

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

537

538 status_code = e.getcode()

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

540

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)

546

547

548 def run(args=None, pyngrok_config=None):

549 """

550 Ensure ``ngrok`` is installed at the default path, then call :func:`~pyngrok.process.run_process`.

551

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

555

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

566

567 install_ngrok(pyngrok_config)

568

569 process.run_process(pyngrok_config.ngrok_path, args)

570

571

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

576

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

582

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

587

588

589 if __name__ == "__main__":

590 main()