Skip to content
Draft
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
28 changes: 28 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,34 @@ The `API Reference <https://trame.readthedocs.io/en/latest/index.html>`_ documen
* **TRAME_SERVER** : If set to true, this will prevent browser from opening by default


**WebSocket authentication**

Clients must provide a secret to connect to the WebSocket. The secret
comes from (in order of precedence):

* ``--authKeyFile PATH`` : File whose (stripped) content is used as the secret.
* ``--authKey KEY`` : Secret provided directly on the command line.
* Otherwise, a random secret is generated every time the server starts.

The browser client reads the secret from the ``?secret=...`` URL parameter,
so the URLs printed at startup, the browser opened automatically and the
desktop window (``--app``) already include it::

App running at:
- Local: http://localhost:8080/?secret=qcNXdzVSWzM9kiVkgSqh9Nk1lPiX0fBDTDaFtmCkx_M
- Network: http://127.0.0.1:8080/?secret=qcNXdzVSWzM9kiVkgSqh9Nk1lPiX0fBDTDaFtmCkx_M

Use ``--hide-secret`` to keep the secret out of the console output (and
therefore out of any captured logs). With a generated secret this means
the printed URLs cannot be used to connect, so combine it with
``--authKey`` / ``--authKeyFile`` or with a launcher that delivers the
secret to the client.

When building a client URL yourself (iframe, testing, ...), use
``server.url_query_params`` once the server is started to get the
parameters to append (e.g. ``{"secret": "..."}``).


**Life cycle callbacks**

Life cycle events are directly managed on the application controller
Expand Down
35 changes: 35 additions & 0 deletions docs/source/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,38 @@ The ``name`` parameter can be used to specify the server name. For example:
>>> server = Server(name="my_server")
>>> server.name
'my_server'

WebSocket authentication
------------------------

Clients must provide a secret to connect to the WebSocket. The secret
comes from (in order of precedence):

* ``--authKeyFile PATH``: file whose (stripped) content is used as the secret.
* ``--authKey KEY``: secret provided directly on the command line.
* Otherwise, a random secret is generated every time the server starts.

The browser client reads the secret from the ``?secret=...`` URL parameter.
The URLs printed at startup, the browser opened automatically and the
desktop window (``--app``) already include it:

.. code-block:: console

$ python app.py --server
App running at:
- Local: http://localhost:8080/?secret=qcNXdzVSWzM9kiVkgSqh9Nk1lPiX0fBDTDaFtmCkx_M
- Network: http://127.0.0.1:8080/?secret=qcNXdzVSWzM9kiVkgSqh9Nk1lPiX0fBDTDaFtmCkx_M

To keep the secret out of the console output (and therefore out of any
captured logs), add ``--hide-secret``. With a generated secret, the printed
URLs then cannot be used to connect, so combine it with ``--authKey`` /
``--authKeyFile`` or with a launcher that delivers the secret to the client.

When building a client URL yourself (iframe, testing, ...), use
:attr:`trame_server.Server.url_query_params` once the server is started:

>>> from urllib.parse import urlencode
>>> url = f"http://localhost:{server.port}/?{urlencode(server.url_query_params)}"

Python clients (``trame_server.client.Client``) take the secret as an
argument of ``connect(url, secret=...)``.
4 changes: 2 additions & 2 deletions examples/client-server/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Example on how to test this example

```bash
python ./server.py --port 1234 --server &
python ./client.py --port 1235 --url ws://localhost:1234/ws --server
python ./server.py --port 1234 --server --authKey my-secret &
python ./client.py --port 1235 --url ws://localhost:1234/ws --secret my-secret --server
```
7 changes: 4 additions & 3 deletions examples/client-server/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
server = get_server(client_type="vue2")
state, ctrl = server.state, server.controller
server.cli.add_argument("--url")
server.cli.add_argument("--secret", help="--authKey used by the remote server")

client = Client()

Expand Down Expand Up @@ -74,9 +75,9 @@ def on_attachment(attch_data, **_):

@ctrl.add("on_server_ready")
def connect_to_server(**_):
url = server.cli.parse_args().url
print(f"Client connect to {url}")
asynchronous.create_task(client.connect(url, secret="wslink-secret"))
args = server.cli.parse_args()
print(f"Client connect to {args.url}")
asynchronous.create_task(client.connect(args.url, secret=args.secret))


with SinglePageLayout(server) as layout:
Expand Down
27 changes: 26 additions & 1 deletion src/trame_server/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,13 @@ def cli(self):
help="Use OS built-in browser",
action="store_true",
)
self._cli_parser.add_argument(
"--hide-secret",
help="""Do not print the WebSocket authentication key
(the '?secret=...' URL parameter) in the console output""",
dest="hide_secret",
action="store_true",
)
self._cli_parser.add_argument(
"--no-http",
help="Do not serve anything over http",
Expand All @@ -412,7 +419,9 @@ def cli(self):
"--authKeyFile",
help="""Path to a File that contains the Authentication key for clients
to connect to the WebSocket.
This takes precedence over '-a, --authKey' from wslink.""",
This takes precedence over '-a, --authKey' from wslink.
When neither is provided, a random key is generated.
The key is reported as a '?secret=...' URL parameter.""",
)
self._cli_parser.add_argument(
"--hot-reload",
Expand Down Expand Up @@ -822,6 +831,22 @@ def port(self) -> int:

return self._running_port

@property
def url_query_params(self) -> dict[str, str]:
"""
URL query parameters a browser client needs to connect, i.e.
``{"secret": "..."}`` holding the WebSocket authentication key.
The key comes from ``--authKeyFile`` or ``--authKey``, otherwise it is
randomly generated at start, so only rely on it once the server is
started (e.g. in ``on_server_ready``).

>>> from urllib.parse import urlencode
>>> url = (
... f"http://localhost:{server.port}/?{urlencode(server.url_query_params)}"
... )
"""
return CoreServer.url_query_params()

@property
def server_options(self):
"""Once started, you can retrieve the server options used"""
Expand Down
36 changes: 33 additions & 3 deletions src/trame_server/protocol.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import inspect
import logging
import os
import secrets
from pathlib import Path
from urllib.parse import urlencode

from trame_common.utils import profiler
from wslink import register as exportRpc
Expand All @@ -15,8 +17,12 @@
log = logging.getLogger(__name__)


# wslink's default value for '--authKey'
DEFAULT_AUTH_KEY = "wslink-secret"


class CoreServer(ServerProtocol):
authentication_token = "wslink-secret"
authentication_token = DEFAULT_AUTH_KEY
server = None

# ---------------------------------------------------------------
Expand All @@ -35,14 +41,38 @@ def bind_server(server):
@staticmethod
def add_arguments(parser):
server.add_arguments(parser)
# Detect when no key is provided so a random one can be generated
# instead of relying on a well known default value.
parser.set_defaults(authKey=None)

@staticmethod
def configure(args):
if args.authKeyFile:
"""
Resolve the WebSocket authentication secret from the parsed CLI args.

Precedence: ``--authKeyFile``, then ``--authKey``, otherwise a random
secret is generated (a new one on every call).
"""
if getattr(args, "authKeyFile", None):
with Path(args.authKeyFile).open("r") as key_file:
CoreServer.authentication_token = key_file.read().strip()
else:
elif getattr(args, "authKey", None):
CoreServer.authentication_token = args.authKey
else:
CoreServer.authentication_token = secrets.token_urlsafe(32)

@staticmethod
def url_query_params():
"""
URL query parameters a browser client needs to authenticate,
i.e. ``{"secret": <authentication token>}``.
"""
return {"secret": CoreServer.authentication_token}

@staticmethod
def url_query():
"""URL query string (including the leading ``?``) for a browser client"""
return f"?{urlencode(CoreServer.url_query_params())}"

@staticmethod
def server_start(
Expand Down
5 changes: 4 additions & 1 deletion src/trame_server/utils/browser.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from trame_server.protocol import CoreServer


def open_browser(server):
args = server.cli.parse_known_args()[0]
local_url = f"http://{args.host}:{server.port}/"
local_url = f"http://{args.host}:{server.port}/{CoreServer.url_query()}"
try:
import webbrowser # noqa: PLC0415

Expand Down
7 changes: 6 additions & 1 deletion src/trame_server/utils/desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import threading
from multiprocessing import Process, Queue

from trame_server.protocol import CoreServer

from .asynchronous import handle_task_result

try:
Expand Down Expand Up @@ -65,6 +67,7 @@ def __init__(
self,
title=None,
port=None,
url_query="",
msg_queue=None,
action_queue=None,
debug=False,
Expand All @@ -75,6 +78,7 @@ def __init__(
Process.__init__(self)
self._title = title
self._port = port
self._url_query = url_query
self._msg_queue = msg_queue
self._action_queue = action_queue
self._menu = menu or []
Expand Down Expand Up @@ -115,7 +119,7 @@ def run_in_thread(self, loop):
def run(self):
self._main_window = webview.create_window(
title=self._title,
url=f"http://localhost:{self._port}/index.html",
url=f"http://localhost:{self._port}/index.html{self._url_query}",
**self._window_args,
)
if hasattr(self._main_window, "events"):
Expand Down Expand Up @@ -159,6 +163,7 @@ async def process_msg():
client_process = BrowserProcess(
title=server.state.trame__title,
port=server.port,
url_query=CoreServer.url_query(),
msg_queue=_msg_queue,
action_queue=_window_action_queue,
debug=server.options.get("desktop_debug"),
Expand Down
14 changes: 11 additions & 3 deletions src/trame_server/utils/server.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import socket
import sys

from trame_server.protocol import CoreServer


def print_informations(server):
options = server.server_options
local_url = f"http://{options.host}:{server.port}/"
hide_secret = getattr(options, "hide_secret", False)
query = "" if hide_secret else CoreServer.url_query()
local_url = f"http://{options.host}:{server.port}/{query}"
print()
print("App running at:")
print(f" - Local: {local_url}")
Expand All @@ -15,9 +19,13 @@ def print_informations(server):
except Exception:
host_name = socket.gethostname()
host_ip = socket.gethostbyname(host_name)
print(f" - Network: http://{host_ip}:{server.port}/")
print(f" - Network: http://{host_ip}:{server.port}/{query}")
except socket.gaierror:
print(f" - Network: http://{options.host}:{server.port}/")
print(f" - Network: http://{options.host}:{server.port}/{query}")

if hide_secret:
print()
print("The '?secret=...' URL parameter is hidden (--hide-secret).")

print()
print(
Expand Down
4 changes: 3 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import pytest_asyncio
from trame.app import asynchronous, get_client, get_server

from trame_server.protocol import CoreServer


@pytest_asyncio.fixture
async def server():
Expand All @@ -23,7 +25,7 @@ async def server():
async def client(server):
url = f"ws://localhost:{server.port}/ws"
client = get_client(url)
asynchronous.create_task(client.connect(secret="wslink-secret"))
asynchronous.create_task(client.connect(secret=CoreServer.authentication_token))

for _ in range(10):
if client.connected == 2:
Expand Down
26 changes: 21 additions & 5 deletions tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from trame.app import asynchronous, get_client, get_server

from trame_server.client import ConnectionStatus, WsLinkSession
from trame_server.protocol import CoreServer
from trame_server.protocol import DEFAULT_AUTH_KEY, CoreServer


@pytest_asyncio.fixture
Expand All @@ -30,7 +30,7 @@ async def server():
@pytest_asyncio.fixture
async def client(server):
client = get_client(f"ws://localhost:{server.port}/ws")
asynchronous.create_task(client.connect(secret="wslink-secret"))
asynchronous.create_task(client.connect(secret=CoreServer.authentication_token))

for _ in range(20):
if client.connected == ConnectionStatus.CONNECTED:
Expand All @@ -57,7 +57,7 @@ async def session(server):
session.pushed_states = []
session.register_subscription("trame.state.topic", session.pushed_states.append)
listen_task = asynchronous.create_task(session.listen())
await (await session.auth(secret="wslink-secret"))
await (await session.auth(secret=CoreServer.authentication_token))
try:
yield session
finally:
Expand Down Expand Up @@ -224,6 +224,18 @@ def test_configure_auth_key(tmp_path):
key_file.write_text("from-file\n")
CoreServer.configure(Namespace(authKeyFile=str(key_file), authKey="unused"))
assert CoreServer.authentication_token == "from-file"
assert CoreServer.url_query_params() == {"secret": "from-file"}
assert CoreServer.url_query() == "?secret=from-file"

# No key provided => random token exposed through the url
CoreServer.configure(Namespace(authKeyFile=None, authKey=None))
token = CoreServer.authentication_token
assert token not in {None, "", DEFAULT_AUTH_KEY}
assert CoreServer.url_query_params() == {"secret": token}
assert CoreServer.url_query() == f"?secret={token}"

CoreServer.configure(Namespace(authKeyFile=None, authKey=None))
assert CoreServer.authentication_token != token
finally:
CoreServer.authentication_token = previous

Expand Down Expand Up @@ -297,7 +309,9 @@ async def test_connected_status_follows_authentication(server):
assert client.connected == ConnectionStatus.DISCONNECTED

statuses = []
task = asynchronous.create_task(client.connect(secret="wslink-secret"))
task = asynchronous.create_task(
client.connect(secret=CoreServer.authentication_token)
)
deadline = asyncio.get_running_loop().time() + 5
while asyncio.get_running_loop().time() < deadline:
status = client.connected
Expand Down Expand Up @@ -346,7 +360,9 @@ async def test_connect_failure_allows_reconnect(server):

# Not stuck in CONNECTING, so a new attempt goes through
task = asynchronous.create_task(
client.connect(f"ws://localhost:{server.port}/ws", secret="wslink-secret")
client.connect(
f"ws://localhost:{server.port}/ws", secret=CoreServer.authentication_token
)
)
for _ in range(50):
if client.connected == ConnectionStatus.CONNECTED:
Expand Down
Loading
Loading