Skip to content
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
2 changes: 1 addition & 1 deletion docs/my_profile_app_manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ To serve the app in a web browser instead of a terminal:
serve_my_profile # textual-serve on $MY_EGERIA_HOST:$MY_PROFILE_PORT (default 0.0.0.0:8020)
```

Behind a reverse proxy, set `MY_PROFILE_PUBLIC_URL` (or `MY_EGERIA_PUBLIC_URL`) to the URL browsers use, e.g. `https://localhost:8843/my-egeria`, so the page's asset and WebSocket URLs are same-origin.
Behind a reverse proxy, set `MY_PROFILE_PUBLIC_URL` (or `MY_EGERIA_PUBLIC_URL`) to where the app is published, e.g. `https://localhost:8843/my-egeria`. Only its path (and default scheme) is used: the host in the page's asset and WebSocket URLs follows the address each browser actually used, so the same setting works from `localhost`, the machine's name or a demo hostname.

## The Splash Screen and Changing User

Expand Down
4 changes: 3 additions & 1 deletion my_egeria/EGERIA_WORKSPACES_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ with pyegeria) to turn the Textual app into a browser app — each WebSocket
connection gets its own isolated app instance. The portal button simply links
to the app's URL (new tab or iframe). No code difference between terminal and
browser modes; the same installed app serves both. Behind a proxy, set
`MY_EGERIA_PUBLIC_URL` (or `MY_PROFILE_PUBLIC_URL`) to the URL browsers use, so
`MY_EGERIA_PUBLIC_URL` (or `MY_PROFILE_PUBLIC_URL`) to where the app is published (e.g.
`https://<site>/my-egeria`). Only its path and default scheme are used: the host follows
each browser's own address, so one setting works whatever hostname users arrive by, and
the page's asset and WebSocket URLs are same-origin.

---
Expand Down
2 changes: 1 addition & 1 deletion my_egeria/HANDOFF_egeria_workspaces_my_egeria.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ file. They must be present in the `my-egeria` service environment:
| `EGERIA_USER_PASSWORD` | `secret` | Password (quickstart) |
| `MY_EGERIA_HOST` | `0.0.0.0` | Listen host for textual-serve |
| `MY_EGERIA_PORT` | `8021` | Listen port for textual-serve |
| `MY_EGERIA_PUBLIC_URL` | `https://<site>/my-egeria` | URL browsers use when behind a proxy, so page and WebSocket URLs are same-origin. `MY_PROFILE_PUBLIC_URL` is the My Profile equivalent; `MY_EGERIA_PUBLIC_URL` is used for either app if the app's own is not set. |
| `MY_EGERIA_PUBLIC_URL` | `https://<site>/my-egeria` | Where the app is published behind a proxy. Only its **path** (and default scheme) is used: the host follows each browser's own address (from `X-Forwarded-Host`/`Host`), so one setting works for localhost, the machine name and a demo hostname. `MY_PROFILE_PUBLIC_URL` is the My Profile equivalent; `MY_EGERIA_PUBLIC_URL` is used for either app if the app's own is not set. |

---

Expand Down
69 changes: 64 additions & 5 deletions my_egeria/my_egeria/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,25 @@
MY_EGERIA_HOST (default: 0.0.0.0) -- shared by all served apps
MY_EGERIA_PORT (default: 8021) -- main MyEgeria app
MY_PROFILE_PORT (default: 8020) -- my_profile demo app
MY_EGERIA_PUBLIC_URL / MY_PROFILE_PUBLIC_URL (optional) -- the URL browsers use,
when it differs from host:port (e.g. behind a reverse proxy);
MY_EGERIA_PUBLIC_URL / MY_PROFILE_PUBLIC_URL (optional) -- where the app is published
behind a reverse proxy, e.g. https://example.com/my-egeria;
MY_EGERIA_PUBLIC_URL applies to either app if the app's own isn't set

The page's asset and WebSocket URLs follow the address each browser actually used, so
the same container works whatever hostname it is reached by (localhost, the machine's
name, a demo site). Behind a proxy (X-Forwarded-Host present) only the *path* and the
default scheme come from the public URL; the host comes from the request.

The apps run with the same Python interpreter as this entry point, so this works
wherever pyegeria is installed (no `textual` CLI from textual-dev needed).
"""
import os
import re
import sys
from urllib.parse import urlsplit

# host[:port] or [ipv6][:port]
_VALID_HOST = re.compile(r"^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)(:\d{1,5})?$")

APPS = {
# name: (module run with `python -m`, port env var, default port, public URL env var)
Expand All @@ -30,11 +40,61 @@ def _command(module: str) -> str:
return f'"{sys.executable}" -m {module}'


def _serve(name: str) -> None:
def public_url_for_request(scheme: str, host: str, headers, configured_public_url: str | None) -> str:
"""The base URL a browser should use for this page's assets and WebSocket.

textual-serve builds every URL in the page from one fixed public_url, so a page
reached by a different hostname than the configured one points the browser at
another origin, which the page's CSP blocks: the page then shows only its title.
Behind a proxy (X-Forwarded-Host), keep the configured path and default scheme
but use the host the browser used; on a direct connection use the request's own
scheme and host, with no path prefix.
"""
forwarded_host = headers.get("X-Forwarded-Host")
if forwarded_host or headers.get("X-Forwarded-For"):
# Use the first X-Forwarded-Host entry that is a real host name: proxies append
# entries, and a misconfigured one can add junk (egeria-workspaces' Apache sends
# "i=99, localhost:8843" from `RequestHeader set X-Forwarded-Host "%{Host}i"`).
# Fall back to Host, which is the browser's host under ProxyPreserveHost On.
candidates = [h.strip() for h in (forwarded_host or "").split(",")]
public_host = next((h for h in candidates if _VALID_HOST.match(h)), host)
configured = urlsplit(configured_public_url) if configured_public_url else None
forwarded_proto = headers.get("X-Forwarded-Proto")
base_scheme = (forwarded_proto.split(",")[0].strip() if forwarded_proto else None) \
or (configured.scheme if configured and configured.scheme else None) or scheme
path = configured.path.rstrip("/") if configured else ""
return f"{base_scheme}://{public_host}{path}"
return f"{scheme}://{host}"


def _server_class():
from textual_serve.server import Server

class _RequestAwareServer(Server):
"""textual-serve Server whose page URLs follow the address the browser used."""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._configured_public_url = kwargs.get("public_url")

async def handle_index(self, request):
# Server.handle_index builds every URL from self.public_url synchronously
# (no await while building), so swapping it per request can't interleave
# with another request.
configured = self.public_url
self.public_url = public_url_for_request(
request.scheme, request.host, request.headers, self._configured_public_url)
try:
return await super().handle_index(request)
finally:
self.public_url = configured

return _RequestAwareServer


def _serve(name: str) -> None:
module, port_env, default_port, public_url_env = APPS[name]
Server(
_server_class()(
_command(module),
host=os.getenv("MY_EGERIA_HOST", "0.0.0.0"),
port=int(os.getenv(port_env, default_port)),
Expand All @@ -51,4 +111,3 @@ def serve_my_profile() -> None:

def serve_my_egeria() -> None:
_serve("My Egeria")

61 changes: 61 additions & 0 deletions tests/micro-tests/test_my_egeria_serve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""
serve_my_profile / serve_my_egeria: the page's asset and WebSocket URLs follow the
address each browser used, so one container works whatever hostname it is reached by.

textual-serve builds every URL in the page from one fixed public_url. When the portal
was reached as https://localhost:8843 while the container was configured for
https://laz.local:8843/my-egeria, the page pointed the browser at another origin, its
CSP blocked the script, and the page showed only its title.
"""
import pytest
from aiohttp.test_utils import TestClient, TestServer

from my_egeria.serve import _server_class, public_url_for_request

CONFIGURED = "https://laz.local:8843/my-egeria"


@pytest.mark.parametrize("headers, expected", [
({"X-Forwarded-Host": "localhost:8843"}, "https://localhost:8843/my-egeria"),
({"X-Forwarded-Host": "laz.local:8843"}, "https://laz.local:8843/my-egeria"),
({"X-Forwarded-Host": "demo.example.org", "X-Forwarded-Proto": "https"}, "https://demo.example.org/my-egeria"),
({"X-Forwarded-Host": "a.example, b.internal", "X-Forwarded-Proto": "http, https"}, "http://a.example/my-egeria"),
# egeria-workspaces' Apache really sends this (junk first, real host appended by mod_proxy)
({"X-Forwarded-Host": "i=99, localhost:8843", "X-Forwarded-Proto": "https"}, "https://localhost:8843/my-egeria"),
({"X-Forwarded-Host": "i=99"}, "https://127.0.0.1:8820/my-egeria"), # no valid entry: fall back to Host
({"X-Forwarded-For": "10.0.0.1"}, "https://127.0.0.1:8820/my-egeria"), # proxied without X-Forwarded-Host
({"X-Forwarded-Host": "[::1]:8843"}, "https://[::1]:8843/my-egeria"),
({}, "http://127.0.0.1:8820"), # direct connection: the request's own address, no prefix
])
def test_public_url_for_request(headers, expected):
assert public_url_for_request("http", "127.0.0.1:8820", headers, CONFIGURED) == expected


def test_no_configured_url_behind_proxy_uses_request_scheme():
assert public_url_for_request("http", "x:1", {"X-Forwarded-Host": "h:2"}, None) == "http://h:2"


@pytest.mark.asyncio
@pytest.mark.parametrize("headers, base", [
({"X-Forwarded-Host": "localhost:8843"}, "localhost:8843/my-egeria"),
({"X-Forwarded-Host": "laz.local:8843"}, "laz.local:8843/my-egeria"),
({"X-Forwarded-Host": "i=99, localhost:8843", "X-Forwarded-Proto": "https"}, "localhost:8843/my-egeria"),
])
async def test_served_page_uses_the_browsers_host(headers, base):
server = _server_class()("true", host="127.0.0.1", port=0, title="My Profile", public_url=CONFIGURED)
async with TestClient(TestServer(await server._make_app())) as client:
page = await (await client.get("/", headers=headers)).text()
assert f'src="https://{base}/static/js/textual.js"' in page
assert f'data-session-websocket-url="wss://{base}/ws"' in page
assert server.public_url == CONFIGURED # restored after the request


@pytest.mark.asyncio
async def test_direct_connection_uses_its_own_address():
server = _server_class()("true", host="127.0.0.1", port=0, title="My Profile", public_url=CONFIGURED)
async with TestClient(TestServer(await server._make_app())) as client:
response = await client.get("/")
page = await response.text()
host = f"{client.host}:{client.port}"
assert f'src="http://{host}/static/js/textual.js"' in page
assert f'data-session-websocket-url="ws://{host}/ws"' in page
Loading