diff --git a/cmd/engineering-dashboard/main.go b/cmd/engineering-dashboard/main.go index 43edb2a3..bec4d7ab 100644 --- a/cmd/engineering-dashboard/main.go +++ b/cmd/engineering-dashboard/main.go @@ -91,7 +91,7 @@ func runServe(args []string) error { if err != nil { return err } - fmt.Printf("engineering-dashboard listening on http://%s/?token=%s\n", addr, srv.Token) + fmt.Printf("engineering-dashboard listening on http://%s/\n", addr) fmt.Printf("repo=%s\n", root) return engineeringdashboard.ListenAndServe(addr, srv.Handler()) } diff --git a/internal/engineeringdashboard/assets/app.js b/internal/engineeringdashboard/assets/app.js index db7f347c..ee50e710 100644 --- a/internal/engineeringdashboard/assets/app.js +++ b/internal/engineeringdashboard/assets/app.js @@ -1,13 +1,4 @@ (function () { - function resolveToken() { - const fromURL = new URL(window.location.href).searchParams.get("token"); - if (fromURL) return fromURL; - const meta = document.querySelector('meta[name="engineering-dashboard-token"]'); - const fromMeta = meta && meta.getAttribute("content"); - if (fromMeta && fromMeta !== "__TOKEN__") return fromMeta; - return ""; - } - const token = resolveToken(); const state = { snap: null, view: "overview", @@ -33,7 +24,7 @@ function syncURL() { const u = new URL(window.location.href); - if (token) u.searchParams.set("token", token); + u.searchParams.delete("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"); @@ -52,13 +43,8 @@ } async function fetchSnapshot(force) { - if (!token) { - el.status.textContent = "缺少访问令牌"; - renderHardError("缺少 token:请使用服务启动时打印的完整 URL(含 ?token=)打开页面"); - return false; - } el.status.textContent = state.snap ? "刷新中…" : "首次加载中…"; - const url = "/api/v1/snapshot?token=" + encodeURIComponent(token) + (force ? "&refresh=1" : ""); + const url = "/api/v1/snapshot" + (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" }); @@ -294,10 +280,6 @@ async function loop() { loadURL(); - if (!token) { - renderHardError("缺少 token:请使用服务启动时打印的完整 URL(含 ?token=)打开页面"); - return; - } try { const changed = await fetchSnapshot(true); if (changed || state.snap) render(); diff --git a/internal/engineeringdashboard/assets/index.html b/internal/engineeringdashboard/assets/index.html index 63d53af2..8a735932 100644 --- a/internal/engineeringdashboard/assets/index.html +++ b/internal/engineeringdashboard/assets/index.html @@ -3,9 +3,8 @@ - 个人工程中控台 - + @@ -38,6 +37,6 @@
- + diff --git a/internal/engineeringdashboard/server.go b/internal/engineeringdashboard/server.go index 51dd7475..0f9ce0df 100644 --- a/internal/engineeringdashboard/server.go +++ b/internal/engineeringdashboard/server.go @@ -1,8 +1,6 @@ package engineeringdashboard import ( - "crypto/rand" - "encoding/hex" "encoding/json" "fmt" "net" @@ -13,27 +11,15 @@ import ( ) // Server is the local read-only HTTP surface. +// Access control is loopback bind + Host/Origin checks (personal local tool; no startup token). type Server struct { Collector *Collector - Token string mu sync.Mutex } -// NewServer creates a server with a random startup token. +// NewServer creates a local read-only server. 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 + return &Server{Collector: c}, nil } // Handler returns the HTTP handler. @@ -76,17 +62,6 @@ func (s *Server) secure(next http.Handler) http.Handler { 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") @@ -117,7 +92,6 @@ func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) { return } } else { - // soft refresh if TTL expired if time.Since(snap.GeneratedAt) > s.Collector.TTL { go func() { _, _ = s.Collector.Refresh(ctx) }() } @@ -176,9 +150,8 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { 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)) + _, _ = w.Write(b) case "/assets/app.js": b, err := readAsset("assets/app.js") if err != nil { diff --git a/internal/engineeringdashboard/server_test.go b/internal/engineeringdashboard/server_test.go index 3d828328..811ad8cd 100644 --- a/internal/engineeringdashboard/server_test.go +++ b/internal/engineeringdashboard/server_test.go @@ -22,7 +22,7 @@ func TestServer_RejectsNonGETAndBadHost(t *testing.T) { } h := srv.Handler() - req := httptest.NewRequest(http.MethodPost, "/api/v1/snapshot?token="+srv.Token, nil) + req := httptest.NewRequest(http.MethodPost, "/api/v1/snapshot", nil) req.Host = "127.0.0.1:8787" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -30,7 +30,7 @@ func TestServer_RejectsNonGETAndBadHost(t *testing.T) { t.Fatalf("POST: %d", rr.Code) } - req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil) + req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot", nil) req.Host = "evil.example" rr = httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -39,7 +39,7 @@ func TestServer_RejectsNonGETAndBadHost(t *testing.T) { } } -func TestServer_TokenAndSnapshotIdentity(t *testing.T) { +func TestServer_SnapshotWithoutToken(t *testing.T) { c := NewCollector(t.TempDir(), &FakeRunner{Map: map[string]FakeResult{}}) snap := Snapshot{ SchemaVersion: SchemaVersion, GenerationID: "gen_9", RevisionID: "rev_abc", @@ -59,16 +59,8 @@ func TestServer_TokenAndSnapshotIdentity(t *testing.T) { 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()) + t.Fatalf("snapshot without token: %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"`) { @@ -78,7 +70,7 @@ func TestServer_TokenAndSnapshotIdentity(t *testing.T) { t.Fatalf("etag %q", rr.Header().Get("ETag")) } - req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil) + req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot", nil) req.Host = "127.0.0.1:8787" req.Header.Set("If-None-Match", `"rev_abc"`) rr = httptest.NewRecorder() @@ -96,7 +88,7 @@ func TestServer_DocPathTraversalDenied(t *testing.T) { if err != nil { t.Fatal(err) } - req := httptest.NewRequest(http.MethodGet, "/api/v1/doc/not-a-real.md?token="+srv.Token, nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/doc/not-a-real.md", nil) req.Host = "127.0.0.1:8787" req.URL.Path = "/api/v1/doc/../../.env" rr := httptest.NewRecorder() @@ -119,7 +111,7 @@ func TestServer_CrossOriginDenied(t *testing.T) { 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 := httptest.NewRequest(http.MethodGet, "/api/v1/snapshot", nil) req.Host = "127.0.0.1:8787" req.Header.Set("Origin", "https://evil.example") rr := httptest.NewRecorder() @@ -129,7 +121,7 @@ func TestServer_CrossOriginDenied(t *testing.T) { } } -func TestServer_IndexUsesMetaTokenNoInlineScript(t *testing.T) { +func TestServer_IndexHasNoTokenMachinery(t *testing.T) { c := NewCollector(t.TempDir(), &FakeRunner{}) c.mu.Lock() c.current = &Snapshot{RevisionID: "r", GenerationID: "g", GeneratedAt: time.Now().UTC()} @@ -138,7 +130,7 @@ func TestServer_IndexUsesMetaTokenNoInlineScript(t *testing.T) { if err != nil { t.Fatal(err) } - req := httptest.NewRequest(http.MethodGet, "/?token="+srv.Token, nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) req.Host = "127.0.0.1:8787" rr := httptest.NewRecorder() srv.Handler().ServeHTTP(rr, req) @@ -146,25 +138,20 @@ func TestServer_IndexUsesMetaTokenNoInlineScript(t *testing.T) { t.Fatalf("index: %d %s", rr.Code, rr.Body.String()) } body := rr.Body.String() - if strings.Contains(body, "window.__DASHBOARD_TOKEN__") { - t.Fatal("index must not use inline token script (CSP script-src 'self')") + for _, banned := range []string{"__DASHBOARD_TOKEN__", "engineering-dashboard-token", "?token="} { + if strings.Contains(body, banned) { + t.Fatalf("index still contains %q", banned) + } } - meta := `name="engineering-dashboard-token" content="` + srv.Token + `"` - if !strings.Contains(body, meta) { - t.Fatalf("missing token meta: %s", body[:min(400, len(body))]) - } - if !strings.Contains(rr.Header().Get("Content-Security-Policy"), "script-src 'self'") { - t.Fatalf("unexpected CSP: %s", rr.Header().Get("Content-Security-Policy")) - } - // app.js must not reference the removed global js, err := readAsset("assets/app.js") if err != nil { t.Fatal(err) } - if strings.Contains(string(js), "__DASHBOARD_TOKEN__") { - t.Fatal("app.js still references __DASHBOARD_TOKEN__") + jsBody := string(js) + if strings.Contains(jsBody, "token=") || strings.Contains(jsBody, "__DASHBOARD_TOKEN__") { + t.Fatal("app.js still references token auth") } - if !strings.Contains(string(js), "engineering-dashboard-token") { - t.Fatal("app.js should read meta engineering-dashboard-token") + if !strings.Contains(rr.Header().Get("Content-Security-Policy"), "script-src 'self'") { + t.Fatalf("unexpected CSP: %s", rr.Header().Get("Content-Security-Policy")) } }