From 13f8207daa4e4815e4dc6f3a2f09b747375d1b0c Mon Sep 17 00:00:00 2001 From: Alexander Druz Date: Mon, 26 Feb 2024 15:54:39 +0100 Subject: [PATCH 1/4] Start Spotlight with TLS --- renumics/spotlight/server.py | 48 ++++++++++++++++++++++++++++++-- renumics/spotlight/viewer.py | 36 +++++++++++++++++++++--- renumics/spotlight/webbrowser.py | 31 ++++++--------------- 3 files changed, 85 insertions(+), 30 deletions(-) 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..a620a6aa 100644 --- a/renumics/spotlight/viewer.py +++ b/renumics/spotlight/viewer.py @@ -129,6 +129,8 @@ class Viewer: _host: str _requested_port: Union[int, Literal["auto"]] + _ssl_keyfile: Optional[str] + _ssl_certfile: Optional[str] _server: Optional[Server] _df: Optional[pd.DataFrame] @@ -136,9 +138,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 +226,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 +287,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 +337,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 +402,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 +431,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 +444,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..863f636e 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,31 @@ 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 + print(url) + 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}." ) From b7f2a5f7e186e100a25c67146d62557b9690eeba Mon Sep 17 00:00:00 2001 From: Alexander Druz Date: Mon, 26 Feb 2024 16:02:20 +0100 Subject: [PATCH 2/4] Remove debug message --- renumics/spotlight/webbrowser.py | 1 - 1 file changed, 1 deletion(-) diff --git a/renumics/spotlight/webbrowser.py b/renumics/spotlight/webbrowser.py index 863f636e..bfe90b92 100644 --- a/renumics/spotlight/webbrowser.py +++ b/renumics/spotlight/webbrowser.py @@ -29,7 +29,6 @@ def launch_browser_in_thread(url: str) -> threading.Thread: def launch_browser(url: str) -> None: """Open the app in a browser once it runs.""" - print(url) wait_for(url) # wait also for socket? try: webbrowser.open(url) From abaaf044534b095443591ca07a48926cce461fff Mon Sep 17 00:00:00 2001 From: Alexander Druz Date: Mon, 26 Feb 2024 16:03:24 +0100 Subject: [PATCH 3/4] Add TLS key and certificate to CLI --- renumics/spotlight/cli.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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, ) From 2cbcaae6eb2f7d7e846396dc5957150867f152fe Mon Sep 17 00:00:00 2001 From: Alexander Druz Date: Mon, 26 Feb 2024 16:07:53 +0100 Subject: [PATCH 4/4] Add missing annotation for SSl key password --- renumics/spotlight/viewer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/renumics/spotlight/viewer.py b/renumics/spotlight/viewer.py index a620a6aa..49d894ae 100644 --- a/renumics/spotlight/viewer.py +++ b/renumics/spotlight/viewer.py @@ -131,6 +131,7 @@ class Viewer: _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]