Coverage for pyngrok/ngrok.py: 87.91%

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 from pyngrok.installer import get_default_config

15

16 __author__ = "Alex Laird"

17 __copyright__ = "Copyright 2023, Alex Laird"

18 __version__ = "6.1.2"

19

20 logger = logging.getLogger(__name__)

21

22 _current_tunnels = {}

23

24

25 class NgrokTunnel:

26 """

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

28

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

48

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

50 self.data = data

51

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

59

60 self.pyngrok_config = pyngrok_config

61 self.api_url = api_url

62

63 def __repr__(self):

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

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

66

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

70

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

76

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

78 timeout=self.pyngrok_config.request_timeout)

79

80 if "metrics" not in data:

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

82

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

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

85

86

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

91

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

98

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

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

101

102 config_path = conf.get_config_path(pyngrok_config)

103

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)

107

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)

112

113

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

118

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

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

121

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

130

131 install_ngrok(pyngrok_config)

132

133 process.set_auth_token(pyngrok_config, token)

134

135

136 def get_ngrok_process(pyngrok_config=None):

137 """

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

139

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

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

142

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

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

145

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

147 installing and starting it.

148

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

157

158 install_ngrok(pyngrok_config)

159

160 return process.get_process(pyngrok_config)

161

162

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

170

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

180

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

182 auth=pyngrok_config.api_key)

183

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

188

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

190 tunnel.proto = edges_prefix

191

192

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

197

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.

203

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

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

206

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

209

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

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

212

213 .. note::

214

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.

218

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

239

240 if pyngrok_config is None:

241 pyngrok_config = conf.get_default()

242

243 config_path = conf.get_config_path(pyngrok_config)

244

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)

249

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"

254

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]

258

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

261

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

267

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

271

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"

276

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

282

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

284

285 config = {

286 "name": name,

287 "addr": addr

288 }

289 options.update(config)

290

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

292 if "labels" not in options:

293 options["proto"] = proto

294

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

304

305 options.pop("bind_tls")

306

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]

313

314 options.pop("auth")

315

316 api_url = get_ngrok_process(pyngrok_config).api_url

317

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

319

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)

323

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)

328

329 _apply_cloud_edge_to_tunnel(tunnel, pyngrok_config)

330

331 _current_tunnels[tunnel.public_url] = tunnel

332

333 return tunnel

334

335

336 def disconnect(public_url, pyngrok_config=None):

337 """

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

339

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

348

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

352

353 api_url = get_ngrok_process(pyngrok_config).api_url

354

355 if public_url not in _current_tunnels:

356 get_tunnels(pyngrok_config)

357

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

361

362 tunnel = _current_tunnels[public_url]

363

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

365

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

367 timeout=pyngrok_config.request_timeout)

368

369 _current_tunnels.pop(public_url, None)

370

371

372 def get_tunnels(pyngrok_config=None):

373 """

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

375

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

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

378

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

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

381

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

390

391 api_url = get_ngrok_process(pyngrok_config).api_url

392

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

399

400 return list(_current_tunnels.values())

401

402

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

407

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

414

415 process.kill_process(pyngrok_config.ngrok_path)

416

417 _current_tunnels.clear()

418

419

420 def get_version(pyngrok_config=None):

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

432

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

434

435 return ngrok_version, __version__

436

437

438 def update(pyngrok_config=None):

439 """

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

441

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

450

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

452

453

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

457

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

459

460 .. code-block:: python

461

462 from pyngrok import ngrok

463

464 public_url = ngrok.connect()

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

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

467

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

469

470 .. code-block:: python

471

472 from pyngrok import ngrok

473

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

477

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

495

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

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

498

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

500

501 if params:

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

503

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

509

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

511

512 try:

513 response = urlopen(request, encoded_data, timeout)

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

515

516 status_code = response.getcode()

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

518

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

524

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

530

531 status_code = e.getcode()

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

533

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)

539

540

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

542 """

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

544

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

548

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

559

560 install_ngrok(pyngrok_config)

561

562 process.run_process(pyngrok_config.ngrok_path, args)

563

564

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

569

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

575

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

580

581

582 if __name__ == "__main__":

583 main()