diff --git a/README.en.md b/README.en.md index b5b99b7..65a9d08 100644 --- a/README.en.md +++ b/README.en.md @@ -90,8 +90,8 @@ everything from a **password-protected web UI** — no CLI or hand-edited config - **LAN cluster (fleet management):** nodes on the same network **auto-discover** each other (signed UDP broadcast — no mDNS) and one node shows **every node's mappings in a single table**, each row tagged with its host (name + IP) and each node's **health** - (uptime · version · running/total). Operators can **remotely start/stop/restart** a - peer's mappings from that view. For routed/L3 networks broadcast can't reach, add + (uptime · version · running/total). Operators can **remotely start/stop/restart and edit** + a peer's mappings from that view. For routed/L3 networks broadcast can't reach, add **manual peers** (host:port). Trust = the **same shared key** on every node; off by default, enable under Settings → LAN cluster. - **Deployment:** official **Docker** image + `docker-compose`; **systemd** unit; diff --git a/README.md b/README.md index 4221c27..e87de10 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ düzenlemeye gerek yok. (imzalı UDP broadcast — mDNS yok) ve bir düğüm **tüm düğümlerin eşlemelerini tek tabloda** gösterir; her satır hangi bilgisayara ait olduğunu (ad + IP) ve her düğümün **sağlığını** (uptime · sürüm · çalışan/toplam) belirtir. Operatörler bu ekrandan - başka bir host'un eşlemelerini **uzaktan başlat/durdur/yeniden başlat** edebilir. + başka bir host'un eşlemelerini **uzaktan başlat/durdur/yeniden başlat ve düzenle** edebilir. Broadcast'in ulaşmadığı yönlendirilmiş/L3 ağlar için **manuel peer** (host:port) eklenebilir. Güven = her düğümde **aynı paylaşılan anahtar**; varsayılan kapalı, Ayarlar → LAN cluster'dan açılır. diff --git a/ROADMAP.md b/ROADMAP.md index 81f795c..503d848 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ser2net — Roadmap -> Current release: **v2.6.0**. CI is green across Linux + Windows × Python 3.10–3.13. +> Current release: **v2.6.1**. CI is green across Linux + Windows × Python 3.10–3.13. ## Shipped @@ -80,9 +80,11 @@ `/api/cluster/local`; the browser only talks to the node it logged into. ### v2.6 — cluster depth -- **Remote control** from the unified view: operators start/stop/restart a peer's mappings - (key-guarded `/api/cluster/control` on the target + a session-authed proxy that validates - the peer against a known-address allowlist, so the browser can't aim it anywhere) +- **Remote control + edit** from the unified view: operators start/stop/restart **and edit** + a peer's mappings. Key-guarded peer endpoints (`/api/cluster/control`, `/mapping-data`, + `/mapping-save`); session-authed proxies validate the target against a known-address + allowlist, and the edit **form is rendered on the controlling node** (so the CSRF token + is the browser's) then proxied to the peer with the shared key. - **Per-node health**: uptime · version · running/total, plus an online/offline indicator and a UI banner when UDP discovery can't bind (manual peers still work) - **Manual peers** (`host:port`) for routed/L3 networks broadcast can't reach, aggregated @@ -96,7 +98,6 @@ - Validate/curb peer-advertised IPs before the server-side fetch (SSRF defense-in-depth) - Optional TLS certificate pinning for peer fetch/control; a light rate-limit on the key-guarded peer endpoints; optional IPv6 (multicast) discovery -- Remote **edit** of a peer's mapping (today's remote control is start/stop/restart) ### v2.7 — industrial/IIoT depth - **Sparkplug B** edge payloads (Modbus register + MQTT plumbing already in place) diff --git a/app/__init__.py b/app/__init__.py index 8dff27f..77bc75e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,4 +1,4 @@ """ser2net — serial-to-network bridge with a web config UI.""" # Single source of truth for the version (ser2net.py and the web layer import this). -__version__ = "2.6.0" +__version__ = "2.6.1" diff --git a/app/engine/cluster.py b/app/engine/cluster.py index 8096629..b577520 100644 --- a/app/engine/cluster.py +++ b/app/engine/cluster.py @@ -250,11 +250,27 @@ def _peer_ssl_ctx(url: str): ctx.verify_mode = ssl.CERT_NONE # cluster trust is the shared key, not the cert return ctx - # ----- peer aggregation (HTTP) ----- - async def fetch_peer(self, peer: dict, timeout: float = 2.5) -> dict | None: - url = f"{peer['scheme']}://{peer['ip']}:{peer['port']}/api/cluster/local" + # ----- peer HTTP (all key-guarded; trust is the shared key) ----- + async def get_peer(self, scheme: str, host: str, port: int, path: str, + timeout: float = 3.0) -> dict | None: + url = f"{scheme}://{host}:{port}{path}" return await asyncio.to_thread(self._http_get_json, url, timeout) + async def post_peer(self, scheme: str, host: str, port: int, path: str, + fields: dict, timeout: float = 6.0) -> dict | None: + url = f"{scheme}://{host}:{port}{path}" + data = urllib.parse.urlencode(fields).encode() + return await asyncio.to_thread(self._http_post_json, url, data, timeout) + + async def fetch_peer(self, peer: dict, timeout: float = 2.5) -> dict | None: + return await self.get_peer(peer["scheme"], peer["ip"], peer["port"], + "/api/cluster/local", timeout) + + async def control_peer(self, scheme: str, host: str, port: int, mapping_id: str, + action: str, timeout: float = 4.0) -> dict | None: + return await self.post_peer(scheme, host, port, "/api/cluster/control", + {"mapping_id": mapping_id, "action": action}, timeout) + def _http_get_json(self, url: str, timeout: float) -> dict | None: req = urllib.request.Request(url, headers={"X-Cluster-Key": self.cluster.key}) try: @@ -263,13 +279,6 @@ def _http_get_json(self, url: str, timeout: float) -> dict | None: except Exception: return None - # ----- remote control (HTTP POST to a peer's key-guarded control endpoint) ----- - async def control_peer(self, scheme: str, host: str, port: int, mapping_id: str, - action: str, timeout: float = 4.0) -> dict | None: - url = f"{scheme}://{host}:{port}/api/cluster/control" - data = urllib.parse.urlencode({"mapping_id": mapping_id, "action": action}).encode() - return await asyncio.to_thread(self._http_post_json, url, data, timeout) - def _http_post_json(self, url: str, data: bytes, timeout: float) -> dict | None: req = urllib.request.Request( url, data=data, method="POST", diff --git a/app/web/routes.py b/app/web/routes.py index 39be078..3321f6b 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -757,6 +757,97 @@ async def cluster_control_peer(request): state.audit(client_ip(request), f"cluster_control_{action}", f"{host}:{port}/{res.get('name', mid)}") return Response("", headers={"HX-Trigger": "refreshCluster"}) + # ---- remote EDIT: peer renders no UI; THIS node renders the form (so the CSRF + # token + form action belong to the browser's session) and proxies the save. + async def cluster_mapping_data(request): + """Peer-facing: a mapping's full config + this node's serial ports + IP + candidates, so the controlling node can render the edit form. Key-guarded.""" + cfg = state.config + key = request.headers.get("x-cluster-key", "") + if not (cfg.cluster.active and key and hmac.compare_digest(key, cfg.cluster.key)): + return JSONResponse({"error": "forbidden"}, status_code=403) + m = cfg.get_mapping(request.query_params.get("mid", "")) + if not m: + return JSONResponse({"error": "not_found"}, status_code=404) + return JSONResponse({"mapping": m.to_dict(), "ports": state.ports.get(), + "ips": netinfo.list_ip_candidates()}) + + async def cluster_mapping_save(request): + """Peer-facing: validate + persist + apply an edited mapping on THIS node, run + from the same form the controlling node rendered. Key-guarded.""" + cfg = state.config + key = request.headers.get("x-cluster-key", "") + if not (cfg.cluster.active and key and hmac.compare_digest(key, cfg.cluster.key)): + return JSONResponse({"error": "forbidden"}, status_code=403) + form = await request.form() + ok, msg = await _save_mapping_from_form(form) + if not ok: + return JSONResponse({"ok": False, "error": msg}, status_code=400) + ip = client_ip(request) + state.log(f"cluster: remote edit of '{msg}' from {ip}") + state.audit(ip, "cluster_remote_save", msg) + return JSONResponse({"ok": True, "name": msg}) + + def _peer_from_request(getter): + """(scheme, host, port) from a request, validated against the peer allowlist. + Returns (tuple, None) on success or (None, error-response).""" + scheme, host = getter("scheme", "http"), getter("host", "") + try: + port = int(getter("port", "0")) + except ValueError: + return None, PlainTextResponse("Bad peer port", status_code=400) + if (host, port) not in state.cluster.known_addresses(): + return None, PlainTextResponse("Unknown cluster peer", status_code=403) + return (scheme, host, port), None + + async def cluster_peer_form(request): + """Browser-facing proxy: load a peer's mapping into the edit form (rendered + here so the CSRF token is the browser's). Submits to /api/cluster/peer-save.""" + if deny := require_role(request, "operator"): + return deny + peer, err = _peer_from_request(request.query_params.get) + if err: + return err + scheme, host, port = peer + mid = request.query_params.get("mid", "") + data = await state.cluster.get_peer(scheme, host, port, f"/api/cluster/mapping-data?mid={mid}") + if not data or "mapping" not in data: + return PlainTextResponse("Could not load the peer's mapping.", status_code=502) + mapping = MappingConfig.from_dict(data["mapping"]) + return render(request, "_mapping_form.html", mapping=mapping, is_new=False, + modbus_points_json=_modbus_points_json(mapping), + ports=data.get("ports", []), ips=data.get("ips", []), + remote={"scheme": scheme, "host": host, "port": port}, error=None) + + async def cluster_peer_save(request): + """Browser-facing proxy: forward an edited mapping form to the peer's key-guarded + save (peer fields stripped); on a peer error, re-render the form with it.""" + if deny := require_role(request, "operator"): + return deny + form = await request.form() + peer, err = _peer_from_request(form.get) + if err: + return err + scheme, host, port = peer + skip = {"_peer_scheme", "_peer_host", "_peer_port", "_csrf", "scheme", "host", "port"} + fields = [(k, v) for k, v in form.multi_items() if k not in skip] + res = await state.cluster.post_peer(scheme, host, port, "/api/cluster/mapping-save", fields) + if res and res.get("ok"): + state.log(f"cluster: edited '{res.get('name', '')}' on {host}:{port}") + state.audit(client_ip(request), "cluster_edit", f"{host}:{port}/{res.get('name', '')}") + return Response("", headers={"HX-Trigger": "refreshCluster"}) + msg = (res or {}).get("error") or "The peer rejected the change or was unreachable." + data = await state.cluster.get_peer(scheme, host, port, + f"/api/cluster/mapping-data?mid={form.get('id', '')}") + try: + mapping = MappingConfig.from_dict(_build_mapping_dict(form, strict=False)) + except Exception: + mapping = MappingConfig(name=form.get("name", "")) + return render(request, "_mapping_form.html", status_code=400, mapping=mapping, is_new=False, + modbus_points_json=_modbus_points_json(mapping), + ports=(data or {}).get("ports", []), ips=(data or {}).get("ips", []), + remote={"scheme": scheme, "host": host, "port": port}, error=msg) + async def settings_cluster_post(request): if deny := require_role(request, "admin"): return deny @@ -839,21 +930,19 @@ async def console_view(request): return render(request, "_console.html", mid=mid, mapping_name=m.name, interactive=interactive) - async def mapping_save(request): - if deny := require_role(request, "operator"): - return deny - form = await request.form() + async def _save_mapping_from_form(form) -> tuple[bool, str]: + """Parse a mapping form, validate, persist and (re)apply. Returns (ok, name) + on success or (False, error). Shared by the local UI save and the cluster + remote-edit save (which runs the same form on the target node).""" try: - data = _mapping_from_form(form) - mapping = MappingConfig.from_dict(data) + mapping = MappingConfig.from_dict(_mapping_from_form(form)) # The form doesn't expose every field; when editing, carry the unexposed # ones over so a save never silently wipes them (get_mapping is a sync # read — safe outside the config lock). _preserve_unmanaged_fields(mapping, state.config.get_mapping(mapping.id)) mapping.validate() except (ValueError, ConfigError) as e: - return _form_error(render, request, state, form, str(e)) - + return False, str(e) # Hold the lock only to mutate + persist config; release it BEFORE the # (potentially slow) supervisor call so other admin requests aren't blocked # while a serial port is being (re)opened. @@ -868,10 +957,19 @@ async def mapping_save(request): await state.asave() except ConfigError as e: state.config.mappings = backup - return _form_error(render, request, state, form, str(e)) + return False, str(e) await state.supervisor.apply_mapping(mapping) - state.log(f"mapping saved: {mapping.name}") - state.audit(client_ip(request), "mapping_save", mapping.name) + return True, mapping.name + + async def mapping_save(request): + if deny := require_role(request, "operator"): + return deny + form = await request.form() + ok, msg = await _save_mapping_from_form(form) + if not ok: + return _form_error(render, request, state, form, msg) + state.log(f"mapping saved: {msg}") + state.audit(client_ip(request), "mapping_save", msg) return Response("", headers=_TRIGGER) async def mapping_delete(request): @@ -957,6 +1055,10 @@ async def mapping_action(request): Route("/api/cluster/local", cluster_local), Route("/api/cluster/control", cluster_control, methods=["POST"]), Route("/api/cluster/control-peer", cluster_control_peer, methods=["POST"]), + Route("/api/cluster/mapping-data", cluster_mapping_data), + Route("/api/cluster/mapping-save", cluster_mapping_save, methods=["POST"]), + Route("/api/cluster/peer-form", cluster_peer_form), + Route("/api/cluster/peer-save", cluster_peer_save, methods=["POST"]), Route("/api/ports.json", api_ports_json), Route("/api/ports/refresh", api_ports_refresh, methods=["POST"]), Route("/api/ports/table", api_ports_table), diff --git a/app/web/server.py b/app/web/server.py index cec8a50..d4a7496 100644 --- a/app/web/server.py +++ b/app/web/server.py @@ -23,11 +23,12 @@ STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") PUBLIC_PREFIXES = ("/static", "/auth/") # /auth/oidc/* is the unauthenticated SSO flow -# /api/cluster/local + /api/cluster/control are authenticated by the shared cluster -# KEY (checked in the handler), not a user session — so they skip the session gate. -# (control-peer is the browser-facing proxy and keeps the normal session + CSRF.) -PUBLIC_PATHS = {"/login", "/setup", "/healthz", "/favicon.ico", - "/api/cluster/local", "/api/cluster/control"} +# The peer-facing cluster endpoints are authenticated by the shared cluster KEY +# (checked in the handler), not a user session — so they skip the session gate. +# (The *-peer / peer-* proxies are browser-facing and keep the normal session + CSRF.) +_CLUSTER_PEER_PATHS = {"/api/cluster/local", "/api/cluster/control", + "/api/cluster/mapping-data", "/api/cluster/mapping-save"} +PUBLIC_PATHS = {"/login", "/setup", "/healthz", "/favicon.ico"} | _CLUSTER_PEER_PATHS # High-frequency automatic UI refreshes — not logged to all.log to avoid flooding # the audit trail (the user actions that drive them ARE logged). @@ -66,7 +67,7 @@ async def dispatch(self, request, call_next): # here, since doing so in a BaseHTTPMiddleware drains the stream before the # route handler can parse the form. if request.method in ("POST", "PUT", "DELETE", "PATCH") and path.startswith("/api/") \ - and path != "/api/cluster/control": # peer-to-peer call: key-authed, no cookie + and path not in _CLUSTER_PEER_PATHS: # peer-to-peer calls: key-authed, no cookie if not auth.csrf_token_matches(request, request.headers.get("x-csrf-token")): return PlainTextResponse("CSRF validation failed. Reload the page.", status_code=403) diff --git a/app/web/templates/_cluster_body.html b/app/web/templates/_cluster_body.html index 8940e09..83885b8 100644 --- a/app/web/templates/_cluster_body.html +++ b/app/web/templates/_cluster_body.html @@ -43,6 +43,7 @@ {% else %} {% endif %} + {% endif %} diff --git a/app/web/templates/_mapping_form.html b/app/web/templates/_mapping_form.html index 1b7ade2..9e9715c 100644 --- a/app/web/templates/_mapping_form.html +++ b/app/web/templates/_mapping_form.html @@ -45,12 +45,17 @@ {% set ip_values = ips | map(attribute='value') | list %} {% set bind_is_custom = mapping.network.bind_ip not in ip_values %} -
+ + {% if remote %} + + + + {% endif %} {% if not is_new %}{% endif %}
-

{{ 'New mapping' if is_new else 'Edit mapping' }}

+

{{ 'New mapping' if is_new else 'Edit mapping' }}{% if remote %} on {{ remote.host }}:{{ remote.port }}{% endif %}

{% if error %}{% endif %} diff --git a/docs/screenshots/02-dashboard.png b/docs/screenshots/02-dashboard.png index 42e5d96..ea349ff 100644 Binary files a/docs/screenshots/02-dashboard.png and b/docs/screenshots/02-dashboard.png differ diff --git a/docs/screenshots/03-add-mapping.png b/docs/screenshots/03-add-mapping.png index 79044ea..701b80e 100644 Binary files a/docs/screenshots/03-add-mapping.png and b/docs/screenshots/03-add-mapping.png differ diff --git a/docs/screenshots/04-settings.png b/docs/screenshots/04-settings.png index 2715b7e..4f46318 100644 Binary files a/docs/screenshots/04-settings.png and b/docs/screenshots/04-settings.png differ diff --git a/docs/screenshots/06-cluster.png b/docs/screenshots/06-cluster.png index a3c87fd..9e6a0ff 100644 Binary files a/docs/screenshots/06-cluster.png and b/docs/screenshots/06-cluster.png differ diff --git a/tests/test_cluster.py b/tests/test_cluster.py index d11c54e..d373586 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -293,7 +293,36 @@ def post_a(path, fields): assert post_a("/api/cluster/control-peer", bad) == 403, "unknown peer must be rejected" print("control proxy rejects an unknown (non-peer) address OK") - print("\nPASS: manual peers + per-node health + remote control") + # ---- remote EDIT: load the peer's mapping into the form, change it, save ---- + form_url = (f"/api/cluster/peer-form?scheme=http&host=127.0.0.1" + f"&port={ui_b}&mid={mid}") + html = op.open(f"http://127.0.0.1:{ui_a}{form_url}", timeout=10).read().decode() + assert "/api/cluster/peer-save" in html and 'name="host"' in html, html[:400] + assert "Edit mapping" in html and "/dev/ttyTEST" in html, "form should show peer's values" + print("remote edit: peer's mapping loads into the form (routed to peer-save) OK") + + # peer-facing save is key-guarded + code, _ = raw_request(ui_b, "/api/cluster/mapping-save", method="POST") + assert code == 403, f"mapping-save without key must be 403, got {code}" + + edit = {"_csrf": csrf(), "scheme": "http", "host": "127.0.0.1", "port": str(ui_b), + "id": mid, "name": "REMOTE1", "enabled": "on", "kind": "net", + "serial_port": "/dev/ttyEDITED", "serial_baudrate": "19200", + "serial_bytesize": "8", "serial_parity": "N", "serial_stopbits": "1", + "serial_flowcontrol": "none", "network_mode": "server", + "network_protocol": "raw", "network_bind_ip": "127.0.0.1", + "network_port": str(map_port)} + assert post_a("/api/cluster/peer-save", edit) == 200, "remote edit should be accepted" + for _ in range(20): + _, body = raw_request(ui_b, "/api/cluster/local", {"X-Cluster-Key": key}) + if json.loads(body)["mappings"][0]["serial"] == "/dev/ttyEDITED": + break + time.sleep(0.1) + assert json.loads(body)["mappings"][0]["serial"] == "/dev/ttyEDITED", \ + "remote edit did not change the peer's mapping" + print("remote edit of a peer's mapping took effect on the peer OK") + + print("\nPASS: manual peers + per-node health + remote control + remote edit") finally: for proc, tmp in ((pa, ta), (pb, tb)): proc.terminate()