From c870f118bfe5b6e7325024fc7dc2c9c56738f6f7 Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 11 Jun 2026 17:41:01 +0100 Subject: [PATCH] feat: allowlisted generic agent proxy route Co-Authored-By: Claude Fable 5 --- routes/controller.py | 33 +++++++++++++++++++++++++++++++++ tests/test_controller.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/routes/controller.py b/routes/controller.py index 864e66d..ca07275 100644 --- a/routes/controller.py +++ b/routes/controller.py @@ -496,6 +496,39 @@ def proxy_mode_stream(agent_id: int, mode: str): return response +# Endpoint prefixes that may be reached through the generic proxy. Extend this +# list instead of writing new per-endpoint proxy routes. +PROXY_ALLOWED_PREFIXES = ("wifi/v2/",) + + +@controller_bp.route("/agents//proxy/", methods=["GET"]) +def proxy_passthrough(agent_id: int, subpath: str): + """Forward an allowlisted GET to a remote agent. + + Keeps remote agents at feature parity with local mode without a + hand-written proxy per endpoint. + """ + if not subpath.startswith(PROXY_ALLOWED_PREFIXES): + return api_error("Endpoint not allowed through agent proxy", 403) + + agent = get_agent(agent_id) + if not agent: + return api_error("Agent not found", 404) + + try: + client = create_client_from_agent(agent) + result = client.get(f"/{subpath}", params=request.args.to_dict()) + return jsonify( + { + "status": "success", + "agent_id": agent_id, + "result": result, + } + ) + except (AgentHTTPError, AgentConnectionError) as e: + return api_error(f"Agent error: {e}", 502) + + @controller_bp.route("/agents//wifi/v2/clients", methods=["GET"]) def proxy_wifi_clients(agent_id: int): """Get the WiFi client list from a remote agent.""" diff --git a/tests/test_controller.py b/tests/test_controller.py index edd8851..20e7bcf 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -552,3 +552,38 @@ class TestSSEStream: # Full SSE testing requires more complex setup response = client.get("/controller/stream/all") assert response.mimetype == "text/event-stream" + + +# ============================================================================= +# Generic Proxy Tests +# ============================================================================= + + +class TestGenericProxy: + """Tests for the allowlisted agent passthrough proxy.""" + + def _mock_agent(self): + return {"id": 1, "name": "node-1", "url": "http://10.0.0.2:5000", "api_key": None} + + def test_proxies_allowlisted_get(self, client): + with ( + patch("routes.controller.get_agent", return_value=self._mock_agent()), + patch("routes.controller.create_client_from_agent") as mock_create, + ): + mock_create.return_value.get.return_value = [{"mac": "AA:BB"}] + resp = client.get("/controller/agents/1/proxy/wifi/v2/clients?bssid=AA:BB") + assert resp.status_code == 200 + data = resp.get_json() + assert data["status"] == "success" + assert data["result"] == [{"mac": "AA:BB"}] + mock_create.return_value.get.assert_called_once_with("/wifi/v2/clients", params={"bssid": "AA:BB"}) + + def test_rejects_non_allowlisted_path(self, client): + with patch("routes.controller.get_agent", return_value=self._mock_agent()): + resp = client.get("/controller/agents/1/proxy/settings/secrets") + assert resp.status_code == 403 + + def test_unknown_agent_404(self, client): + with patch("routes.controller.get_agent", return_value=None): + resp = client.get("/controller/agents/99/proxy/wifi/v2/clients") + assert resp.status_code == 404