Hide keyboard shortcuts

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

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

17__version__ = "5.0.2" 

18 

19logger = logging.getLogger(__name__) 

20 

21_current_tunnels = {} 

22 

23 

24class 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 

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

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

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

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

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

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

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

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

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

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

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

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

509if __name__ == "__main__": 

510 main()