Coverage for pyngrok/ngrok.py: 87.70%

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.0.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.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, pyngrok_config.ngrok_version)

101

102 # If no config_path is set, ngrok will use its default path

103 if pyngrok_config.config_path is not None:

104 config_path = pyngrok_config.config_path

105 else:

106 config_path = conf.DEFAULT_NGROK_CONFIG_PATH

107

108 # Install the config to the requested path

109 if not os.path.exists(config_path):

110 installer.install_default_config(config_path, ngrok_version=pyngrok_config.ngrok_version)

111

112 # Install the default config, even if we don't need it this time, if it doesn't already exist

113 if conf.DEFAULT_NGROK_CONFIG_PATH != config_path and \

114 not os.path.exists(conf.DEFAULT_NGROK_CONFIG_PATH):

115 installer.install_default_config(conf.DEFAULT_NGROK_CONFIG_PATH, ngrok_version=pyngrok_config.ngrok_version)

116

117

118 def set_auth_token(token, pyngrok_config=None):

119 """

120 Set the ``ngrok`` auth token in the config file, enabling authenticated features (for instance,

121 more concurrent tunnels, custom subdomains, etc.).

122

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

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

125

126 :param token: The auth token to set.

127 :type token: str

128 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,

129 overriding :func:`~pyngrok.conf.get_default()`.

130 :type pyngrok_config: PyngrokConfig, optional

131 """

132 if pyngrok_config is None:

133 pyngrok_config = conf.get_default()

134

135 install_ngrok(pyngrok_config)

136

137 process.set_auth_token(pyngrok_config, token)

138

139

140 def get_ngrok_process(pyngrok_config=None):

141 """

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

143

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

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

146

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

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

149

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

151 installing and starting it.

152

153 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,

154 overriding :func:`~pyngrok.conf.get_default()`.

155 :type pyngrok_config: PyngrokConfig, optional

156 :return: The ``ngrok`` process.

157 :rtype: NgrokProcess

158 """

159 if pyngrok_config is None:

160 pyngrok_config = conf.get_default()

161

162 install_ngrok(pyngrok_config)

163

164 return process.get_process(pyngrok_config)

165

166

167 def connect(addr=None, proto=None, name=None, pyngrok_config=None, **options):

168 """

169 Establish a new ``ngrok`` tunnel for the given protocol to the given port, returning an object representing

170 the connected tunnel.

171

172 If a `tunnel definition in ngrok's config file <https://ngrok.com/docs/ngrok-agent/api#start-tunnel>`_ matches the given

173 ``name``, it will be loaded and used to start the tunnel. When ``name`` is ``None`` and a "pyngrok-default" tunnel

174 definition exists in ``ngrok``'s config, it will be loaded and use. Any ``kwargs`` passed as ``options`` will

175 override properties from the loaded tunnel definition.

176

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

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

179

180 ``pyngrok`` is compatible with ``ngrok`` v2 and v3, but by default it will install v3. To install v2 instead,

181 set ``ngrok_version`` in :class:`~pyngrok.conf.PyngrokConfig`:

182

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

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

185

186 .. note::

187

188 ``ngrok`` v2's default behavior for ``http`` when no additional properties are passed is to open *two* tunnels,

189 one ``http`` and one ``https``. This method will return a reference to the ``http`` tunnel in this case. If

190 only a single tunnel is needed, pass ``bind_tls=True`` and a reference to the ``https`` tunnel will be returned.

191

192 :param addr: The local port to which the tunnel will forward traffic, or a

193 `local directory or network address <https://ngrok.com/docs/secure-tunnels/tunnels/http-tunnels#file-url>`_, defaults to "80".

194 :type addr: str, optional

195 :param proto: A valid `tunnel protocol <https://ngrok.com/docs/ngrok-agent/api#start-tunnel>`_, defaults to "http".

196 :type proto: str, optional

197 :param name: A friendly name for the tunnel, or the name of a `ngrok tunnel definition <https://ngrok.com/docs/ngrok-agent/api#start-tunnel>`_

198 to be used.

199 :type name: str, optional

200 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,

201 overriding :func:`~pyngrok.conf.get_default()`.

202 :type pyngrok_config: PyngrokConfig, optional

203 :param options: Remaining ``kwargs`` are passed as `configuration for the ngrok

204 tunnel <https://ngrok.com/docs/ngrok-agent/api#start-tunnel>`_.

205 :type options: dict, optional

206 :return: The created ``ngrok`` tunnel.

207 :rtype: NgrokTunnel

208 """

209 if pyngrok_config is None:

210 pyngrok_config = conf.get_default()

211

212 if pyngrok_config.config_path is not None:

213 config_path = pyngrok_config.config_path

214 else:

215 config_path = conf.DEFAULT_NGROK_CONFIG_PATH

216

217 if os.path.exists(config_path):

218 config = installer.get_ngrok_config(config_path)

219 else:

220 config = get_default_config(pyngrok_config.ngrok_version)

221

222 # If a "pyngrok-default" tunnel definition exists in the ngrok config, use that

223 tunnel_definitions = config.get("tunnels", {})

224 if not name and "pyngrok-default" in tunnel_definitions:

225 name = "pyngrok-default"

226

227 # Use a tunnel definition for the given name, if it exists

228 if name and name in tunnel_definitions:

229 tunnel_definition = tunnel_definitions[name]

230

231 addr = tunnel_definition.get("addr") if not addr else addr

232 proto = tunnel_definition.get("proto") if not proto else proto

233 # Use the tunnel definition as the base, but override with any passed in options

234 tunnel_definition.update(options)

235 options = tunnel_definition

236

237 addr = str(addr) if addr else "80"

238 if not proto:

239 proto = "http"

240

241 if not name:

242 if not addr.startswith("file://"):

243 name = "{}-{}-{}".format(proto, addr, uuid.uuid4())

244 else:

245 name = "{}-file-{}".format(proto, uuid.uuid4())

246

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

248

249 config = {

250 "name": name,

251 "addr": addr,

252 "proto": proto

253 }

254 options.update(config)

255

256 # Upgrade legacy parameters, if present

257 if pyngrok_config.ngrok_version == "v3":

258 if "bind_tls" in options:

259 if options.get("bind_tls") is True or options.get("bind_tls") == "true":

260 options["schemes"] = ["https"]

261 elif not options.get("bind_tls") is not False or options.get("bind_tls") == "false":

262 options["schemes"] = ["http"]

263 else:

264 options["schemes"] = ["http", "https"]

265

266 options.pop("bind_tls")

267

268 if "auth" in options:

269 auth = options.get("auth")

270 if isinstance(auth, list):

271 options["basic_auth"] = auth

272 else:

273 options["basic_auth"] = [auth]

274

275 options.pop("auth")

276

277 api_url = get_ngrok_process(pyngrok_config).api_url

278

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

280

281 tunnel = NgrokTunnel(api_request("{}/api/tunnels".format(api_url), method="POST", data=options,

282 timeout=pyngrok_config.request_timeout),

283 pyngrok_config, api_url)

284

285 if pyngrok_config.ngrok_version == "v2" and proto == "http" and options.get("bind_tls", "both") == "both":

286 tunnel = NgrokTunnel(api_request("{}{}%20%28http%29".format(api_url, tunnel.uri), method="GET",

287 timeout=pyngrok_config.request_timeout),

288 pyngrok_config, api_url)

289

290 _current_tunnels[tunnel.public_url] = tunnel

291

292 return tunnel

293

294

295 def disconnect(public_url, pyngrok_config=None):

296 """

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

298

299 :param public_url: The public URL of the tunnel to disconnect.

300 :type public_url: str

301 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,

302 overriding :func:`~pyngrok.conf.get_default()`.

303 :type pyngrok_config: PyngrokConfig, optional

304 """

305 if pyngrok_config is None:

306 pyngrok_config = conf.get_default()

307

308 # If ngrok is not running, there are no tunnels to disconnect

309 if not process.is_process_running(pyngrok_config.ngrok_path):

310 return

311

312 api_url = get_ngrok_process(pyngrok_config).api_url

313

314 if public_url not in _current_tunnels:

315 get_tunnels(pyngrok_config)

316

317 # One more check, if the given URL is still not in the list of tunnels, it is not active

318 if public_url not in _current_tunnels:

319 return

320

321 tunnel = _current_tunnels[public_url]

322

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

324

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

326 timeout=pyngrok_config.request_timeout)

327

328 _current_tunnels.pop(public_url, None)

329

330

331 def get_tunnels(pyngrok_config=None):

332 """

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

334

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

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

337

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

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

340

341 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,

342 overriding :func:`~pyngrok.conf.get_default()`.

343 :type pyngrok_config: PyngrokConfig, optional

344 :return: The active ``ngrok`` tunnels.

345 :rtype: list[NgrokTunnel]

346 """

347 if pyngrok_config is None:

348 pyngrok_config = conf.get_default()

349

350 api_url = get_ngrok_process(pyngrok_config).api_url

351

352 _current_tunnels.clear()

353 for tunnel in api_request("{}/api/tunnels".format(api_url), method="GET",

354 timeout=pyngrok_config.request_timeout)["tunnels"]:

355 ngrok_tunnel = NgrokTunnel(tunnel, pyngrok_config, api_url)

356 _current_tunnels[ngrok_tunnel.public_url] = ngrok_tunnel

357

358 return list(_current_tunnels.values())

359

360

361 def kill(pyngrok_config=None):

362 """

363 Terminate the ``ngrok`` processes, if running, for the given config's ``ngrok_path``. This method will not

364 block, it will just issue a kill request.

365

366 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,

367 overriding :func:`~pyngrok.conf.get_default()`.

368 :type pyngrok_config: PyngrokConfig, optional

369 """

370 if pyngrok_config is None:

371 pyngrok_config = conf.get_default()

372

373 process.kill_process(pyngrok_config.ngrok_path)

374

375 _current_tunnels.clear()

376

377

378 def get_version(pyngrok_config=None):

379 """

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

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: A tuple of ``(ngrok_version, pyngrok_version)``.

386 :rtype: tuple

387 """

388 if pyngrok_config is None:

389 pyngrok_config = conf.get_default()

390

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

392

393 return ngrok_version, __version__

394

395

396 def update(pyngrok_config=None):

397 """

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

399

400 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,

401 overriding :func:`~pyngrok.conf.get_default()`.

402 :type pyngrok_config: PyngrokConfig, optional

403 :return: The result from the ``ngrok`` update.

404 :rtype: str

405 """

406 if pyngrok_config is None:

407 pyngrok_config = conf.get_default()

408

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

410

411

412 def api_request(url, method="GET", data=None, params=None, timeout=4):

413 """

414 Invoke an API request to the given URL, returning JSON data from the response.

415

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

417

418 .. code-block:: python

419

420 from pyngrok import ngrok

421

422 public_url = ngrok.connect()

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

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

425

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

427

428 .. code-block:: python

429

430 from pyngrok import ngrok

431

432 api_url = ngrok.get_ngrok_process().api_url

433 response = ngrok.api_request("{}/api/requests/http".format(api_url),

434 params={"tunnel_name": "foo"})

435

436 :param url: The request URL.

437 :type url: str

438 :param method: The HTTP method.

439 :type method: str, optional

440 :param data: The request body.

441 :type data: dict, optional

442 :param params: The URL parameters.

443 :type params: dict, optional

444 :param timeout: The request timeout, in seconds.

445 :type timeout: float, optional

446 :return: The response from the request.

447 :rtype: dict

448 """

449 if params is None:

450 params = []

451

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

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

454

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

456

457 if params:

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

459

460 request = Request(url, method=method.upper())

461 request.add_header("Content-Type", "application/json")

462

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

464

465 try:

466 response = urlopen(request, data, timeout)

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

468

469 status_code = response.getcode()

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

471

472 if str(status_code)[0] != "2":

473 raise PyngrokNgrokHTTPError("ngrok client API returned {}: {}".format(status_code, response_data), url,

474 status_code, None, request.headers, response_data)

475 elif status_code == HTTPStatus.NO_CONTENT:

476 return None

477

478 return json.loads(response_data)

479 except socket.timeout:

480 raise PyngrokNgrokURLError("ngrok client exception, URLError: timed out", "timed out")

481 except HTTPError as e:

482 response_data = e.read().decode("utf-8")

483

484 status_code = e.getcode()

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

486

487 raise PyngrokNgrokHTTPError("ngrok client exception, API returned {}: {}".format(status_code, response_data),

488 e.url,

489 status_code, e.msg, e.hdrs, response_data)

490 except URLError as e:

491 raise PyngrokNgrokURLError("ngrok client exception, URLError: {}".format(e.reason), e.reason)

492

493

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

495 """

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

497

498 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily

499 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like

500 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.

501

502 :param args: Arguments to be passed to the ``ngrok`` process.

503 :type args: list[str], optional

504 :param pyngrok_config: A ``pyngrok`` configuration to use when interacting with the ``ngrok`` binary,

505 overriding :func:`~pyngrok.conf.get_default()`.

506 :type pyngrok_config: PyngrokConfig, optional

507 """

508 if args is None:

509 args = []

510 if pyngrok_config is None:

511 pyngrok_config = conf.get_default()

512

513 install_ngrok(pyngrok_config)

514

515 process.run_process(pyngrok_config.ngrok_path, args)

516

517

518 def main():

519 """

520 Entry point for the package's ``console_scripts``. This initializes a call from the command

521 line and invokes :func:`~pyngrok.ngrok.run`.

522

523 This method is meant for interacting with ``ngrok`` from the command line and is not necessarily

524 compatible with non-blocking API methods. For that, use :mod:`~pyngrok.ngrok`'s interface methods (like

525 :func:`~pyngrok.ngrok.connect`), or use :func:`~pyngrok.process.get_process`.

526 """

527 run(sys.argv[1:])

528

529 if len(sys.argv) == 1 or len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") == "help":

530 print("\nPYNGROK VERSION:\n {}".format(__version__))

531 elif len(sys.argv) == 2 and sys.argv[1].lstrip("-").lstrip("-") in ["v", "version"]:

532 print("pyngrok version {}".format(__version__))

533

534

535 if __name__ == "__main__":

536 main()