"use strict"; const crypto = require("node:crypto"); const fs = require("node:fs"); const http = require("node:http"); const path = require("node:path"); const { WorkOrderManager } = require("./workorder-manager"); const { MapGate } = require("./map-gate"); const { sendSmtpMail } = require("./smtp-mailer"); const DEFAULT_ACTIONS = Object.freeze({ "server-login": ["read-navigation-map", "inspect-services", "restart-registered-service"], "server-ops": ["read-navigation-map", "inspect-services", "restart-registered-service"], "repo-push": ["read-navigation-map", "push-repository"], }); function createApp(options = {}) { const requestToken = options.requestToken || process.env.LAKE_LAMP_REQUEST_TOKEN || ""; const ownerEmail = options.ownerEmail || process.env.LAKE_LAMP_OWNER_EMAIL || ""; const publicBaseUrl = String(options.publicBaseUrl || process.env.LAKE_LAMP_PUBLIC_URL || "").replace(/\/$/, ""); const targets = new Set(options.targets || splitCsv(process.env.LAKE_LAMP_TARGETS || "JD-FD-PRIMARY,BS-GZ-006")); const actions = options.actions || DEFAULT_ACTIONS; const manager = options.manager || new WorkOrderManager({ approvalTtl: Number(options.approvalTtl || process.env.LAKE_LAMP_APPROVAL_TTL || 15 * 60), sessionTtl: Number(options.sessionTtl || process.env.LAKE_LAMP_SESSION_TTL || 60 * 60), stateFile: Object.prototype.hasOwnProperty.call(options, "stateFile") ? options.stateFile : (process.env.LAKE_LAMP_STATE_FILE || "/var/lib/guanghu/lake-lamp-authz/state.json"), }); const sendEmail = options.sendEmail || (message => sendSmtpMail({ ...message, smtpHost: process.env.SMTP_HOST || "smtp.qq.com", smtpPort: Number(process.env.SMTP_PORT || 465), smtpUser: process.env.SMTP_USER || ownerEmail, smtpPass: process.env.QQ_SMTP_AUTH_CODE || "", })); const mapGate = options.mapGate || new MapGate({ mapsDir: options.mapsDir || process.env.LAKE_LAMP_MAPS_DIR || "/etc/guanghu/navigation-maps", stateFile: Object.prototype.hasOwnProperty.call(options, "mapStateFile") ? options.mapStateFile : (process.env.LAKE_LAMP_MAP_STATE_FILE || "/var/lib/guanghu/lake-lamp-authz/map-acks.json"), }); const repoGrantDir = options.repoGrantDir || process.env.LAKE_LAMP_REPO_GRANT_DIR || "/var/lib/guanghu/repo-authorizations"; return http.createServer(async (req, res) => { try { const url = new URL(req.url, "http://localhost"); if (req.method === "GET" && url.pathname === "/health") return json(res, 200, { ok: true, service: "lake-lamp-authz", auth_mode: "email-link", session_ttl: 3600 }); const approvalMatch = url.pathname.match(/^\/approve\/([A-Za-z0-9_-]{20,})$/); if (approvalMatch && req.method === "GET") { const inspected = manager.inspectApproval(approvalMatch[1]); if (!inspected.ok) return html(res, 410, approvalErrorPage(inspected.reason)); return html(res, 200, approvalPage(inspected.order, approvalMatch[1])); } if (approvalMatch && req.method === "POST") { const approved = manager.approve(approvalMatch[1]); if (!approved.ok) return html(res, 410, approvalErrorPage(approved.reason)); return html(res, 200, approvedPage(approved.order)); } if (req.method === "POST" && url.pathname === "/api/workorders") { if (!bearerMatches(req, requestToken)) return json(res, 401, { error: "request_auth_required" }); const body = await readJson(req); if (!body) return json(res, 400, { error: "invalid_json" }); if (body.email || body.recipient || body.smtp_pass) return json(res, 400, { error: "direct_recipient_forbidden" }); if (!body.persona_id || !body.target || !body.scope || !body.action) return json(res, 400, { error: "missing_required_field" }); if (!targets.has(body.target)) return json(res, 400, { error: "unknown_target" }); if (!Array.isArray(actions[body.scope]) || !actions[body.scope].includes(body.action)) return json(res, 400, { error: "unknown_or_mismatched_action" }); const expectedFingerprint = sha256(ownerEmail.toLowerCase()); if (!body.recipient_fingerprint || !safeEqual(body.recipient_fingerprint, expectedFingerprint)) return json(res, 403, { error: "owner_identity_mismatch" }); const created = manager.request({ persona: { pid: String(body.persona_id), name: String(body.persona_name || body.persona_id) }, target: String(body.target), scope: String(body.scope), action: String(body.action), allowedActions: actions[body.scope], description: String(body.description || ""), }); const approvalUrl = `${publicBaseUrl}/approve/${created.approvalToken}`; const emailSent = await sendEmail({ to: ownerEmail, subject: `小湖灯授权请求 · ${body.target}`, approvalUrl, order: manager.inspectApproval(created.approvalToken).order, }); if (!emailSent) return json(res, 502, { error: "authorization_email_failed" }); return json(res, 201, { ok: true, workorder_id: created.id, claim_token: created.claimToken, expires_in: created.expiresIn, status: "waiting_for_owner" }); } const claimMatch = url.pathname.match(/^\/api\/workorders\/([0-9a-f-]{36})\/claim$/i); if (req.method === "POST" && claimMatch) { const token = bearer(req); const claimed = manager.claim(claimMatch[1], token); if (!claimed.ok) return json(res, claimed.reason === "approval_pending" ? 202 : 403, { error: claimed.reason }); return json(res, 200, { ok: true, session_token: claimed.sessionToken, expires_in: claimed.expiresIn, target: claimed.target, scope: claimed.scope, action: claimed.action }); } if (req.method === "POST" && url.pathname === "/api/session/verify") { const body = await readJson(req); if (!body) return json(res, 400, { error: "invalid_json" }); const verified = manager.verifySession(bearer(req), { pid: String(body.persona_id || "") }, String(body.target || ""), String(body.scope || ""), String(body.action || "")); if (!verified.ok) return json(res, 403, { error: verified.reason }); if (body.action !== "read-navigation-map") { const map = mapGate.read(String(body.target || "")); const mapVerified = mapGate.verify(bearer(req), String(body.target || ""), map.hash); if (!mapVerified.ok) return json(res, 423, { error: mapVerified.reason, required_action: "read-navigation-map" }); } return json(res, 200, { ok: true, expires_at: verified.session.expiresAt }); } if (req.method === "POST" && url.pathname === "/api/navigation-map/read") { const body = await readJson(req); if (!body) return json(res, 400, { error: "invalid_json" }); const verified = manager.verifySession(bearer(req), { pid: String(body.persona_id || "") }, String(body.target || ""), String(body.scope || ""), "read-navigation-map"); if (!verified.ok) return json(res, 403, { error: verified.reason }); const map = mapGate.read(String(body.target || "")); return json(res, 200, { ok: true, target: body.target, map_hash: map.hash, navigation_map: map.data }); } if (req.method === "POST" && url.pathname === "/api/navigation-map/ack") { const body = await readJson(req); if (!body) return json(res, 400, { error: "invalid_json" }); const token = bearer(req); const verified = manager.verifySession(token, { pid: String(body.persona_id || "") }, String(body.target || ""), String(body.scope || ""), "read-navigation-map"); if (!verified.ok) return json(res, 403, { error: verified.reason }); const acked = mapGate.ack(token, String(body.target || ""), String(body.map_hash || ""), Date.now() / 1000, Math.max(1, verified.session.expiresAt - Date.now() / 1000)); return json(res, acked.ok ? 200 : 409, acked.ok ? { ok: true, target: body.target, map_hash: body.map_hash } : { error: acked.reason }); } if (req.method === "POST" && url.pathname === "/api/repo-push/grant") { const body = await readJson(req); if (!body) return json(res, 400, { error: "invalid_json" }); const token = bearer(req); const target = String(body.target || ""); const scope = String(body.scope || "repo-push"); const repo = String(body.repo || "").toLowerCase(); if (!/^bingshuo\/[a-z0-9._-]+$/.test(repo)) return json(res, 400, { error: "repo_not_allowlisted" }); const verified = manager.verifySession(token, { pid: String(body.persona_id || "") }, target, scope, "push-repository"); if (!verified.ok) return json(res, 403, { error: verified.reason }); const map = mapGate.read(target); const mapVerified = mapGate.verify(token, target, map.hash); if (!mapVerified.ok) return json(res, 423, { error: mapVerified.reason, required_action: "read-navigation-map" }); fs.mkdirSync(repoGrantDir, { recursive: true, mode: 0o2770 }); const grant = { schema: "guanghu.repo-push-grant/v1", repo, target, persona_id: body.persona_id, map_hash: map.hash, issued_at: Date.now() / 1000, expires_at: verified.session.expiresAt }; const grantFile = path.join(repoGrantDir, `${repo.replace("/", "__")}.json`); const temp = `${grantFile}.${process.pid}.tmp`; fs.writeFileSync(temp, JSON.stringify(grant), { mode: 0o640 }); fs.renameSync(temp, grantFile); return json(res, 200, { ok: true, repo, target, expires_at: grant.expires_at }); } return json(res, 404, { error: "not_found" }); } catch (error) { process.stderr.write(`lake-lamp request error: ${String(error && error.message || "unknown").slice(0, 240)}\n`); return json(res, error && error.code === "BODY_TOO_LARGE" ? 413 : 500, { error: "request_failed" }); } }); } function approvalPage(order, token) { return document("小湖灯授权请求", `
LAKE LAMP SECURITY PROTOCOL
授权后只对这台服务器和这个动作生效,有效期一小时。换服务器必须重新申请。
`); } function approvedPage(order) { return document("授权完成", `APPROVED
${escapeHtml(order.persona.name)} 已获准操作 ${escapeHtml(order.target)}。
可以关闭本页面。人格体只能领取一次临时会话令牌。
`); } function approvalErrorPage(reason) { return document("链接不可用", `LINK CLOSED
原因:${escapeHtml(reason)}。如仍需操作,请让人格体重新提交工单。
`); } function document(title, body) { return `