Merge pull request #167 from tiqi-group/feat/stripprefix_support

Feat: support for service deployments behind PathPrefix proxy rules
This commit is contained in:
Mose Müller 2024-11-18 10:20:57 +01:00 committed by GitHub
commit a8b46f191b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 185 additions and 30 deletions

View File

@ -0,0 +1,59 @@
# Deploying Services Behind a Reverse Proxy
In some environments, you may need to deploy your services behind a reverse proxy. Typically, this involves adding a CNAME record for your service that points to the reverse proxy in your DNS server. The proxy then routes requests to the `pydase` backend on the appropriate web server port.
However, in scenarios where you dont control the DNS server, or where adding new CNAME records is time-consuming, `pydase` supports **service multiplexing** using a path prefix. This means multiple services can be hosted on a single CNAME (e.g., `services.example.com`), with each service accessible through a unique path such as `services.example.com/my-service`.
To ensure seamless operation, the reverse proxy must strip the path prefix (e.g., `/my-service`) from the request URL and forward it as the `X-Forwarded-Prefix` header. `pydase` then uses this header to dynamically adjust the frontend paths, ensuring all resources are correctly located.
## Example Deployment with Traefik
Below is an example setup using [Traefik](https://doc.traefik.io/traefik/), a widely-used reverse proxy. This configuration demonstrates how to forward requests for a `pydase` service using a path prefix.
### 1. Reverse Proxy Configuration
Save the following configuration to a file (e.g., `/etc/traefik/dynamic_conf/my-service-config.yml`):
```yaml
http:
routers:
my-service-route:
rule: PathPrefix(`/my-service`)
entryPoints:
- web
service: my-service
middlewares:
- strip-prefix
services:
my-service:
loadBalancer:
servers:
- url: http://127.0.0.1:8001
middlewares:
strip-prefix:
stripprefix:
prefixes: /my-service
```
This configuration:
- Routes requests with the path prefix `/my-service` to the `pydase` backend.
- Strips the prefix (`/my-service`) from the request URL using the `stripprefix` middleware.
- Forwards the stripped prefix as the `X-Forwarded-Prefix` header.
### 2. Static Configuration for Traefik
Ensure Traefik is set up to use the dynamic configuration. Add this to your Traefik static configuration (e.g., `/etc/traefik/traefik.yml`):
```yaml
providers:
file:
filename: /etc/traefik/dynamic_conf/my-service-config.yml
entrypoints:
web:
address: ":80"
```
### 3. Accessing the Service
Once configured, your `pydase` service will be accessible at `http://services.example.com/my-service`. The path prefix will be handled transparently by `pydase`, so you dont need to make any changes to your application code or frontend resources.

View File

@ -8,6 +8,11 @@
<meta name="description" content="Web site displaying a pydase UI." /> <meta name="description" content="Web site displaying a pydase UI." />
</head> </head>
<script>
// this will be set by the python backend if the service is behind a proxy which strips a prefix. The frontend can use this to build the paths to the resources.
window.__FORWARDED_PREFIX__ = "";
</script>`
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useReducer, useState } from "react"; import { useCallback, useEffect, useReducer, useState } from "react";
import { Navbar, Form, Offcanvas, Container } from "react-bootstrap"; import { Navbar, Form, Offcanvas, Container } from "react-bootstrap";
import { hostname, port, socket } from "./socket"; import { authority, socket } from "./socket";
import "./App.css"; import "./App.css";
import { import {
Notifications, Notifications,
@ -68,12 +68,12 @@ const App = () => {
useEffect(() => { useEffect(() => {
// Allow the user to add a custom css file // Allow the user to add a custom css file
fetch(`http://${hostname}:${port}/custom.css`) fetch(`http://${authority}/custom.css`)
.then((response) => { .then((response) => {
if (response.ok) { if (response.ok) {
// If the file exists, create a link element for the custom CSS // If the file exists, create a link element for the custom CSS
const link = document.createElement("link"); const link = document.createElement("link");
link.href = `http://${hostname}:${port}/custom.css`; link.href = `http://${authority}/custom.css`;
link.type = "text/css"; link.type = "text/css";
link.rel = "stylesheet"; link.rel = "stylesheet";
document.head.appendChild(link); document.head.appendChild(link);
@ -83,7 +83,7 @@ const App = () => {
socket.on("connect", () => { socket.on("connect", () => {
// Fetch data from the API when the client connects // Fetch data from the API when the client connects
fetch(`http://${hostname}:${port}/service-properties`) fetch(`http://${authority}/service-properties`)
.then((response) => response.json()) .then((response) => response.json())
.then((data: State) => { .then((data: State) => {
dispatch({ type: "SET_DATA", data }); dispatch({ type: "SET_DATA", data });
@ -91,7 +91,7 @@ const App = () => {
document.title = data.name; // Setting browser tab title document.title = data.name; // Setting browser tab title
}); });
fetch(`http://${hostname}:${port}/web-settings`) fetch(`http://${authority}/web-settings`)
.then((response) => response.json()) .then((response) => response.json())
.then((data: Record<string, WebSetting>) => setWebSettings(data)); .then((data: Record<string, WebSetting>) => setWebSettings(data));
setConnectionStatus("connected"); setConnectionStatus("connected");

View File

@ -2,14 +2,23 @@ import { io } from "socket.io-client";
import { serializeDict, serializeList } from "./utils/serializationUtils"; import { serializeDict, serializeList } from "./utils/serializationUtils";
import { SerializedObject } from "./types/SerializedObject"; import { SerializedObject } from "./types/SerializedObject";
export const hostname = const hostname =
process.env.NODE_ENV === "development" ? `localhost` : window.location.hostname; process.env.NODE_ENV === "development" ? `localhost` : window.location.hostname;
export const port = const port = process.env.NODE_ENV === "development" ? 8001 : window.location.port;
process.env.NODE_ENV === "development" ? 8001 : window.location.port;
// Get the forwarded prefix from the global variable
export const forwardedPrefix: string =
(window as any) /* eslint-disable-line @typescript-eslint/no-explicit-any */
.__FORWARDED_PREFIX__ || "";
export const authority = `${hostname}:${port}${forwardedPrefix}`;
const URL = `ws://${hostname}:${port}/`; const URL = `ws://${hostname}:${port}/`;
console.debug("Websocket: ", URL); console.debug("Websocket: ", URL);
export const socket = io(URL, {
export const socket = io(URL, { path: "/ws/socket.io", transports: ["websocket"] }); path: `${forwardedPrefix}/ws/socket.io`,
transports: ["websocket"],
});
export const updateValue = ( export const updateValue = (
serializedObject: SerializedObject, serializedObject: SerializedObject,

View File

@ -12,6 +12,8 @@ nav:
- Understanding Units: user-guide/Understanding-Units.md - Understanding Units: user-guide/Understanding-Units.md
- Validating Property Setters: user-guide/Validating-Property-Setters.md - Validating Property Setters: user-guide/Validating-Property-Setters.md
- Configuring pydase: user-guide/Configuration.md - Configuring pydase: user-guide/Configuration.md
- Advanced:
- Deploying behind a Reverse Proxy: user-guide/advanced/Reverse-Proxy.md
- Developer Guide: - Developer Guide:
- Developer Guide: dev-guide/README.md - Developer Guide: dev-guide/README.md
- API Reference: dev-guide/api.md - API Reference: dev-guide/api.md

37
poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]] [[package]]
name = "aiohappyeyeballs" name = "aiohappyeyeballs"
@ -149,6 +149,28 @@ files = [
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
] ]
[[package]]
name = "anyio"
version = "4.6.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false
python-versions = ">=3.9"
files = [
{file = "anyio-4.6.0-py3-none-any.whl", hash = "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a"},
{file = "anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb"},
]
[package.dependencies]
exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
idna = ">=2.8"
sniffio = ">=1.1"
typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""}
[package.extras]
doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.21.0b1)"]
trio = ["trio (>=0.26.1)"]
[[package]] [[package]]
name = "appdirs" name = "appdirs"
version = "1.4.4" version = "1.4.4"
@ -2244,6 +2266,17 @@ files = [
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
] ]
[[package]]
name = "sniffio"
version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
[[package]] [[package]]
name = "soupsieve" name = "soupsieve"
version = "2.5" version = "2.5"
@ -2496,4 +2529,4 @@ multidict = ">=4.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "7131eddc2065147a18c145bb6da09492f03eb7fe050e968109cecb6044d17ed6" content-hash = "011b118225386513fc1c953c02bc1d58e40c198313de2a1f76183dd61ab9eec6"

View File

@ -17,6 +17,7 @@ websocket-client = "^1.7.0"
aiohttp = "^3.9.3" aiohttp = "^3.9.3"
click = "^8.1.7" click = "^8.1.7"
aiohttp-middlewares = "^2.3.0" aiohttp-middlewares = "^2.3.0"
anyio = "^4.6.0"
[tool.poetry.group.dev] [tool.poetry.group.dev]
optional = true optional = true

View File

@ -2,6 +2,7 @@ import asyncio
import logging import logging
import sys import sys
import threading import threading
import urllib.parse
from typing import TYPE_CHECKING, Any, TypedDict, cast from typing import TYPE_CHECKING, Any, TypedDict, cast
import socketio # type: ignore import socketio # type: ignore
@ -83,6 +84,16 @@ class Client:
block_until_connected: bool = True, block_until_connected: bool = True,
sio_client_kwargs: dict[str, Any] = {}, sio_client_kwargs: dict[str, Any] = {},
): ):
# Parse the URL to separate base URL and path prefix
parsed_url = urllib.parse.urlparse(url)
# Construct the base URL without the path
self._base_url = urllib.parse.urlunparse(
(parsed_url.scheme, parsed_url.netloc, "", "", "", "")
)
# Store the path prefix (e.g., "/service" in "ws://localhost:8081/service")
self._path_prefix = parsed_url.path.rstrip("/") # Remove trailing slash if any
self._url = url self._url = url
self._sio = socketio.AsyncClient(**sio_client_kwargs) self._sio = socketio.AsyncClient(**sio_client_kwargs)
self._loop = asyncio.new_event_loop() self._loop = asyncio.new_event_loop()
@ -121,8 +132,8 @@ class Client:
logger.debug("Connecting to server '%s' ...", self._url) logger.debug("Connecting to server '%s' ...", self._url)
await self._setup_events() await self._setup_events()
await self._sio.connect( await self._sio.connect(
self._url, self._base_url,
socketio_path="/ws/socket.io", socketio_path=f"{self._path_prefix}/ws/socket.io",
transports=["websocket"], transports=["websocket"],
retry=True, retry=True,
) )

View File

@ -6,10 +6,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta name="description" content="Web site displaying a pydase UI." /> <meta name="description" content="Web site displaying a pydase UI." />
<script type="module" crossorigin src="/assets/index-BjsjosWf.js"></script> <script type="module" crossorigin src="/assets/index-BHmSn57t.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D2aktF3W.css"> <link rel="stylesheet" crossorigin href="/assets/index-D2aktF3W.css">
</head> </head>
<script>
// this will be set by the python backend if the service is behind a proxy which strips a prefix. The frontend can use this to build the paths to the resources.
window.__FORWARDED_PREFIX__ = "";
</script>`
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>

View File

@ -1,4 +1,5 @@
import asyncio import asyncio
import html
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
@ -6,6 +7,7 @@ from typing import Any
import aiohttp.web import aiohttp.web
import aiohttp_middlewares.cors import aiohttp_middlewares.cors
import anyio
from pydase.config import ServiceConfig, WebServerConfig from pydase.config import ServiceConfig, WebServerConfig
from pydase.data_service.data_service_observer import DataServiceObserver from pydase.data_service.data_service_observer import DataServiceObserver
@ -99,7 +101,35 @@ class WebServer:
self._loop = asyncio.get_running_loop() self._loop = asyncio.get_running_loop()
self._sio = setup_sio_server(self.observer, self.enable_cors, self._loop) self._sio = setup_sio_server(self.observer, self.enable_cors, self._loop)
async def index(request: aiohttp.web.Request) -> aiohttp.web.FileResponse: async def index(
request: aiohttp.web.Request,
) -> aiohttp.web.Response | aiohttp.web.FileResponse:
# Read the X-Forwarded-Prefix header from the request
forwarded_prefix = request.headers.get("X-Forwarded-Prefix", "")
if forwarded_prefix != "":
# Escape the forwarded prefix to prevent XSS
escaped_prefix = html.escape(forwarded_prefix)
# Read the index.html file
index_file_path = self.frontend_src / "index.html"
async with await anyio.open_file(index_file_path) as f:
html_content = await f.read()
# Inject the escaped forwarded prefix into the HTML
modified_html = html_content.replace(
'window.__FORWARDED_PREFIX__ = "";',
f'window.__FORWARDED_PREFIX__ = "{escaped_prefix}";',
)
modified_html = modified_html.replace(
"/assets/",
f"{escaped_prefix}/assets/",
)
return aiohttp.web.Response(
text=modified_html, content_type="text/html"
)
return aiohttp.web.FileResponse(self.frontend_src / "index.html") return aiohttp.web.FileResponse(self.frontend_src / "index.html")
app = aiohttp.web.Application() app = aiohttp.web.Application()