guanghu-ice-heart/modules/guanghu-panel-kit/panel/server.js
冰朔 8544cae938 feat(modules): add guanghu-panel-kit reusable modules + two-system causal chain
光湖子系统面板模块包(胖头鱼/守望两系统沉淀):
- panel/server.js 配置驱动审批面板(登录门/签字/写入窗口/部署通道/唤醒导航)
- deploy/verify-and-deploy.py 部署校验Agent(地图/因果链/范围/完整性,0或100)
- hooks/pre-receive-review.template 推送审核钩子模板
- frontend 两套门面样例(湖绿深色/宠物暖色玻璃动态)
- CAUSAL-CHAIN-20260806.hdlp 两系统建设完整因果链
2026-08-06 21:21:58 +08:00

271 lines
18 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.0
2026-08-06 · 铸渊 ICE-GL-ZY001 建 · 胖头鱼SYS-GLW-PTS-0001 首创 · 守望SYS-GLW-SW-0001 复用
⊢ 令牌与密钥不出服务器 ⊢ 审批必须人类登录 ⊢ 写入窗口三小时 ⊢ 全程留痕
配置: 同目录 config.json路径/仓库/人格体/端口全部外置,换节点只改配置) */
const http=require("http"),fs=require("fs"),path=require("path"),crypto=require("crypto"),os=require("os"),{execSync,spawn}=require("child_process");
const CFG=JSON.parse(fs.readFileSync(path.join(__dirname,"config.json"),"utf8"));
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 与 key"}
],
endpoints:{accessRequest:"POST "+CFG.domain+"/api/access-request",accessPickup:"POST "+CFG.domain+"/api/access-pickup",wakeReceipt:"POST "+CFG.domain+"/api/wake-receipt"}
};
}
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,
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"),
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}};
}
function accessRequest(b){
if(!personaKeyOk(b.persona,b.key))return {status:403,body:{error:"人格体密钥错误"}};
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"});
jwrite(CFG.access_dir+"/requests.json",reqs);
return {status:200,body:{ok:true,id,message:"申请已送达面板,等待人类审批。通过后用同一 id+key 调 /api/access-pickup 领取3小时写入令牌"}};
}
function accessPickup(b){
if(!personaKeyOk(b.persona,b.key))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===b.persona);
if(!g)return {status:404,body:{error:"授权不存在"}};
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 分支由人类合并"}};
}
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()+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"});
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小时写入窗口"}};
}
r.status="rejected";jwrite(CFG.access_dir+"/requests.json",reqs);
return {status:200,body:{ok:true,message:"已拒绝该写入申请"}};
}
function deployApprove(b,user){
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已启动进度实时可见"}};
}
function wakeReceipt(b){
if(!personaKeyOk(b.persona,b.key))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)});
return {status:200,body:{ok:true,message:"唤醒回执已收录"}};
}
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);
}
const SHORTCUTS=["machine","recovery","personas","manifest","operations","imports","automation","evidence"];
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/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);}
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("/")});
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/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);});