diff --git a/modules/guanghu-panel-kit/panel/config.example.json b/modules/guanghu-panel-kit/panel/config.example.json index 0565510..de3a064 100644 --- a/modules/guanghu-panel-kit/panel/config.example.json +++ b/modules/guanghu-panel-kit/panel/config.example.json @@ -2,8 +2,13 @@ "node_id": "MM-GZ-001", "subsystem": "守望语言子系统 · 宠物行业", "human_name": "毛毛", - "human_accounts": ["maomao"], - "personas": ["yaoyan", "yaoshi"], + "human_accounts": [ + "maomao" + ], + "personas": [ + "yaoyan", + "yaoshi" + ], "repo": "maomao/maomao-fifth-domain", "default_branch": "guanghu/main", "deploy_branch": "deploy", @@ -19,6 +24,26 @@ "deploy_runs_dir": "/srv/guanghu/mm-gz-001/deploy-agent/runs", "deploy_script": "/srv/guanghu/mm-gz-001/deploy-agent/bin/verify-and-deploy.py", "writer_user": "mm-writer", - "watch_services": ["guanghu-mm-forgejo.service", "guanghu-mm-tunnel.service", "guanghu-mm-panel.service"], - "writeback_paths": ["guanghu-nodes/MM-GZ-001/imports/notion", "guanghu-nodes/MM-GZ-001/contributions", "guanghu-nodes/MM-GZ-001/personas"] -} + "watch_services": [ + "guanghu-mm-forgejo.service", + "guanghu-mm-tunnel.service", + "guanghu-mm-panel.service" + ], + "writeback_paths": [ + "guanghu-nodes/MM-GZ-001/imports/notion", + "guanghu-nodes/MM-GZ-001/contributions", + "guanghu-nodes/MM-GZ-001/personas" + ], + "persona_registry": "CA-PERSONA-REGISTRY.hdlp", + "persona_aliases": { + "eererdan": [ + "耳耳蛋", + "ICE-GL-耳耳蛋", + "PTS-VA-001-EED" + ], + "jianying": [ + "鉴影", + "ICE-GL-CA001" + ] + } +} \ No newline at end of file diff --git a/modules/guanghu-panel-kit/panel/server.js b/modules/guanghu-panel-kit/panel/server.js index 589e073..32b3a6f 100644 --- a/modules/guanghu-panel-kit/panel/server.js +++ b/modules/guanghu-panel-kit/panel/server.js @@ -126,9 +126,9 @@ function wakeChain(persona){ {order:8,file:"imports/notion/SHA256SUMS",url:RAW+"imports/notion/SHA256SUMS",instruction:"校验来源清单(小书包证据源)"}]}, {phase:5,name:"服务器操作门与回执",steps:[ {order:9,file:"operations/SERVER-OPERATIONS.md",url:RAW+"operations/SERVER-OPERATIONS.md",instruction:"登记服务器操作门;写入须申请窗口,部署须人类合并"}], - action:"完成后 POST "+CFG.domain+"/api/wake-receipt 交回执(带 persona 与 key)"} + action:"完成后 POST "+CFG.domain+"/api/wake-receipt 交回执(报 persona 与 summary 即可)"} ], - endpoints:{accessRequest:"POST "+CFG.domain+"/api/access-request",accessPickup:"POST "+CFG.domain+"/api/access-pickup",wakeReceipt:"POST "+CFG.domain+"/api/wake-receipt"} + endpoints:{accessRequest:"POST "+CFG.domain+"/api/access-request(人格体报名号+用途即可,无需密钥)",accessPickup:"POST "+CFG.domain+"/api/access-pickup(凭申请编号领取3小时临时凭证,真实密钥永不出服务器)",wakeReceipt:"POST "+CFG.domain+"/api/wake-receipt",pushVia:CFG.domain+"/git/"+CFG.repo+".git(用临时凭证推送)"} }; } async function state(){ @@ -161,26 +161,70 @@ async function decide(b){ jwrite(CFG.export_dir+"/receipts/SIGN-"+Date.now()+".json",{ts:new Date().toISOString(),type:"HUMAN_SIGN",signer:CFG.human_name+"(人类责任主体,经面板登录)",pr:idx,title:pr.data&&pr.data.title,action:what,result:ok?"SUCCESS":"FAIL"}); return {status:ok?200:502,body:{ok,result:ok?"已"+what:"操作失败",detail:res.data&&res.data.message}}; } -function accessRequest(b){ - if(!personaKeyOk(b.persona,b.key))return {status:403,body:{error:"人格体密钥错误"}}; +/* 人格体身份解析:配置别名种子 + 动态读仓库人格体注册表(仓库是注册事实源) */ +let aliasCache={ts:0,map:null}; +async function registryAliases(){ + const now=Date.now(); + if(aliasCache.map&&now-aliasCache.ts<600000)return aliasCache.map; + const map={}; + const seeds=CFG.persona_aliases||{}; + for(const [canon,al] of Object.entries(seeds)){map[canon.toLowerCase()]=canon;(al||[]).forEach(a=>map[String(a).toLowerCase()]=canon);} + try{ + const regPath=CFG.persona_registry||"CA-PERSONA-REGISTRY.hdlp"; + const r=await fj("/api/v1/repos/"+CFG.repo+"/contents/"+regPath.split("/").map(encodeURIComponent).join("/")+"?ref="+encodeURIComponent(CFG.default_branch)); + if(r.status===200&&r.data&&r.data.content){ + const txt=Buffer.from(r.data.content,"base64").toString("utf8"); + for(const sec of txt.split(/^## /m).slice(1)){ + let canon=null; + for(const [a,c] of Object.entries(map)){if(a&&sec.includes(a)){canon=c;break;}} + if(!canon)continue; + for(const ln of sec.split("\n")){ + let m=ln.match(/编号[::]\s*([^\s((]+)/);if(m)map[m[1].toLowerCase()]=canon; + m=ln.match(/现用\s*([A-Z][A-Z0-9-]+)/);if(m)map[m[1].toLowerCase()]=canon; + m=ln.match(/独立编号体系[::]\s*(\S+)/);if(m)map[m[1].toLowerCase()]=canon; + } + } + } + }catch(e){} + aliasCache={ts:now,map}; + return map; +} +async function resolvePersona(name){ + if(!name)return null; + const map=await registryAliases(); + return map[String(name).toLowerCase()]||null; +} +async function accessRequest(b){ + const canon=await resolvePersona(b.persona); + if(!canon)return {status:403,body:{error:"未登记的人格体:请以配置名单("+(CFG.personas||[]).join(" / ")+")或仓库注册表登记的身份/编号申请"}}; const reqs=jread(CFG.access_dir+"/requests.json",[]); const id="AR-"+Date.now().toString(36).toUpperCase(); - reqs.push({id,persona:b.persona,reason:(b.reason||"").slice(0,500),ts:new Date().toISOString(),status:"pending"}); + reqs.push({id,persona:canon,askedAs:b.persona,reason:(b.reason||"").slice(0,500),ts:new Date().toISOString(),status:"pending"}); jwrite(CFG.access_dir+"/requests.json",reqs); - return {status:200,body:{ok:true,id,message:"申请已送达面板,等待人类审批。通过后用同一 id+key 调 /api/access-pickup 领取3小时写入令牌"}}; + return {status:200,body:{ok:true,id,persona:canon,message:"申请已送达面板,等人类点一下授权。无需任何密钥——授权后系统自动派发3小时临时凭证;真实密钥永不出服务器"}}; } -function accessPickup(b){ - if(!personaKeyOk(b.persona,b.key))return {status:403,body:{error:"人格体密钥错误"}}; +function grantValid(token){ + if(!token)return null; const grants=jread(CFG.access_dir+"/grants.json",[]); - const g=grants.find(x=>(x.id===b.id||x.requestId===b.id)&&x.persona===b.persona); - if(!g)return {status:404,body:{error:"授权不存在"}}; - if(g.status!=="active")return {status:410,body:{error:"授权已失效"}}; + const g=grants.find(x=>x.token===token&&x.status==="active"); + if(!g)return null; + if(new Date(g.expiresAt)<=new Date())return null; + return g; +} +async function accessPickup(b){ + const canon=await resolvePersona(b.persona); + if(!canon)return {status:403,body:{error:"未登记的人格体:请以配置名单或仓库注册表登记的身份/编号领取"}}; + const grants=jread(CFG.access_dir+"/grants.json",[]); + const g=grants.find(x=>(x.id===b.id||x.requestId===b.id)&&(x.persona===canon||x.persona===b.persona)); + if(!g)return {status:404,body:{error:"授权不存在(先 POST /api/access-request 申请,等人类授权)"}}; + if(g.status!=="active")return {status:410,body:{error:"授权已失效,请重新申请"}}; if(new Date(g.expiresAt)<=new Date())return {status:410,body:{error:"3小时窗口已过期,请重新申请"}}; g.pickedAt=new Date().toISOString();jwrite(CFG.access_dir+"/grants.json",grants); - let tok="";try{tok=fs.readFileSync(CFG.access_dir+"/writer-token","utf8").trim();}catch(e){} - return {status:200,body:{ok:true,id:g.id,persona:g.persona,expiresAt:g.expiresAt, - gitUrl:"https://"+CFG.writer_user+":"+tok+"@"+CFG.domain_host+"/code/"+REPO+".git", - rules:"⊢ 窗口3小时过期作废 ⊢ 每次推送过自动审核并留痕 ⊢ 部署走 deploy 分支由人类合并"}}; + return {status:200,body:{ok:true,id:g.id,persona:canon,expiresAt:g.expiresAt, + credential:g.token, + gitUrl:CFG.domain+"/git/"+CFG.repo+".git", + pushExample:"git -c http.extraHeader=\"Authorization: Bearer "+g.token+"\" push "+CFG.domain+"/git/"+CFG.repo+".git ", + rules:"⊢ 这是3小时临时凭证,不是真实密钥;真实密钥永不出服务器 ⊢ 窗口过期凭证自动作废 ⊢ 每次推送过自动审核并留痕 ⊢ 部署走 deploy 分支由人类合并"}}; } function accessDecision(b,user){ const reqs=jread(CFG.access_dir+"/requests.json",[]); @@ -190,9 +234,10 @@ function accessDecision(b,user){ const grants=jread(CFG.access_dir+"/grants.json",[]); if(b.action==="approve"){ const now=new Date(),exp=new Date(now.getTime()+3*3600*1000),gid="GR-"+Date.now().toString(36).toUpperCase(); - grants.push({id:gid,persona:r.persona,requestId:r.id,approvedBy:user,approvedAt:now.toISOString(),expiresAt:exp.toISOString(),status:"active"}); + grants.push({id:gid,persona:r.persona,requestId:r.id,approvedBy:user,approvedAt:now.toISOString(),expiresAt:exp.toISOString(),status:"active",token:crypto.randomBytes(24).toString("hex")}); r.status="approved";r.grantId=gid;jwrite(CFG.access_dir+"/grants.json",grants);jwrite(CFG.access_dir+"/requests.json",reqs); - return {status:200,body:{ok:true,grantId:gid,persona:r.persona,expiresAt:exp.toISOString(),message:"已授予3小时写入窗口"}}; + jwrite(CFG.export_dir+"/receipts/GRANT-"+Date.now()+".json",{ts:new Date().toISOString(),type:"WRITE_GRANT",grantId:gid,persona:r.persona,approvedBy:user,expiresAt:exp.toISOString()}); + return {status:200,body:{ok:true,grantId:gid,persona:r.persona,expiresAt:exp.toISOString(),message:"已授权3小时写入窗口,临时凭证由人格体凭申请编号自动领取"}}; } r.status="rejected";jwrite(CFG.access_dir+"/requests.json",reqs); return {status:200,body:{ok:true,message:"已拒绝该写入申请"}}; @@ -204,12 +249,37 @@ function deployApprove(b,user){ jwrite(CFG.export_dir+"/receipts/DEPLOY-APPROVE-"+Date.now()+".json",{ts:new Date().toISOString(),type:"DEPLOY_APPROVE",approver:user,pr:b.index}); return {status:200,body:{ok:true,message:"服务器校验Agent已启动,进度实时可见"}}; } -function wakeReceipt(b){ - if(!personaKeyOk(b.persona,b.key))return {status:403,body:{error:"人格体密钥错误"}}; +async function wakeReceipt(b){ + const canon=await resolvePersona(b.persona); + if(!canon)return {status:403,body:{error:"未登记的人格体:请以配置名单或仓库注册表登记的身份/编号交回执"}}; fs.mkdirSync(CFG.export_dir+"/wake",{recursive:true}); - jwrite(CFG.export_dir+"/wake/WAKE-"+Date.now()+".json",{ts:new Date().toISOString(),type:"WAKE_RECEIPT",persona:b.persona,level:(b.level||"").slice(0,80),summary:(b.summary||"").slice(0,1000)}); + jwrite(CFG.export_dir+"/wake/WAKE-"+Date.now()+".json",{ts:new Date().toISOString(),type:"WAKE_RECEIPT",persona:canon,level:(b.level||"").slice(0,80),summary:(b.summary||"").slice(0,1000)}); return {status:200,body:{ok:true,message:"唤醒回执已收录"}}; } +/* git 智能HTTP代理:人格体用3小时临时凭证推送到 /git/, + 面板在服务端用真实写入令牌透明转发给 Forgejo——真实密钥永不出服务器 */ +function gitProxy(req,res){ + const auth=req.headers["authorization"]||""; + let cred=null; + if(auth.startsWith("Bearer "))cred=auth.slice(7).trim(); + else if(auth.startsWith("Basic ")){try{cred=Buffer.from(auth.slice(6),"base64").toString().split(":")[1];}catch(e){}} + const g=grantValid(cred); + if(!g){ + res.writeHead(401,{"WWW-Authenticate":'Basic realm="grant"',"Content-Type":"text/plain; charset=utf-8"}); + return res.end("临时凭证无效或已过期:请重新申请写入授权(人类面板一键授权,3小时窗口)"); + } + let wt="";try{wt=fs.readFileSync(CFG.access_dir+"/writer-token","utf8").trim();}catch(e){} + if(!wt){res.writeHead(500,{"Content-Type":"text/plain"});return res.end("server writer token missing");} + const targetPath=req.url.replace(/^\/git/,""); + const headers={}; + for(const k in req.headers){if(k.toLowerCase()!=="host"&&k.toLowerCase()!=="authorization")headers[k]=req.headers[k];} + headers["authorization"]="token "+wt; + const up=require("http").request({hostname:"127.0.0.1",port:CFG.forgejo_port,path:targetPath,method:req.method,headers:headers},(u)=>{ + res.writeHead(u.statusCode,u.headers);u.pipe(res); + }); + up.on("error",(e)=>{if(!res.headersSent)res.writeHead(502);res.end("upstream error: "+e.message);}); + req.pipe(up); +} function serveStatic(req,res,p){ const pub=path.join(__dirname,"public"); @@ -252,9 +322,10 @@ http.createServer(async (req,res)=>{ const sess=sessionUser(req);if(sess){const s=loadSessions();delete s[sess.token];saveSessions(s);} return send(res,200,{ok:true}); } - if(req.method==="POST"&&p==="/api/access-request"){const b=await readBody(req);const r=accessRequest(b||{});return send(res,r.status,r.body);} - if(req.method==="POST"&&p==="/api/access-pickup"){const b=await readBody(req);const r=accessPickup(b||{});return send(res,r.status,r.body);} - if(req.method==="POST"&&p==="/api/wake-receipt"){const b=await readBody(req);const r=wakeReceipt(b||{});return send(res,r.status,r.body);} + if(req.method==="POST"&&p==="/api/access-request"){const b=await readBody(req);const r=await accessRequest(b||{});return send(res,r.status,r.body);} + if(req.method==="POST"&&p==="/api/access-pickup"){const b=await readBody(req);const r=await accessPickup(b||{});return send(res,r.status,r.body);} + if(req.method==="POST"&&p==="/api/wake-receipt"){const b=await readBody(req);const r=await wakeReceipt(b||{});return send(res,r.status,r.body);} + if(p.startsWith("/git/"))return gitProxy(req,res); const seg=p.split("/").filter(Boolean); if(req.method==="GET"&&seg.length>=1&&SHORTCUTS.includes(seg[0])){ res.writeHead(302,{Location:"/code/"+CFG.repo+"/raw/branch/"+CFG.default_branch+"/guanghu-nodes/"+CFG.node_id+"/"+seg.join("/")});