feat(engineering-dashboard): U6-U7 serve UI and offline export
Add loopback-only HTTP server with startup token, Host/Origin/CSP guards, embedded live UI, CLI serve/export, and safe single-file HTML. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/engineeringdashboard"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
cmd := os.Args[1]
|
||||
args := os.Args[2:]
|
||||
switch cmd {
|
||||
case "serve":
|
||||
if err := runServe(args); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "serve: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "export":
|
||||
if err := runExport(args); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "export: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command %q\n", cmd)
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, `engineering-dashboard — local read-only personal engineering control center
|
||||
|
||||
Usage:
|
||||
engineering-dashboard serve [--repo PATH] [--addr 127.0.0.1:PORT]
|
||||
engineering-dashboard export [--repo PATH] [--output FILE]
|
||||
|
||||
Does not write docs, operate git/docker, deploy, or control Herdr.
|
||||
`)
|
||||
}
|
||||
|
||||
func parseFlags(args []string) (repo, addr, output string) {
|
||||
addr = "127.0.0.1:8787"
|
||||
output = "engineering-dashboard-snapshot.html"
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--repo":
|
||||
i++
|
||||
if i < len(args) {
|
||||
repo = args[i]
|
||||
}
|
||||
case "--addr":
|
||||
i++
|
||||
if i < len(args) {
|
||||
addr = args[i]
|
||||
}
|
||||
case "--output":
|
||||
i++
|
||||
if i < len(args) {
|
||||
output = args[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return repo, addr, output
|
||||
}
|
||||
|
||||
func runServe(args []string) error {
|
||||
repo, addr, _ := parseFlags(args)
|
||||
root, err := engineeringdashboard.ResolveRepoRoot(repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(addr, "127.0.0.1:") && !strings.HasPrefix(addr, "localhost:") && !strings.HasPrefix(addr, "[::1]:") {
|
||||
return fmt.Errorf("addr must be loopback (got %s)", addr)
|
||||
}
|
||||
c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{})
|
||||
ctx := context.Background()
|
||||
if _, err := c.Refresh(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: initial refresh: %v\n", err)
|
||||
}
|
||||
srv, err := engineeringdashboard.NewServer(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("engineering-dashboard listening on http://%s/?token=%s\n", addr, srv.Token)
|
||||
fmt.Printf("repo=%s\n", root)
|
||||
return engineeringdashboard.ListenAndServe(addr, srv.Handler())
|
||||
}
|
||||
|
||||
func runExport(args []string) error {
|
||||
repo, _, output := parseFlags(args)
|
||||
root, err := engineeringdashboard.ResolveRepoRoot(repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !filepath.IsAbs(output) {
|
||||
wd, _ := os.Getwd()
|
||||
output = filepath.Join(wd, output)
|
||||
}
|
||||
c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{})
|
||||
snap, err := c.Refresh(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return engineeringdashboard.ExportHTML(*snap, output)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--panel: #1a222c;
|
||||
--text: #e7ecf1;
|
||||
--muted: #9aa7b5;
|
||||
--accent: #3d8bfd;
|
||||
--warn: #d4a017;
|
||||
--bad: #d64545;
|
||||
--ok: #3d9a6a;
|
||||
--border: #2b3642;
|
||||
--font: "IBM Plex Sans", "Noto Sans SC", sans-serif;
|
||||
--mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
background: radial-gradient(1200px 600px at 10% -10%, #1b2735, var(--bg));
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.skip {
|
||||
position: absolute; left: -9999px;
|
||||
}
|
||||
.skip:focus { left: 1rem; top: 1rem; background: #fff; color: #000; padding: .5rem; z-index: 9; }
|
||||
.top {
|
||||
display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem;
|
||||
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||
background: rgba(26,34,44,.85); backdrop-filter: blur(6px);
|
||||
}
|
||||
.top h1 { margin: 0; font-size: 1.35rem; letter-spacing: .02em; }
|
||||
.meta { margin: .35rem 0 0; color: var(--muted); font-size: .85rem; font-family: var(--mono); }
|
||||
.banner { margin: 0; color: var(--warn); font-size: .85rem; max-width: 28rem; }
|
||||
.layout { display: grid; grid-template-columns: 16rem 1fr; min-height: calc(100vh - 5rem); }
|
||||
.nav-toggle { display: none; margin: .75rem; }
|
||||
nav {
|
||||
border-right: 1px solid var(--border); padding: 1rem; background: var(--panel);
|
||||
}
|
||||
nav ul { list-style: none; margin: 0; padding: 0; }
|
||||
nav li { margin: 0 0 .35rem; }
|
||||
.nav-btn {
|
||||
width: 100%; text-align: left; background: transparent; color: var(--text);
|
||||
border: 1px solid transparent; padding: .5rem .65rem; border-radius: .35rem; cursor: pointer;
|
||||
}
|
||||
.nav-btn.active, .nav-btn:hover, .nav-btn:focus-visible {
|
||||
border-color: var(--accent); background: #243041;
|
||||
}
|
||||
.search-label { display: block; margin-top: 1rem; font-size: .8rem; color: var(--muted); }
|
||||
#q {
|
||||
width: 100%; margin-top: .35rem; padding: .45rem .55rem; border-radius: .35rem;
|
||||
border: 1px solid var(--border); background: #10161d; color: var(--text);
|
||||
}
|
||||
.source-strip { margin-top: 1rem; font-size: .75rem; color: var(--muted); }
|
||||
.source-strip .chip {
|
||||
display: inline-block; margin: .15rem .2rem 0 0; padding: .15rem .4rem;
|
||||
border: 1px solid var(--border); border-radius: .25rem;
|
||||
}
|
||||
.chip.ok { border-color: var(--ok); }
|
||||
.chip.stale, .chip.degraded { border-color: var(--warn); color: var(--warn); }
|
||||
.chip.error { border-color: var(--bad); color: var(--bad); }
|
||||
main { padding: 1rem 1.25rem 2rem; overflow: auto; }
|
||||
.view-status { min-height: 1.25rem; color: var(--muted); font-size: .85rem; margin-bottom: .75rem; }
|
||||
.panel {
|
||||
background: rgba(26,34,44,.7); border: 1px solid var(--border); border-radius: .5rem;
|
||||
padding: 1rem; margin-bottom: 1rem;
|
||||
}
|
||||
.panel h2 { margin: 0 0 .75rem; font-size: 1.05rem; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
||||
th, td { text-align: left; padding: .45rem .5rem; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
.badge {
|
||||
display: inline-block; padding: .1rem .4rem; border-radius: .25rem;
|
||||
border: 1px solid var(--border); font-size: .75rem; margin-right: .25rem;
|
||||
}
|
||||
.badge.warn { border-color: var(--warn); color: var(--warn); }
|
||||
.badge.bad { border-color: var(--bad); color: var(--bad); }
|
||||
.pre {
|
||||
white-space: pre-wrap; word-break: break-word; font-family: var(--mono);
|
||||
font-size: .82rem; background: #0c1117; padding: .75rem; border-radius: .35rem;
|
||||
border: 1px solid var(--border); max-height: 28rem; overflow: auto;
|
||||
}
|
||||
.axes { display: grid; grid-template-columns: repeat(4, minmax(8rem, 1fr)); gap: .5rem; }
|
||||
.axis { border: 1px solid var(--border); border-radius: .35rem; padding: .5rem; font-size: .8rem; }
|
||||
.axis strong { display: block; color: var(--muted); margin-bottom: .25rem; }
|
||||
a, button.linkish {
|
||||
color: var(--accent); background: none; border: none; cursor: pointer; padding: 0; font: inherit;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.nav-toggle { display: inline-block; }
|
||||
nav { display: none; border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
nav.open { display: block; }
|
||||
.axes { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
(function () {
|
||||
const token = window.__DASHBOARD_TOKEN__;
|
||||
const state = {
|
||||
snap: null,
|
||||
view: "overview",
|
||||
q: "",
|
||||
selectedDoc: "",
|
||||
scrollY: 0,
|
||||
};
|
||||
|
||||
const el = {
|
||||
content: document.getElementById("content"),
|
||||
meta: document.getElementById("repo-meta"),
|
||||
sources: document.getElementById("source-strip"),
|
||||
status: document.getElementById("view-status"),
|
||||
q: document.getElementById("q"),
|
||||
navToggle: document.getElementById("nav-toggle"),
|
||||
nav: document.getElementById("nav"),
|
||||
};
|
||||
|
||||
function qs() {
|
||||
const u = new URL(window.location.href);
|
||||
return u.searchParams;
|
||||
}
|
||||
|
||||
function syncURL() {
|
||||
const u = new URL(window.location.href);
|
||||
u.searchParams.set("token", token);
|
||||
u.searchParams.set("view", state.view);
|
||||
if (state.q) u.searchParams.set("q", state.q); else u.searchParams.delete("q");
|
||||
if (state.selectedDoc) u.searchParams.set("doc", state.selectedDoc); else u.searchParams.delete("doc");
|
||||
history.replaceState(null, "", u.toString());
|
||||
}
|
||||
|
||||
function loadURL() {
|
||||
const p = qs();
|
||||
state.view = p.get("view") || "overview";
|
||||
state.q = p.get("q") || "";
|
||||
state.selectedDoc = p.get("doc") || "";
|
||||
el.q.value = state.q;
|
||||
document.querySelectorAll(".nav-btn").forEach((b) => {
|
||||
b.classList.toggle("active", b.dataset.view === state.view);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSnapshot(force) {
|
||||
el.status.textContent = state.snap ? "刷新中…" : "首次加载中…";
|
||||
const url = "/api/v1/snapshot?token=" + encodeURIComponent(token) + (force ? "&refresh=1" : "");
|
||||
const headers = {};
|
||||
if (state.snap && state.snap.revision_id) headers["If-None-Match"] = '"' + state.snap.revision_id + '"';
|
||||
const res = await fetch(url, { headers, credentials: "same-origin" });
|
||||
if (res.status === 304) {
|
||||
el.status.textContent = "已是最新 revision " + state.snap.revision_id;
|
||||
return false;
|
||||
}
|
||||
if (!res.ok) {
|
||||
el.status.textContent = "加载失败 HTTP " + res.status;
|
||||
if (!state.snap) renderHardError("无法获取快照");
|
||||
return false;
|
||||
}
|
||||
state.snap = await res.json();
|
||||
el.status.textContent = "generation " + state.snap.generation_id + " · revision " + state.snap.revision_id;
|
||||
return true;
|
||||
}
|
||||
|
||||
function escapeText(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s == null ? "" : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function badge(text, cls) {
|
||||
return '<span class="badge ' + (cls || "") + '">' + escapeText(text) + "</span>";
|
||||
}
|
||||
|
||||
function renderMeta() {
|
||||
const s = state.snap;
|
||||
if (!s) return;
|
||||
el.meta.textContent = [
|
||||
s.repository.root,
|
||||
"branch=" + s.repository.branch,
|
||||
"HEAD=" + (s.repository.head || "").slice(0, 12),
|
||||
s.repository.dirty ? "dirty" : "clean",
|
||||
"at " + s.generated_at,
|
||||
].join(" · ");
|
||||
el.sources.innerHTML = (s.sources || []).map((src) => {
|
||||
const cls = src.freshness === "stale" || src.status === "degraded" ? "stale" :
|
||||
src.status === "error" ? "error" : "ok";
|
||||
return '<span class="chip ' + cls + '">' + escapeText(src.name + ":" + src.status + "/" + src.freshness) + "</span>";
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function matchQuery(text) {
|
||||
if (!state.q) return true;
|
||||
return String(text || "").toLowerCase().includes(state.q.toLowerCase());
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
const s = state.snap;
|
||||
const tasks = (s.tasks || []).filter((t) => ["planned", "in_progress", "blocked"].includes(t.doc_status) || matchQuery(t.title + t.path));
|
||||
const conflicts = s.conflicts || [];
|
||||
let html = '<section class="panel"><h2>焦点任务</h2><div class="table-wrap"><table><thead><tr><th>任务</th><th>计划</th><th>Git</th><th>验证</th><th>实装</th></tr></thead><tbody>';
|
||||
tasks.slice(0, 40).forEach((t) => {
|
||||
html += "<tr><td>" + escapeText(t.title) + "<div class='meta'>" + escapeText(t.path) + "</div></td>";
|
||||
html += "<td>" + badge(t.doc_status) + (t.claim && t.claim.claimed ? badge("claimed") : badge("unclaimed", "warn")) + "</td>";
|
||||
html += "<td>" + escapeText((t.git && t.git.branch) || "—") + " " + escapeText((t.git && t.git.cleanliness) || "") + "</td>";
|
||||
html += "<td>" + escapeText((t.verification && t.verification.result) || "unknown") + "</td>";
|
||||
const dep = t.deployment || {};
|
||||
html += "<td>" + escapeText(Object.keys(dep).map((k) => k + ":" + (dep[k].app_readiness || "unknown")).join(" ")) + "</td></tr>";
|
||||
});
|
||||
html += "</tbody></table></div></section>";
|
||||
html += '<section class="panel"><h2>漂移 / 冲突</h2>';
|
||||
if (!conflicts.length) html += "<p class='meta'>无冲突</p>";
|
||||
else {
|
||||
html += "<ul>" + conflicts.map((c) => "<li>" + badge(c.kind, "warn") + " " + escapeText(c.subject + " · " + c.field + " — " + c.summary) + "</li>").join("") + "</ul>";
|
||||
}
|
||||
html += "</section>";
|
||||
el.content.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderModules() {
|
||||
const rows = (state.snap.modules || []).filter((m) => matchQuery(m.name + (m.mutex || "")));
|
||||
let html = '<section class="panel"><h2>模块</h2><div class="table-wrap"><table><thead><tr><th>模块</th><th>状态</th><th>mutex</th></tr></thead><tbody>';
|
||||
rows.forEach((m) => {
|
||||
html += "<tr><td>" + escapeText(m.name) + "</td><td>" + escapeText(m.status) + "</td><td>" + escapeText(m.mutex) + "</td></tr>";
|
||||
});
|
||||
html += "</tbody></table></div></section>";
|
||||
el.content.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderTasks() {
|
||||
const rows = (state.snap.tasks || []).filter((t) => matchQuery(t.title + t.path + (t.claim && t.claim.owner || "")));
|
||||
let html = "";
|
||||
rows.forEach((t) => {
|
||||
html += '<section class="panel"><h2>' + escapeText(t.title) + "</h2>";
|
||||
html += '<div class="axes">';
|
||||
html += '<div class="axis"><strong>计划</strong>' + escapeText(t.doc_status) + "<br>" + escapeText((t.claim && t.claim.owner) || "unassigned") + " · " + escapeText((t.claim && t.claim.agent_kind) || "") + " · activity=" + escapeText((t.claim && t.claim.activity) || "unknown") + "</div>";
|
||||
html += '<div class="axis"><strong>Git 实施</strong>' + escapeText(JSON.stringify(t.git || {})) + "</div>";
|
||||
html += '<div class="axis"><strong>验证</strong>' + escapeText(JSON.stringify(t.verification || {})) + "</div>";
|
||||
html += '<div class="axis"><strong>实装</strong>' + escapeText(JSON.stringify(t.deployment || {})) + "</div>";
|
||||
html += "</div>";
|
||||
if (t.evidence_chain && t.evidence_chain.length) {
|
||||
html += "<p><strong>证据链</strong></p><ul>" + t.evidence_chain.map((p) => '<li><button type="button" class="linkish" data-doc="' + escapeText(p) + '">' + escapeText(p) + "</button></li>").join("") + "</ul>";
|
||||
}
|
||||
html += "</section>";
|
||||
});
|
||||
el.content.innerHTML = html || "<p>无匹配任务</p>";
|
||||
}
|
||||
|
||||
function renderGit() {
|
||||
const rows = (state.snap.git || []).filter((g) => matchQuery(g.name + (g.worktree || "")));
|
||||
let html = '<section class="panel"><h2>分支 / worktree</h2><p class="meta">远端状态基于本地 refs,未联网刷新</p><div class="table-wrap"><table><thead><tr><th>分支</th><th>ahead/behind</th><th>worktree</th><th>备注</th></tr></thead><tbody>';
|
||||
rows.forEach((g) => {
|
||||
html += "<tr><td>" + escapeText(g.name) + (g.is_current ? " *" : "") + "</td><td>" + escapeText(g.ahead + "/" + g.behind) + "</td><td>" + escapeText(g.worktree || "") + "</td><td>" + escapeText(g.remote_note || "") + "</td></tr>";
|
||||
});
|
||||
html += "</tbody></table></div></section>";
|
||||
el.content.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderDeploy() {
|
||||
const rows = state.snap.environments || [];
|
||||
let html = "";
|
||||
rows.forEach((e) => {
|
||||
html += '<section class="panel"><h2>环境 ' + escapeText(e.name) + "</h2>";
|
||||
html += "<p>" + badge("container:" + (e.facets && e.facets.container_status)) + badge("ready:" + (e.facets && e.facets.app_readiness)) + badge("services:" + (e.facets && e.facets.services_ok), e.facets && e.facets.services_ok === "partial" ? "warn" : "") + "</p>";
|
||||
if (e.drift_notes && e.drift_notes.length) {
|
||||
html += "<ul>" + e.drift_notes.map((n) => "<li class='badge warn'>" + escapeText(n) + "</li>").join("") + "</ul>";
|
||||
}
|
||||
if (e.containers && e.containers.length) {
|
||||
html += '<div class="table-wrap"><table><thead><tr><th>容器</th><th>状态</th><th>health</th><th>image</th></tr></thead><tbody>';
|
||||
e.containers.forEach((c) => {
|
||||
html += "<tr><td>" + escapeText(c.name) + "</td><td>" + escapeText(c.status) + "</td><td>" + escapeText(c.health) + "</td><td>" + escapeText(c.image) + "</td></tr>";
|
||||
});
|
||||
html += "</tbody></table></div>";
|
||||
}
|
||||
html += "</section>";
|
||||
});
|
||||
el.content.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderDocs() {
|
||||
const rows = (state.snap.documents || []).filter((d) => {
|
||||
if (!state.q) return true;
|
||||
return matchQuery(d.title + d.path + (d.body || ""));
|
||||
});
|
||||
rows.sort((a, b) => {
|
||||
const score = (d) => {
|
||||
let s = 0;
|
||||
if ((d.status || "") === "in_progress" || (d.status || "") === "blocked") s += 2;
|
||||
if ((d.path || "").includes("short-term")) s += 1;
|
||||
return -s;
|
||||
};
|
||||
const d = score(a) - score(b);
|
||||
return d !== 0 ? d : String(a.path).localeCompare(String(b.path));
|
||||
});
|
||||
let html = '<section class="panel"><h2>文档</h2><div class="table-wrap"><table><thead><tr><th>标题</th><th>路径</th><th>状态</th></tr></thead><tbody>';
|
||||
rows.slice(0, 200).forEach((d) => {
|
||||
html += '<tr><td><button type="button" class="linkish" data-doc="' + escapeText(d.id) + '">' + escapeText(d.title) + "</button></td><td>" + escapeText(d.path) + "</td><td>" + escapeText(d.status || "") + "</td></tr>";
|
||||
});
|
||||
html += "</tbody></table></div></section>";
|
||||
if (state.selectedDoc) {
|
||||
const doc = (state.snap.documents || []).find((d) => d.id === state.selectedDoc || d.path === state.selectedDoc);
|
||||
html += '<section class="panel"><h2>正文(纯文本)</h2>';
|
||||
if (!doc) html += "<p>未找到文档</p>";
|
||||
else {
|
||||
html += "<p class='meta'>" + escapeText(doc.path) + (doc.body_truncated ? " · truncated" : "") + "</p>";
|
||||
html += '<div class="pre">' + escapeText(doc.body || "") + "</div>";
|
||||
}
|
||||
html += "</section>";
|
||||
}
|
||||
el.content.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderEvidence() {
|
||||
const tasks = (state.snap.tasks || []).filter((t) => (t.evidence_chain || []).length && matchQuery(t.title));
|
||||
let html = '<section class="panel"><h2>证据链导航</h2>';
|
||||
tasks.forEach((t) => {
|
||||
html += "<h3>" + escapeText(t.title) + "</h3><ol>" + (t.evidence_chain || []).map((p) => '<li><button type="button" class="linkish" data-doc="' + escapeText(p) + '">' + escapeText(p) + "</button></li>").join("") + "</ol>";
|
||||
});
|
||||
html += "</section>";
|
||||
el.content.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderHardError(msg) {
|
||||
el.content.innerHTML = '<section class="panel"><h2>错误</h2><p class="badge bad">' + escapeText(msg) + "</p><p>无可用缓存快照。</p></section>";
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.snap) return;
|
||||
renderMeta();
|
||||
const y = window.scrollY;
|
||||
switch (state.view) {
|
||||
case "modules": renderModules(); break;
|
||||
case "tasks": renderTasks(); break;
|
||||
case "git": renderGit(); break;
|
||||
case "deploy": renderDeploy(); break;
|
||||
case "docs": renderDocs(); break;
|
||||
case "evidence": renderEvidence(); break;
|
||||
default: renderOverview();
|
||||
}
|
||||
window.scrollTo(0, state.scrollY || y);
|
||||
syncURL();
|
||||
}
|
||||
|
||||
document.querySelectorAll(".nav-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.view = btn.dataset.view;
|
||||
state.scrollY = 0;
|
||||
document.querySelectorAll(".nav-btn").forEach((b) => b.classList.toggle("active", b === btn));
|
||||
if (el.nav) el.nav.classList.remove("open");
|
||||
if (el.navToggle) el.navToggle.setAttribute("aria-expanded", "false");
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
el.content.addEventListener("click", (ev) => {
|
||||
const t = ev.target.closest("[data-doc]");
|
||||
if (!t) return;
|
||||
state.selectedDoc = t.getAttribute("data-doc");
|
||||
state.view = "docs";
|
||||
document.querySelectorAll(".nav-btn").forEach((b) => b.classList.toggle("active", b.dataset.view === "docs"));
|
||||
render();
|
||||
});
|
||||
|
||||
el.q.addEventListener("input", () => {
|
||||
state.q = el.q.value.trim();
|
||||
render();
|
||||
});
|
||||
|
||||
if (el.navToggle) {
|
||||
el.navToggle.addEventListener("click", () => {
|
||||
const open = el.nav.classList.toggle("open");
|
||||
el.navToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", () => {
|
||||
loadURL();
|
||||
render();
|
||||
});
|
||||
|
||||
async function loop() {
|
||||
loadURL();
|
||||
try {
|
||||
const changed = await fetchSnapshot(true);
|
||||
if (changed || state.snap) render();
|
||||
} catch (e) {
|
||||
el.status.textContent = "网络错误";
|
||||
if (!state.snap) renderHardError(String(e));
|
||||
}
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const changed = await fetchSnapshot(false);
|
||||
if (changed) render();
|
||||
} catch (_) { /* keep last snapshot */ }
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
loop();
|
||||
})();
|
||||
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>个人工程中控台</title>
|
||||
<link rel="stylesheet" href="/assets/app.css?token=__TOKEN__">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">跳到主内容</a>
|
||||
<header class="top">
|
||||
<div>
|
||||
<h1>个人工程中控台</h1>
|
||||
<p class="meta" id="repo-meta">加载中…</p>
|
||||
</div>
|
||||
<p class="banner" role="status">只读本地工具 · 不写文档 · 不操作 Git/Docker · 不控制 Herdr</p>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<button type="button" class="nav-toggle" id="nav-toggle" aria-controls="nav" aria-expanded="false">菜单</button>
|
||||
<nav id="nav" aria-label="主导航">
|
||||
<ul>
|
||||
<li><button type="button" data-view="overview" class="nav-btn active">总览</button></li>
|
||||
<li><button type="button" data-view="modules" class="nav-btn">架构/模块</button></li>
|
||||
<li><button type="button" data-view="tasks" class="nav-btn">任务</button></li>
|
||||
<li><button type="button" data-view="git" class="nav-btn">Git</button></li>
|
||||
<li><button type="button" data-view="deploy" class="nav-btn">部署</button></li>
|
||||
<li><button type="button" data-view="docs" class="nav-btn">文档</button></li>
|
||||
<li><button type="button" data-view="evidence" class="nav-btn">证据链</button></li>
|
||||
</ul>
|
||||
<label class="search-label">搜索
|
||||
<input type="search" id="q" placeholder="标题 / 路径 / 正文" autocomplete="off">
|
||||
</label>
|
||||
<div id="source-strip" class="source-strip" aria-live="polite"></div>
|
||||
</nav>
|
||||
<main id="main" tabindex="-1">
|
||||
<div id="view-status" class="view-status" role="status"></div>
|
||||
<div id="content"></div>
|
||||
</main>
|
||||
</div>
|
||||
<script>window.__DASHBOARD_TOKEN__ = "__TOKEN__";</script>
|
||||
<script src="/assets/app.js?token=__TOKEN__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
package engineeringdashboard
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed assets/index.html assets/app.js assets/app.css
|
||||
var assetFS embed.FS
|
||||
|
||||
func readAsset(name string) ([]byte, error) {
|
||||
return assetFS.ReadFile(name)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package engineeringdashboard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExportManifest describes what was embedded in an offline HTML.
|
||||
type ExportManifest struct {
|
||||
Included []string `json:"included"`
|
||||
Truncated []string `json:"truncated"`
|
||||
MetadataOnly []string `json:"metadata_only"`
|
||||
SensitiveWarn []string `json:"sensitive_warnings,omitempty"`
|
||||
}
|
||||
|
||||
// ExportHTML writes a self-contained offline snapshot (mode 0600).
|
||||
func ExportHTML(snap Snapshot, outputPath string) error {
|
||||
css, err := readAsset("assets/app.css")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manifest := ExportManifest{}
|
||||
docs := make([]DocumentNode, 0, len(snap.Documents))
|
||||
for _, d := range snap.Documents {
|
||||
cp := d
|
||||
if IsAssetsPath(cp.Path) {
|
||||
cp.Body = ""
|
||||
manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path)
|
||||
} else if cp.BodyTruncated {
|
||||
manifest.Truncated = append(manifest.Truncated, cp.Path)
|
||||
manifest.Included = append(manifest.Included, cp.Path)
|
||||
} else if cp.Body != "" {
|
||||
manifest.Included = append(manifest.Included, cp.Path)
|
||||
} else {
|
||||
manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path)
|
||||
}
|
||||
// strip any accidental absolute secret-looking paths from body display only — keep relative
|
||||
if strings.Contains(strings.ToLower(cp.Body), "/.secrets/") {
|
||||
manifest.SensitiveWarn = append(manifest.SensitiveWarn, cp.Path+": contains .secrets path mention")
|
||||
}
|
||||
docs = append(docs, cp)
|
||||
}
|
||||
exportSnap := snap
|
||||
exportSnap.Documents = docs
|
||||
|
||||
payload, err := MarshalSnapshotJSON(exportSnap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// HTML-safe JSON: break </script> sequences
|
||||
safeJSON := strings.ReplaceAll(string(payload), "<", "\\u003c")
|
||||
safeJSON = strings.ReplaceAll(safeJSON, ">", "\\u003e")
|
||||
safeJSON = strings.ReplaceAll(safeJSON, "&", "\\u0026")
|
||||
safeJSON = strings.ReplaceAll(safeJSON, "</script", "<\\/script")
|
||||
|
||||
manJSON, _ := json.MarshalIndent(manifest, "", " ")
|
||||
manSafe := strings.ReplaceAll(string(manJSON), "<", "\\u003c")
|
||||
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>工程中控台快照 %s</title>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'none'; connect-src 'none'; base-uri 'none'">
|
||||
<style>%s</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<div>
|
||||
<h1>个人工程中控台(离线快照)</h1>
|
||||
<p class="meta">repo=%s · HEAD=%s · generation=%s · revision=%s · generated_at=%s</p>
|
||||
</div>
|
||||
<p class="banner" role="status">敏感快照 · 默认权限 0600 · 不含 assets 二进制 · 交接方自行控制分发</p>
|
||||
</header>
|
||||
<main id="main" style="padding:1rem">
|
||||
<section class="panel">
|
||||
<h2>覆盖清单</h2>
|
||||
<pre class="pre" id="manifest"></pre>
|
||||
</section>
|
||||
<div id="content"></div>
|
||||
</main>
|
||||
<script type="application/json" id="snapshot-data">%s</script>
|
||||
<script type="application/json" id="manifest-data">%s</script>
|
||||
<script>
|
||||
(function(){
|
||||
const snap = JSON.parse(document.getElementById('snapshot-data').textContent);
|
||||
const manifest = JSON.parse(document.getElementById('manifest-data').textContent);
|
||||
document.getElementById('manifest').textContent = JSON.stringify(manifest, null, 2);
|
||||
const root = document.getElementById('content');
|
||||
function esc(s){ const d=document.createElement('div'); d.textContent=s==null?'':String(s); return d.innerHTML; }
|
||||
let html = '<section class="panel"><h2>任务四轴</h2>';
|
||||
(snap.tasks||[]).forEach(t=>{
|
||||
html += '<article><h3>'+esc(t.title)+'</h3>';
|
||||
html += '<div class="axes">';
|
||||
html += '<div class="axis"><strong>计划</strong>'+esc(t.doc_status)+'</div>';
|
||||
html += '<div class="axis"><strong>Git</strong>'+esc(JSON.stringify(t.git||{}))+'</div>';
|
||||
html += '<div class="axis"><strong>验证</strong>'+esc(JSON.stringify(t.verification||{}))+'</div>';
|
||||
html += '<div class="axis"><strong>实装</strong>'+esc(JSON.stringify(t.deployment||{}))+'</div>';
|
||||
html += '</div></article>';
|
||||
});
|
||||
html += '</section>';
|
||||
html += '<section class="panel"><h2>冲突</h2><ul>'+(snap.conflicts||[]).map(c=>'<li>'+esc(c.kind+': '+c.summary)+'</li>').join('')+'</ul></section>';
|
||||
html += '<section class="panel"><h2>文档正文</h2>';
|
||||
(snap.documents||[]).forEach(d=>{
|
||||
if(!d.body) return;
|
||||
html += '<h3>'+esc(d.title)+'</h3><p class="meta">'+esc(d.path)+(d.body_truncated?' · truncated':'')+'</p>';
|
||||
const pre=document.createElement('div');
|
||||
pre.className='pre';
|
||||
pre.textContent=d.body;
|
||||
html += pre.outerHTML;
|
||||
});
|
||||
html += '</section>';
|
||||
root.innerHTML = html;
|
||||
// Note: textContent assignment above via outerHTML of node with textContent is safe.
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`, snap.RevisionID, string(css), snap.Repository.Root, snap.Repository.Head, snap.GenerationID, snap.RevisionID, snap.GeneratedAt.Format(timeRFC3339), safeJSON, manSafe)
|
||||
|
||||
dir := filepath.Dir(outputPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".engdash-*.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := tmp.Write([]byte(html)); err != nil {
|
||||
tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, outputPath); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const timeRFC3339 = "2006-01-02T15:04:05Z07:00"
|
||||
@@ -0,0 +1,56 @@
|
||||
package engineeringdashboard
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExport_OfflineReadableAndSafe(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "snap.html")
|
||||
snap := Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
GenerationID: "gen_export",
|
||||
RevisionID: "rev_export",
|
||||
GeneratedAt: time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC),
|
||||
Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: "deadbeef"},
|
||||
Tasks: []TaskState{SampleRDevTaskFixture()},
|
||||
Conflicts: []Conflict{NewConflict("env:main", "health", ConflictDeclaredVsObserved, []string{"a", "b"}, "no aggregate green")},
|
||||
Documents: []DocumentNode{{
|
||||
ID: ".doc/ops/note.md", Path: ".doc/ops/note.md", Title: "Note",
|
||||
Body: "hello </script><script>alert(1)</script> and more",
|
||||
}},
|
||||
Environments: []EnvironmentState{SampleMainEventsForkFixture()},
|
||||
Git: []GitBranchView{SampleDevAheadGitFixture()},
|
||||
}
|
||||
if err := ExportHTML(snap, out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi, err := os.Stat(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("perm %o", fi.Mode().Perm())
|
||||
}
|
||||
raw, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := string(raw)
|
||||
if !strings.Contains(html, "gen_export") || !strings.Contains(html, "rev_export") {
|
||||
t.Fatal("missing snapshot identity")
|
||||
}
|
||||
if strings.Contains(html, "</script><script>alert") {
|
||||
t.Fatal("script breakout in data")
|
||||
}
|
||||
if !strings.Contains(html, "hello") {
|
||||
t.Fatal("body missing")
|
||||
}
|
||||
if strings.Contains(html, "http://") && strings.Contains(html, "src=\"http") {
|
||||
t.Fatal("unexpected remote resource")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package engineeringdashboard
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server is the local read-only HTTP surface.
|
||||
type Server struct {
|
||||
Collector *Collector
|
||||
Token string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewServer creates a server with a random startup token.
|
||||
func NewServer(c *Collector) (*Server, error) {
|
||||
tok, err := randomToken(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{Collector: c, Token: tok}, nil
|
||||
}
|
||||
|
||||
func randomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", s.handleHealthz)
|
||||
mux.HandleFunc("/api/v1/snapshot", s.handleSnapshot)
|
||||
mux.HandleFunc("/api/v1/doc/", s.handleDoc)
|
||||
mux.HandleFunc("/", s.handleStatic)
|
||||
return s.secure(mux)
|
||||
}
|
||||
|
||||
func (s *Server) secure(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "..") || strings.Contains(r.URL.RawPath, "..") || strings.Contains(r.URL.RawQuery, "..") {
|
||||
http.Error(w, "path traversal denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if host != "127.0.0.1" && host != "localhost" && host != "::1" {
|
||||
http.Error(w, "host not allowed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && !isLocalOrigin(origin) {
|
||||
http.Error(w, "origin not allowed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
site := r.Header.Get("Sec-Fetch-Site")
|
||||
if site == "cross-site" {
|
||||
http.Error(w, "cross-site fetch denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// token required for API and page (query or header)
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/" || strings.HasPrefix(r.URL.Path, "/assets") {
|
||||
tok := r.URL.Query().Get("token")
|
||||
if tok == "" {
|
||||
tok = r.Header.Get("X-Dashboard-Token")
|
||||
}
|
||||
if tok != s.Token {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; img-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func isLocalOrigin(origin string) bool {
|
||||
return strings.HasPrefix(origin, "http://127.0.0.1") ||
|
||||
strings.HasPrefix(origin, "http://localhost") ||
|
||||
strings.HasPrefix(origin, "http://[::1]")
|
||||
}
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
snap := s.Collector.Current()
|
||||
if snap == nil || r.URL.Query().Get("refresh") == "1" {
|
||||
var err error
|
||||
snap, err = s.Collector.Refresh(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// soft refresh if TTL expired
|
||||
if time.Since(snap.GeneratedAt) > s.Collector.TTL {
|
||||
go func() { _, _ = s.Collector.Refresh(ctx) }()
|
||||
}
|
||||
}
|
||||
etag := `"` + snap.RevisionID + `"`
|
||||
w.Header().Set("ETag", etag)
|
||||
if match := r.Header.Get("If-None-Match"); match == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
raw, err := MarshalSnapshotJSON(*snap)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleDoc(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/v1/doc/")
|
||||
id = strings.TrimPrefix(id, "/")
|
||||
if id == "" {
|
||||
http.Error(w, "missing doc id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := SafeDocPath(s.Collector.RepoRoot, id); err != nil {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
snap := s.Collector.Current()
|
||||
if snap == nil {
|
||||
var err error
|
||||
snap, err = s.Collector.Refresh(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, d := range snap.Documents {
|
||||
if d.ID == id || d.Path == id {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(d)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
switch path {
|
||||
case "/", "/index.html":
|
||||
b, err := readAsset("assets/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
html := strings.ReplaceAll(string(b), "__TOKEN__", s.Token)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(html))
|
||||
case "/assets/app.js":
|
||||
b, err := readAsset("assets/app.js")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
_, _ = w.Write(b)
|
||||
case "/assets/app.css":
|
||||
b, err := readAsset("assets/app.css")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
_, _ = w.Write(b)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServe binds loopback-only.
|
||||
func ListenAndServe(addr string, h http.Handler) error {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return fmt.Errorf("refusing non-loopback bind address: %s", addr)
|
||||
}
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort(host, port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return http.Serve(ln, h)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package engineeringdashboard
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestServer_RejectsNonGETAndBadHost(t *testing.T) {
|
||||
c := NewCollector(t.TempDir(), &FakeRunner{Map: map[string]FakeResult{}})
|
||||
c.mu.Lock()
|
||||
c.current = &Snapshot{
|
||||
SchemaVersion: SchemaVersion, GenerationID: "gen_1", RevisionID: "rev_1",
|
||||
GeneratedAt: time.Now().UTC(), Repository: RepositoryIdentity{Root: "/r"},
|
||||
}
|
||||
c.mu.Unlock()
|
||||
srv, err := NewServer(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/snapshot?token="+srv.Token, nil)
|
||||
req.Host = "127.0.0.1:8787"
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("POST: %d", rr.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil)
|
||||
req.Host = "evil.example"
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("bad host: %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_TokenAndSnapshotIdentity(t *testing.T) {
|
||||
c := NewCollector(t.TempDir(), &FakeRunner{Map: map[string]FakeResult{}})
|
||||
snap := Snapshot{
|
||||
SchemaVersion: SchemaVersion, GenerationID: "gen_9", RevisionID: "rev_abc",
|
||||
GeneratedAt: time.Now().UTC(), Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: "h"},
|
||||
Tasks: []TaskState{SampleRDevTaskFixture()},
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.current = &snap
|
||||
c.mu.Unlock()
|
||||
srv, err := NewServer(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/snapshot", nil)
|
||||
req.Host = "127.0.0.1:8787"
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("no token: %d", rr.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil)
|
||||
req.Host = "127.0.0.1:8787"
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("snapshot: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `"generation_id": "gen_9"`) || !strings.Contains(body, `"revision_id": "rev_abc"`) {
|
||||
t.Fatalf("identity missing: %s", body)
|
||||
}
|
||||
if rr.Header().Get("ETag") != `"rev_abc"` {
|
||||
t.Fatalf("etag %q", rr.Header().Get("ETag"))
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil)
|
||||
req.Host = "127.0.0.1:8787"
|
||||
req.Header.Set("If-None-Match", `"rev_abc"`)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusNotModified {
|
||||
t.Fatalf("etag: %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_DocPathTraversalDenied(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeDoc(t, root, ".doc/ops/ok.md", "# ok\n")
|
||||
c := NewCollector(root, &FakeRunner{Map: map[string]FakeResult{}})
|
||||
srv, err := NewServer(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/doc/not-a-real.md?token="+srv.Token, nil)
|
||||
req.Host = "127.0.0.1:8787"
|
||||
req.URL.Path = "/api/v1/doc/../../.env"
|
||||
rr := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAndServe_RejectsNonLoopback(t *testing.T) {
|
||||
err := ListenAndServe("0.0.0.0:8787", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
if err == nil || !strings.Contains(err.Error(), "loopback") {
|
||||
t.Fatalf("expected loopback reject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_CrossOriginDenied(t *testing.T) {
|
||||
c := NewCollector(t.TempDir(), &FakeRunner{})
|
||||
c.mu.Lock()
|
||||
c.current = &Snapshot{RevisionID: "r", GenerationID: "g", GeneratedAt: time.Now().UTC()}
|
||||
c.mu.Unlock()
|
||||
srv, _ := NewServer(c)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil)
|
||||
req.Host = "127.0.0.1:8787"
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rr := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user