mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-04-21 16:50:02 +02:00
Merge pull request #167 from tiqi-group/feat/stripprefix_support
Feat: support for service deployments behind PathPrefix proxy rules
This commit is contained in:
commit
a8b46f191b
59
docs/user-guide/advanced/Reverse-Proxy.md
Normal file
59
docs/user-guide/advanced/Reverse-Proxy.md
Normal 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 don’t 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 don’t need to make any changes to your application code or frontend resources.
|
@ -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>
|
||||||
|
@ -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");
|
||||||
|
@ -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,
|
||||||
|
@ -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
37
poetry.lock
generated
@ -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"
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
File diff suppressed because one or more lines are too long
@ -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>
|
||||||
|
@ -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()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user