guanghu-ice-heart/modules/guanghu-panel-kit/panel/server.js

342 lines
22 KiB
JavaScript
Raw Normal View History

#!/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 与 summary 即可)"}
],
endpoints:{accessRequest:"POST "+CFG.domain+"/api/access-request人格体报名号+用途即可,无需密钥)",accessPickup:"POST "+CFG.domain+"/api/access-pickup凭申请编号领取3小时临时凭证真实密钥永不出服务器",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,
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}};
}
/* 人格体身份解析:配置别名种子 + 动态读仓库人格体注册表(仓库是注册事实源) */
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");
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:"申请已送达面板等人类点一下授权。无需任何密钥——授权后系统自动派发3小时临时凭证真实密钥永不出服务器"}};
}
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:"3小时窗口已过期请重新申请"}};
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:"⊢ 这是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",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:"已授权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已启动进度实时可见"}};
}
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:"唤醒回执已收录"}};
}
/* git HTTP3 /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("临时凭证无效或已过期请重新申请写入授权人类面板一键授权3小时窗口");
}
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);
}
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=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("/")});
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);});