From 95b9db1ad4cc1d62024b688f936ddd1a57f5204e Mon Sep 17 00:00:00 2001 From: ConvergeLoop Bot Date: Wed, 30 Sep 2026 20:57:55 +0800 Subject: [PATCH 1/3] revert(iec104): restore standard frame format - 1-byte APDU length, 6-byte ASDU header ConvergeLoop commit 1df6f50 mistakenly applied IEC 60870-5-101 (FT1.2) frame layout to IEC 60870-5-104: 2-byte APDU length (L1 L2) and 2-byte COT/OA in the ASDU header. Standard 104 frames are 68 L(1) C1..C4 with a 6-byte ASDU header (TI+VSQ+COT(1)+OA(1)+CA(2 LE)). The regression made the server drop standard masters on connect (0x07 control byte rejected as L2) and reintroduced the CA/IOA x256 bug fixed in 4f7f803, which shipped in v1.4.0. Restore the wire-verified 4f7f803 server. --- protoforge/protocols/iec104/server.py | 42 ++++++++++----------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/protoforge/protocols/iec104/server.py b/protoforge/protocols/iec104/server.py index c4585a3..86ac2bb 100644 --- a/protoforge/protocols/iec104/server.py +++ b/protoforge/protocols/iec104/server.py @@ -350,13 +350,6 @@ async def _handle_connection(self, reader: asyncio.StreamReader, writer: asyncio logger.warning("IEC 104: bad APDU length %d", length) self.record_protocol_error(ProtocolErrorCategory.FRAME_PARSE, f"bad APDU length {length}") break - # 标准 IEC 104 APDU 长度字段为 2 字节(L1 L2,L2 恒 0): - # 68 L1 L2 C1..C4 ASDU。原实现只读 1 字节长度,导致标准 - # 客户端(EdgeLite / 真实主站)的帧控制域与 ASDU 整体错位。 - l2 = await asyncio.wait_for(reader.readexactly(1), timeout=self._t1_timeout) - if l2[0] != 0: - self.record_protocol_error(ProtocolErrorCategory.FRAME_PARSE, f"bad L2 byte {l2[0]:#x}") - break apdu = await asyncio.wait_for(reader.readexactly(length), timeout=self._t1_timeout) conn_state["last_activity"] = time.time() @@ -383,11 +376,9 @@ async def _handle_connection(self, reader: asyncio.StreamReader, writer: asyncio self._log_debug("send", "disconnected", f"IEC104 client disconnected: {peer[0]}:{peer[1]}") def _write_apdu(self, writer: asyncio.StreamWriter, control: bytes, asdu: bytes = b"") -> None: - """Write one standard APDU: 0x68 L1 L2(=0) control[4] asdu.""" + """Write one APDU: 0x68 len control[4] asdu.""" payload = control + asdu - # 标准 IEC 104 长度字段为 2 字节(L1 L2,L2 恒 0);原实现只写 - # 1 字节长度导致标准客户端把 0x0B/0x0D 等 U/I 控制码当 L2 解析。 - writer.write(bytes([APDU_START, len(payload), 0x00]) + payload) + writer.write(bytes([APDU_START, len(payload)]) + payload) async def _handle_u_frame(self, writer: asyncio.StreamWriter, apdu: bytes, conn_state: dict) -> None: u_type = apdu[0] @@ -454,14 +445,14 @@ async def _process_asdus(self, writer: asyncio.StreamWriter, data: bytes, conn_s self.record_protocol_error(ProtocolErrorCategory.INTERNAL, str(e)) def _asdu_length(self, ti: int, num: int, sq: bool) -> int | None: - """Total ASDU length: standard 8-byte header + information objects.""" + """Total ASDU length: header 6 (TI+VSQ+COT+OA+CA) + information objects.""" obj = self._object_size(ti) if obj is None: return None if sq: # only the first object carries the IOA - return 8 + 3 + num * obj - return 8 + num * (3 + obj) + return 6 + 3 + num * obj + return 6 + num * (3 + obj) def _object_size(self, ti: int) -> int | None: """Information object payload size (excluding IOA) or None if unsupported.""" @@ -489,11 +480,12 @@ def _object_size(self, ti: int) -> int | None: async def _process_asdu(self, writer: asyncio.StreamWriter, asdu: bytes, conn_state: dict) -> None: ti = asdu[0] vsq = asdu[1] - # 标准 104 ASDU 头 8 字节: TI(1) VSQ(1) COT(2 LE) OA(2 LE) CA(2 LE)。 - # 旧实现按 6 字节头(COT/OA 各 1 字节)解析,与标准主站不兼容。 - cot = struct.unpack(" None: - qoi = asdu[11] if len(asdu) > 11 else 20 # header(8)+IOA(3) 之后即 QOI + qoi = asdu[9] if len(asdu) > 9 else 20 # header(6)+IOA(3) 之后即 QOI # ACT confirm ack = bytearray(self._asdu_header(TI_INTERROGATION, 1, COT_ACTCONFIRM, ca)) ack += b"\x00\x00\x00" + bytes([qoi]) @@ -554,7 +546,7 @@ async def _handle_clock_sync(self, writer: asyncio.StreamWriter, asdu: bytes, ca async def _handle_commands(self, writer: asyncio.StreamWriter, asdu: bytes, ti: int, ca: int, num: int, sq: bool, conn_state: dict) -> None: obj_size = self._object_size(ti) or 1 - offset = 8 # standard ASDU header: TI(1)+VSQ(1)+COT(2)+OA(2)+CA(2) + offset = 6 # ASDU header: TI(1)+VSQ(1)+COT(1)+OA(1)+CA(2) for i in range(num): if sq: if offset + 3 + obj_size > len(asdu): @@ -647,14 +639,12 @@ def _build_command_ack_asdu(self, ti: int, ca: int, ioa: int, payload: bytes, co # monitor direction (spontaneous / interrogation data) # ------------------------------------------------------------------ def _asdu_header(self, ti: int, num: int, cot: int, ca: int) -> bytes: - # 标准 IEC 60870-5-104 ASDU 固定头(8 字节): TI(1) + VSQ(1) + COT(2 LE) - # + OA(2 LE) + CA(2 LE)。原实现 COT/OA 各只占 1 字节(IEC 101 串行版 - # 布局),导致标准主站(EdgeLite / lib60870)的 CA 与 IOA 全部错位。 + # IEC 60870-5-104 ASDU 固定头: TI(1) + VSQ(1) + COT(1) + OA(1) + CA(2 LE) h = bytearray() h.append(ti & 0xFF) h.append(num & 0x7F) - h += struct.pack(" Date: Wed, 30 Sep 2026 20:58:27 +0800 Subject: [PATCH 2/3] fix(deploy): explicit sqlalchemy[asyncio], privileged-port precheck, ASCII alembic.ini - pyproject: declare sqlalchemy[asyncio]>=2.0.0. SQLAlchemy 2.1 dropped the default platform greenlet dependency (extra-only); alembic resolved to 2.1.1 in the v1.4.0 image so containers crashed at migration step with 'No module named greenlet' - alembic.ini: ASCII-only comments - configparser decodes it with the system locale, which crashes on Windows GBK systems when non-ASCII characters are present (also affected local pip installs) - engine.start_protocol: pre-flight bind probe for ports < 1024. PermissionError used to be misread as 'port in use', auto-switch then walked the whole privileged range and died with 'No free port found' (API 503) - the reported S7-on-102 failure in Docker. Now surfaces an actionable bilingual error (cap-add NET_BIND_SERVICE or port >= 1024) - defaults.get_friendly_error: map 'no free port found' to the same root-cause guidance (zh/en) - DEPLOYMENT.md + docker-compose.simple.yml: document/enable NET_BIND_SERVICE for privileged ports (S7 102) - tests/test_privileged_port_precheck.py: 4 regression cases --- DEPLOYMENT.md | 8 +- alembic.ini | 9 ++- docker-compose.simple.yml | 6 +- protoforge/engine/defaults.py | 3 + protoforge/engine/engine.py | 23 ++++++ pyproject.toml | 5 +- tests/test_privileged_port_precheck.py | 107 +++++++++++++++++++++++++ 7 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 tests/test_privileged_port_precheck.py diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 9f81329..98aa64e 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -115,6 +115,7 @@ python -m protoforge.main docker run -d --name protoforge \ -p 8000:8000 \ -p 502:502 -p 102:102 -p 4840:4840 -p 44818:44818 \ + --cap-add NET_BIND_SERVICE \ -v protoforge-data:/app/data \ suoten/protoforge:latest ``` @@ -123,10 +124,15 @@ docker run -d --name protoforge \ > 且连接指南里显示的 `172.17.x.x` 是容器内网 IP,宿主机/外部机器**不可直达**, > 客户端请填宿主机 IP(同机填 `127.0.0.1`)+ 映射后的端口。 +> ⚠️ 特权端口提示(v1.4.1):S7 默认端口 102 属于 Linux 特权端口(<1024),容器默认以非 root 用户运行, +> **不加以 `--cap-add NET_BIND_SERVICE` 参数时无法绑定**,启动 S7 会报错(界面提示 503)。 +> 两种解决方式任选其一:① 启动容器时加 `--cap-add NET_BIND_SERVICE`(上方示例已包含); +> ② 在协议服务「高级配置」中把 S7 端口改为 1024 以上(如 1102),客户端同步使用新端口连接。 + 打开浏览器访问 http://localhost:8000 登录。管理员账号为 `admin`,密码说明: - 未设置 `PROTOFORGE_ADMIN_PASSWORD` 时,首次启动会**自动生成随机密码**,打印在启动横幅中,用 `docker logs protoforge` 查看; -- 也可以在启动时直接指定密码:`docker run -d --name protoforge -p 8000:8000 -p 502:502 -p 102:102 -e PROTOFORGE_ADMIN_PASSWORD=你的密码 -v protoforge-data:/app/data suoten/protoforge:latest`。 +- 也可以在启动时直接指定密码:`docker run -d --name protoforge -p 8000:8000 -p 502:502 -p 102:102 --cap-add NET_BIND_SERVICE -e PROTOFORGE_ADMIN_PASSWORD=你的密码 -v protoforge-data:/app/data suoten/protoforge:latest`(使用 S7 102 特权端口时 `--cap-add NET_BIND_SERVICE` 必须加,其他端口可省略)。 停止服务:`docker stop protoforge && docker rm protoforge` diff --git a/alembic.ini b/alembic.ini index 6ed7534..8def5f9 100644 --- a/alembic.ini +++ b/alembic.ini @@ -2,9 +2,12 @@ script_location = migrations prepend_sys_path = . version_path_separator = os -# FIXED: P4 - W19 此处 SQLite URL 仅为占位默认值,运行时由 migrations/env.py 通过 -# PROTOFORGE_DB_PATH 环境变量覆盖。生产环境应设置 PROTOFORGE_DB_PATH 指向实际数据库。 -# FIXED: 使用 aiosqlite 异步驱动,与 env.py 中 async_engine_from_config 兼容 +# NOTE: This SQLite URL is only a placeholder default. It is overridden at runtime by +# migrations/env.py via the PROTOFORGE_DB_PATH environment variable. In production, +# set PROTOFORGE_DB_PATH to point to the actual database. +# NOTE: Uses the aiosqlite async driver, compatible with async_engine_from_config in env.py. +# (Keep this file ASCII-only: configparser decodes it with the system locale, which breaks +# on Windows GBK systems if non-ASCII characters are present.) sqlalchemy.url = sqlite+aiosqlite:///data/protoforge.db [post_write_hooks] diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml index 1c07360..ee5aa9c 100644 --- a/docker-compose.simple.yml +++ b/docker-compose.simple.yml @@ -35,7 +35,7 @@ services: protoforge: - image: suoten/protoforge:1.3.4 + image: suoten/protoforge:1.4.1 container_name: protoforge command: - sh @@ -78,6 +78,10 @@ services: - "34980:34980" # --- GB28181 RTP 媒体流端口范围(需外部播放视频流时取消注释) --- # - "6000-6999:6000-6999/udp" + # S7 默认端口 102 是 Linux 特权端口(<1024),容器以非 root 用户运行, + # 需要此能力才能绑定;若不需要 S7 可删除此项 + cap_add: + - NET_BIND_SERVICE environment: PROTOFORGE_DB_PATH: data/protoforge.db PROTOFORGE_LOG_LEVEL: info diff --git a/protoforge/engine/defaults.py b/protoforge/engine/defaults.py index 7cd7547..8b82d9c 100644 --- a/protoforge/engine/defaults.py +++ b/protoforge/engine/defaults.py @@ -619,6 +619,8 @@ def get_http_timeout_long() -> float: "authentication failed": "Authentication failed. Please check that the username, password, or authentication key is correct.", "access denied": "Access denied. The target server rejected the connection. Please check authentication credentials and permissions.", "endpoint": "Failed to connect to the specified endpoint. Please check that the server address and port are correct, and that the target service is running.", + # FIXED(v1.4.1): S7 等特权端口(<1024)在 Docker/Linux 下被误判为占用后换端口失败的真实原因 + "no free port found": "No free port found in the searched range. If the protocol uses a privileged port below 1024 (e.g. S7 on 102) on Linux/Docker, binding requires root or --cap-add NET_BIND_SERVICE. Alternatively, change the protocol port to >= 1024 in advanced config and update clients.", }) @@ -654,6 +656,7 @@ def get_http_timeout_long() -> float: "authentication failed": "认证失败。请检查用户名、密码或认证密钥是否正确。", "access denied": "访问被拒绝。目标服务器拒绝了连接。请检查认证凭据和权限。", "endpoint": "无法连接到指定的端点。请检查服务器地址和端口是否正确,以及目标服务是否运行。", + "no free port found": "在搜索范围内未找到可用端口。若协议使用 1024 以下特权端口(如 S7 默认端口 102),在 Linux/Docker 下需要 root 权限或启动容器时加 --cap-add NET_BIND_SERVICE 参数;也可以在高级配置中把协议端口改为 1024 以上(客户端同步修改端口)。", }) diff --git a/protoforge/engine/engine.py b/protoforge/engine/engine.py index a0544f4..cd7a20d 100644 --- a/protoforge/engine/engine.py +++ b/protoforge/engine/engine.py @@ -421,6 +421,29 @@ async def start_protocol(self, protocol_name: str, config: dict[str, Any], resta host = config.get("host", "0.0.0.0") # FIXED-P0: GB28181/BACnet 使用 UDP,需要用 SOCK_DGRAM 检测端口 port_protocol = "udp" if protocol_name in ("gb28181", "bacnet") else "tcp" + # FIXED(v1.4.1): 特权端口预检 —— Linux/Docker 下绑定 <1024 端口需要 root 或 + # CAP_NET_BIND_SERVICE(如 S7 默认端口 102)。此前 PermissionError 被 _is_port_in_use + # 误判为"端口被占用",自动换端口又连续撞上 103..1023 的权限墙,最终报 + # "No free port found"(API 503),用户无法理解。这里用测试 socket 提前探测, + # 区分"权限不足"与"真被占用",给出可操作提示。 + if host and not _is_serial_path(host) and config["port"] < 1024: + af = socket.AF_INET6 if ":" in str(host) else socket.AF_INET + sock_type = socket.SOCK_DGRAM if port_protocol == "udp" else socket.SOCK_STREAM + try: + with socket.socket(af, sock_type) as probe: + probe.bind((str(host) or "0.0.0.0", config["port"])) + except PermissionError as perm_err: + raise RuntimeError( + f"Port {config['port']} is a privileged port (<1024) and binding it was denied. " + f"On Linux/Docker it requires root or CAP_NET_BIND_SERVICE; on Windows it may be " + f"a system-reserved port range. Fixes: run the Docker container with " + f"--cap-add NET_BIND_SERVICE, or change the protocol port to >= 1024 (e.g. 1102) " + f"in advanced config and point clients to the new port. " + f"(特权端口(<1024)绑定被拒绝:Linux/Docker 需 root 或 --cap-add NET_BIND_SERVICE;" + f"也可在高级配置中把端口改为 1024 以上,客户端同步修改)" + ) from perm_err + except OSError: + pass # 其他绑定错误(如端口真的被占用)交由下方占用检测处理 if host and not _is_serial_path(host) and _is_port_in_use(config["port"], host, port_protocol): original_port = config["port"] new_port = _find_free_port(original_port + 1, host, protocol=port_protocol) diff --git a/pyproject.toml b/pyproject.toml index eb28f22..dacf7df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "protoforge" -version = "1.4.0" +version = "1.4.1" description = "IoT Protocol Simulation & Testing Platform - Postman for IoT" readme = "README.md" license = "MIT" @@ -36,6 +36,9 @@ dependencies = [ "PyJWT>=2.8.0", "bcrypt>=4.0.1", "alembic>=1.13.0", + # FIXED(v1.4.1): SQLAlchemy 仅经由 alembic 间接安装,新版 SQLAlchemy 不再默认携带 greenlet, + # Docker 容器内 alembic 迁移报 "No module named 'greenlet'";显式声明 asyncio extra 保证 greenlet 随装 + "sqlalchemy[asyncio]>=2.0.0", ] [project.optional-dependencies] diff --git a/tests/test_privileged_port_precheck.py b/tests/test_privileged_port_precheck.py new file mode 100644 index 0000000..c1c4b9a --- /dev/null +++ b/tests/test_privileged_port_precheck.py @@ -0,0 +1,107 @@ +"""v1.4.1 特权端口预检回归测试。 + +背景(用户反馈):Docker 容器以非 root 用户运行时,S7 默认端口 102 是 Linux 特权端口, +绑定报 PermissionError。旧逻辑中 PermissionError 被 _is_port_in_use 误判为"端口被占用", +自动换端口又连续撞上 103..1023 的权限墙,最终报 "No free port found in range"(API 503), +用户无法理解。修复:start_protocol 在占用检测前用测试 socket 预检,PermissionError +时抛出带可操作建议的 RuntimeError(API 503 + 友好文案)。 + +测试通过 monkeypatch 模拟内核的 bind 权限行为(Windows 上无法真实复现 Linux 权限拒绝), +不依赖平台特性。 +""" + +import socket + +import pytest + +from protoforge.engine import engine as engine_module +from protoforge.engine.engine import SimulationEngine +from protoforge.engine.defaults import get_friendly_error +from protoforge.protocols.base import ProtocolStatus +from protoforge.protocols.custom_tcp.server import CustomTcpServer + + +class _BindDeniedSocket: + """模拟 Linux 非 root 环境:绑定 <1024 端口抛 PermissionError,其余端口抛占用。""" + + denied_below = 1024 + + def __init__(self, family=socket.AF_INET, type=socket.SOCK_STREAM, *args, **kwargs): + self.family = family + self.type = type + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + return False + + def bind(self, address): + port = address[1] if isinstance(address, tuple) else 0 + if port and port < self.denied_below: + raise PermissionError(1, "Operation not permitted") + raise OSError(98, "Address already in use") + + def connect_ex(self, address): + return 101 # 非零:无监听 + + def settimeout(self, value): + pass + + def close(self): + pass + + +class _AlwaysInUseSocket(_BindDeniedSocket): + """模拟所有端口都占用(用于验证占用路径不受预检影响)。""" + + denied_below = 0 # 任何端口 bind 都抛 OSError(EADDRINUSE) + + +def _make_engine() -> SimulationEngine: + engine = SimulationEngine() + engine.register_protocol(CustomTcpServer()) + return engine + + +@pytest.mark.asyncio +async def test_privileged_port_permission_denied_gives_actionable_error(monkeypatch): + """特权端口绑定被拒(Linux/Docker 非 root)→ RuntimeError 且提示可操作解法,而非 503 无从排查。""" + monkeypatch.setattr(engine_module.socket, "socket", _BindDeniedSocket) + eng = _make_engine() + with pytest.raises(RuntimeError) as exc_info: + await eng.start_protocol("custom_tcp", {"host": "0.0.0.0", "port": 102}) + msg = str(exc_info.value) + assert "privileged port" in msg + assert "NET_BIND_SERVICE" in msg # Docker 解法 + assert "1024" in msg # 改端口解法 + assert eng._protocol_servers["custom_tcp"].status != ProtocolStatus.RUNNING + + +@pytest.mark.asyncio +async def test_non_privileged_port_in_use_falls_to_occupancy_path(monkeypatch): + """≥1024 端口 bind 被拒(真实占用)→ 不受预检影响,仍走占用检测 → 自动换端口路径。""" + monkeypatch.setattr(engine_module.socket, "socket", _BindDeniedSocket) + eng = _make_engine() + with pytest.raises(RuntimeError) as exc_info: + await eng.start_protocol("custom_tcp", {"host": "0.0.0.0", "port": 1030}) + assert "No free port found" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_all_ports_in_use_still_reports_no_free_port(monkeypatch): + """所有端口都占用 → 仍走占用检测路径报 "No free port found"(回归:预检不改变占用语义)。""" + monkeypatch.setattr(engine_module.socket, "socket", _AlwaysInUseSocket) + eng = _make_engine() + with pytest.raises(RuntimeError) as exc_info: + await eng.start_protocol("custom_tcp", {"host": "0.0.0.0", "port": 38000}) + assert "No free port found" in str(exc_info.value) + + +def test_friendly_error_no_free_port_mentions_s7_and_cap(): + """友好报错映射:'No free port found' 中英文均提示特权端口根因与解法。""" + zh = get_friendly_error("No free port found in range 103-202", lang="zh") + assert "特权端口" in zh and "NET_BIND_SERVICE" in zh + en = get_friendly_error("No free port found in range 103-202", lang="en") + assert "privileged port" in en and "NET_BIND_SERVICE" in en From 6a413083422684fbcf025245c23a471667410745 Mon Sep 17 00:00:00 2001 From: ConvergeLoop Bot Date: Wed, 30 Sep 2026 20:58:48 +0800 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=E5=8D=87=E8=87=B3=201.4.1=EF=BC=88v1.4.0=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=E4=B8=93=E9=A1=B9=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ protoforge/__init__.py | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8291c7..4374166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## v1.4.1 — 2026-09-30 + +### 🐛 Bug Fix(v1.4.0 用户反馈专项) + +**修复 — IEC 104 帧格式回归(v1.4.0 已携带,标准主站连上即被踢、遥测地址 ×256 复发):** + +- 根因:自动化提交 `1df6f50`(9 月 26 日"联调批次修复")将 IEC 60870-5-**101**(FT1.2)的帧格式错套到 **104** 上:APDU 长度域被改为 2 字节(L1 L2)、ASDU 头被改为 COT 2 字节 + OA 2 字节(8 字节头)。IEC 60870-5-104 标准为**单字节长度域** + **6 字节 ASDU 头**(TI+VSQ+COT(1)+OA(1)+CA(2 LE))——该改动不仅让标准客户端(QTester104、lib60870 等)连接即被断开(控制码 0x07 被当作 L2 校验失败),还回退了此前修复 QTester104 遥测地址 ×256 的 COT 1 字节修正(`4f7f803`),等于 v1.4.0 重新带上了用户报告过的原始 Bug +- 修复:`protoforge/protocols/iec104/server.py` 整体回退到标准实现(`4f7f803` 版本);回归测试 `tests/test_iec104_wire.py` 7 例全绿(含 STARTDT/时钟同步/总召/遥控选择-执行/时标命令的严格字节级断言) +- 教训记录:APDU 帧格式 `68 L(1字节) C1..C4`、ASDU 头 6 字节,是 104 与 101 的核心区别之一,后续任何协议格式修改须对照标准原文并跑 wire 回归 + +**修复 — Docker 镜像启动报 `ModuleNotFoundError: No module named 'greenlet'`(alembic 迁移失败,容器无法启动):** + +- 根因:SQLAlchemy 经由 alembic 间接安装,未显式声明。SQLAlchemy 2.1 起移除了默认的平台级 greenlet 依赖(仅保留 `sqlalchemy[asyncio]` extra),v1.4.0 镜像构建时 pip 解析到 SQLAlchemy 2.1.1 → 容器内缺 greenlet → `alembic upgrade head` 导入 `sqlalchemy.ext.asyncio` 时崩溃 +- 修复:核心依赖显式加入 `sqlalchemy[asyncio]>=2.0.0`,保证任何 SQLAlchemy 版本下 greenlet 随装 +- 顺手修复:`alembic.ini` 含中文注释,Windows GBK locale 下 configparser 解码失败(本地 pip 安装用户执行数据库迁移同样会崩)——改为 ASCII 注释,并保留说明 + +**修复 — Docker 中启动 S7 协议报 503("No free port found",无从排查):** + +- 根因:S7 默认端口 102 是 Linux 特权端口(<1024),容器以非 root 用户(uid 1000)运行,绑定报 PermissionError;旧逻辑把 PermissionError 误判为"端口被占用",自动换端口又连续撞上 103..1023 的权限墙,最终报 "No free port found in range"(API 503),用户无法理解 +- 修复:引擎在端口占用检测前增加**特权端口预检**——用测试 socket 实际探测绑定,PermissionError 时直接给出可操作提示(`--cap-add NET_BIND_SERVICE` 或改用 1024 以上端口),中英双语;"No free port found" 友好报错映射同步补充根因说明 +- 部署文档与 compose 同步:`DEPLOYMENT.md` Docker run 示例加入 `--cap-add NET_BIND_SERVICE` 并新增特权端口专述;`docker-compose.simple.yml` 加 `cap_add: NET_BIND_SERVICE`(映射了 102 端口的场景必需) +- 回归测试 `tests/test_privileged_port_precheck.py`(4 例):权限拒绝→可操作报错、≥1024 端口占用路径不受预检影响、全端口占用仍报 "No free port found"、友好报错中英文映射 + +### 升级提示 + +Docker 用户升级命令:`docker pull suoten/protoforge:1.4.1`(或 latest)。**使用 IEC 104 的用户建议立即升级**(v1.4.0 的 IEC104 服务端对标准主站不兼容);**使用 S7(端口 102)的用户**:升级后需以 `--cap-add NET_BIND_SERVICE` 参数重建容器,或在协议高级配置中把 S7 端口改为 1024 以上(如 1102)。 + ## v1.4.0 — 2026-09-30 ### 🚀 新功能 —— 体验与调试专项(用户反馈驱动) diff --git a/protoforge/__init__.py b/protoforge/__init__.py index e94c379..4aa1a70 100644 --- a/protoforge/__init__.py +++ b/protoforge/__init__.py @@ -1,3 +1,3 @@ """ProtoForge - IoT Protocol Simulation & Testing Platform""" -__version__ = "1.4.0" +__version__ = "1.4.1"