sycommon-python-lib 0.2.8a3__py3-none-any.whl → 0.2.8a4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,542 @@
1
+ """沙箱出网收口网关(egress gateway)。
2
+
3
+ 嵌入沙箱服务端进程内的 aiohttp.web 反向代理。子进程的所有出网请求指向本网关
4
+ (child env 的 SYT_BASE_URL/HTTPS_PROXY = http://127.0.0.1:<port>),网关做四件事:
5
+
6
+ 1. 按「目标 host → 凭据形态」注入正确凭据(SYT 用 Authorization Bearer +
7
+ X-Sy-Api-Authz;OA 系用 SYU-Token + SYU-Url 改写;企微 gettoken 用
8
+ URL query 的 corpid/corpsecret;内网服务无鉴权透传)。
9
+ 2. 复用 skill_wl_check.match 做 path 白名单校验,不在白名单 → 403。
10
+ 3. 删子进程自带的凭据头(Authorization/SYU-Token/X-Sy-Api-Authz),由网关
11
+ 重新注入正确值(防子进程塞脏值/旧值)。
12
+ 4. 审计:每个请求记录 user/host/path/状态码/字节量。
13
+
14
+ 子进程 → 网关走 HTTP(loopback 明文),网关 → 真上游才用 HTTPS。这样网关能
15
+ 看到并改写请求头;若走 HTTPS CONNECT 隧道则端到端加密、网关无法注入凭据。
16
+
17
+ 凭据来源:主进程通过 POST /v1/sandbox/credentials 推送(out-of-band,不进子进程
18
+ env、不进命令串),存进本模块的内存凭据表 CredentialStore。
19
+
20
+ local 模式(每用户独立容器):单用户单端口,凭据表只有一个条目,无绑定问题。
21
+ shared 模式(多用户共享):per-user 临时端口,凭据表按 user_id 索引。
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import fnmatch
28
+ from typing import Optional
29
+ from urllib.parse import urlparse, urlunparse, urlencode, parse_qsl
30
+
31
+ from aiohttp import web, ClientSession, ClientTimeout
32
+
33
+ from sycommon.logging.kafka_log import SYLogger
34
+
35
+
36
+ # ============== 凭据存储 ==============
37
+
38
+ # 子进程自带、必须剥离的凭据头(小写)。网关重新注入正确值,防脏值。
39
+ _INBOUND_CRED_HEADERS = {
40
+ "authorization", "syu-token", "syu-url", "x-sy-api-authz",
41
+ "x-host-name",
42
+ }
43
+
44
+
45
+ class CredentialStore:
46
+ """线程安全(单事件循环内)的 per-user 凭据表。
47
+
48
+ 刷新用「整体替换 dict 引用」(store[uid] = new_dict),不原地 mutate,
49
+ 故正在处理的请求 snapshot 的旧 dict 不受刷新影响(无半截态竞态)。
50
+ """
51
+
52
+ def __init__(self) -> None:
53
+ # user_id -> creds dict。creds dict 形如:
54
+ # {"SYT_TOKEN": ..., "SYT_API_AUTHZ": ..., "SYT_BASE_URL": <真上游>,
55
+ # "SYT_X_HOST_NAME": ..., "WECOM_CORP_ID": ..., "WECOM_CORP_SECRET": ...,
56
+ # "allowlist": [...]}
57
+ self._creds: dict[str, dict] = {}
58
+ self._lock = asyncio.Lock()
59
+
60
+ async def set(self, user_id: str, creds: dict) -> None:
61
+ async with self._lock:
62
+ self._creds[user_id] = dict(creds) # 拷贝,隔离调用方后续 mutate
63
+
64
+ async def delete(self, user_id: str) -> bool:
65
+ async with self._lock:
66
+ return self._creds.pop(user_id, None) is not None
67
+
68
+ def get(self, user_id: str) -> Optional[dict]:
69
+ """同步读(请求处理热路径,锁外读 dict 引用——整体替换保证一致性)。"""
70
+ return self._creds.get(user_id)
71
+
72
+ def any_user(self) -> Optional[str]:
73
+ """local 模式单用户时取唯一 user_id(凭据表只有一个条目)。"""
74
+ for uid in self._creds:
75
+ return uid
76
+ return None
77
+
78
+
79
+ # ============== host → 凭据形态路由表 ==============
80
+
81
+ def _match_syt_upstream(host: str, syt_base_url: str) -> bool:
82
+ """请求目标 host 是否是 SYT 网关上游(syapi / cloudapi-*)。"""
83
+ if not syt_base_url:
84
+ return False
85
+ try:
86
+ syt_host = (urlparse(syt_base_url).hostname or "").lower()
87
+ except Exception:
88
+ return False
89
+ return host == syt_host or fnmatch.fnmatch(host, "*.syitservice.com") \
90
+ and ("cloudapi" in host or host == syt_host)
91
+
92
+
93
+ def _is_oa_backend(host: str) -> bool:
94
+ """OA-workorder/xinghan/yealink 等 MCP 后端(带 SYU-Token + SYU-Url)。"""
95
+ return host in ("ai.syitservice.com",) or host.endswith(".syitservice.com")
96
+
97
+
98
+ def _is_wecom(host: str) -> bool:
99
+ return host in ("qyapi.weixin.qq.com",)
100
+
101
+
102
+ def _is_internal_passthrough(host: str) -> bool:
103
+ """无鉴权的内网服务(OCR / 合同比对),仅靠白名单放行,透传。"""
104
+ return host.startswith("10.") or host in (
105
+ "contract-compare.syitservice.com", "newocr-test.syitservice.com",
106
+ )
107
+
108
+
109
+ def _strip_cred_headers(headers) -> dict:
110
+ """剥离请求里的凭据头,返回干净的普通 dict(非凭据头透传)。"""
111
+ out = {}
112
+ for k, v in headers.items():
113
+ if k.lower() not in _INBOUND_CRED_HEADERS:
114
+ out[k] = v
115
+ return out
116
+
117
+
118
+ def _apply_credentials(method: str, target_url: str, headers: dict,
119
+ creds: dict) -> tuple[str, dict]:
120
+ """按目标 host/path 改写 URL(query 注入)+ 注入凭据头。返回 (url, headers)。
121
+
122
+ 网关→真上游的请求构造在此完成。host 路由表见模块 docstring。
123
+ """
124
+ host = (urlparse(target_url).hostname or "").lower()
125
+
126
+ # --- SYT 网关:Bearer + X-Sy-Api-Authz + X-Host-Name ---
127
+ if _match_syt_upstream(host, creds.get("SYT_BASE_URL", "")):
128
+ token = creds.get("SYT_TOKEN", "")
129
+ if token:
130
+ headers["Authorization"] = f"Bearer {token}"
131
+ authz = creds.get("SYT_API_AUTHZ", "")
132
+ if authz:
133
+ headers["X-Sy-Api-Authz"] = authz
134
+ x_host = creds.get("SYT_X_HOST_NAME", "")
135
+ if x_host:
136
+ headers["X-Host-Name"] = x_host
137
+ return target_url, headers
138
+
139
+ # --- 企微 gettoken:corpid/corpsecret 在 URL query ---
140
+ if _is_wecom(host):
141
+ corp_id = creds.get("WECOM_CORP_ID", "")
142
+ corp_secret = creds.get("WECOM_CORP_SECRET", "")
143
+ if corp_id and corp_secret and "/cgi-bin/gettoken" in target_url:
144
+ parsed = urlparse(target_url)
145
+ q = dict(parse_qsl(parsed.query))
146
+ q.setdefault("corpid", corp_id)
147
+ q.setdefault("corpsecret", corp_secret)
148
+ target_url = urlunparse(parsed._replace(query=urlencode(q)))
149
+ return target_url, headers
150
+
151
+ # --- OA MCP 后端:SYU-Token + SYU-Url(改写为真上游 base)---
152
+ if _is_oa_backend(host):
153
+ token = creds.get("SYT_TOKEN", "")
154
+ syt_base = (creds.get("SYT_BASE_URL") or "").rstrip("/")
155
+ if token:
156
+ headers["SYU-Token"] = token
157
+ # SYU-Url 必须指向真上游云平台(后端靠它反查校验 token),
158
+ # 而非子进程 SYT_BASE_URL(那是网关地址)。这里改写回真上游。
159
+ if syt_base:
160
+ headers["SYU-Url"] = f"{syt_base}/oauth/loadUser"
161
+ return target_url, headers
162
+
163
+ # --- 内网服务:无鉴权透传 ---
164
+ return target_url, headers
165
+
166
+
167
+ # ============== 白名单(host 级,来源沙箱 dataId EgressPolicy)==============
168
+
169
+ def _check_allowlist(target_url: str, creds: dict,
170
+ skill_scope: str = "") -> Optional[str]:
171
+ """返回 None 表示放行,返回 str 表示拒绝原因。
172
+
173
+ 白名单来源改为沙箱自身 dataId 的 EgressPolicy(viaGateway+directAllow 的
174
+ host 全集):目标 host 不在集合内 → 403。这与 iptables 硬墙的 DROP 对齐
175
+ (墙放行 directAllow 的直连,网关放行全部允许 host——child 两路都进得来,
176
+ 但名单外两头都拒)。
177
+
178
+ 向后兼容:凭据包里仍可带 allowlist(path 级,主进程推送)——配了则叠加
179
+ path 级校验(host 先过 EgressPolicy,再过 path)。
180
+
181
+ 按技能 scope 分级(用户决策):
182
+ system / shengye-platform 技能 → 全管制:名单外 host 一律 403
183
+ (host 须在 EgressPolicy 集合内,与原 SkillApiWhitelist fail-closed 对齐)
184
+ user 技能 / 普通(无技能)命令 → 只管制 SYT_BASE_URL 同域名 + viaGateway
185
+ (星瀚/OA),其余放行(defaultMode=allow 语义)
186
+ scope 由 /execute 从命令提取 skills/<scope>/ 注入 child env
187
+ SANDBOX_SKILL_SCOPE,库层重写随 X-Skill-Scope 头传来;缺省按 user 宽松。
188
+ """
189
+ try:
190
+ p = urlparse(target_url)
191
+ host = (p.hostname or "").lower()
192
+ except Exception:
193
+ return "URL 解析失败"
194
+ if not host:
195
+ return "URL 无 host"
196
+
197
+ # 真上游(凭据包 SYT_BASE_URL,主进程按环境解析)——SYT 管制判定的锚点:
198
+ # 与它同域名的 host 自动纳入管制/放行集合,换环境无需改配置。
199
+ syt_base = (creds.get("SYT_BASE_URL") or "").strip()
200
+ policy = None
201
+ allowed: list = []
202
+ try:
203
+ from sycommon.config.EgressPolicyConfig import EgressPolicyConfig
204
+ policy = EgressPolicyConfig.from_config()
205
+ allowed = policy.allowed_hosts(syt_base)
206
+ except Exception as e:
207
+ # 读配置失败 → fail-open(不设限,凭据注入仍有效)。policy 保持 None,
208
+ # 下游用 allow_mode 时需判空(修审核 P1-5:原实现 policy 未绑定致 500)。
209
+ SYLogger.warning(f"[EgressGateway] EgressPolicy 读取失败,按未配置处理: {e}")
210
+
211
+ # 技能 scope:system/shengye-platform → 全管制;其余(user/普通) → 宽松
212
+ strict_scope = skill_scope in ("system", "shengye-platform")
213
+
214
+ if strict_scope:
215
+ # 全管制:名单外 host 一律拒(defaultMode 不豁免)
216
+ if allowed and not EgressPolicyConfig._host_match(host, allowed):
217
+ return f"目标 host 不在出网策略内: {host}"
218
+ else:
219
+ # user/普通:defaultMode='allow' → 名单外放行;'drop' → 拒
220
+ # (policy 为 None=配置读取失败 → 按 allow 宽松,修审核 P1-5)
221
+ allow_mode = (policy.defaultMode if policy else "allow") or "allow"
222
+ allow_mode = allow_mode.lower() != "drop"
223
+ if not allow_mode and allowed and not EgressPolicyConfig._host_match(host, allowed):
224
+ return f"目标 host 不在出网策略内: {host}"
225
+
226
+ # 叠加 path 级(凭据包 allowlist,可选)
227
+ allowlist = creds.get("allowlist")
228
+ if allowlist and not _url_allowed(target_url, allowlist):
229
+ return f"URL 不在白名单内: {target_url}"
230
+ return None
231
+
232
+
233
+ def _url_allowed(url: str, allowlist: list[str]) -> bool:
234
+ """与 skill_wl_check._is_allowed 同构的 path 级前缀匹配(不依赖 env)。"""
235
+ try:
236
+ p = urlparse(url if "://" in url else "http://" + str(url))
237
+ except Exception:
238
+ return False
239
+ req_scheme = (p.scheme or "").lower()
240
+ req_host = (p.hostname or "").lower()
241
+ req_port = p.port
242
+ req_path = (p.path or "").rstrip("/")
243
+ for entry in allowlist:
244
+ entry = (entry or "").strip().rstrip("/")
245
+ if not entry:
246
+ continue
247
+ if entry.startswith(("http://", "https://")):
248
+ e = urlparse(entry)
249
+ epfx = (e.path or "").rstrip("/")
250
+ pfx_match = (epfx == "" or req_path == epfx
251
+ or req_path.startswith(epfx + "/"))
252
+ if not pfx_match:
253
+ continue
254
+ if (e.scheme or "").lower() != req_scheme or (e.hostname or "").lower() != req_host:
255
+ continue
256
+ if e.port is not None and req_port not in (None, e.port):
257
+ continue
258
+ return True
259
+ else:
260
+ # 纯 path 前缀(无 host)
261
+ if not entry.startswith("/"):
262
+ entry = "/" + entry
263
+ if req_path == entry or req_path.startswith(entry + "/"):
264
+ return True
265
+ return False
266
+
267
+
268
+ def _sanitize_url(url: str) -> str:
269
+ """抹掉 URL 里的敏感 query 参数(审计日志脱敏,审核 P1-7)。
270
+
271
+ corpsecret/token/secret/key/password 等参数值替换为 ***。
272
+ """
273
+ if not url or "?" not in url:
274
+ return url
275
+ base, _, qs = url.partition("?")
276
+ sensitive = ("secret", "token", "key", "password", "passwd", "credential")
277
+ kept = []
278
+ for pair in qs.split("&"):
279
+ k = pair.split("=", 1)[0].lower()
280
+ if any(s in k for s in sensitive):
281
+ kept.append(f"{pair.split('=', 1)[0]}=***")
282
+ else:
283
+ kept.append(pair)
284
+ return f"{base}?{'&'.join(kept)}"
285
+
286
+
287
+ # ============== aiohttp.web 反向代理 ==============
288
+
289
+ def _is_loopback_or_private(hostport: str) -> bool:
290
+ """上游 host(或 host:port)是否指向回环/私有地址段(SSRF 防护)。
291
+
292
+ 域名先解析再判定(域名可能解析到内网 IP)。包含:
293
+ 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1, localhost
294
+ """
295
+ import ipaddress
296
+ import socket as _socket
297
+ host = (hostport or "").strip()
298
+ if not host:
299
+ return True
300
+ # 剥端口(单冒号;IPv6 字面量 [::1]:port 形态)
301
+ if host.startswith("["):
302
+ host = host.split("]")[0].lstrip("[")
303
+ elif host.count(":") == 1:
304
+ host = host.split(":")[0]
305
+ hl = host.lower()
306
+ if hl in ("localhost", "::1"):
307
+ return True
308
+ try:
309
+ ipaddress.ip_address(hl)
310
+ ips = [hl]
311
+ except ValueError:
312
+ try:
313
+ infos = _socket.getaddrinfo(hl, None)
314
+ ips = list({i[4][0] for i in infos})
315
+ except Exception:
316
+ return True # 解析失败按内网处理(保守拒绝)
317
+ for ip in ips:
318
+ try:
319
+ addr = ipaddress.ip_address(ip)
320
+ if (addr.is_loopback or addr.is_private or addr.is_link_local
321
+ or addr.is_reserved):
322
+ return True
323
+ except ValueError:
324
+ continue
325
+ return False
326
+
327
+
328
+ # 转发时跳过的 hop-by-hop / 网关自身头(模块级,供 _proxy 与响应透传共用)。
329
+ _HOP_BY_HOP = frozenset((
330
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
331
+ "te", "trailers", "transfer-encoding", "upgrade", "host", "content-length",
332
+ ))
333
+
334
+
335
+ class EgressGateway:
336
+ """嵌入沙箱服务端进程的出网网关。监听 127.0.0.1,单端口(local 模式单用户)。"""
337
+
338
+ # 响应透传时复用模块级 _HOP_BY_HOP(旧类属性保留兼容)。
339
+ _HOP_BY_HOP = _HOP_BY_HOP
340
+
341
+ def __init__(self, store: CredentialStore, port: int = 0,
342
+ bind: str = "127.0.0.1") -> None:
343
+ self._store = store
344
+ self._requested_port = port # 0 = OS 分配
345
+ self._bind = bind
346
+ self._actual_port: Optional[int] = None
347
+ self._runner: Optional[web.AppRunner] = None
348
+ self._site: Optional[web.TCPSite] = None
349
+ self._client: Optional[ClientSession] = None
350
+
351
+ @property
352
+ def port(self) -> Optional[int]:
353
+ return self._actual_port
354
+
355
+ @property
356
+ def base_url(self) -> str:
357
+ """供 child env 注入的网关地址(子进程 SYT_BASE_URL 指向它)。"""
358
+ p = self._actual_port
359
+ return f"http://{self._bind}:{p}" if p else ""
360
+
361
+ async def start(self) -> int:
362
+ app = web.Application(client_max_size=100 * 1024 * 1024)
363
+ app.router.add_route("*", "/{tail:.*}", self._proxy)
364
+ self._runner = web.AppRunner(app, access_log=None)
365
+ await self._runner.setup()
366
+ self._site = web.TCPSite(self._runner, self._bind, self._requested_port)
367
+ await self._site.start()
368
+ # 取实际监听端口(requested_port=0 时由 OS 分配)
369
+ sockets = self._site._server.sockets
370
+ self._actual_port = sockets[0].getsockname()[1] if sockets else self._requested_port
371
+ # 连接池:网关→真上游
372
+ self._client = ClientSession(
373
+ timeout=ClientTimeout(total=300),
374
+ headers={"Connection": "keep-alive"},
375
+ )
376
+ SYLogger.info(f"[EgressGateway] 监听 {self._bind}:{self._actual_port}")
377
+ return self._actual_port
378
+
379
+ async def stop(self) -> None:
380
+ if self._client is not None:
381
+ await self._client.close()
382
+ self._client = None
383
+ if self._runner is not None:
384
+ await self._runner.cleanup()
385
+ self._runner = None
386
+ SYLogger.info("[EgressGateway] 已停止")
387
+
388
+ def _resolve_user(self, request: web.Request) -> Optional[dict]:
389
+ """local 模式:凭据表唯一条目即该容器用户。shared 模式留待扩展。"""
390
+ return self._store.any_user() and self._store.get(self._store.any_user())
391
+
392
+ async def _proxy(self, request: web.Request) -> web.StreamResponse:
393
+ # SSRF 防护(审核 P0-2):拒绝 CONNECT(网关不做隧道),拒绝把上游指向
394
+ # 本机/回环/私有地址——否则子进程可经网关打沙箱自身的无鉴权 API
395
+ # (/v1/sandbox/execute 任意 user_id 跨用户读写)或造成网关自递归。
396
+ if request.method == "CONNECT":
397
+ SYLogger.warning("[EgressGateway] 拒绝 CONNECT(网关非隧道代理)")
398
+ return web.Response(status=405, text="gateway: CONNECT not supported")
399
+
400
+ creds = self._resolve_user(request)
401
+ if creds is None:
402
+ # 无凭据:local 模式下凭据未推送/已清除。503 让子进程脚本明确报错。
403
+ SYLogger.warning("[EgressGateway] 无凭据,拒绝(/credentials 未推送或已清除)")
404
+ return web.Response(status=503, text="gateway: no credentials provisioned")
405
+
406
+ # 子进程以网关为 origin(SYT_BASE_URL=http://127.0.0.1:port)发请求,
407
+ # 但真实目标 host 通过特殊头 X-Upstream-Host 传入(库层重写时设置),
408
+ # 或:子进程直接用 http://<真host>/ 绝对路径(含真 host),网关按 host 路由。
409
+ # 约定:子进程发的 URL 形如 http://127.0.0.1:port/<path>,真 host 由
410
+ # X-Upstream-Host 携带;若缺省则取 SYT 上游(兼容直接拼 SYT_BASE_URL 的脚本)。
411
+ upstream_host = request.headers.get("X-Upstream-Host", "").strip()
412
+ syt_base = (creds.get("SYT_BASE_URL") or "").rstrip("/")
413
+ if upstream_host:
414
+ # SSRF 防护:上游 host 禁止指向回环/本机/私有网段(本机服务含沙箱
415
+ # 自身 API,免鉴权,打穿即跨用户任意执行)。非回环的 10.x/172.16-31.x/
416
+ # 192.168.x 内网目标按 EgressPolicy 判定(directAllow 命中才放)。
417
+ if _is_loopback_or_private(upstream_host):
418
+ SYLogger.warning(
419
+ f"[EgressGateway] 拒绝内网/回环上游(SSRF 防护): {upstream_host}")
420
+ return web.Response(status=403, text="gateway: upstream host not allowed")
421
+ scheme = "https"
422
+ target_url = f"{scheme}://{upstream_host}{request.path_qs}"
423
+ elif syt_base:
424
+ target_url = f"{syt_base}{request.path_qs}"
425
+ else:
426
+ return web.Response(status=502, text="gateway: no upstream resolvable")
427
+
428
+ # 技能 scope(审核 P0-3 修正:命令级注册,/execute 在 spawn 前设置,
429
+ # 子进程不参与传递——无法伪造/降级。请求头 X-Skill-Scope 仅作展示
430
+ # 覆盖不再信任,以注册值为准)
431
+ skill_scope = get_active_scope()
432
+
433
+ # 白名单校验(按技能 scope 分级)
434
+ deny = _check_allowlist(target_url, creds, skill_scope)
435
+ if deny is not None:
436
+ SYLogger.info(
437
+ f"[EgressGateway] 白名单拒绝: {target_url} (scope={skill_scope or 'user'})")
438
+ return web.Response(status=403, text=f"gateway: blocked by allowlist")
439
+
440
+ # 剥离子进程自带凭据头 + 透传其余(跳 hop-by-hop + X-Upstream-Host/X-Skill-Scope)
441
+ fwd_headers: dict = {}
442
+ for k, v in request.headers.items():
443
+ kl = k.lower()
444
+ if kl in _HOP_BY_HOP or kl in _INBOUND_CRED_HEADERS \
445
+ or kl == "x-upstream-host" or kl == "x-skill-scope":
446
+ continue
447
+ fwd_headers[k] = v
448
+
449
+ # 注入凭据头 / 改写 URL(query 注入)
450
+ target_url, fwd_headers = _apply_credentials(
451
+ request.method, target_url, fwd_headers, creds)
452
+
453
+ # 读请求体(上传场景)
454
+ body = await request.read() if request.can_read_body else None
455
+
456
+ # 审计:入口。注意用【脱敏 URL】——target_url 此时可能已含注入的
457
+ # corpsecret 等敏感 query(企微 gettoken),原样落日志即凭据泄漏
458
+ # (审核 P1-7)。_sanitize_url 抹掉敏感 query 参数。
459
+ user = self._store.any_user() or "?"
460
+ SYLogger.info(
461
+ f"[EgressGateway] -> {request.method} {_sanitize_url(target_url)} "
462
+ f"(user={user}, scope={skill_scope or 'user'})")
463
+
464
+ # 转发(流式响应)
465
+ try:
466
+ assert self._client is not None
467
+ async with self._client.request(
468
+ request.method, target_url, headers=fwd_headers,
469
+ data=body, allow_redirects=False,
470
+ ) as upstream:
471
+ resp = web.StreamResponse(
472
+ status=upstream.status, reason=upstream.reason)
473
+ # 透传响应头(跳 hop-by-hop)
474
+ for k, v in upstream.headers.items():
475
+ if k.lower() in self._HOP_BY_HOP:
476
+ continue
477
+ resp.headers[k] = v
478
+ await resp.prepare(request)
479
+ bytes_out = 0
480
+ async for chunk in upstream.content.iter_any():
481
+ await resp.write(chunk)
482
+ bytes_out += len(chunk)
483
+ await resp.write_eof()
484
+ SYLogger.info(
485
+ f"[EgressGateway] <- {upstream.status} {_sanitize_url(target_url)} "
486
+ f"({bytes_out}B, user={user})")
487
+ return resp
488
+ except asyncio.CancelledError:
489
+ raise
490
+ except Exception as e:
491
+ SYLogger.error(f"[EgressGateway] 转发失败 {_sanitize_url(target_url)}: {e}")
492
+ return web.Response(status=502, text=f"gateway: upstream error: {e}")
493
+
494
+
495
+ # ============== 命令级 scope(审核 P0-3:不经子进程,不可伪造) ==============
496
+ # /execute 在 spawn 子进程【之前】把本条命令的技能 scope 注册到这里;
497
+ # 网关 _proxy 按注册值分级——子进程无法通过改 env/不发头来降级,因为它
498
+ # 根本不参与传递。值生命周期与命令执行对齐:execute 开始设、结束清。
499
+ # 多用户并发(local 单用户)下按最近一次注册执行(同一命令的子进程内,
500
+ # 请求都发生在该命令运行期间,时序成立)。
501
+ _active_scope: str = "user"
502
+
503
+
504
+ def set_active_scope(scope: str) -> None:
505
+ """/execute 开始时注册本命令的 scope(system/shengye-platform/user)。"""
506
+ global _active_scope
507
+ _active_scope = (scope or "user").strip().lower() or "user"
508
+
509
+
510
+ def get_active_scope() -> str:
511
+ """网关取当前命令的 scope。"""
512
+ return _active_scope
513
+
514
+
515
+ # ============== 模块级单例(供 services.py lifespan 启动)==============
516
+
517
+ _store = CredentialStore()
518
+ _gateway: Optional[EgressGateway] = None
519
+
520
+
521
+ async def start_gateway(port: int = 0) -> int:
522
+ """启动网关 listener,返回实际端口。services.py combined_lifespan 调用。"""
523
+ global _gateway
524
+ if _gateway is not None:
525
+ return _gateway.port or port
526
+ _gateway = EgressGateway(_store, port=port)
527
+ return await _gateway.start()
528
+
529
+
530
+ async def stop_gateway() -> None:
531
+ global _gateway
532
+ if _gateway is not None:
533
+ await _gateway.stop()
534
+ _gateway = None
535
+
536
+
537
+ def gateway_base_url() -> str:
538
+ return _gateway.base_url if _gateway else ""
539
+
540
+
541
+ def credential_store() -> CredentialStore:
542
+ return _store
@@ -4,7 +4,7 @@
4
4
  用于沙箱服务的 Pydantic 模型定义
5
5
  """
6
6
 
7
- from typing import Optional
7
+ from typing import Optional, Dict, Any
8
8
  from pydantic import BaseModel
9
9
 
10
10
 
@@ -24,6 +24,31 @@ class ExecuteResponse(BaseModel):
24
24
  truncated: bool = False
25
25
 
26
26
 
27
+ # ============== 出网收口凭据推送(out-of-band)==============
28
+ # 主进程把凭据推给沙箱网关(不进子进程 env、不进命令串)。网关按目标 host 注入。
29
+ # 详见 sycommon/middleware/sandbox_gateway.py。
30
+
31
+ class CredentialPushRequest(BaseModel):
32
+ """向沙箱网关推送凭据的请求体。
33
+
34
+ creds 是扁平 key-value(不嵌套),形如:
35
+ {"SYT_TOKEN": ..., "SYT_API_AUTHZ": ...,
36
+ "SYT_BASE_URL": <真上游,非网关地址>, "SYT_X_HOST_NAME": ...,
37
+ "WECOM_CORP_ID": ..., "WECOM_CORP_SECRET": ...,
38
+ "WECOM_DOC_BOT_ID": ..., "WECOM_DOC_BOT_SECRET": ...,
39
+ "allowlist": [<path前缀或完整URL>, ...]}
40
+ user_id 用于 shared 模式 per-user 绑定;local 模式凭据表仅一条目。
41
+ """
42
+ user_id: str
43
+ creds: Dict[str, Any] = {}
44
+
45
+
46
+ class CredentialPushResponse(BaseModel):
47
+ """凭据推送响应。gateway_base_url 是网关地址(子进程 SYT_BASE_URL 应指向它)。"""
48
+ user_id: str
49
+ gateway_base_url: str
50
+
51
+
27
52
  # ============== 文件操作 ==============
28
53
 
29
54
  class UploadRequest(BaseModel):
sycommon/services.py CHANGED
@@ -264,7 +264,6 @@ class Services(metaclass=SingletonMeta):
264
264
  sandbox_cleanup_task = None
265
265
 
266
266
  try:
267
- # 注册 asyncio 异常处理器(需在事件循环运行后)
268
267
  try:
269
268
  from sycommon.logging.kafka_log import KafkaLogger
270
269
  loop = asyncio.get_running_loop()
@@ -344,6 +343,23 @@ class Services(metaclass=SingletonMeta):
344
343
  # fire-and-forget:不阻塞启动,失败只记日志。
345
344
  asyncio.create_task(cleanup_all_pip_tmp_residue_async())
346
345
  logging.info("沙箱 tmp pip 残留启动清理任务已触发")
346
+ # 出网收口网关:嵌入进程内的反向代理,子进程所有出网经此网关。
347
+ # 主进程通过 /v1/sandbox/credentials 推送凭据(out-of-band),
348
+ # 网关按目标 host 注入;子进程 env 零密钥。端口由 OS 分配,
349
+ # 启动后 gateway_base_url() 供 /execute 构造子进程 SYT_BASE_URL。
350
+ try:
351
+ from sycommon.middleware.sandbox_gateway import start_gateway
352
+ from sycommon.middleware.egress_wall import (
353
+ setup_egress_wall_async, teardown_egress_wall_async)
354
+ from sycommon.middleware.sandbox import CHILD_DROP_UID
355
+ gw_port = await start_gateway()
356
+ logging.info(f"沙箱出网网关已启动: port={gw_port}")
357
+ # 网关起来后配 iptables 硬墙:只允许 child uid 连网关+DNS,其余 DROP。
358
+ # node/裸socket/任意二进制绕不过。无 NET_ADMIN 时降级告警不阻断。
359
+ await setup_egress_wall_async(
360
+ CHILD_DROP_UID, gw_port, "127.0.0.1")
361
+ except Exception as e:
362
+ logging.error(f"沙箱出网网关启动失败: {e}", exc_info=True)
347
363
 
348
364
  # 6. 初始化 MCP Server(路由已全部注册,可安全读取 OpenAPI schema)
349
365
  if instance._enable_mcp_server:
@@ -375,6 +391,18 @@ class Services(metaclass=SingletonMeta):
375
391
  pass
376
392
  logging.info("沙箱后台清理任务已取消")
377
393
 
394
+ # 关闭出网收口网关 + iptables 硬墙
395
+ if instance._enable_sandbox:
396
+ try:
397
+ from sycommon.middleware.sandbox_gateway import stop_gateway
398
+ from sycommon.middleware.egress_wall import teardown_egress_wall_async
399
+ from sycommon.middleware.sandbox import CHILD_DROP_UID
400
+ await teardown_egress_wall_async(CHILD_DROP_UID)
401
+ await stop_gateway()
402
+ logging.info("沙箱出网网关与硬墙已停止")
403
+ except Exception as e:
404
+ logging.debug(f"沙箱出网网关停止异常: {e}")
405
+
378
406
  # 关闭 MCP Server
379
407
  if instance._enable_mcp_server:
380
408
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sycommon-python-lib
3
- Version: 0.2.8a3
3
+ Version: 0.2.8a4
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown