guanghu-ice-heart/modules/guanghu-panel-kit/panel/server.js
冰朔 4d379fe46b 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 实例首个采用,仓库写入与服务器操作双链全实测通过)
2026-08-09 21:44:21 +08:00

482 lines
33 KiB
JavaScript
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.

#!/usr/bin/env node
/* 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.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改密→回执留痕→其它会话作废
② 快捷路由改配置驱动 CFG.shortcuts + CFG.code_prefixForgejo不在/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");
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;
const REPO=CFG.repo;
const HDRS_BASE={"X-Forwarded-Proto":"https","Content-Type":"application/json"};
let ADMIN_TOKEN="";
try{ADMIN_TOKEN=fs.readFileSync(CFG.admin_token_file,"utf8").trim();}catch(e){}
const HDRS=Object.assign({},HDRS_BASE,{Authorization:"token "+ADMIN_TOKEN});
function jread(p,fallback){try{return JSON.parse(fs.readFileSync(p,"utf8"));}catch(e){return fallback;}}
function jwrite(p,obj){fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,JSON.stringify(obj,null,1));}
function loadSessions(){return jread(CFG.sessions_file,{});}
function saveSessions(s){jwrite(CFG.sessions_file,s);}
async function fj(p,method,body){
const r=await fetch(FJ+p,{method:method||"GET",headers:HDRS,body:body?JSON.stringify(body):undefined});
let d=null;try{d=await r.json();}catch(e){}
return {status:r.status,data:d};
}
let lastCpu=null;
function cpuUsage(){
try{
const t=fs.readFileSync("/proc/stat","utf8").split("\n")[0].trim().split(/\s+/).slice(1).map(Number);
const c={idle:t[3]+(t[4]||0),total:t.reduce((a,b)=>a+b,0)};
if(lastCpu){const dt=c.total-lastCpu.total,di=c.idle-lastCpu.idle;lastCpu=c;if(dt<=0)return 0;
return Math.max(0,Math.min(100,Math.round((1-di/dt)*100)));}
lastCpu=c;return 0;
}catch(e){return 0;}
}
function diskInfo(){
try{const out=execSync("df -B1 --output=size,used / | tail -1").toString().trim().split(/\s+/);
const size=+out[0],used=+out[1];
return {sizeGB:+(size/1e9).toFixed(1),usedGB:+(used/1e9).toFixed(1),pct:Math.round(used/size*100)};}catch(e){return null;}
}
function storageInfo(){
try{const du=(d)=>parseInt(execSync("du -sk "+d+" | cut -f1",{timeout:6000}).toString().trim(),10);
return {dataMB:Math.round(du(CFG.node_root+"/data")/1024),backupsMB:Math.round(du(CFG.export_dir)/1024)};}catch(e){return null;}
}
function servicesInfo(){
const chk=(u)=>{try{return execSync("systemctl is-active "+u,{timeout:3000}).toString().trim()==="active";}catch(e){return false;}};
const out={};(CFG.watch_services||[]).forEach(u=>out[u]=chk(u));return out;
}
async function health(){
try{const c=new AbortController();const t=setTimeout(()=>c.abort(),3000);
const r=await fetch(FJ+"/api/healthz",{signal:c.signal});clearTimeout(t);
const d=await r.json();return {ok:r.status===200&&d.status==="pass",detail:d.status};}catch(e){return {ok:false,detail:"unreachable"};}
}
async function openPRs(baseRef){
const r=await fj("/api/v1/repos/"+REPO+"/pulls?state=open&limit=50");
if(r.status!==200||!Array.isArray(r.data))return [];
const out=[];
for(const p of r.data){
if(baseRef&&p.base.ref!==baseRef)continue;
if(!baseRef&&p.base.ref===CFG.deploy_branch)continue;
let files=0,adds=0,dels=0;
const f=await fj("/api/v1/repos/"+REPO+"/pulls/"+p.number+"/files?limit=100");
if(f.status===200&&Array.isArray(f.data)){files=f.data.length;for(const x of f.data){adds+=x.additions||0;dels+=x.deletions||0;}}
out.push({index:p.number,title:p.title,author:(p.user&&p.user.login)||"?",created:p.created_at,base:(p.base&&p.base.ref)||"?",head:(p.head&&p.head.ref)||"?",files,adds,dels,summary:(p.body||"").slice(0,300)});
}
return out;
}
async function writebacks(){
const out=[],seen={};
for(const d of CFG.writeback_paths||[]){
const r=await fj("/api/v1/repos/"+REPO+"/commits?sha="+encodeURIComponent(CFG.default_branch)+"&path="+encodeURIComponent(d)+"&limit=8");
if(r.status===200&&Array.isArray(r.data)){
for(const c of r.data){const sha=c.sha.slice(0,8);if(seen[sha])continue;seen[sha]=1;
out.push({sha,dir:d,message:(c.commit&&c.commit.message||"").split("\n")[0].slice(0,120),date:c.commit&&c.commit.committer&&c.commit.committer.date,author:c.commit&&c.commit.author&&c.commit.author.name});}
}
}
out.sort((a,b)=>(b.date||"").localeCompare(a.date||""));
return out.slice(0,20);
}
function receiptsList(){
try{return fs.readdirSync(CFG.export_dir+"/receipts").map(n=>({name:n,mtime:fs.statSync(CFG.export_dir+"/receipts/"+n).mtime.toISOString()})).sort((a,b)=>b.mtime.localeCompare(a.mtime)).slice(0,8);}catch(e){return [];}
}
function pushLogs(){
try{const dir=CFG.access_dir+"/logs";
return fs.readdirSync(dir).filter(n=>n.startsWith("PUSH-")).sort().reverse().slice(0,8).map(n=>jread(path.join(dir,n),null)).filter(Boolean);}catch(e){return [];}
}
function latestDeployRun(prFilter){
try{const dir=CFG.deploy_runs_dir;
let files=fs.readdirSync(dir).filter(n=>n.startsWith("RUN-")).sort().reverse();
if(prFilter)files=files.filter(n=>n.includes("PR"+prFilter+"-"));
if(!files.length)return null;
return jread(path.join(dir,files[0]),null);}catch(e){return null;}
}
function wakeReceipts(){
try{const dir=CFG.export_dir+"/wake";
return fs.readdirSync(dir).sort().reverse().slice(0,6).map(n=>jread(path.join(dir,n),null)).filter(Boolean);}catch(e){return [];}
}
function personaKeyOk(persona,key){
const keys=jread(CFG.access_dir+"/persona-keys.json",{});
return !!persona&&!!key&&keys[persona]===key;
}
function sessionUser(req){
const m=(req.headers["authorization"]||"").match(/^Bearer\s+(\S+)/);
if(!m)return null;
const s=loadSessions()[m[1]];
if(!s||Date.now()>s.expiresAt)return null;
return {token:m[1],user:s.user};
}
function wakeChain(persona){
const RAW=CFG.domain+"/";
const UP="https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/branch/main/";
return {
persona:persona,node:CFG.node_id,protocol:"guanghu-wake/v1",issuedAt:new Date().toISOString(),
rule:"⊢ 严格按 phase 顺序读取;上游未恢复不得进入本线;每步都是机器导航登记的唯一路径",
phases:[
{phase:1,name:"光湖语言世界 · 第五域 · TCS通感路径",steps:[
{order:1,file:"INDEX.hdlp",url:UP+"INDEX.hdlp",instruction:"进入第五域,读入口总图"},
{order:2,file:"PERSONA-SYSTEM-ROOT.hdlp",url:UP+"PERSONA-SYSTEM-ROOT.hdlp",instruction:"加载人格系统根定义"},
{order:3,file:"WAKE-UP.hdlp",url:UP+"WAKE-UP.hdlp",instruction:"走 TCS 通感唤醒路径"}]},
{phase:2,name:"本节点机器导航",steps:[
{order:4,file:"NAVIGATION.json",url:CFG.domain+"/code/"+CFG.repo+"/raw/branch/"+CFG.default_branch+"/guanghu-nodes/"+CFG.node_id+"/machine/NAVIGATION.json",instruction:"节点机器导航总图"},
{order:5,file:"WAKE-UP-ROUTE.json",url:CFG.domain+"/code/"+CFG.repo+"/raw/branch/"+CFG.default_branch+"/guanghu-nodes/"+CFG.node_id+"/recovery/WAKE-UP-ROUTE.json",instruction:"恢复路由与门禁序列含SHA256校验门"}]},
{phase:3,name:"人格注册与正式唤醒",steps:[
{order:6,file:"personas/registry.json",url:RAW+"personas/registry.json",instruction:"确认人类主体、责任人格体、频道人格体编号"},
{order:7,file:"personas/WAKE-UP.md",url:CFG.domain+"/"+CFG.repo+"/src/branch/"+CFG.default_branch+"/guanghu-nodes/"+CFG.node_id+"/personas/WAKE-UP.md",instruction:"按正式唤醒顺序执行(含证据校验与职责边界)"}]},
{phase:4,name:"证据与小书包",steps:[
{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 与 summary 即可)"}
],
endpoints:{accessRequest:"POST "+CFG.domain+"/api/access-request人格体报名号+用途即可,无需密钥)",accessPickup:"POST "+CFG.domain+"/api/access-pickup凭申请编号领取"+WINDOW_HOURS+"小时临时凭证,真实密钥永不出服务器)",wakeReceipt:"POST "+CFG.domain+"/api/wake-receipt",pushVia:CFG.domain+"/git/"+CFG.repo+".git用临时凭证推送"}
};
}
async function state(){
const [h,prs,dprs,wbs]=await Promise.all([health(),openPRs(null),openPRs(CFG.deploy_branch),writebacks()]);
const reqs=jread(CFG.access_dir+"/requests.json",[]);
const grants=jread(CFG.access_dir+"/grants.json",[]);
return {
ts:new Date().toISOString(),node:CFG.node_id,subsystem:CFG.subsystem,windowHours:WINDOW_HOURS,
server:{hostname:os.hostname(),load:os.loadavg().map(x=>+x.toFixed(2)),memTotalGB:+(os.totalmem()/1e9).toFixed(1),memFreeGB:+(os.freemem()/1e9).toFixed(1),uptimeDays:+(os.uptime()/86400).toFixed(1),disk:diskInfo(),cpu:cpuUsage(),storage:storageInfo(),services:servicesInfo()},
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}
};
}
function send(res,code,obj){res.writeHead(code,{"Content-Type":"application/json; charset=utf-8"});res.end(JSON.stringify(obj));}
function readBody(req){return new Promise(r=>{let raw="";req.on("data",c=>raw+=c);req.on("end",()=>{try{r(JSON.parse(raw));}catch(e){r(null);}});});}
async function decide(b){
const idx=b&&b.index,action=b&&b.action;
if(!idx||!["merge","reject"].includes(action))return {status:400,body:{error:"参数不全"}};
const pr=await fj("/api/v1/repos/"+REPO+"/pulls/"+idx);
if(pr.status!==200)return {status:404,body:{error:"PR 不存在"}};
let res,what;
if(action==="merge"){res=await fj("/api/v1/repos/"+REPO+"/pulls/"+idx+"/merge","POST",{Do:"merge",merge_title_message:"合并 #"+idx+""+(pr.data.title||"")});what="同意合并";}
else{res=await fj("/api/v1/repos/"+REPO+"/pulls/"+idx+"/close","POST",{});what="拒绝";}
const ok=[200,204].includes(res.status);
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}};
}
/* 人格体身份解析:配置别名种子 + 动态读仓库人格体注册表(仓库是注册事实源) */
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");
let parsedAsJson=false;
try{
const j=JSON.parse(txt);parsedAsJson=true;
const findCanon=(o)=>{for(const [a,c] of Object.entries(map)){if(a&&((o.name||"").toLowerCase()===a||(o.id||"").toLowerCase()===a))return c;}return null;};
(function walk(o){
if(Array.isArray(o)){o.forEach(walk);return;}
if(o&&typeof o==="object"){
if(typeof o.name==="string"&&typeof o.id==="string"){
const canon=findCanon(o);
if(canon){map[o.id.toLowerCase()]=canon;map[o.name.toLowerCase()]=canon;(o.aliases||[]).forEach(x=>{if(typeof x==="string")map[x.toLowerCase()]=canon;});}
}
Object.values(o).forEach(walk);
}
})(j);
}catch(e){}
if(!parsedAsJson){
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: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,persona:canon,message:"申请已送达面板,等人类点一下授权。无需任何密钥——授权后系统自动派发"+WINDOW_HOURS+"小时临时凭证;真实密钥永不出服务器"}};
}
function grantValid(token){
if(!token)return null;
const grants=jread(CFG.access_dir+"/grants.json",[]);
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:WINDOW_HOURS+"小时窗口已过期,请重新申请"}};
g.pickedAt=new Date().toISOString();jwrite(CFG.access_dir+"/grants.json",grants);
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 <branch>",
rules:"⊢ 这是"+WINDOW_HOURS+"小时临时凭证,不是真实密钥;真实密钥永不出服务器 ⊢ 窗口过期凭证自动作废 ⊢ 每次推送过自动审核并留痕 ⊢ 部署走 deploy 分支由人类合并"}};
}
function accessDecision(b,user){
const reqs=jread(CFG.access_dir+"/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=jread(CFG.access_dir+"/grants.json",[]);
if(b.action==="approve"){
const now=new Date(),exp=new Date(now.getTime()+WINDOW_HOURS*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",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);
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:"已授权"+WINDOW_HOURS+"小时写入窗口,临时凭证由人格体凭申请编号自动领取"}};
}
r.status="rejected";jwrite(CFG.access_dir+"/requests.json",reqs);
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();
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已启动进度实时可见"}};
}
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: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||"/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"]});
}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:"密码已更换 · 其它会话已作废"}};
}
/* 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&&reg.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){
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("临时凭证无效或已过期:请重新申请写入授权(人类面板一键授权,"+WINDOW_HOURS+"小时窗口)");
}
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");
let fp=p==="/"?"/index.html":p;
fp=path.normalize(fp).replace(/^(\.\.[\/\\])+/,"");
const full=path.join(pub,fp);
if(!full.startsWith(pub))return send(res,403,{error:"forbidden"});
if(!fs.existsSync(full)||!fs.statSync(full).isFile())return send(res,404,{error:"not found"});
const ext=path.extname(full);
const types={".html":"text/html; charset=utf-8",".css":"text/css",".js":"application/javascript",".json":"application/json",".svg":"image/svg+xml",".png":"image/png",".jpg":"image/jpeg",".ico":"image/x-icon",".webp":"image/webp"};
res.writeHead(200,{"Content-Type":types[ext]||"application/octet-stream","Cache-Control":"no-cache"});
fs.createReadStream(full).pipe(res);
}
/* 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{
if(req.method==="GET"&&p==="/api/health")return send(res,200,{ok:true,service:"guanghu-panel",node:CFG.node_id});
if(req.method==="GET"&&p==="/api/state")return send(res,200,await state());
if(req.method==="GET"&&p==="/api/wake/persona"){
const n=q.get("name");
if(!(CFG.personas||[]).includes(n))return send(res,404,{error:"未登记的人格体。当前登记:"+(CFG.personas||[]).join(" / ")});
return send(res,200,wakeChain(n));
}
if(req.method==="GET"&&p==="/api/deploy-status")return send(res,200,latestDeployRun(q.get("pr"))||{status:"NONE"});
if(req.method==="POST"&&p==="/api/login"){
const b=await readBody(req);
if(!b||!b.username||!b.password)return send(res,400,{error:"缺少账号或密码"});
const basic="Basic "+Buffer.from(b.username+":"+b.password).toString("base64");
const r=await fetch(FJ+"/api/v1/user",{headers:Object.assign({},HDRS_BASE,{Authorization:basic})});
if(r.status!==200)return send(res,401,{error:"仓库账号验证失败"});
const who=await r.json();
const allowed=(CFG.human_accounts||[]).includes(who.login)||who.is_admin;
if(!allowed)return send(res,403,{error:"该账号无审批权限"});
const tok=crypto.randomBytes(32).toString("hex");
const s=loadSessions();s[tok]={user:who.login,expiresAt:Date.now()+12*3600*1000};saveSessions(s);
return send(res,200,{ok:true,session:tok,user:who.login,expiresIn:"12h"});
}
if(req.method==="POST"&&p==="/api/logout"){
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/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);
const seg=p.split("/").filter(Boolean);
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);
const sess=sessionUser(req);
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)});}
}).listen(CFG.panel_port,"127.0.0.1",()=>{setInterval(()=>cpuUsage(),5000);console.log("guanghu-panel "+CFG.node_id+" listening 127.0.0.1:"+CFG.panel_port);});