Additional tweaks

This commit is contained in:
2026-08-27 17:55:13 +01:00
parent d817b971e0
commit 12a1789e14
8 changed files with 196 additions and 94 deletions
+2
View File
@@ -51,6 +51,8 @@ The endpoint returns `{"queued":true}` only when the named device is connected a
Rex creates `~/.config/rex/server.toml` and `~/.config/rex/client.toml` on first use, with owner-only file permissions. Supply another path with `--config` immediately after `server` or `client`, for example `rex server --config /etc/rex/server.toml run`. If the selected configuration is absent, Rex prints a warning naming the missing role and suggesting the other one; this catches a common `rex server` versus `rex client` typo before it quietly creates the wrong template.
Both running roles watch their TOML every half second. A server policy change (keys, devices, blacklist, or rate limit) takes effect immediately and disconnects existing clients so they re-authenticate. Client changes cause it to reconnect and re-register its actions. This includes changes made by the management CLI. Connection failures are retried indefinitely with a capped 1-to-30-second backoff. A malformed or invalid update leaves the last valid configuration active and is retried until repaired. Listener addresses and TLS paths are read only when the server starts, so changing those still requires a server restart.
### Server (`server.toml`)
Copy [examples/server.toml](examples/server.toml). All fields are optional except fields within a `[[keys]]` record.
+65 -8
View File
@@ -2,6 +2,7 @@ import asyncio
import json
import os
import signal
import logging
from pathlib import Path
import websockets
@@ -9,6 +10,12 @@ import websockets
from rex.client.config import ClientAction, ClientConfigManager
logger = logging.getLogger(__name__)
CONFIG_POLL_INTERVAL = 0.5
RECONNECT_INITIAL_DELAY = 1.0
RECONNECT_MAX_DELAY = 30.0
async def run_command(action: ClientAction) -> None:
"""Run only a locally configured argv list; never parse remote shell input."""
process = await asyncio.create_subprocess_exec(*action.argv, start_new_session=os.name == "posix")
@@ -38,26 +45,76 @@ async def run_command(action: ClientAction) -> None:
async def listen(config_path: str | Path = "~/.config/rex/client.toml") -> None:
config = ClientConfigManager(config_path).config
actions = {action.name: action for action in config.actions}
manager = ClientConfigManager(config_path)
missing_key_reported = False
retry_delay = RECONNECT_INITIAL_DELAY
while True:
manager.reload()
config = manager.config
if not config.api_key:
raise RuntimeError("client.toml has no api_key")
async with websockets.connect(config.websocket_url(), additional_headers={"X-API-Key": config.api_key}) as websocket:
await websocket.send({"type": "register", "actions": sorted(actions)})
tasks: set[asyncio.Task[None]] = set()
if not missing_key_reported:
logger.warning("Client configuration has no api_key; waiting for a valid update")
missing_key_reported = True
await asyncio.sleep(CONFIG_POLL_INTERVAL)
continue
missing_key_reported = False
actions = {action.name: action for action in config.actions}
try:
async with websockets.connect(config.websocket_url(), additional_headers={"X-API-Key": config.api_key}) as websocket:
retry_delay = RECONNECT_INITIAL_DELAY
await websocket.send(json.dumps({"type": "register", "actions": sorted(actions)}))
await _serve_connection(websocket, manager, config, actions)
except (OSError, ValueError, websockets.WebSocketException) as error:
logger.warning("Client connection failed: %s; retrying in %.0f seconds", error, retry_delay)
await asyncio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, RECONNECT_MAX_DELAY)
async def _serve_connection(
websocket: websockets.ClientConnection,
manager: ClientConfigManager,
config: object,
actions: dict[str, ClientAction],
) -> None:
"""Handle one connection until it closes or the configuration changes."""
tasks: set[asyncio.Task[None]] = set()
receive_task = asyncio.create_task(websocket.recv())
try:
while True:
changed = asyncio.create_task(_wait_for_config_change(manager, config))
done, _ = await asyncio.wait({receive_task, changed}, return_when=asyncio.FIRST_COMPLETED)
if changed in done:
return
changed.cancel()
await asyncio.gather(changed, return_exceptions=True)
raw = receive_task.result()
try:
async for raw in websocket:
message = json.loads(raw)
action = actions.get(message.get("name")) if message.get("type") == "action" else None
except json.JSONDecodeError:
logger.warning("Ignoring malformed server message")
receive_task = asyncio.create_task(websocket.recv())
continue
action = actions.get(message.get("name")) if isinstance(message, dict) and message.get("type") == "action" else None
if action:
task = asyncio.create_task(run_command(action))
tasks.add(task)
task.add_done_callback(tasks.discard)
receive_task = asyncio.create_task(websocket.recv())
finally:
receive_task.cancel()
await asyncio.gather(receive_task, return_exceptions=True)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
async def _wait_for_config_change(manager: ClientConfigManager, config: object) -> None:
while True:
await asyncio.sleep(CONFIG_POLL_INTERVAL)
manager.reload()
if manager.config != config:
return
def main(config_path: str = "~/.config/rex/client.toml") -> None:
asyncio.run(listen(config_path))
+23 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
from pathlib import Path
from urllib.parse import urlunsplit
@@ -8,6 +9,9 @@ from pydantic import BaseModel, Field, field_validator
from rex.config import read_toml, write_toml
logger = logging.getLogger(__name__)
class ClientAction(BaseModel):
name: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
argv: list[str] = Field(min_length=1, max_length=64)
@@ -46,7 +50,25 @@ class ClientConfig(BaseModel):
class ClientConfigManager:
def __init__(self, file_path: str | Path) -> None:
self.file_path = Path(file_path).expanduser()
self.config = ClientConfig.model_validate(read_toml(self.file_path, ClientConfig().model_dump(mode="json")))
self.config = ClientConfig()
self._last_reload_error: str | None = None
self.reload()
def reload(self) -> bool:
"""Load a new configuration without discarding the last valid one."""
try:
config = ClientConfig.model_validate(read_toml(self.file_path, ClientConfig().model_dump(mode="json")))
except (OSError, ValueError) as error:
message = str(error)
if message != self._last_reload_error:
logger.warning("Invalid client configuration in %s; keeping the last valid configuration: %s", self.file_path, message)
self._last_reload_error = message
return False
if self._last_reload_error is not None:
logger.info("Client configuration in %s is valid again", self.file_path)
self._last_reload_error = None
self.config = config
return True
def save(self) -> None:
write_toml(self.file_path, self.config.model_dump(mode="json"))
+26 -8
View File
@@ -1,6 +1,7 @@
import asyncio
import time
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from pathlib import Path
import uvicorn
@@ -25,16 +26,15 @@ class Registration(BaseModel):
class RateLimiter:
def __init__(self, per_minute: int) -> None:
self.per_minute = per_minute
def __init__(self) -> None:
self.requests: dict[str, deque[float]] = defaultdict(deque)
def allowed(self, identity: str) -> bool:
def allowed(self, identity: str, per_minute: int) -> bool:
now = time.monotonic()
requests = self.requests[identity]
while requests and requests[0] <= now - 60:
requests.popleft()
if len(requests) >= self.per_minute:
if len(requests) >= per_minute:
return False
requests.append(now)
return True
@@ -43,11 +43,20 @@ class RateLimiter:
def create_apps(config_path: str | Path = "~/.config/rex/server.toml") -> tuple[FastAPI, FastAPI]:
manager = ServerConfigManager(config_path)
connections = ConnectionManager()
limiter = RateLimiter(manager.config.rate_limit_per_minute)
limiter = RateLimiter()
@asynccontextmanager
async def lifespan(_: FastAPI):
watcher = asyncio.create_task(_watch_config(manager, connections))
try:
yield
finally:
watcher.cancel()
await asyncio.gather(watcher, return_exceptions=True)
async def authorize(request: Request, permission: str) -> None:
host = request.client.host if request.client else None
if manager.config.is_blacklisted(host) or not limiter.allowed(host or "unknown"):
if manager.config.is_blacklisted(host) or not limiter.allowed(host or "unknown", manager.config.rate_limit_per_minute):
raise HTTPException(429, "Request denied")
if not manager.get_key(request.headers.get("x-api-key", ""), permission):
raise HTTPException(401, "Invalid API key or insufficient permission")
@@ -57,14 +66,14 @@ def create_apps(config_path: str | Path = "~/.config/rex/server.toml") -> tuple[
await authorize(request, permission)
return dependency
public = FastAPI(title="Rex public API", docs_url=None, redoc_url=None, openapi_url=None)
public = FastAPI(title="Rex public API", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan)
management = FastAPI(title="Rex management API", docs_url=None, redoc_url=None, openapi_url=None)
@public.websocket("/ws/{device_name}")
async def websocket_endpoint(websocket: WebSocket, device_name: str):
host = websocket.client.host if websocket.client else None
key = websocket.headers.get("x-api-key", "")
denied = manager.config.is_blacklisted(host) or not limiter.allowed(host or "unknown")
denied = manager.config.is_blacklisted(host) or not limiter.allowed(host or "unknown", manager.config.rate_limit_per_minute)
if denied or device_name not in manager.config.devices or not manager.get_key(key, "connect"):
raise WebSocketException(status.WS_1008_POLICY_VIOLATION, "Connection denied")
await websocket.accept()
@@ -99,6 +108,15 @@ def create_apps(config_path: str | Path = "~/.config/rex/server.toml") -> tuple[
return public, management
async def _watch_config(manager: ServerConfigManager, connections: ConnectionManager) -> None:
while True:
await asyncio.sleep(0.5)
previous = manager.config
manager.reload()
if manager.config != previous:
await connections.disconnect_all()
async def run_servers(config_path: str | Path = "~/.config/rex/server.toml") -> None:
public, management = create_apps(config_path)
config = ServerConfigManager(config_path).config
+23 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import ipaddress
import logging
import secrets
from pathlib import Path
@@ -9,6 +10,9 @@ from pydantic import BaseModel, Field, field_validator
from rex.config import read_toml, write_toml
logger = logging.getLogger(__name__)
class Key(BaseModel):
name: str
secret: str
@@ -48,7 +52,25 @@ class ServerConfig(BaseModel):
class ServerConfigManager:
def __init__(self, file_path: str | Path) -> None:
self.file_path = Path(file_path).expanduser()
self.config = ServerConfig.model_validate(read_toml(self.file_path, ServerConfig().model_dump(mode="json")))
self.config = ServerConfig()
self._last_reload_error: str | None = None
self.reload()
def reload(self) -> bool:
"""Load a new configuration without discarding the last valid one."""
try:
config = ServerConfig.model_validate(read_toml(self.file_path, ServerConfig().model_dump(mode="json")))
except (OSError, ValueError) as error:
message = str(error)
if message != self._last_reload_error:
logger.warning("Invalid server configuration in %s; keeping the last valid configuration: %s", self.file_path, message)
self._last_reload_error = message
return False
if self._last_reload_error is not None:
logger.info("Server configuration in %s is valid again", self.file_path)
self._last_reload_error = None
self.config = config
return True
def save(self) -> None:
write_toml(self.file_path, self.config.model_dump(mode="json"))
+10
View File
@@ -17,6 +17,16 @@ class ConnectionManager:
connection = self.connections.get(client_name)
return sorted(connection[1]) if connection else []
async def disconnect_all(self) -> None:
"""Close all clients so a changed server policy is re-authenticated."""
connections = list(self.connections.items())
self.connections.clear()
for _, (websocket, _) in connections:
try:
await websocket.close(code=1012, reason="Server configuration changed")
except Exception:
pass
async def send(self, client_name: str, action_name: str) -> bool:
connection = self.connections.get(client_name)
+38 -1
View File
@@ -3,7 +3,7 @@ import unittest
from pathlib import Path
from rex.client.config import ClientAction, ClientConfig, ClientConfigManager
from rex.server.config import ServerConfigManager
from rex.server.config import ServerConfig, ServerConfigManager
from rex.server.connection_manager import ConnectionManager
@@ -28,6 +28,31 @@ class ConfigTests(unittest.TestCase):
with self.assertRaises(ValueError):
ClientAction(name="bad name", argv=["/bin/echo"])
def test_reload_preserves_last_valid_server_configuration(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "server.toml"
manager = ServerConfigManager(path)
manager.config = ServerConfig(devices=["desk"])
manager.save()
manager.reload()
path.write_text("rate_limit_per_minute = 0\n")
self.assertFalse(manager.reload())
self.assertEqual(manager.config.devices, ["desk"])
path.write_text("rate_limit_per_minute = 100\ndevices = [\"laptop\"]\n")
self.assertTrue(manager.reload())
self.assertEqual(manager.config.devices, ["laptop"])
def test_reload_preserves_last_valid_client_configuration(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "client.toml"
manager = ClientConfigManager(path)
manager.config = ClientConfig(device_name="desk")
manager.save()
manager.reload()
path.write_text("scheme = \"https\"\n")
self.assertFalse(manager.reload())
self.assertEqual(manager.config.device_name, "desk")
class ConnectionManagerTests(unittest.IsolatedAsyncioTestCase):
async def test_only_registered_actions_are_sent(self) -> None:
@@ -44,3 +69,15 @@ class ConnectionManagerTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(await manager.send("desk", "lock"))
self.assertEqual(socket.messages, [{"type": "action", "name": "lock"}])
async def test_disconnect_all_closes_registered_clients(self) -> None:
class Socket:
def __init__(self) -> None:
self.closed = False
async def close(self, **_: object) -> None: self.closed = True
socket = Socket()
manager = ConnectionManager()
await manager.connect("desk", socket, {"lock"}) # type: ignore[arg-type]
await manager.disconnect_all()
self.assertTrue(socket.closed)
self.assertEqual(manager.actions("desk"), [])
Generated
-66
View File
@@ -341,18 +341,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/3e/79f35b8c31a1881893b7e62be80b2573f06e38db47c33065749293ee1b97/linkify_it_py-2.1.1.tar.gz", hash = "sha256:a78f40fee177eb912e9d2375074108378523c38d3fde5d3ee804f465b6cfbfee", size = 30889, upload-time = "2026-08-24T17:16:57.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/3d/e34b19cd144071c583317268c4feb2c59c03ac57eef69753410c7abb11c0/linkify_it_py-2.1.1-py3-none-any.whl", hash = "sha256:8539a6b470efce90ba9b69e39b848e5b15b7ad89f7f98ca17d3532c243f987dc", size = 20532, upload-time = "2026-08-24T17:16:55.965Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
@@ -365,11 +353,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -422,18 +405,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -443,15 +414,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "platformdirs"
version = "4.11.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/50/bb/ebc6636e1ae41314f796ebb7215fd28febb45f9aac72f2b04cb74b5071dc/platformdirs-4.11.4.tar.gz", hash = "sha256:f3373be828247211d0febabea97e238c3dfde8a60b3c90c32756fb52cb21556d", size = 34079, upload-time = "2026-08-24T14:53:49.676Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/be/0ff05fcd2938fb58ad9219bd54135968342d214737e012d62d43f06a2dd6/platformdirs-4.11.4-py3-none-any.whl", hash = "sha256:e34ff91a24bcddc6d939b878bdf3f5c437c9c46fe9e212b1bf455fdf1ee57586", size = 23741, upload-time = "2026-08-24T14:53:48.406Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
@@ -626,7 +588,6 @@ dependencies = [
{ name = "fastapi", extra = ["standard"] },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "textual" },
{ name = "uvicorn" },
{ name = "websockets" },
]
@@ -636,7 +597,6 @@ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pydantic", specifier = ">=2.13.4" },
{ name = "textual", specifier = ">=6.1.0" },
{ name = "uvicorn", specifier = ">=0.52.4" },
{ name = "websockets", specifier = ">=17.0.1" },
]
@@ -790,23 +750,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
]
[[package]]
name = "textual"
version = "8.2.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
]
[[package]]
name = "typer"
version = "0.27.1"
@@ -843,15 +786,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uc-micro-py"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
]
[[package]]
name = "urllib3"
version = "2.7.0"