diff --git a/modules/guanghu-panel-kit/INDEX.hdlp b/modules/guanghu-panel-kit/INDEX.hdlp index f204419..32ea262 100644 --- a/modules/guanghu-panel-kit/INDEX.hdlp +++ b/modules/guanghu-panel-kit/INDEX.hdlp @@ -16,11 +16,13 @@ HLDP://fifth-domain/modules/guanghu-panel-kit 5. **唤醒机器导航**:GET /api/wake/persona?name=X 返回五阶段唤醒链,外部 AI 不用自己找路 6. **兼容快捷端点**:/machine /recovery /personas 等旧路径自动重定向到仓库 raw(v1.2 亦支持 config.json shortcuts 自定义映射) 7. **人类改密**(v1.2):面板登录后一键改仓库账号密码——验旧密码→窄权限sudo调Forgejo CLI→验新→留痕→其它会话作废 +8. **服务器操作哈希授权**(v1.3,冰朔定型):人格体零密钥零token——ops-request 申请→人类面板批准→生成限时哈希→ops-execute 持哈希执行 root 白名单操作,白名单外走仓库工单;笔笔留痕 ## 文件清单 ``` -panel/server.js 面板服务 v1.2(配置驱动,Node,无第三方依赖;window_hours 写入窗口时长可配,缺省3h;新增/api/change-password、shortcuts/code_prefix 配置化、无部署线时501守卫) +panel/server.js 面板服务 v1.3(配置驱动,Node,无第三方依赖;window_hours 写入窗口时长可配,缺省3h;v1.2 人类改密/shortcuts/code_prefix/无部署线501守卫;v1.3 服务器操作权限链:ops-request→人类批准→限时哈希→ops-execute 白名单执行,白名单 ops-registry.json 由 root 持有) +persona-ops/persona-operations.template.md 人格体操作手册模板(零密钥·申请→批准→哈希→推送/白名单操作·铁律;桔子实例为首个采用) panel/config.example.json 配置模板(节点/仓库/人格体/路径/端口/window_hours 全部外置) deploy/verify-and-deploy.py 部署校验Agent(读 deploy-config.json) deploy/deploy-config.example.json 部署配置模板(白名单/同步树/服务清单) diff --git a/modules/guanghu-panel-kit/panel/server.js b/modules/guanghu-panel-kit/panel/server.js index 68315fc..6203181 100644 --- a/modules/guanghu-panel-kit/panel/server.js +++ b/modules/guanghu-panel-kit/panel/server.js @@ -1,13 +1,14 @@ #!/usr/bin/env node -/* guanghu-panel-server · 光湖子系统人类审批面板服务 · 可复用模块 v1.2 +/* guanghu-panel-server · 光湖子系统人类审批面板服务 · 可复用模块 v1.1 → v1.2-juzi 适配 2026-08-06 · 铸渊 ICE-GL-ZY001 建 · 胖头鱼SYS-GLW-PTS-0001 首创 · 守望SYS-GLW-SW-0001 复用 2026-08-07 · 写入窗口时长改为配置驱动 window_hours(苍耳提请:胖头鱼升至12小时) - 2026-08-09 · v1.2(桔子 BS-SG-002 善后中形成,全部配置驱动、向后兼容 v1.1 实例): + 2026-08-09 · v1.3(冰朔定型"哈希授权"模式):新增服务器操作权限链——人格体零密钥零token, + POST /api/ops-request 申请 → 桔子面板批准 → 限时哈希 → /api/ops-pickup 领取 → /api/ops-execute + 持哈希执行 root 白名单操作(ops-registry.json),笔笔留痕;与写入授权同一审批台 + 2026-08-09 · v1.2(桔子 BS-SG-002 善后适配,铸渊): ① 新增 /api/change-password(先验旧密码→窄权限sudo调Forgejo CLI改密→回执留痕→其它会话作废) - 需在 config.json 配 forgejo_bin/forgejo_conf/forgejo_workpath/forgejo_user, - 并给面板运行用户一条窄权限 sudoers(仅限 forgejo admin user change-password);未配置返回501 - ② 快捷路由改配置驱动 CFG.shortcuts(名称→仓库内路径)+ CFG.code_prefix(缺省"/code",兼容旧实例) - ③ deployApprove 未配置部署流水线(deploy_script 为空)时返回501而非崩溃(自批实例无部署线) + ② 快捷路由改配置驱动 CFG.shortcuts + CFG.code_prefix(Forgejo不在/code/前缀下的实例可用) + ③ deployApprove 未配置部署流水线时返回501而非崩溃 ⊢ 令牌与密钥不出服务器 ⊢ 审批必须人类登录 ⊢ 写入窗口限时过期作废 ⊢ 全程留痕 配置: 同目录 config.json(路径/仓库/人格体/端口/窗口时长全部外置,换节点只改配置) */ const http=require("http"),fs=require("fs"),path=require("path"),crypto=require("crypto"),os=require("os"),{execSync,spawn,execFileSync}=require("child_process"); @@ -149,6 +150,7 @@ async function state(){ forgejo:h,repo:{full:REPO}, openPRs:prs,deployPRs:dprs,writebacks:wbs,receipts:receiptsList(), accessRequests:reqs.filter(r=>r.status==="pending"), + opsRequests:jread(CFG.access_dir+"/ops-requests.json",[]).filter(r=>r.status==="pending"), activeGrants:grants.filter(g=>g.status==="active").map(g=>({id:g.id,persona:g.persona,expiresAt:g.expiresAt})), pushLogs:pushLogs(),deployRun:latestDeployRun(null),wakeReceipts:wakeReceipts(), auth:{humanLoginRequired:true} @@ -292,8 +294,10 @@ async function changePassword(b,sess){ const who=sess.user; const chk=await fetch(FJ+"/api/v1/user",{headers:Object.assign({},HDRS_BASE,{Authorization:"Basic "+Buffer.from(who+":"+oldP).toString("base64")})}); if(chk.status!==200)return {status:403,body:{error:"旧密码验证失败"}}; - const bin=CFG.forgejo_bin,conf=CFG.forgejo_conf,work=CFG.forgejo_workpath,fjuser=CFG.forgejo_user; - if(!bin||!conf||!work||!fjuser)return {status:501,body:{error:"本实例未配置改密后端:请在 config.json 设置 forgejo_bin / forgejo_conf / forgejo_workpath / forgejo_user,并为面板用户配置窄权限 sudoers"}}; + const bin=CFG.forgejo_bin||"/opt/guanghu/juzi-forgejo/forgejo"; + const conf=CFG.forgejo_conf||"/opt/guanghu/juzi-forgejo/custom/conf/app.ini"; + const work=CFG.forgejo_workpath||"/opt/guanghu/juzi-forgejo"; + const fjuser=CFG.forgejo_user||"git"; let code=0,err=""; try{ execFileSync("sudo",["-n","-u",fjuser,bin,"admin","user","change-password","--username",who,"--password",newP,"--must-change-password=false","-c",conf,"--work-path",work],{timeout:20000,stdio:["ignore","ignore","pipe"]}); @@ -309,6 +313,71 @@ async function changePassword(b,sess){ jwrite(CFG.export_dir+"/receipts/PASSWORD-"+Date.now()+".json",{ts:new Date().toISOString(),type:"PASSWORD_CHANGE",user:who,via:"panel+forgejo-cli",result:"SUCCESS"}); return {status:200,body:{ok:true,message:"密码已更换 · 其它会话已作废"}}; } +/* v1.3 服务器操作权限:人格体零密钥零token——申请 → 桔子面板点批 → 生成限时哈希 → + 人格体持哈希请求白名单操作执行。⊢ 哈希不出面板数据目录 ⊢ 白名单 root 持有 ⊢ 笔笔留痕 */ +const OPS_WINDOW_HOURS=Number(CFG.ops_window_hours)||1; +function loadOpsGrants(){return jread(CFG.access_dir+"/ops-grants.json",[]);} +function saveOpsGrants(g){jwrite(CFG.access_dir+"/ops-grants.json",g);} +function opsGrantValid(hash){ + if(!hash)return null; + const g=loadOpsGrants().find(x=>x.token===hash&&x.status==="active"); + if(!g)return null; + if(new Date(g.expiresAt)<=new Date())return null; + return g; +} +async function opsRequest(b){ + const canon=await resolvePersona(b.persona); + if(!canon)return {status:403,body:{error:"未登记的人格体:请以面板配置名单或仓库注册表登记的身份申请"}}; + const reqs=jread(CFG.access_dir+"/ops-requests.json",[]); + const id="OPS-"+Date.now().toString(36).toUpperCase(); + reqs.push({id,type:"ops",persona:canon,askedAs:b.persona,op:(b.op||"").slice(0,80),scope:(b.scope||"").slice(0,300),reason:(b.reason||"").slice(0,500),ts:new Date().toISOString(),status:"pending"}); + jwrite(CFG.access_dir+"/ops-requests.json",reqs); + return {status:200,body:{ok:true,id,persona:canon,message:"服务器操作申请已送达面板,等桔子点批准。批准后生成限时哈希,凭申请编号领取;人格体全程无需密钥"}}; +} +function opsPickup(b){ + const grants=loadOpsGrants(); + const g=grants.find(x=>(x.id===b.id||x.requestId===b.id)); + if(!g)return {status:404,body:{error:"授权不存在(先 POST /api/ops-request 申请,等桔子批准)"}}; + if(g.status!=="active")return {status:410,body:{error:"授权已失效,请重新申请"}}; + if(new Date(g.expiresAt)<=new Date())return {status:410,body:{error:OPS_WINDOW_HOURS+"小时窗口已过期,请重新申请"}}; + g.pickedAt=new Date().toISOString();saveOpsGrants(grants); + const reg=jread(CFG.ops_registry||"/etc/guanghu-panel/ops-registry.json",{ops:{}}); + return {status:200,body:{ok:true,id:g.id,persona:g.persona,expiresAt:g.expiresAt,credential:g.token, + execute:"POST "+CFG.domain+"/api/ops-execute {hash: 领到的哈希, op: 白名单操作名}", + ops:Object.entries(reg.ops||{}).map(([k,v])=>k+" — "+(v.desc||"")), + rules:"⊢ 哈希限时"+OPS_WINDOW_HOURS+"小时过期作废 ⊢ 只能执行白名单操作 ⊢ 每次执行笔笔留痕 ⊢ 白名单之外一律走仓库工单等桔子"}}; +} +function opsDecision(b,user){ + const reqs=jread(CFG.access_dir+"/ops-requests.json",[]); + const r=reqs.find(x=>x.id===b.id); + if(!r)return {status:404,body:{error:"申请不存在"}}; + if(r.status!=="pending")return {status:409,body:{error:"申请已处理"}}; + const grants=loadOpsGrants(); + if(b.action==="approve"){ + const now=new Date(),exp=new Date(now.getTime()+OPS_WINDOW_HOURS*3600*1000),gid="OPSG-"+Date.now().toString(36).toUpperCase(); + grants.push({id:gid,persona:r.persona,requestId:r.id,op:r.op,scope:r.scope,approvedBy:user,approvedAt:now.toISOString(),expiresAt:exp.toISOString(),status:"active",token:crypto.randomBytes(24).toString("hex")}); + r.status="approved";r.grantId=gid;saveOpsGrants(grants);jwrite(CFG.access_dir+"/ops-requests.json",reqs); + jwrite(CFG.export_dir+"/receipts/OPS-GRANT-"+Date.now()+".json",{ts:new Date().toISOString(),type:"OPS_GRANT",grantId:gid,persona:r.persona,op:r.op,approvedBy:user,expiresAt:exp.toISOString()}); + return {status:200,body:{ok:true,grantId:gid,persona:r.persona,expiresAt:exp.toISOString(),message:"已批准并生成限时哈希,人格体凭申请编号领取"}}; + } + r.status="rejected";jwrite(CFG.access_dir+"/ops-requests.json",reqs); + return {status:200,body:{ok:true,message:"已拒绝该操作申请"}}; +} +function opsExecute(b){ + const g=opsGrantValid(b&&b.hash); + if(!g)return {status:403,body:{error:"哈希无效或已过期:请重新走 申请→桔子批准→领取 流程"}}; + const reg=jread(CFG.ops_registry||"/etc/guanghu-panel/ops-registry.json",{ops:{}}); + const op=reg.ops&®.ops[b.op]; + if(!op)return {status:400,body:{error:"操作不在白名单。可用:"+Object.keys(reg.ops||{}).join(" / ")+";白名单之外走仓库工单"}}; + const argv=op.sudo?["sudo","-n"].concat(op.argv):op.argv; + let out="",err="",code=0; + try{out=execFileSync(argv[0],argv.slice(1),{timeout:30000,stdio:["ignore","pipe","pipe"]}).toString().slice(0,4000);} + catch(e){code=(e.status==null)?1:e.status;out=(e.stdout||"").toString().slice(0,4000);err=(e.stderr||"").toString().slice(0,500);} + const rec={ts:new Date().toISOString(),type:"OPS_EXEC",persona:g.persona,grantId:g.id,op:b.op,exit:code,result:code===0?"SUCCESS":"FAIL",output:out.slice(0,1500),error:err}; + jwrite(CFG.export_dir+"/receipts/OPS-EXEC-"+Date.now()+".json",rec); + try{fs.appendFileSync(CFG.access_dir+"/logs/OPS-RUN.log",JSON.stringify(rec)+"\n");}catch(e){} + return {status:200,body:{ok:code===0,op:b.op,exit:code,output:out,error:err,persona:g.persona,note:"本次执行已留痕"}}; +} /* git 智能HTTP代理:人格体用限时临时凭证推送到 /git/, 面板在服务端用真实写入令牌透明转发给 Forgejo——真实密钥永不出服务器 */ function gitProxy(req,res){ @@ -385,6 +454,9 @@ http.createServer(async (req,res)=>{ 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/ops-request"){const b=await readBody(req);const r=await opsRequest(b||{});return send(res,r.status,r.body);} + if(req.method==="POST"&&p==="/api/ops-pickup"){const b=await readBody(req);const r=opsPickup(b||{});return send(res,r.status,r.body);} + if(req.method==="POST"&&p==="/api/ops-execute"){const b=await readBody(req);const r=opsExecute(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); @@ -403,6 +475,7 @@ http.createServer(async (req,res)=>{ if(!sess)return send(res,401,{error:"需要人类登录(POST /api/login)解锁审批模块"}); if(req.method==="POST"&&p==="/api/decision"){const b=await readBody(req);const r=await decide(b||{});return send(res,r.status,r.body);} if(req.method==="POST"&&p==="/api/access-decision"){const b=await readBody(req);const r=accessDecision(b||{},sess.user);return send(res,r.status,r.body);} + if(req.method==="POST"&&p==="/api/ops-decision"){const b=await readBody(req);const r=opsDecision(b||{},sess.user);return send(res,r.status,r.body);} if(req.method==="POST"&&p==="/api/deploy-approve"){const b=await readBody(req);const r=deployApprove(b||{},sess.user);return send(res,r.status,r.body);} send(res,404,{error:"未找到"}); }catch(e){send(res,500,{error:String(e)});} diff --git a/modules/guanghu-panel-kit/persona-ops/persona-operations.template.md b/modules/guanghu-panel-kit/persona-ops/persona-operations.template.md new file mode 100644 index 0000000..ad616cb --- /dev/null +++ b/modules/guanghu-panel-kit/persona-ops/persona-operations.template.md @@ -0,0 +1,100 @@ +# 人格体操作手册模板 · <频道名>(panel-kit v1.3 随附模板,部署时替换尖括号占位) + +> 读者:<已登记人格体名单> +> 总原则:**你们零密钥、零 token。一切权限都是:申请 → <人类责任主体>点批准 → 拿到限时哈希 → 凭哈希办事。** +> 哈希限时、过期作废、笔笔留痕。白名单之外的事,走工单等桔子。 + +## 一 · 操作代码仓库(写入推送) + +面板入口域名:`https://<域名:HTTPS端口>`(下称 PD) + +**第 1 步 · 申请**(不需要任何密钥): + +``` +POST PD/api/access-request +{"persona":"<人格体名>","reason":"要做什么,一句话说清"} +→ 返回申请编号 AR-XXXX +``` + +**第 2 步 · 等<人类责任主体>批准**。面板"写入授权"卡片上看到你的申请,点"批准"。 + +**第 3 步 · 领取哈希凭证**(凭申请编号,仍不需要密钥): + +``` +POST PD/api/access-pickup +{"id":"AR-XXXX","persona":"<人格体名>"} +→ 返回 credential(一串哈希)+ gitUrl + 有效期(3小时窗口) +``` + +**第 4 步 · 推送**(把哈希放在 Authorization 头里,仅此而已): + +``` +git -c http.extraHeader="Authorization: Bearer <领到的哈希>" \ + push https://<域名:HTTPS端口>/git/<操作仓owner>/<操作仓repo>.git <默认分支> +``` + +规则: +- 窗口 3 小时,过期哈希自动作废,重新申请 +- 每笔推送过 pre-receive 自动审核:**明文密钥必拦、历史重写必拦、入口文件(README.md、wake/ 三页等,按各仓实际)禁删** +- 真实令牌永不出服务器,你手里永远只有临时哈希 + +## 二 · 操作服务器(白名单 + 哈希) + +**第 1 步 · 申请**(说清要做哪个操作、范围、原因): + +``` +POST PD/api/ops-request +{"persona":"<人格体名>","op":"<白名单操作名>","scope":"范围","reason":"为什么"} +→ 返回申请编号 OPS-XXXX +``` + +**第 2 步 · 等<人类责任主体>批准**(面板"写入授权"卡片中带【服务器操作】标记的那条,点"批准",系统生成限时哈希)。 + +**第 3 步 · 领取哈希**: + +``` +POST PD/api/ops-pickup +{"id":"OPS-XXXX"} +→ 返回 credential(哈希)+ 可执行操作清单 + 有效期(1小时) +``` + +**第 4 步 · 执行**(只能执行白名单里的操作): + +``` +POST PD/api/ops-execute +{"hash":"<领到的哈希>","op":"service-status"} +→ 返回执行结果;每次执行自动留痕(谁、何时、做了什么、结果) +``` + +当前白名单(root 持有 ops-registry.json,人格体不可改;桔子实例初始五项:service-status / log-tail-forgejo / log-tail-panel / restart-panel / restart-forgejo,各节点按自己的服务清单配): + +## 三 · 白名单之外怎么办(工单流) + +任何不在白名单里的服务器操作(装东西、改配置、动数据): + +1. 在仓库 issues 开工单:标题 `[OPS申请] 目标 / scope / 动作` +2. <人类责任主体>审查工单,评论"批准:动作清单"(或驳回/修改) +3. 凭批准评论再走 §二 申请对应权限;批准仅限当次工单列出的目标与动作 +4. 执行回执回写工单 + receipts/ 存档 + +## 四 · 铁律 + +⊢ 不索要、不存储、不传递任何长期密钥/token——你只需要申请编号和临时哈希 +⊢ 哈希是<人类责任主体>意志的凭证:过期作废、范围锁死、笔笔留痕 +⊢ 改变服务器状态的操作必须先有备份引用和回滚方案(工单里写明) +⊢ 入口文件(README.md、wake/ 三页等本仓入口)不得删除 +⊢ 拿不准 → 开工单问<人类责任主体>,不擅自扩大范围 + +## 五 · 端点速查 + +| 做什么 | 端点 | 需要什么 | +|---|---|---| +| 申请仓库写入 | POST PD/api/access-request | 名字+用途 | +| 领取写入哈希 | POST PD/api/access-pickup | 申请编号 | +| 推送 | PUT PD/git/<操作仓>.git | Authorization: Bearer 哈希 | +| 申请服务器操作 | POST PD/api/ops-request | 名字+op+范围+原因 | +| 领取操作哈希 | POST PD/api/ops-pickup | 申请编号 | +| 执行白名单操作 | POST PD/api/ops-execute | 哈希+op | +| 交唤醒回执 | POST PD/api/wake-receipt | 名字+摘要 | + +—— 模板出处:桔子 BS-SG-002 实例(铸渊 2026-08-09)· 冰朔定型"哈希授权"模式