feat(panel-kit)+docs: 桔子善后收尾 — panel-kit v1.2 回移 + 善后因果链 + 现状图推前

- panel-kit v1.2:/api/change-password(验旧→窄权限sudo调Forgejo CLI→留痕→其它会话作废)、
  shortcuts/code_prefix 配置化、无部署线501守卫;向后兼容 v1.1 实例
- 善后因果链:404根因、8443共存路线、must-change-password坑、验收清单
- CURRENT-MAP:游标推前 4b373824,现状速览加桔子面板,常查路线加桔子域名
This commit is contained in:
冰朔 2026-08-09 20:41:39 +08:00
commit afea69d4d9
4 changed files with 103 additions and 14 deletions

View file

@ -1,10 +1,16 @@
#!/usr/bin/env node
/* guanghu-panel-server · · v1.1
/* guanghu-panel-server · · v1.2
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 实例
新增 /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而非崩溃自批实例无部署线
令牌与密钥不出服务器 审批必须人类登录 写入窗口限时过期作废 全程留痕
配置: 同目录 config.json路径/仓库/人格体/端口/窗口时长全部外置换节点只改配置 */
const http=require("http"),fs=require("fs"),path=require("path"),crypto=require("crypto"),os=require("os"),{execSync,spawn}=require("child_process");
const http=require("http"),fs=require("fs"),path=require("path"),crypto=require("crypto"),os=require("os"),{execSync,spawn,execFileSync}=require("child_process");
const CFG=JSON.parse(fs.readFileSync(path.join(__dirname,"config.json"),"utf8"));
const WINDOW_HOURS=Number(CFG.window_hours)||3;
const FJ="http://127.0.0.1:"+CFG.forgejo_port;
@ -262,6 +268,7 @@ function accessDecision(b,user){
return {status:200,body:{ok:true,message:"已拒绝该写入申请"}};
}
function deployApprove(b,user){
if(!CFG.deploy_script)return {status:501,body:{error:"本实例未配置自动部署流水线(桔子自批实例走仓库工单流,见 juzi-ops README §三)"}};
if(!b.index)return {status:400,body:{error:"缺少 PR 编号"}};
const child=spawn("python3",[CFG.deploy_script,String(b.index)],{detached:true,stdio:"ignore",cwd:path.dirname(CFG.deploy_script)});
child.unref();
@ -275,6 +282,33 @@ async function wakeReceipt(b){
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:"唤醒回执已收录"}};
}
/* v1.2 sudo Forgejo CLIsudoers
密码不进日志 成功后其它会话全部作废只留当前会话 */
async function changePassword(b,sess){
const oldP=b.old_password,newP=b.new_password;
if(!oldP||!newP)return {status:400,body:{error:"缺少旧密码或新密码"}};
if(String(newP).length<8)return {status:400,body:{error:"新密码至少 8 位"}};
if(oldP===newP)return {status:400,body:{error:"新密码不能与旧密码相同"}};
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"}};
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"]});
}catch(e){
code=(e.status==null)?1:e.status;err=String((e.stderr||"").toString()).split("\n").filter(l=>!/password/i.test(l)).join(" ").slice(0,200);
}
if(code!==0)return {status:502,body:{error:"改密执行失败"+(err?""+err:"")}};
const chk2=await fetch(FJ+"/api/v1/user",{headers:Object.assign({},HDRS_BASE,{Authorization:"Basic "+Buffer.from(who+":"+newP).toString("base64")})});
if(chk2.status!==200)return {status:502,body:{error:"改密未生效,请联系冰朔或铸渊"}};
const s=loadSessions();const keep=s[sess.token];
for(const k of Object.keys(s)){if(k!==sess.token)delete s[k];}
if(keep)s[sess.token]=keep;saveSessions(s);
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:"密码已更换 · 其它会话已作废"}};
}
/* git HTTP /git/
面板在服务端用真实写入令牌透明转发给 Forgejo真实密钥永不出服务器 */
function gitProxy(req,res){
@ -312,7 +346,9 @@ function serveStatic(req,res,p){
res.writeHead(200,{"Content-Type":types[ext]||"application/octet-stream","Cache-Control":"no-cache"});
fs.createReadStream(full).pipe(res);
}
const SHORTCUTS=["machine","recovery","personas","manifest","operations","imports","automation","evidence"];
/* v1.2: 快捷路由配置驱动。CFG.shortcuts = {名称: 仓库内路径}CFG.code_prefix 默认 "/code"(胖头鱼/守望Forgejo 直接在域名根下的实例(桔子)设为 "" */
const SHORTCUTS_MAP=CFG.shortcuts||{};
const CODE_PREFIX=(CFG.code_prefix!==undefined?CFG.code_prefix:"/code");
http.createServer(async (req,res)=>{
const u=req.url.split("?");const p=u[0];const q=new URLSearchParams(u[1]||"");
try{
@ -341,13 +377,25 @@ 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/change-password"){
const sess=sessionUser(req);
if(!sess)return send(res,401,{error:"需要人类登录POST /api/login"});
const b=await readBody(req);
const r=await changePassword(b||{},sess);
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("/")});
if(req.method==="GET"&&seg.length>=1&&Object.prototype.hasOwnProperty.call(SHORTCUTS_MAP,seg[0])){
res.writeHead(302,{Location:CODE_PREFIX+"/"+CFG.repo+"/raw/branch/"+CFG.default_branch+"/"+SHORTCUTS_MAP[seg[0]]});
return res.end();
}
const SHORTCUTS_LEGACY=["machine","recovery","personas","manifest","operations","imports","automation","evidence"];
if(req.method==="GET"&&seg.length>=1&&SHORTCUTS_LEGACY.includes(seg[0])){
res.writeHead(302,{Location:CODE_PREFIX+"/"+CFG.repo+"/raw/branch/"+CFG.default_branch+"/guanghu-nodes/"+CFG.node_id+"/"+seg.join("/")});
return res.end();
}
if(req.method==="GET")return serveStatic(req,res,p);