Coverage for pyngrok/ngrok.py: 87.70%

187 statements  

« prev     ^ index     » next       coverage.py v7.2.3, created at 2023-04-12 13:51 +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 2023, Alex Laird" 

17__version__ = "5.2.3" 

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

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

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

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

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

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

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

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

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

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

535if __name__ == "__main__": 

536 main()