diff --git a/renumics/spotlight/cli.py b/renumics/spotlight/cli.py index 340ba4be..ec117e9f 100644 --- a/renumics/spotlight/cli.py +++ b/renumics/spotlight/cli.py @@ -109,6 +109,21 @@ def cli_dtype_callback( multiple=True, help="Columns to embed (if no --embed-all).", ) +@click.option( + "--ssl-keyfile", + type=click.Path(exists=True, dir_okay=False), + default=None, + help="SSL key file", +) +@click.option( + "--ssl-certfile", + type=click.Path(exists=True, dir_okay=False), + default=None, + help="SSL certificate file", +) +@click.option( + "--ssl-keyfile-password", type=str, default=None, help="SSL keyfile password" +) @click.option("-v", "--verbose", is_flag=True) @click.version_option(spotlight.__version__) def main( @@ -124,6 +139,9 @@ def main( analyze_all: bool, embed: Tuple[str], embed_all: bool, + ssl_keyfile: Optional[str], + ssl_certfile: Optional[str], + ssl_keyfile_password: Optional[str], verbose: bool, ) -> None: """ @@ -150,4 +168,7 @@ def main( wait="forever", analyze=True if analyze_all else list(analyze), embed=True if embed_all else list(embed), + ssl_keyfile=ssl_keyfile, + ssl_certfile=ssl_certfile, + ssl_keyfile_password=ssl_keyfile_password, ) diff --git a/renumics/spotlight/server.py b/renumics/spotlight/server.py index 33a52629..dd7f9e95 100644 --- a/renumics/spotlight/server.py +++ b/renumics/spotlight/server.py @@ -24,6 +24,14 @@ from renumics.spotlight.settings import settings +class MissingTLSCertificate(Exception): + pass + + +class MissingTLSKey(Exception): + pass + + class Server: """ Local proxy object for the spotlight server process @@ -32,6 +40,9 @@ class Server: _host: str _port: int _requested_port: int + _ssl_keyfile: Optional[str] + _ssl_certfile: Optional[str] + _ssl_keyfile_password: Optional[str] _vite: Optional[Vite] @@ -54,15 +65,41 @@ class Server: _all_frontends_disconnected: threading.Event _any_frontend_connected: threading.Event - def __init__(self, host: str = "127.0.0.1", port: int = 8000) -> None: + def __init__( + self, + host: str = "127.0.0.1", + port: int = 8000, + ssl_keyfile: Optional[str] = None, + ssl_certfile: Optional[str] = None, + ssl_keyfile_password: Optional[str] = None, + ) -> None: + self.process = None + self._vite = None self._app_config = AppConfig() self._host = host + if self._host not in ("127.0.0.1", "localhost"): + if ssl_certfile is None: + raise MissingTLSCertificate( + "Starting Spotlight on non-localhost without TLS certificate is insecure. Please provide TLS certificate and key." + ) + if ssl_keyfile is None: + raise MissingTLSKey( + "Starting Spotlight on non-localhost without TLS certificate is insecure. Please provide TLS certificate and key." + ) + elif ssl_certfile is None and ssl_keyfile is not None: + raise MissingTLSCertificate( + "TLS key provided, but TLS certificate is missing." + ) + elif ssl_certfile is not None and ssl_keyfile is None: + raise MissingTLSKey("TLS certificate provided, but TLS key is missing.") + self._ssl_keyfile = ssl_keyfile + self._ssl_certfile = ssl_certfile + self._ssl_keyfile_password = ssl_keyfile_password self._requested_port = port self._port = self._requested_port - self.process = None self.connected_frontends = 0 self._any_frontend_connected = threading.Event() @@ -145,7 +182,12 @@ def start(self, config: AppConfig) -> None: sock.close() else: command += ["--fd", str(sock.fileno())] - + if self._ssl_keyfile is not None: + command += ["--ssl-keyfile", self._ssl_keyfile] + if self._ssl_certfile is not None: + command += ["--ssl-certfile", self._ssl_certfile] + if self._ssl_keyfile_password is not None: + command += ["--ssl-keyfile-password", self._ssl_keyfile_password] if settings.dev: command.append("--reload") diff --git a/renumics/spotlight/viewer.py b/renumics/spotlight/viewer.py index 34ecb943..49d894ae 100644 --- a/renumics/spotlight/viewer.py +++ b/renumics/spotlight/viewer.py @@ -129,6 +129,9 @@ class Viewer: _host: str _requested_port: Union[int, Literal["auto"]] + _ssl_keyfile: Optional[str] + _ssl_certfile: Optional[str] + _ssl_keyfile_password: Optional[str] _server: Optional[Server] _df: Optional[pd.DataFrame] @@ -136,9 +139,15 @@ def __init__( self, host: str = "127.0.0.1", port: Union[int, Literal["auto"]] = "auto", + ssl_keyfile: Optional[str] = None, + ssl_certfile: Optional[str] = None, + ssl_keyfile_password: Optional[str] = None, ) -> None: self._host = host self._requested_port = port + self._ssl_keyfile = ssl_keyfile + self._ssl_certfile = ssl_certfile + self._ssl_keyfile_password = ssl_keyfile_password self._server = None self._df = None @@ -218,7 +227,13 @@ def show( if not self._server: port = 0 if self._requested_port == "auto" else self._requested_port - self._server = Server(host=self._host, port=port) + self._server = Server( + self._host, + port, + self._ssl_keyfile, + self._ssl_certfile, + self._ssl_keyfile_password, + ) self._server.start(config) if self not in _VIEWERS: @@ -273,7 +288,11 @@ def open_browser(self) -> None: """ if not self.port: return - launch_browser_in_thread("localhost", self.port) + if self._ssl_certfile is not None: + protocol = "https" + else: + protocol = "http" + launch_browser_in_thread(f"{protocol}://{self.host}:{self.port}/") def refresh(self) -> None: """ @@ -319,7 +338,11 @@ def url(self) -> str: """ The viewer's url. """ - return f"http://{self.host}:{self.port}/" + if self._ssl_certfile is not None: + protocol = "https" + else: + protocol = "http" + return f"{protocol}://{self.host}:{self.port}/" def __repr__(self) -> str: return self.url @@ -380,6 +403,9 @@ def show( analyze: Optional[Union[bool, List[str]]] = None, issues: Optional[Collection[DataIssue]] = None, embed: Optional[Union[List[str], bool]] = None, + ssl_keyfile: Optional[str] = None, + ssl_certfile: Optional[str] = None, + ssl_keyfile_password: Optional[str] = None, ) -> Viewer: """ Start a new Spotlight viewer. @@ -406,6 +432,9 @@ def show( issues: Custom dataset issues displayed in the viewer. embed: Automatically embed all or given columns with default embedders (disabled by default). + ssl_keyfile: Optional SSL key file. + ssl_certfile: Optional SSL certificate file. + ssl_certfile: Optional SSL keyfile password. """ viewer = None @@ -416,7 +445,7 @@ def show( viewer = _VIEWERS[index] break if not viewer: - viewer = Viewer(host, port) + viewer = Viewer(host, port, ssl_keyfile, ssl_certfile, ssl_keyfile_password) viewer.show( dataset, diff --git a/renumics/spotlight/webbrowser.py b/renumics/spotlight/webbrowser.py index cb946c68..bfe90b92 100644 --- a/renumics/spotlight/webbrowser.py +++ b/renumics/spotlight/webbrowser.py @@ -2,8 +2,6 @@ Launch browser. """ -import os -import sys import threading import time import webbrowser @@ -11,44 +9,30 @@ import requests from loguru import logger -from renumics.spotlight.environ import set_temp_environ - def wait_for(url: str) -> None: """Wait until the service at url is reachable.""" while True: try: - requests.head(url, timeout=10) + requests.head(url, timeout=10, verify=False) break except requests.exceptions.ConnectionError: time.sleep(0.5) -def launch_browser_in_thread(host: str, port: int) -> threading.Thread: +def launch_browser_in_thread(url: str) -> threading.Thread: """Open the app in a browser in background once it runs.""" - thread = threading.Thread(target=launch_browser, args=(host, port)) + thread = threading.Thread(target=launch_browser, args=(url,)) thread.start() return thread -def launch_browser(host: str, port: int) -> None: +def launch_browser(url: str) -> None: """Open the app in a browser once it runs.""" - app_url = f"http://{host}:{port}/" - wait_for(app_url) # wait also for socket? - - # If we want to launch firefox with the webbrowser module, - # we need to restore LD_LIBRARY_PATH if running through pyinstaller. - # The original LD_LIBRARY_PATH is stored as LD_LIBRARY_PATH_ORIG. - # See https://github.com/pyinstaller/pyinstaller/issues/6334 + wait_for(url) # wait also for socket? try: - if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): - with set_temp_environ( - LD_LIBRARY_PATH=os.environ.get("LD_LIBRARY_PATH_ORIG") - ): - webbrowser.open(app_url) - else: - webbrowser.open(app_url) + webbrowser.open(url) except Exception: logger.warning( - f"Couldn't launch browser automatically, you can reach Spotlight at {app_url}." + f"Couldn't launch browser automatically, you can reach Spotlight at {url}." )