feat: add GET /api/settings/sync federation endpoint
This commit is contained in:
+30
-9
@@ -64,7 +64,12 @@ from muxplex.state import (
|
|||||||
save_state,
|
save_state,
|
||||||
state_lock,
|
state_lock,
|
||||||
)
|
)
|
||||||
from muxplex.settings import load_federation_key, load_settings, patch_settings
|
from muxplex.settings import (
|
||||||
|
get_syncable_settings,
|
||||||
|
load_federation_key,
|
||||||
|
load_settings,
|
||||||
|
patch_settings,
|
||||||
|
)
|
||||||
from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
|
from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -140,9 +145,8 @@ async def _run_poll_cycle() -> None:
|
|||||||
if active_remote_id is not None:
|
if active_remote_id is not None:
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
remote_instances = settings.get("remote_instances", [])
|
remote_instances = settings.get("remote_instances", [])
|
||||||
if (
|
if isinstance(active_remote_id, int) and 0 <= active_remote_id < len(
|
||||||
isinstance(active_remote_id, int)
|
remote_instances
|
||||||
and 0 <= active_remote_id < len(remote_instances)
|
|
||||||
):
|
):
|
||||||
remote = remote_instances[active_remote_id]
|
remote = remote_instances[active_remote_id]
|
||||||
remote_url: str = remote.get("url", "").rstrip("/")
|
remote_url: str = remote.get("url", "").rstrip("/")
|
||||||
@@ -157,13 +161,13 @@ async def _run_poll_cycle() -> None:
|
|||||||
and view_mode == "fullscreen"
|
and view_mode == "fullscreen"
|
||||||
and (now - last_interaction_at) < 60
|
and (now - last_interaction_at) < 60
|
||||||
):
|
):
|
||||||
bell_clear_url = (
|
bell_clear_url = f"{remote_url}/api/sessions/{viewing_session}/bell/clear"
|
||||||
f"{remote_url}/api/sessions/{viewing_session}/bell/clear"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
await _federation_client.post(
|
await _federation_client.post(
|
||||||
bell_clear_url,
|
bell_clear_url,
|
||||||
headers={"Authorization": f"Bearer {remote_key}"} if remote_key else {},
|
headers={"Authorization": f"Bearer {remote_key}"}
|
||||||
|
if remote_key
|
||||||
|
else {},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.warning(
|
_log.warning(
|
||||||
@@ -720,6 +724,21 @@ async def update_settings(request: Request) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/settings/sync")
|
||||||
|
async def get_settings_sync() -> dict:
|
||||||
|
"""Return syncable settings + timestamp for federation sync.
|
||||||
|
|
||||||
|
Authenticated via federation Bearer token (same auth middleware as all other
|
||||||
|
non-exempt endpoints). Returns only the keys in SYNCABLE_KEYS plus the
|
||||||
|
settings_updated_at timestamp; infrastructure keys (host, port, federation_key,
|
||||||
|
etc.) are never included.
|
||||||
|
"""
|
||||||
|
syncable = get_syncable_settings()
|
||||||
|
ts = syncable.get("settings_updated_at", 0.0)
|
||||||
|
settings = {k: v for k, v in syncable.items() if k != "settings_updated_at"}
|
||||||
|
return {"settings": settings, "settings_updated_at": ts}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/instance-info")
|
@app.get("/api/instance-info")
|
||||||
async def instance_info() -> dict:
|
async def instance_info() -> dict:
|
||||||
"""Return this instance's display name and version.
|
"""Return this instance's display name and version.
|
||||||
@@ -916,7 +935,9 @@ async def federation_terminal_ws_proxy(websocket: WebSocket, remote_id: int) ->
|
|||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
ws_url,
|
ws_url,
|
||||||
subprotocols=[Subprotocol("tty")],
|
subprotocols=[Subprotocol("tty")],
|
||||||
additional_headers={"Authorization": f"Bearer {remote_key}"} if remote_key else {},
|
additional_headers={"Authorization": f"Bearer {remote_key}"}
|
||||||
|
if remote_key
|
||||||
|
else {},
|
||||||
ssl=ssl_context,
|
ssl=ssl_context,
|
||||||
) as remote_ws:
|
) as remote_ws:
|
||||||
|
|
||||||
|
|||||||
@@ -2802,6 +2802,72 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/settings/sync (task-7)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_sync_returns_200(client, tmp_path, monkeypatch):
|
||||||
|
"""GET /api/settings/sync returns HTTP 200."""
|
||||||
|
import muxplex.settings as settings_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
||||||
|
response = client.get("/api/settings/sync")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_sync_response_has_settings_and_timestamp(
|
||||||
|
client, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""GET /api/settings/sync returns {settings: dict, settings_updated_at: float}."""
|
||||||
|
import muxplex.settings as settings_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
||||||
|
response = client.get("/api/settings/sync")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "settings" in data, (
|
||||||
|
f"Response must have 'settings' key, got: {list(data.keys())}"
|
||||||
|
)
|
||||||
|
assert "settings_updated_at" in data, (
|
||||||
|
f"Response must have 'settings_updated_at' key, got: {list(data.keys())}"
|
||||||
|
)
|
||||||
|
assert isinstance(data["settings"], dict), (
|
||||||
|
f"'settings' must be a dict, got: {type(data['settings'])}"
|
||||||
|
)
|
||||||
|
assert isinstance(data["settings_updated_at"], float), (
|
||||||
|
f"'settings_updated_at' must be a float, got: {type(data['settings_updated_at'])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_sync_excludes_infrastructure_keys(client, tmp_path, monkeypatch):
|
||||||
|
"""GET /api/settings/sync settings dict must not contain infrastructure keys."""
|
||||||
|
import muxplex.settings as settings_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
||||||
|
response = client.get("/api/settings/sync")
|
||||||
|
assert response.status_code == 200
|
||||||
|
settings = response.json()["settings"]
|
||||||
|
infra_keys = (
|
||||||
|
"host",
|
||||||
|
"port",
|
||||||
|
"auth",
|
||||||
|
"session_ttl",
|
||||||
|
"tls_cert",
|
||||||
|
"tls_key",
|
||||||
|
"device_name",
|
||||||
|
"federation_key",
|
||||||
|
"remote_instances",
|
||||||
|
"multi_device_enabled",
|
||||||
|
"new_session_template",
|
||||||
|
"delete_session_template",
|
||||||
|
)
|
||||||
|
for key in infra_keys:
|
||||||
|
assert key not in settings, (
|
||||||
|
f"Infrastructure key '{key}' must not appear in /api/settings/sync response"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_federation_auth_headers_guard_empty_key():
|
def test_federation_auth_headers_guard_empty_key():
|
||||||
"""Every federation Authorization header construction must guard against empty key.
|
"""Every federation Authorization header construction must guard against empty key.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user