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
13 changes: 8 additions & 5 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ with low latency.

![Dashboard](docs/screenshots/02-dashboard.png)

**LAN cluster — every node's mappings in one table, each row tagged with its host (name + IP):**
**LAN cluster — every node's mappings in one table; per-node health + remote start/stop:**

![Cluster](docs/screenshots/06-cluster.png)

Expand Down Expand Up @@ -87,10 +87,13 @@ everything from a **password-protected web UI** — no CLI or hand-edited config
- **REST API:** a JSON API (`/api/v1`) for automation — mapping CRUD, start/stop/restart,
status and ports; **bearer-token** auth (with a role — `viewer` is read-only); OpenAPI
3.0 (`/api/v1/openapi.json`). The token is generated in Settings.
- **LAN cluster (fleet view):** 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
read-only table**, each row tagged with its host (name + IP). Set the **same shared key**
on every node so they trust each other. Off by default; enable under Settings → LAN cluster.
- **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
**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;
Linux+Windows × Python 3.10–3.13 **CI** (GitHub Actions).
- **MQTT publishing (optional):** publish each mapping's serial lines to an MQTT
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ web arayüzünden yönetilen, çapraz-platform (Windows + Linux) saf-Python köp

![Pano](docs/screenshots/02-dashboard.png)

**LAN cluster — ağdaki tüm düğümlerin eşlemeleri tek tabloda, her satırda host (ad + IP):**
**LAN cluster — tüm düğümlerin eşlemeleri tek tabloda; düğüm sağlığı + uzaktan başlat/durdur:**

![Cluster](docs/screenshots/06-cluster.png)

Expand Down Expand Up @@ -87,11 +87,14 @@ düzenlemeye gerek yok.
- **REST API:** otomasyon için JSON API (`/api/v1`) — eşleme CRUD, başlat/durdur/yeniden,
durum ve portlar; **bearer-token** kimlik doğrulama (rol seçilebilir — `viewer`
salt-okunur); OpenAPI 3.0 (`/api/v1/openapi.json`). Token, Ayarlar'dan üretilir.
- **LAN cluster (filo görünümü):** aynı ağdaki düğümler birbirini **otomatik bulur**
- **LAN cluster (filo yönetimi):** aynı ağdaki düğümler birbirini **otomatik bulur**
(imzalı UDP broadcast — mDNS yok) ve bir düğüm **tüm düğümlerin eşlemelerini tek
salt-okunur tabloda** gösterir; her satır hangi bilgisayara ait olduğunu (ad + IP)
belirtir. Düğümlerin birbirine güvenmesi için her birine **aynı paylaşılan anahtar**
girilir. Varsayılan kapalı; Ayarlar → LAN cluster'dan açılır.
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.
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.
- **Dağıtım:** resmi **Docker** imajı + `docker-compose`; **systemd** birimi;
Linux+Windows × Python 3.10–3.13 **CI** (GitHub Actions).
- **MQTT yayınlama (opsiyonel):** seri satırlarını eşleme başına bir MQTT broker'ına
Expand Down
28 changes: 16 additions & 12 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ser2net — Roadmap

> Current release: **v2.5.0**. CI is green across Linux + Windows × Python 3.10–3.13.
> Current release: **v2.6.0**. CI is green across Linux + Windows × Python 3.10–3.13.

## Shipped

Expand Down Expand Up @@ -75,24 +75,28 @@
### v2.5 — LAN cluster
- **Auto-discovery + unified fleet view**: nodes find each other via HMAC-signed UDP
broadcast beacons (no mDNS) and one node aggregates every node's mappings into a
single **read-only** table, each row tagged with its host (name + IP). Opt-in, off by
single read-only table, each row tagged with its host (name + IP). Opt-in, off by
default; trust = a shared cluster key. Server-side fan-out to peers' key-guarded
`/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)
- **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
alongside auto-discovered nodes

---

## Planned

### v2.6 — cluster depth & hardening
- **Remote control** from the unified view: start/stop/edit a peer's mappings (the read
path exists; add a key-guarded write endpoint + role check on the target node)
- **Per-node health/uptime** column; surface a node-level "discovery disabled" status in
the UI when the UDP bind fails (today it only logs)
- **Manual peer list** for routed/L3 networks where broadcast doesn't reach (complement
to auto-discovery)
- **Security hardening** (from the v2.5 review): validate/curb peer-advertised IPs before
server-side fetch (SSRF defense-in-depth), optional TLS certificate pinning for peer
fetch, a light rate-limit on `/api/cluster/local`, optional IPv6 (multicast) discovery
### v2.6.x — cluster hardening (from the v2.5 review)
- 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
4 changes: 4 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +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"
33 changes: 31 additions & 2 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,24 +526,51 @@ def validate(self) -> None:
raise ConfigError(f"OIDC default role must be one of {ROLES} or blank.")


def parse_cluster_peer(entry: str) -> tuple[str, str, int]:
"""Parse a manual cluster-peer entry into (scheme, host, port). Accepts
'host:port' (defaults to http) or 'http(s)://host:port'. Raises ConfigError."""
s = (entry or "").strip()
scheme = "http"
if "://" in s:
scheme, s = s.split("://", 1)
scheme = scheme.lower()
if scheme not in ("http", "https"):
raise ConfigError(f"Cluster peer scheme must be http or https: {entry}")
host, sep, port_s = s.rpartition(":")
if not sep or not host:
raise ConfigError(f"Cluster peer must be host:port — got: {entry}")
try:
port = int(port_s)
except ValueError:
raise ConfigError(f"Cluster peer port is not a number: {entry}") from None
if not (1 <= port <= 65535):
raise ConfigError(f"Cluster peer port must be 1..65535: {entry}")
return scheme, host, port


@dataclass
class ClusterSettings:
"""LAN cluster: instances discover each other via signed UDP broadcast beacons
and one node aggregates every node's mappings into a single read-only view.
Opt-in: disabled until `enabled` is set AND a shared `key` is configured. The
key both signs beacons (so only same-key nodes trust each other) and guards the
peer-facing status endpoint. `advertise_ip` overrides the auto-detected LAN IP
that peers use to reach this node's web UI (blank => auto)."""
that peers use to reach this node's web UI (blank => auto). `peers` is a list of
manual 'host:port' entries for routed/L3 networks where UDP broadcast doesn't
reach — they're aggregated alongside auto-discovered peers."""
enabled: bool = False
key: str = "" # shared secret (PSK); empty => cluster off
discovery_port: int = 41750 # UDP port for broadcast beacons
advertise_ip: str = "" # override advertised web-UI IP (blank => auto-detect)
peers: list[str] = field(default_factory=list) # manual 'host:port' peer entries

@staticmethod
def from_dict(d: dict[str, Any]) -> "ClusterSettings":
d = dict(d or {})
known = {f.name for f in dataclasses.fields(ClusterSettings)}
return ClusterSettings(**{k: v for k, v in d.items() if k in known})
c = ClusterSettings(**{k: v for k, v in d.items() if k in known})
c.peers = [str(p).strip() for p in (c.peers or []) if str(p).strip()]
return c

@property
def active(self) -> bool:
Expand All @@ -566,6 +593,8 @@ def validate(self) -> None:
except ValueError:
raise ConfigError(
f"Cluster advertise IP is not a valid address: {self.advertise_ip}") from None
for entry in self.peers:
parse_cluster_peer(entry) # raises ConfigError on a bad entry


@dataclass
Expand Down
74 changes: 67 additions & 7 deletions app/engine/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@
import socket
import ssl
import time
import urllib.parse
import urllib.request

from . import netinfo
from ..config import parse_cluster_peer

BEACON_INTERVAL = 5.0 # seconds between outbound beacons
PEER_TTL = 20.0 # a peer is dropped if not heard from within this window
Expand Down Expand Up @@ -60,6 +62,7 @@ def __init__(self, config, logger):
self._beacon_task: asyncio.Task | None = None
self._peers: dict[str, dict] = {} # id -> {id,name,ip,port,scheme,last_seen}
self._running = False
self.discovery_error = "" # non-empty when UDP discovery couldn't start

# ----- config helpers -----
@property
Expand Down Expand Up @@ -124,6 +127,37 @@ def peers(self) -> list[dict]:
self._peers = {p["id"]: p for p in live}
return sorted(live, key=lambda p: (p["name"].lower(), p["ip"]))

def manual_targets(self) -> list[dict]:
"""Configured manual peers (for routed/L3 networks broadcast can't reach),
parsed into fetch targets. Bad entries are skipped (validate() rejects them
at save time, so this only guards a hand-edited config)."""
out = []
for entry in self.cluster.peers:
try:
scheme, host, port = parse_cluster_peer(entry)
except Exception:
continue
out.append({"scheme": scheme, "ip": host, "port": port, "source": "manual"})
return out

def all_targets(self) -> list[dict]:
"""Auto-discovered peers + manual peers, deduped by (ip, port). When a manual
peer is also auto-discovered the richer auto entry wins."""
seen, targets = set(), []
for p in self.peers():
seen.add((p["ip"], p["port"]))
targets.append({**p, "source": "auto"})
for m in self.manual_targets():
if (m["ip"], m["port"]) not in seen:
seen.add((m["ip"], m["port"]))
targets.append(m)
return targets

def known_addresses(self) -> set:
"""(ip, port) allowlist of peers this node may reach — used to bound the
remote-control proxy so the browser can't aim it at an arbitrary address."""
return {(t["ip"], int(t["port"])) for t in self.all_targets()}

# ----- lifecycle -----
async def start(self) -> None:
if self._running or not self.cluster.active:
Expand All @@ -139,15 +173,19 @@ async def start(self) -> None:
sock.bind(("", port))
except OSError as e:
sock.close()
self.log(f"cluster: could not bind UDP {port}: {e} — discovery disabled")
self.discovery_error = f"UDP discovery port {port} unavailable: {e}"
self.log(f"cluster: could not bind UDP {port}: {e} — discovery disabled "
"(manual peers still work)")
return
try:
self._transport, _ = await loop.create_datagram_endpoint(
lambda: _BeaconProtocol(self.handle_datagram), sock=sock)
except OSError as e:
sock.close() # endpoint creation owns the socket only on success
self.discovery_error = f"UDP listener on {port} failed: {e}"
self.log(f"cluster: could not start UDP listener on {port}: {e} — discovery disabled")
return
self.discovery_error = ""
self._running = True
self._beacon_task = asyncio.create_task(self._beacon_loop(), name="cluster-beacon")
self.log(f"cluster: discovery on UDP {port} as '{socket.gethostname()}' "
Expand Down Expand Up @@ -203,20 +241,42 @@ def _broadcast_addrs() -> list[str]:
out.append(a)
return out

@staticmethod
def _peer_ssl_ctx(url: str):
if not url.startswith("https"):
return None
ctx = ssl.create_default_context()
ctx.check_hostname = False
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"
return await asyncio.to_thread(self._http_get_json, url, timeout)

def _http_get_json(self, url: str, timeout: float) -> dict | None:
req = urllib.request.Request(url, headers={"X-Cluster-Key": self.cluster.key})
ctx = None
if url.startswith("https"):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE # cluster trust is the shared key, not the cert
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:
with urllib.request.urlopen(req, timeout=timeout, context=self._peer_ssl_ctx(url)) as r:
return json.loads(r.read().decode("utf-8"))
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",
headers={"X-Cluster-Key": self.cluster.key,
"Content-Type": "application/x-www-form-urlencoded"})
try:
with urllib.request.urlopen(req, timeout=timeout, context=self._peer_ssl_ctx(url)) as r:
return json.loads(r.read().decode("utf-8"))
except Exception:
return None
Loading
Loading