feat(panel-kit): v1.3 哈希授权模式——服务器操作权限链 + 人格体操作手册模板
冰朔定型:人格体零密钥零token,申请→人类批准→限时哈希→凭哈希办事。 - server.js v1.3:/api/ops-request、/api/ops-decision、/api/ops-pickup、 /api/ops-execute(root 白名单 ops-registry.json + 窄权限sudoers,笔笔留痕) - persona-ops/persona-operations.template.md:人格体操作手册模板 (桔子 BS-SG-002 实例首个采用,仓库写入与服务器操作双链全实测通过)
This commit is contained in:
parent
5ba368ef05
commit
4d379fe46b
3 changed files with 184 additions and 9 deletions
|
|
@ -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)});}
|
||||
|
|
|
|||
Loading…
Reference in a new issue