Coverage for pyngrok/ngrok.py: 87.78%

180 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-28 22:19 +0000

1import json 

2import logging 

3import os 

4import socket 

5import sys 

6import uuid 

7from http import HTTPStatus 

8from urllib.error import HTTPError, URLError 

9from urllib.parse import urlencode 

10from urllib.request import urlopen, Request 

11 

12from pyngrok import process, conf, installer 

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

14 

15__author__ = "Alex Laird" 

16__copyright__ = "Copyright 2022, Alex Laird" 

17__version__ = "5.2.0" 

18 

19from pyngrok.installer import get_default_config 

20 

21logger = logging.getLogger(__name__) 

22 

23_current_tunnels = {} 

24 

25 

26class 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#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 

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

118def 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 

140def 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 

167def 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#tunnel-definitions>`_ 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 v2. To install v3 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#http-file-urls>`_, defaults to "80". 

194 :type addr: str, optional 

195 :param proto: A valid `tunnel protocol <https://ngrok.com/docs#tunnel-definitions>`_, 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#tunnel-definitions>`_ 

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#tunnel-definitions>`_. 

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" and "bind_tls" in options: 

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

259 options["schemes"] = ["https"] 

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

261 options["schemes"] = ["http"] 

262 else: 

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

264 

265 options.pop("bind_tls") 

266 

267 api_url = get_ngrok_process(pyngrok_config).api_url 

268 

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

270 

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

272 timeout=pyngrok_config.request_timeout), 

273 pyngrok_config, api_url) 

274 

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

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

277 timeout=pyngrok_config.request_timeout), 

278 pyngrok_config, api_url) 

279 

280 _current_tunnels[tunnel.public_url] = tunnel 

281 

282 return tunnel 

283 

284 

285def disconnect(public_url, pyngrok_config=None): 

286 """ 

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

288 

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

290 :type public_url: str 

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

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

293 :type pyngrok_config: PyngrokConfig, optional 

294 """ 

295 if pyngrok_config is None: 

296 pyngrok_config = conf.get_default() 

297 

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

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

300 return 

301 

302 api_url = get_ngrok_process(pyngrok_config).api_url 

303 

304 if public_url not in _current_tunnels: 

305 get_tunnels(pyngrok_config) 

306 

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

308 if public_url not in _current_tunnels: 

309 return 

310 

311 tunnel = _current_tunnels[public_url] 

312 

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

314 

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

316 timeout=pyngrok_config.request_timeout) 

317 

318 _current_tunnels.pop(public_url, None) 

319 

320 

321def get_tunnels(pyngrok_config=None): 

322 """ 

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

324 

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

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

327 

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

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

330 

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

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

333 :type pyngrok_config: PyngrokConfig, optional 

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

335 :rtype: list[NgrokTunnel] 

336 """ 

337 if pyngrok_config is None: 

338 pyngrok_config = conf.get_default() 

339 

340 api_url = get_ngrok_process(pyngrok_config).api_url 

341 

342 _current_tunnels.clear() 

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

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

345 ngrok_tunnel = NgrokTunnel(tunnel, pyngrok_config, api_url) 

346 _current_tunnels[ngrok_tunnel.public_url] = ngrok_tunnel 

347 

348 return list(_current_tunnels.values()) 

349 

350 

351def kill(pyngrok_config=None): 

352 """ 

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

354 block, it will just issue a kill request. 

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

360 if pyngrok_config is None: 

361 pyngrok_config = conf.get_default() 

362 

363 process.kill_process(pyngrok_config.ngrok_path) 

364 

365 _current_tunnels.clear() 

366 

367 

368def get_version(pyngrok_config=None): 

369 """ 

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

371 

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

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

374 :type pyngrok_config: PyngrokConfig, optional 

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

376 :rtype: tuple 

377 """ 

378 if pyngrok_config is None: 

379 pyngrok_config = conf.get_default() 

380 

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

382 

383 return ngrok_version, __version__ 

384 

385 

386def update(pyngrok_config=None): 

387 """ 

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

389 

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

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

392 :type pyngrok_config: PyngrokConfig, optional 

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

394 :rtype: str 

395 """ 

396 if pyngrok_config is None: 

397 pyngrok_config = conf.get_default() 

398 

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

400 

401 

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

403 """ 

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

405 

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

407 

408 .. code-block:: python 

409 

410 from pyngrok import ngrok 

411 

412 public_url = ngrok.connect() 

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

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

415 

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

417 

418 .. code-block:: python 

419 

420 from pyngrok import ngrok 

421 

422 api_url = ngrok.get_ngrok_process().api_url 

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

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

425 

426 :param url: The request URL. 

427 :type url: str 

428 :param method: The HTTP method. 

429 :type method: str, optional 

430 :param data: The request body. 

431 :type data: dict, optional 

432 :param params: The URL parameters. 

433 :type params: dict, optional 

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

435 :type timeout: float, optional 

436 :return: The response from the request. 

437 :rtype: dict 

438 """ 

439 if params is None: 

440 params = [] 

441 

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

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

444 

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

446 

447 if params: 

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

449 

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

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

452 

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

454 

455 try: 

456 response = urlopen(request, data, timeout) 

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

458 

459 status_code = response.getcode() 

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

461 

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

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

464 status_code, None, request.headers, response_data) 

465 elif status_code == HTTPStatus.NO_CONTENT: 

466 return None 

467 

468 return json.loads(response_data) 

469 except socket.timeout: 

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

471 except HTTPError as e: 

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

473 

474 status_code = e.getcode() 

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

476 

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

478 e.url, 

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

480 except URLError as e: 

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

482 

483 

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

485 """ 

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

487 

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

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

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

491 

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

493 :type args: list[str], optional 

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

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

496 :type pyngrok_config: PyngrokConfig, optional 

497 """ 

498 if args is None: 

499 args = [] 

500 if pyngrok_config is None: 

501 pyngrok_config = conf.get_default() 

502 

503 install_ngrok(pyngrok_config) 

504 

505 process.run_process(pyngrok_config.ngrok_path, args) 

506 

507 

508def main(): 

509 """ 

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

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

512 

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

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

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

516 """ 

517 run(sys.argv[1:]) 

518 

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

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

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

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

523 

524 

525if __name__ == "__main__": 

526 main()