Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/start with tls #439

Merged
merged 4 commits into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions renumics/spotlight/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:
"""
Expand All @@ -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,
)
48 changes: 45 additions & 3 deletions renumics/spotlight/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]

Expand All @@ -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()
Expand Down Expand Up @@ -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")

Expand Down
37 changes: 33 additions & 4 deletions renumics/spotlight/viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,16 +129,25 @@ 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]

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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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,
Expand Down
30 changes: 7 additions & 23 deletions renumics/spotlight/webbrowser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,53 +2,37 @@
Launch browser.
"""

import os
import sys
import threading
import time
import webbrowser

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}."
)
Loading