Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 6 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
29 changes: 19 additions & 10 deletions app/engine/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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",
Expand Down
124 changes: 113 additions & 11 deletions app/web/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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):
Expand Down Expand Up @@ -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),
Expand Down
13 changes: 7 additions & 6 deletions app/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions app/web/templates/_cluster_body.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
{% else %}
<button class="btn sm" hx-post="/api/cluster/control-peer" hx-vals='{{ vals }}start"}' hx-swap="none">Start</button>
{% endif %}
<button class="btn sm" hx-get="/api/cluster/peer-form?scheme={{ n.scheme }}&host={{ n.ip }}&port={{ n.port }}&mid={{ m.id }}" hx-target="#form-panel" hx-swap="innerHTML">Edit</button>
{% endif %}
</td>
</tr>
Expand Down
9 changes: 7 additions & 2 deletions app/web/templates/_mapping_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,17 @@

{% set ip_values = ips | map(attribute='value') | list %}
{% set bind_is_custom = mapping.network.bind_ip not in ip_values %}
<form class="mform" hx-post="/api/mappings/save" hx-target="#form-panel" hx-swap="innerHTML">
<form class="mform" hx-post="{{ '/api/cluster/peer-save' if remote else '/api/mappings/save' }}" hx-target="#form-panel" hx-swap="innerHTML">
<input type="hidden" name="_csrf" value="{{ request.state.csrf_token }}">
{% if remote %}
<input type="hidden" name="scheme" value="{{ remote.scheme }}">
<input type="hidden" name="host" value="{{ remote.host }}">
<input type="hidden" name="port" value="{{ remote.port }}">
{% endif %}
{% if not is_new %}<input type="hidden" name="id" value="{{ mapping.id }}">{% endif %}

<div class="form-head">
<h3>{{ 'New mapping' if is_new else 'Edit mapping' }}</h3>
<h3>{{ 'New mapping' if is_new else 'Edit mapping' }}{% if remote %} <span class="tag">on {{ remote.host }}:{{ remote.port }}</span>{% endif %}</h3>
<button type="button" class="btn sm" data-cancel>Cancel</button>
</div>
{% if error %}<div class="banner error">{{ error }}</div>{% endif %}
Expand Down
Binary file modified docs/screenshots/02-dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/03-add-mapping.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/04-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/06-cluster.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading