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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

### 🚀 新功能 —— 体验与调试专项(用户反馈驱动)
Expand Down
8 changes: 7 additions & 1 deletion DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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`

Expand Down
9 changes: 6 additions & 3 deletions alembic.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
6 changes: 5 additions & 1 deletion docker-compose.simple.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@

services:
protoforge:
image: suoten/protoforge:1.3.4
image: suoten/protoforge:1.4.1
container_name: protoforge
command:
- sh
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion protoforge/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""ProtoForge - IoT Protocol Simulation & Testing Platform"""

__version__ = "1.4.0"
__version__ = "1.4.1"
3 changes: 3 additions & 0 deletions protoforge/engine/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
})


Expand Down Expand Up @@ -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 以上(客户端同步修改端口)。",
})


Expand Down
23 changes: 23 additions & 0 deletions protoforge/engine/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 16 additions & 26 deletions protoforge/protocols/iec104/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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]
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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("<H", asdu[2:4])[0] & 0x3F
oa = struct.unpack("<H", asdu[4:6])[0]
ca = struct.unpack("<H", asdu[6:8])[0]
# IEC 60870-5-104: COT 为 1 字节 + OA 1 字节 + CA 2 字节(小端)。
# 之前误将 COT 编码/解析为 2 字节,导致主站侧 CA 显示为 256 倍、
# IOA 整体左移 8 位、遥测浮点数错位成乱值。
cot = asdu[2] & 0x3F
oa = asdu[3]
ca = struct.unpack("<H", asdu[4:6])[0]
num = vsq & 0x7F
sq = bool(vsq & 0x80)

Expand All @@ -520,7 +512,7 @@ async def _process_asdu(self, writer: asyncio.StreamWriter, asdu: bytes, conn_st

# -- general interrogation -----------------------------------------
async def _handle_interrogation(self, writer: asyncio.StreamWriter, asdu: bytes, ca: int) -> 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])
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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("<H", cot & 0x3F)
h += struct.pack("<H", self._originator_address & 0xFFFF)
h.append(cot & 0x3F)
h.append(self._originator_address & 0xFF)
h += struct.pack("<H", ca & 0xFFFF)
return bytes(h)

Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading