Coverage for pyngrok/ngrok.py: 90.70%

# Coverage for pyngrok/ngrok.py : 90.70%

## 172 statements

Hot-keys on this page

r m x p toggle line displays

j k next/prev highlighted chunk

0 (zero) top of page

1 (one) first highlighted chunk

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 2021, Alex Laird"

17 __version__ = "5.0.4"

18

19 logger = logging.getLogger(__name__)

20

21 _current_tunnels = {}

22

23

24 class NgrokTunnel:

25 """

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

27

28 :var data: The original tunnel data.

29 :vartype data: dict

30 :var name: The name of the tunnel.

31 :vartype name: str

32 :var proto: A valid `tunnel protocol <https://ngrok.com/docs#tunnel-definitions>`_.

33 :vartype proto: str

34 :var uri: The tunnel URI, a relative path that can be used to make requests to the ``ngrok`` web interface.

35 :vartype uri: str

36 :var public_url: The public ``ngrok`` URL.

37 :vartype public_url: str

38 :var config: The config for the tunnel.

39 :vartype config: dict

40 :var metrics: Metrics for `the tunnel <https://ngrok.com/docs#list-tunnels>`_.

41 :vartype metrics: dict

42 :var pyngrok_config: The ``pyngrok`` configuration to use when interacting with the ``ngrok``.

43 :vartype pyngrok_config: PyngrokConfig

44 :var api_url: The API URL for the ``ngrok`` web interface.

45 :vartype api_url: str

46 """

47

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

49 self.data = data

50

51 self.name = data.get("name")

52 self.proto = data.get("proto")

53 self.uri = data.get("uri")

54 self.public_url = data.get("public_url")

55 self.config = data.get("config", {})

56 self.metrics = data.get("metrics", {})

57

58 self.pyngrok_config = pyngrok_config

59 self.api_url = api_url

60

61 def __repr__(self):

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

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

64

65 def __str__(self): # pragma: no cover

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

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

68

69 def refresh_metrics(self):

70 """

71 Get the latest metrics for the tunnel and update the ``metrics`` variable.

72 """

73 logger.info("Refreshing metrics for tunnel: {}".format(self.public_url))

74

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

76 timeout=self.pyngrok_config.request_timeout)

77

78 if "metrics" not in data:

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

80

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

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

83

84

85 def install_ngrok(pyngrok_config=None):

86 """

87 Download, install, and initialize ``ngrok`` for the given config. If ``ngrok`` and its default

88 config is already installed, calling this method will do nothing.

89

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

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

92 :type pyngrok_config: PyngrokConfig, optional

93 """

94 if pyngrok_config is None:

95 pyngrok_config = conf.get_default()

96

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

98 installer.install_ngrok(pyngrok_config.ngrok_path)

99

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

101 if pyngrok_config.config_path is not None:

102 config_path = pyngrok_config.config_path

103 else:

104 config_path = conf.DEFAULT_NGROK_CONFIG_PATH

105

106 # Install the config to the requested path

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

108 installer.install_default_config(config_path)

109

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

111 if conf.DEFAULT_NGROK_CONFIG_PATH != config_path and \

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

113 installer.install_default_config(conf.DEFAULT_NGROK_CONFIG_PATH)

114

115

116 def set_auth_token(token, pyngrok_config=None):

117 """

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

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

120

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

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

123

124 :param token: The auth token to set.

125 :type token: str

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

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

128 :type pyngrok_config: PyngrokConfig, optional

129 """

130 if pyngrok_config is None:

131 pyngrok_config = conf.get_default()

132

133 install_ngrok(pyngrok_config)

134

135 process.set_auth_token(pyngrok_config, token)

136

137

138 def get_ngrok_process(pyngrok_config=None):

139 """

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

141

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

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

144

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

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

147

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

149 installing and starting it.

150

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

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

153 :type pyngrok_config: PyngrokConfig, optional

154 :return: The ``ngrok`` process.

155 :rtype: NgrokProcess

156 """

157 if pyngrok_config is None:

158 pyngrok_config = conf.get_default()

159

160 install_ngrok(pyngrok_config)

161

162 return process.get_process(pyngrok_config)

163

164

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

166 """

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

168 the connected tunnel.

169

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

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

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

173 override properties from the loaded tunnel definition.

174

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

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

177

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

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

180

181 .. note::

182

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

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

185 only a single tunnel is needed, pass ``bind_tls=True``.

186

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

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

189 :type addr: str, optional

190 :param proto: The protocol to tunnel, defaults to "http".

191 :type proto: str, optional

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

193 to be used.

194 :type name: str, optional

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

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

197 :type pyngrok_config: PyngrokConfig, optional

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

199 tunnel <https://ngrok.com/docs#tunnel-definitions>`_.

200 :type options: dict, optional

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

202 :rtype: NgrokTunnel

203 """

204 if pyngrok_config is None:

205 pyngrok_config = conf.get_default()

206

207 if pyngrok_config.config_path is not None:

208 config_path = pyngrok_config.config_path

209 else:

210 config_path = conf.DEFAULT_NGROK_CONFIG_PATH

211

212 if os.path.exists(config_path):

213 config = installer.get_ngrok_config(config_path)

214 else:

215 config = {}

216

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

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

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

220 name = "pyngrok-default"

221

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

223 if name and name in tunnel_definitions:

224 tunnel_definition = tunnel_definitions[name]

225

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

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

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

229 tunnel_definition.update(options)

230 options = tunnel_definition

231

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

233 if not proto:

234 proto = "http"

235

236 if not name:

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

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

239 else:

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

241

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

243

244 config = {

245 "name": name,

246 "addr": addr,

247 "proto": proto

248 }

249 options.update(config)

250

251 api_url = get_ngrok_process(pyngrok_config).api_url

252

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

254

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

256 timeout=pyngrok_config.request_timeout),

257 pyngrok_config, api_url)

258

259 if proto == "http" and options.get("bind_tls", "both") == "both":

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

261 timeout=pyngrok_config.request_timeout),

262 pyngrok_config, api_url)

263

264 _current_tunnels[tunnel.public_url] = tunnel

265

266 return tunnel

267

268

269 def disconnect(public_url, pyngrok_config=None):

270 """

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

272

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

274 :type public_url: str

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

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

277 :type pyngrok_config: PyngrokConfig, optional

278 """

279 if pyngrok_config is None:

280 pyngrok_config = conf.get_default()

281

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

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

284 return

285

286 api_url = get_ngrok_process(pyngrok_config).api_url

287

288 if public_url not in _current_tunnels:

289 get_tunnels(pyngrok_config)

290

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

292 if public_url not in _current_tunnels:

293 return

294

295 tunnel = _current_tunnels[public_url]

296

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

298

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

300 timeout=pyngrok_config.request_timeout)

301

302 _current_tunnels.pop(public_url, None)

303

304

305 def get_tunnels(pyngrok_config=None):

306 """

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

308

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

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

311

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

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

314

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

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

317 :type pyngrok_config: PyngrokConfig, optional

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

319 :rtype: list[NgrokTunnel]

320 """

321 if pyngrok_config is None:

322 pyngrok_config = conf.get_default()

323

324 api_url = get_ngrok_process(pyngrok_config).api_url

325

326 _current_tunnels.clear()

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

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

329 ngrok_tunnel = NgrokTunnel(tunnel, pyngrok_config, api_url)

330 _current_tunnels[ngrok_tunnel.public_url] = ngrok_tunnel

331

332 return list(_current_tunnels.values())

333

334

335 def kill(pyngrok_config=None):

336 """

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

338 block, it will just issue a kill request.

339

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

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

342 :type pyngrok_config: PyngrokConfig, optional

343 """

344 if pyngrok_config is None:

345 pyngrok_config = conf.get_default()

346

347 process.kill_process(pyngrok_config.ngrok_path)

348

349 _current_tunnels.clear()

350

351

352 def get_version(pyngrok_config=None):

353 """

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

355

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

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

358 :type pyngrok_config: PyngrokConfig, optional

359 :return: A tuple of ``(ngrok_version, pyngrok_version)``.

360 :rtype: tuple

361 """

362 if pyngrok_config is None:

363 pyngrok_config = conf.get_default()

364

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

366

367 return ngrok_version, __version__

368

369

370 def update(pyngrok_config=None):

371 """

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

373

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

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

376 :type pyngrok_config: PyngrokConfig, optional

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

378 :rtype: str

379 """

380 if pyngrok_config is None:

381 pyngrok_config = conf.get_default()

382

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

384

385

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

387 """

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

389

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

391

392 .. code-block:: python

393

394 from pyngrok import ngrok

395

396 public_url = ngrok.connect()

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

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

399

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

401

402 .. code-block:: python

403

404 from pyngrok import ngrok

405

406 api_url = ngrok.get_ngrok_process().api_url

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

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

409

410 :param url: The request URL.

411 :type url: str

412 :param method: The HTTP method.

413 :type method: str, optional

414 :param data: The request body.

415 :type data: dict, optional

416 :param params: The URL parameters.

417 :type params: dict, optional

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

419 :type timeout: float, optional

420 :return: The response from the request.

421 :rtype: dict

422 """

423 if params is None:

424 params = []

425

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

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

428

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

430

431 if params:

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

433

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

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

436

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

438

439 try:

440 response = urlopen(request, data, timeout)

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

442

443 status_code = response.getcode()

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

445

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

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

448 status_code, None, request.headers, response_data)

449 elif status_code == HTTPStatus.NO_CONTENT:

450 return None

451

452 return json.loads(response_data)

453 except socket.timeout:

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

455 except HTTPError as e:

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

457

458 status_code = e.getcode()

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

460

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

462 e.url,

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

464 except URLError as e:

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

466

467

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

469 """

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

471

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

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

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

475

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

477 :type args: list[str], optional

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

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

480 :type pyngrok_config: PyngrokConfig, optional

481 """

482 if args is None:

483 args = []

484 if pyngrok_config is None:

485 pyngrok_config = conf.get_default()

486

487 install_ngrok(pyngrok_config)

488

489 process.run_process(pyngrok_config.ngrok_path, args)

490

491

492 def main():

493 """

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

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

496

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

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

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

500 """

501 run(sys.argv[1:])

502

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

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

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

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

507

508

509 if __name__ == "__main__":

510 main()