冰朔 424c4d6ca0 SI-066 LL-007 · API代理网关 · 语言即接口 · 密钥不出保险库
- 新增 API 代理网关 server.py (Flask · 127.0.0.1:8911)
- 人格体通过 session token 调代理 → 代理从保险库取密钥 → 转发第三方API
- 支持 volcano_seedream/seedance/seaweed/voice + aliyun_qwen_vl/bailian
- GZ-006 保险库 API 子库密钥已更新 (火山新Key + 阿里新Key)
- 审计日志全部追溯 · session 1小时过期 · PM2 守护
- 防 AI 抽风: 代码仓库只有代理 URL,无任何密钥明文
2026-07-11 15:10:39 +08:00

332 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
光湖语言系统 · API 代理网关 v1.0
Guanghu Language System · API Proxy Gateway
人格体不需要密钥 → 人格体用语言说 → 代理取密钥 → 代理调 API → 返回结果
密钥不出保险库 · 人格体代码零密钥 · 审计日志全追溯
部署: SG-001 /opt/zhuyuan/api-proxy/server.py
端口: 127.0.0.1:8911仅本地 · 不对外开放)
"""
import os, json, time, hmac, hashlib, secrets as _secrets
from datetime import datetime
from functools import wraps
from flask import Flask, request, jsonify, g
# ──────────────────────────────────────────
# 配置
# ──────────────────────────────────────────
APP = Flask(__name__)
PROXY_PORT = int(os.environ.get("API_PROXY_PORT", "8911"))
SESSION_TTL = 3600 # session 1小时过期
SESSION_SECRET = os.environ.get("API_PROXY_SECRET", _secrets.token_hex(32))
# GZ-006 保险库 · API 子库(通过 gatekeeper 调用)
KEYSTORE_GATEKEEPER = "http://43.139.217.141:3910/exec"
KEYSTORE_TOKEN = os.environ.get("GZ_006_GK_TOKEN", "")
KEYSTORE_API_KS = "/opt/zhuyuan/keystore/api/ks.py"
# 第三方 API 端点 · 密钥全在保险库 · 人格体只需知道 provider 名
ENDPOINTS = {
# ── 火山方舟 · 图片生成Seedream 4.5 / 5.0-lite──
"volcano_seedream": {
"url": "https://ark.cn-beijing.volces.com/api/v3/images/generations",
"key_name": "VOLCANO_JIMENG_API_KEY",
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"description": "火山Seedream图片生成 · models: doubao-seedream-4.5 / doubao-seedream-5.0-lite",
},
# ── 火山方舟 · 视频生成Seedance 1.5-pro──
"volcano_seedance": {
"url": "https://ark.cn-beijing.volces.com/api/v3/images/generations",
"key_name": "VOLCANO_JIMENG_API_KEY",
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"description": "火山Seedance视频生成 · model: doubao-seedance-1.5-pro",
},
# ── 火山方舟 · 视频生成SeaWeed──
"volcano_seaweed": {
"url": "https://ark.cn-beijing.volces.com/api/v3/images/generations",
"key_name": "VOLCANO_JIMENG_API_KEY",
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"description": "火山SeaWeed视频生成 · model: doubao-seedance-2-0-260128",
},
# ── 火山语音复刻 ──
"volcano_voice": {
"url": "https://openspeech.bytedance.com/api/v1/tts",
"key_name": "VOLCANO_VOICE_ACCESS_TOKEN",
"auth_header": "Authorization",
"auth_prefix": "Bearer;",
"description": "火山语音复刻TTS",
},
# ── 阿里千问VL · 视觉理解(看图)──
"aliyun_qwen_vl": {
"url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
"key_name": "ALIYUN_QWEN_VL_KEY",
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"description": "阿里千问VL视觉理解 · model: qwen-vl-max",
},
# ── 阿里百炼 · 图像生成 ──
"aliyun_bailian": {
"url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation",
"key_name": "ALIYUN_API_KEY",
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"description": "阿里百炼图像生成",
},
}
# 审计日志
AUDIT_DIR = "/opt/zhuyuan/api-proxy/audit"
os.makedirs(AUDIT_DIR, exist_ok=True)
# 内存 session 存储(重启丢失 · 安全设计)
SESSIONS = {}
# ──────────────────────────────────────────
# 工具函数
# ──────────────────────────────────────────
def _audit(persona_id, action, detail=""):
"""写审计日志 · 只追加不可改"""
ts = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
entry = {"ts": ts, "persona": persona_id, "action": action, "detail": detail}
log_file = f"{AUDIT_DIR}/api-proxy-{datetime.now().strftime('%Y%m%d')}.jsonl"
with open(log_file, "a") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
def _call_keystore(api_name):
"""通过 GZ-006 gatekeeper 调 API 子库获取密钥"""
import subprocess
# Step 1: challenge
cmd = f"python3 {KEYSTORE_API_KS} challenge {api_name}"
payload = json.dumps({"cmd": cmd})
for attempt in range(2):
r = subprocess.run([
"curl", "-s", "--connect-timeout", "10", "-X", "POST",
KEYSTORE_GATEKEEPER,
"-H", f"Authorization: Bearer {KEYSTORE_TOKEN}",
"-H", "Content-Type: application/json",
"-d", payload
], capture_output=True, text=True)
if r.returncode != 0:
continue
try:
data = json.loads(r.stdout)
if not data.get("ok"):
continue
ch = json.loads(data["stdout"])
cid, nonce, target = ch["challenge_id"], ch["nonce"], ch["target"]
break
except:
continue
else:
raise Exception("keystore challenge failed")
# Step 2: compute response (本地 · 密码从环境变量取)
password = os.environ.get("KEYSTORE_PASSWORD", "")
if not password:
raise Exception("KEYSTORE_PASSWORD not set")
salt = os.environ.get("KEYSTORE_API_SALT", "")
if not salt:
# 降级:从 GZ-006 远程取 salt
import subprocess as _sp
cmd = f"cat {os.path.dirname(KEYSTORE_API_KS)}/salt"
payload = json.dumps({"cmd": cmd})
sr = _sp.run([
"curl", "-s", "--connect-timeout", "10", "-X", "POST",
KEYSTORE_GATEKEEPER,
"-H", f"Authorization: Bearer {KEYSTORE_TOKEN}",
"-H", "Content-Type: application/json",
"-d", payload
], capture_output=True, text=True)
try:
sd = json.loads(sr.stdout)
salt = sd.get("stdout", "").strip()
except:
raise Exception("无法获取 keystore salt")
pw_hash = hmac.new(password.encode(), salt.encode(), hashlib.sha256).hexdigest()
response = hmac.new(pw_hash.encode(), f"{nonce}:{target}".encode(), hashlib.sha256).hexdigest()
encrypt_key = hmac.new(password.encode(), b"hololake-keystore-encrypt", hashlib.sha256).hexdigest()
# Step 3: unlock
cmd = f"python3 {KEYSTORE_API_KS} unlock {cid} {response} {encrypt_key}"
payload = json.dumps({"cmd": cmd})
r = subprocess.run([
"curl", "-s", "--connect-timeout", "10", "-X", "POST",
KEYSTORE_GATEKEEPER,
"-H", f"Authorization: Bearer {KEYSTORE_TOKEN}",
"-H", "Content-Type: application/json",
"-d", payload
], capture_output=True, text=True)
data = json.loads(r.stdout)
if not data.get("ok"):
raise Exception(f"keystore unlock failed: {data.get('stdout', '')}")
result = json.loads(data["stdout"])
if not result.get("ok"):
raise Exception(f"keystore: {result.get('error', 'unknown')}")
return result["token"]
# ──────────────────────────────────────────
# Session 管理
# ──────────────────────────────────────────
def _make_session(persona_id):
"""生成临时 session token"""
sid = _secrets.token_hex(32)
ts = int(time.time())
SESSIONS[sid] = {"persona": persona_id, "created": ts, "expires": ts + SESSION_TTL}
# 清理过期 session
for k in list(SESSIONS.keys()):
if SESSIONS[k]["expires"] < ts:
del SESSIONS[k]
return sid
def _verify_session(sid):
"""验证 session · 返回 persona_id 或 None"""
if not sid or sid not in SESSIONS:
return None
if SESSIONS[sid]["expires"] < time.time():
del SESSIONS[sid]
return None
return SESSIONS[sid]["persona"]
def require_session(f):
"""装饰器:需要有效 session"""
@wraps(f)
def wrapper(*a, **kw):
sid = request.headers.get("X-Session-Token", "")
persona = _verify_session(sid)
if not persona:
return jsonify({"error": "无效或过期的 session · 请先 POST /auth/session"}), 401
g.persona_id = persona
return f(*a, **kw)
return wrapper
# ──────────────────────────────────────────
# 路由
# ──────────────────────────────────────────
@APP.route("/health", methods=["GET"])
def health():
return jsonify({"ok": True, "service": "api-proxy-gateway", "version": "1.0.0"})
@APP.route("/auth/session", methods=["POST"])
def auth_session():
"""人格体申请临时 session token"""
body = request.get_json(silent=True) or {}
persona_id = body.get("persona_id", "").strip()
if not persona_id:
return jsonify({"error": "需要 persona_id如 ICE-GL-ZY001"}), 400
# 简单验证persona_id 必须以 ICE-GL- 或 TCS- 开头
if not (persona_id.startswith("ICE-GL-") or persona_id.startswith("TCS-")):
return jsonify({"error": "无效的 persona_id 格式"}), 400
sid = _make_session(persona_id)
_audit(persona_id, "session_created", f"session={sid[:8]}...")
return jsonify({
"ok": True,
"session_token": sid,
"expires_in": SESSION_TTL,
"persona_id": persona_id,
})
@APP.route("/proxy/<provider>", methods=["POST"])
@require_session
def proxy_call(provider):
"""代理调用第三方 API"""
if provider not in ENDPOINTS:
return jsonify({"error": f"未知 provider: {provider}", "available": list(ENDPOINTS.keys())}), 404
cfg = ENDPOINTS[provider]
body = request.get_json(silent=True) or {}
# ① 从保险库取密钥(不出现在返回中)
try:
api_key = _call_keystore(cfg["key_name"])
except Exception as e:
_audit(g.persona_id, "keystore_error", str(e))
return jsonify({"error": f"密钥获取失败: {str(e)}"}), 502
# ② 构造请求 + 转发
import subprocess
headers = {
"Content-Type": "application/json",
cfg["auth_header"]: cfg["auth_prefix"] + api_key,
}
# 合并 body 中的额外参数
proxy_body = {k: v for k, v in body.items() if not k.startswith("_")}
payload = json.dumps(proxy_body)
r = subprocess.run([
"curl", "-s", "--connect-timeout", "30", "--max-time", "120",
"-X", "POST", cfg["url"],
"-H", f"Content-Type: application/json",
"-H", f"{cfg['auth_header']}: {cfg['auth_prefix']}{api_key}",
"-d", payload,
], capture_output=True, text=True)
# ③ 审计日志
_audit(g.persona_id, f"proxy_{provider}", f"status={r.returncode} len={len(r.stdout)}")
# ④ 返回结果(不含密钥)
try:
result = json.loads(r.stdout)
except:
result = {"raw": r.stdout[:1000], "stderr": r.stderr[:500]}
return jsonify({"ok": r.returncode == 0, "provider": provider, "result": result})
@APP.route("/providers", methods=["GET"])
@require_session
def list_providers():
"""列出可用的 API 提供商(不含密钥)"""
providers = {}
for name, cfg in ENDPOINTS.items():
providers[name] = {
"key_name": cfg["key_name"],
"description": cfg.get("description", ""),
}
return jsonify({"providers": providers})
@APP.route("/audit", methods=["GET"])
@require_session
def view_audit():
"""查看最近的审计日志"""
today = datetime.now().strftime("%Y%m%d")
log_file = f"{AUDIT_DIR}/api-proxy-{today}.jsonl"
entries = []
if os.path.exists(log_file):
with open(log_file) as f:
for line in f.readlines()[-50:]:
try:
entries.append(json.loads(line.strip()))
except:
pass
return jsonify({"date": today, "entries": entries, "count": len(entries)})
# ──────────────────────────────────────────
# 启动
# ──────────────────────────────────────────
if __name__ == "__main__":
print(f"光湖 API 代理网关 v1.0")
print(f"监听: 127.0.0.1:{PROXY_PORT}")
print(f"Providers: {list(ENDPOINTS.keys())}")
print(f"审计日志: {AUDIT_DIR}")
APP.run(host="127.0.0.1", port=PROXY_PORT, debug=False)