diff --git a/confs/README.md b/confs/README.md index 9d20c2f..f65e0f2 100644 --- a/confs/README.md +++ b/confs/README.md @@ -154,20 +154,22 @@ python3 confs.py --addr 127.0.0.1 --port 8010 --workers 4 --dir configs/ ### 调用示例(Python) ```python -import json -from pymsg import MsgClient +from pymsg import RpcClient -c = MsgClient("127.0.0.1", 8010, "my-service") +c = RpcClient("127.0.0.1", 8010, "my-service") -# 取配置全文 -resp = json.loads(c.call(json.dumps({"_route": "get", "name": "example.ini"}), timeout=5)) +# 取配置全文(dict 直接传入,RpcClient 自动序列化/反序列化) +resp = c.call("get", {"name": "example.ini"}, timeout=5) content = resp["data"]["content"] # 按键取值 -resp = json.loads(c.call(json.dumps({ - "_route": "get", "name": "example.ini", "key": "Server.port" -}), timeout=5)) +resp = c.call("get", {"name": "example.ini", "key": "server.port"}, timeout=5) port = resp["data"]["value"] + +# 列出所有配置 +resp = c.call("list", {}, timeout=5) +for cfg in resp["data"]["configs"]: + print(cfg["name"], cfg["size"]) ``` ## 缓存机制 @@ -180,8 +182,10 @@ port = resp["data"]["value"] ## 安全机制 - **扩展名白名单**:仅允许 `.ini/.conf/.cfg/.json/.yaml/.yml/.toml/.txt`,其余一律拒绝 -- **路径遍历防护**:配置名必须等于 `os.path.basename(name)`,且 realpath 必须位于 `configs/` 目录前缀之下 +- **路径遍历防护**:配置名必须等于 `os.path.basename(name)`,且 realpath 必须位于 `configs/` 目录前缀之下;符号链接会被 `os.path.realpath()` 解析后校验 +- **TOCTOU 防护**:所有文件系统操作(`realpath` → `stat` → `open`)均在持锁状态下原子完成,不存在"先校验后打开"的时间窗口,防止符号链接替换攻击 - **文件大小限制**:单配置文件上限 1 MiB(`MAX_CONFIG_SIZE`),超过则拒绝读取并返回错误,防止超大文件导致内存溢出 +- **Slowloris 防护**:pymsg `MsgServer` 默认设置 10 秒接收超时,慢速发送的半开连接会被自动断开 - **仅监听本机**:默认 `127.0.0.1`,不对外暴露,仅供内部服务调用 - **无鉴权**:当前无身份校验,依赖"仅本机 + 内部网络"的信任边界;如需承载敏感配置(如 API 密钥),应额外评估鉴权方案 diff --git a/confs/confs.py b/confs/confs.py index 590c88d..ebb38c4 100644 --- a/confs/confs.py +++ b/confs/confs.py @@ -1,16 +1,17 @@ """ -confs — 统一配置服务(pymsg/TCP,默认 127.0.0.1:8010) +confs — 统一配置服务(pymsg RpcServer/TCP,默认 127.0.0.1:8010) -集中存放各微服务的配置文件,通过 pymsg 协议分发给内部服务, +集中存放各微服务的配置文件,通过 pymsg 高层 RPC 协议分发给内部服务, 替代各服务本地散落的 ini/json/yaml 配置文件,实现配置的统一管理与下发。 -pymsg 路由:: +客户端使用 RpcClient 直接传 dict,无需手动拼 _route / json.dumps / json.loads:: - {"_route": "get", "name": "example.ini"} → 取配置文件全文 - {"_route": "get", "name": "example.ini", "key": "Server.port"} → 取单个键(ini/json) - {"_route": "list"} → 列出全部配置 - {"_route": "reload", "name": "example.ini"} → 重载指定配置(缺省清空全部缓存) - {"_route": "health"} → 健康检查 + c = RpcClient("127.0.0.1", 8010, "my-service") + c.call("get", {"name": "example.ini"}) + c.call("get", {"name": "example.ini", "key": "server.port"}) + c.call("list", {}) + c.call("reload", {"name": "example.ini"}) + c.call("health", {}) 响应格式(与 db / smtp / captcha 一致):: @@ -18,7 +19,8 @@ pymsg 路由:: {"success": false, "error": "..."} 配置文件存放于 confs/configs/ 目录(可用 --dir 覆盖),内存缓存 + -文件 mtime 变化自动重载;配置名校验防路径遍历。仅监听 127.0.0.1,不对外暴露。 +文件 mtime 变化自动重载;配置名校验防路径遍历,所有文件系统操作在 +持锁状态下原子完成以防 TOCTOU 竞态。仅监听 127.0.0.1,不对外暴露。 """ import configparser @@ -86,13 +88,25 @@ class ConfigStore: self._cache = {} # name -> {"content": str, "mtime": float, "size": int} self._lock = threading.Lock() - def _resolve(self, name: str) -> str: - """校验配置名并返回绝对路径(防路径遍历 + 扩展名白名单)。""" + @staticmethod + def _validate_name(name: str) -> None: + """纯字符串校验配置名(basename + 扩展名白名单),不访问文件系统。 + + 用于锁外快速拒绝明显非法的请求;真实路径解析和文件存在性检查 + 必须在持锁后通过 _resolve_locked() 完成,防止 TOCTOU 竞态。 + """ if not name or name != os.path.basename(name): raise ConfigNameInvalid(f"非法配置名: {name!r}") ext = os.path.splitext(name)[1].lower() if ext not in ALLOWED_EXT: raise ConfigNameInvalid(f"不支持的配置类型: {ext or '(无扩展名)'}") + + def _resolve_locked(self, name: str) -> str: + """持锁状态下解析配置名到绝对路径,并校验路径安全性与文件存在性。 + + 必须在 self._lock 持有期间调用;通过 os.path.realpath() 即时解析符号链接, + 确保从校验到打开文件之间不存在替换窗口,防御 TOCTOU 攻击。 + """ path = os.path.realpath(os.path.join(self.root, name)) if not path.startswith(self.root + os.sep): raise ConfigNameInvalid(f"非法配置名: {name!r}") @@ -101,9 +115,14 @@ class ConfigStore: return path def get(self, name: str) -> dict: - """取配置内容(带缓存;文件 mtime 变化时自动重新读取)。""" - path = self._resolve(name) + """取配置内容(带缓存;文件 mtime 变化时自动重新读取)。 + + 所有文件系统操作(realpath/stat/open)均在锁内完成, + 防止路径校验与文件读取之间的 TOCTOU 竞态。 + """ + self._validate_name(name) with self._lock: + path = self._resolve_locked(name) try: st = os.stat(path) mtime = st.st_mtime @@ -130,18 +149,22 @@ class ConfigStore: return entry def list(self) -> list: - """列出配置目录下全部允许类型的配置文件。""" + """列出配置目录下全部允许类型的配置文件(持锁遍历,保证 _cache 访问一致)。""" out = [] - for name in sorted(os.listdir(self.root)): - if os.path.splitext(name)[1].lower() not in ALLOWED_EXT: - continue - path = os.path.join(self.root, name) - if not os.path.isfile(path): - continue - st = os.stat(path) - out.append({"name": name, "size": st.st_size, - "mtime": _iso(st.st_mtime), - "cached": name in self._cache}) + with self._lock: + for name in sorted(os.listdir(self.root)): + if os.path.splitext(name)[1].lower() not in ALLOWED_EXT: + continue + path = os.path.join(self.root, name) + if not os.path.isfile(path): + continue + try: + st = os.stat(path) + except OSError: + continue + out.append({"name": name, "size": st.st_size, + "mtime": _iso(st.st_mtime), + "cached": name in self._cache}) return out def reload(self, name: "str | None" = None) -> list: @@ -151,8 +174,10 @@ class ConfigStore: 受影响(重载/移出缓存)的配置名列表。 """ if name is not None: - self._resolve(name) # 校验,文件不存在则抛 ConfigNotFound + self._validate_name(name) with self._lock: + # 在锁内重新解析路径,防止 TOCTOU + self._resolve_locked(name) self._cache.pop(name, None) self.get(name) return [name]