diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a38b0b0..4e8620b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -246,7 +246,15 @@ jobs: trap 'docker logs artex-smoke; docker rm -f artex-smoke' EXIT curl --fail --retry 30 --retry-connrefused --retry-delay 2 --max-time 3 \ http://127.0.0.1:8787/api/health > /tmp/artex-health.json - jq -e --arg version "${RELEASE_TAG#v}" '.ok == true and .version == $version' /tmp/artex-health.json + jq -e '. == {"ok": true}' /tmp/artex-health.json + curl --fail -H 'Content-Type: application/json' \ + --data '{"password":"smoke-test-admin-password"}' \ + http://127.0.0.1:8787/api/auth/init > /tmp/artex-auth.json + token=$(jq -er '.token' /tmp/artex-auth.json) + echo "::add-mask::$token" + curl --fail -H "Authorization: Bearer $token" \ + http://127.0.0.1:8787/api/version > /tmp/artex-version.json + jq -e --arg version "${RELEASE_TAG#v}" '.version == $version' /tmp/artex-version.json curl --fail http://127.0.0.1:8787/ > /tmp/artex-index.html grep -qi ' maxPasswordBytes || username != cfg.Username || bcrypt.CompareHashAndPassword([]byte(cfg.PasswordHash), []byte(password)) != nil { - w.Header().Set("WWW-Authenticate", `Basic realm="ARTEX", charset="UTF-8"`) + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted", charset="UTF-8"`) writeErr(w, http.StatusUnauthorized, "请先完成 HTTP Basic Auth 验证") return } diff --git a/server/basic_auth_test.go b/server/basic_auth_test.go index 82cbd0c..8bbb16c 100644 --- a/server/basic_auth_test.go +++ b/server/basic_auth_test.go @@ -115,7 +115,7 @@ func TestBasicAuthBlockedSaveDoesNotBlockRequests(t *testing.T) { h := s.requireBasicAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) for _, path := range []string{"/", "/_next/static/app.js", "/api/tasks", "/api/health"} { r := httptest.NewRequest(http.MethodGet, path, nil) - if enabled && path != "/api/health" { + if enabled { r.AddCookie(initial.Result().Cookies()[0]) } w := httptest.NewRecorder() @@ -126,7 +126,11 @@ func TestBasicAuthBlockedSaveDoesNotBlockRequests(t *testing.T) { } } w := httptest.NewRecorder() - s.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/health", nil)) + r := httptest.NewRequest(http.MethodGet, "/api/health", nil) + if enabled { + r.AddCookie(initial.Result().Cookies()[0]) + } + s.Handler().ServeHTTP(w, r) if w.Code != http.StatusOK { reads <- errors.New("health handler unavailable during save") return @@ -247,7 +251,9 @@ func TestBasicAuthGate(t *testing.T) { {name: "asset challenge", path: "/_next/static/app.js", want: 401}, {name: "auth API challenge", path: "/api/auth/status", want: 401}, {name: "check challenge", path: "/api/basic-auth/check", want: 401}, - {name: "health exempt", path: "/api/health", want: 204}, + {name: "health challenge", path: "/api/health", want: 401}, + {name: "health credentials", path: "/api/health", user: "entry", pass: "密码:test-pass", want: 204}, + {name: "health gate cookie", path: "/api/health", cookie: cookie, want: 204}, {name: "health prefix not exempt", path: "/api/health/anything", want: 401}, {name: "wrong password", path: "/login", user: "entry", pass: "wrong", want: 401}, {name: "wrong user", path: "/login", user: "wrong", pass: "密码:test-pass", want: 401}, @@ -273,7 +279,7 @@ func TestBasicAuthGate(t *testing.T) { if w.Code != tc.want { t.Fatalf("status=%d want %d: %s", w.Code, tc.want, w.Body.String()) } - if w.Code == 401 && w.Header().Get("WWW-Authenticate") != `Basic realm="ARTEX", charset="UTF-8"` { + if w.Code == 401 && w.Header().Get("WWW-Authenticate") != `Basic realm="Restricted", charset="UTF-8"` { t.Fatal("missing browser challenge") } }) diff --git a/server/health_test.go b/server/health_test.go new file mode 100644 index 0000000..c643e88 --- /dev/null +++ b/server/health_test.go @@ -0,0 +1,153 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealthGateAndResponse(t *testing.T) { + for _, tc := range []struct { + name string + gate bool + auth string + want int + }{ + {name: "gate disabled", want: http.StatusOK}, + {name: "anonymous", gate: true, want: http.StatusUnauthorized}, + {name: "wrong credentials", gate: true, auth: "wrong", want: http.StatusUnauthorized}, + {name: "valid credentials", gate: true, auth: "basic", want: http.StatusOK}, + {name: "valid gate cookie", gate: true, auth: "cookie", want: http.StatusOK}, + {name: "admin JWT only", gate: true, auth: "jwt", want: http.StatusUnauthorized}, + } { + t.Run(tc.name, func(t *testing.T) { + s, _ := basicAuthTestServer(t) + var cookie *http.Cookie + if tc.gate { + w := saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"test-pass"}`) + if w.Code != http.StatusOK { + t.Fatalf("enable gate: %d %s", w.Code, w.Body.String()) + } + cookie = w.Result().Cookies()[0] + } + h := s.Handler() + for _, method := range []string{http.MethodGet, http.MethodHead} { + t.Run(method, func(t *testing.T) { + r := httptest.NewRequest(method, "/api/health", nil) + switch tc.auth { + case "wrong": + r.SetBasicAuth("entry", "wrong-password") + case "basic": + r.SetBasicAuth("entry", "test-pass") + case "cookie": + r.AddCookie(cookie) + case "jwt": + token, err := signJWT(s.jwtKey) + if err != nil { + t.Fatal(err) + } + r.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != tc.want { + t.Fatalf("status=%d want=%d: %s", w.Code, tc.want, w.Body.String()) + } + if w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("health response must not be cached") + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if tc.want == http.StatusUnauthorized { + if w.Header().Get("WWW-Authenticate") != `Basic realm="Restricted", charset="UTF-8"` { + t.Fatal("health must require Basic Auth") + } + for _, field := range []string{"ok", "service", "version"} { + if _, exists := body[field]; exists { + t.Fatalf("unauthenticated health exposed %s: %s", field, w.Body.String()) + } + } + return + } + if w.Header().Get("WWW-Authenticate") != "" || len(body) != 1 || body["ok"] != true { + t.Fatalf("unexpected health response: %v %s", w.Header(), w.Body.String()) + } + }) + } + }) + } +} + +func TestVersionRequiresAuthentication(t *testing.T) { + for _, tc := range []struct { + name string + gate bool + basic bool + token string + cookie bool + want int + }{ + {name: "anonymous", want: http.StatusUnauthorized}, + {name: "invalid JWT", token: "invalid", want: http.StatusUnauthorized}, + {name: "valid JWT", token: "valid", want: http.StatusOK}, + {name: "valid JWT cookie", token: "valid", cookie: true, want: http.StatusOK}, + {name: "gate anonymous", gate: true, want: http.StatusUnauthorized}, + {name: "gate Basic Auth only", gate: true, basic: true, want: http.StatusUnauthorized}, + {name: "gate JWT only", gate: true, token: "valid", want: http.StatusUnauthorized}, + {name: "gate invalid JWT", gate: true, basic: true, token: "invalid", want: http.StatusUnauthorized}, + {name: "gate both credentials", gate: true, basic: true, token: "valid", cookie: true, want: http.StatusOK}, + } { + t.Run(tc.name, func(t *testing.T) { + s, _ := basicAuthTestServer(t) + if tc.gate { + w := saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"test-pass"}`) + if w.Code != http.StatusOK { + t.Fatalf("enable gate: %d %s", w.Code, w.Body.String()) + } + } + r := httptest.NewRequest(http.MethodGet, "/api/version", nil) + if tc.basic { + r.SetBasicAuth("entry", "test-pass") + } + token := tc.token + if token == "valid" { + var err error + token, err = signJWT(s.jwtKey) + if err != nil { + t.Fatal(err) + } + } + if token != "" { + if tc.cookie { + r.AddCookie(&http.Cookie{Name: "artex_token", Value: token}) + } else { + r.Header.Set("Authorization", "Bearer "+token) + } + } + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != tc.want { + t.Fatalf("status=%d want=%d: %s", w.Code, tc.want, w.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if tc.want != http.StatusOK { + if _, ok := body["version"]; ok { + t.Fatalf("unauthorized request exposed version: %s", w.Body.String()) + } + return + } + if len(body) != 1 || body["version"] != BuildVersion { + t.Fatalf("unexpected authenticated version: %s", w.Body.String()) + } + if w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("version response must not be cached") + } + }) + } +} diff --git a/server/server.go b/server/server.go index 0c8e32a..114f0e9 100644 --- a/server/server.go +++ b/server/server.go @@ -34,7 +34,7 @@ import ( // BuildVersion is the backend application version, injected from cmd/artex at // startup (which in turn gets it from -ldflags "-X main.version="). -// Defaults to "dev" for local builds. Exposed to the frontend via GET /api/health. +// Defaults to "dev" for local builds. Exposed via the authenticated GET /api/version. var BuildVersion = "dev" // Server exposes the ARTEX backend over a JSON HTTP API for the shadcn/ui @@ -803,6 +803,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PUT /api/settings/basic-auth", s.saveBasicAuthSettings) mux.HandleFunc("GET /api/health", s.health) + mux.HandleFunc("GET /api/version", s.version) mux.HandleFunc("GET /api/stats", s.stats) mux.HandleFunc("GET /api/system/metrics", s.systemMetrics) mux.HandleFunc("GET /api/logs", s.getLogs) @@ -1149,8 +1150,16 @@ func (s *Server) Handler() http.Handler { // --- handlers --- +// health requires the optional Basic Auth gate but no admin JWT, and exposes no application fingerprints. func (s *Server) health(w http.ResponseWriter, r *http.Request) { - writeJSON(w, 200, map[string]any{"ok": true, "service": "artex", "version": BuildVersion}) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, 200, map[string]any{"ok": true}) +} + +// version is protected by the normal JWT middleware and optional Basic Auth gate. +func (s *Server) version(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, 200, map[string]any{"version": BuildVersion}) } func (s *Server) stats(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/app/(main)/_components/main-content.tsx b/web/src/app/(main)/_components/main-content.tsx index d6951cf..4b9d453 100644 --- a/web/src/app/(main)/_components/main-content.tsx +++ b/web/src/app/(main)/_components/main-content.tsx @@ -48,7 +48,7 @@ function MainContentInner({ children }: { children: ReactNode }) { useEffect(() => { api - .health() + .version() .then((h) => setVersion((h.version ?? "").replace(/^v(?=\d)/, ""))) .catch(() => setVersion("")); }, []); diff --git a/web/src/app/(main)/system/settings/_components/update-card.tsx b/web/src/app/(main)/system/settings/_components/update-card.tsx index dc0068b..5847cc6 100644 --- a/web/src/app/(main)/system/settings/_components/update-card.tsx +++ b/web/src/app/(main)/system/settings/_components/update-card.tsx @@ -43,7 +43,7 @@ export function UpdateCard() { const [info, setInfo] = React.useState(null); const [checking, setChecking] = React.useState(true); const [progress, setProgress] = React.useState(null); - // 与 progress 分开:暂存完成后进程就没了,SSE 会断,此时要切到轮询 /api/health。 + // 与 progress 分开:暂存完成后进程就没了,SSE 会断,此时要切到轮询 /api/version。 const [restarting, setRestarting] = React.useState(false); const [busy, setBusy] = React.useState(false); @@ -116,7 +116,7 @@ export function UpdateCard() { } }; - // 轮询 /api/health 直到版本号变化。 + // 携带管理员 JWT 轮询 /api/version 直到版本号变化。 // // 判据必须是"版本变了"而不是"能连上了":换装过程中旧版本会短暂地重新起来一次 // (那一次只负责把 artex.new 换上去然后立刻退出),只看连通性会误判成功。 @@ -126,15 +126,12 @@ export function UpdateCard() { while (Date.now() < deadline) { await sleep(2000); try { - const r = await fetch("/api/health", { cache: "no-store" }); - if (r.ok) { - const j = (await r.json()) as { version?: string }; - if (j.version && j.version !== fromVersion) { - toast.success(`已更新到 ${j.version},正在重新加载页面`); - await sleep(800); - window.location.reload(); - return; - } + const { version } = await api.version(); + if (version && version !== fromVersion) { + toast.success(`已更新到 ${version},正在重新加载页面`); + await sleep(800); + window.location.reload(); + return; } } catch { // 重启窗口内连不上是预期的,继续轮询。 @@ -172,7 +169,7 @@ export function UpdateCard() { streamRef.current = es; es.onerror = () => { // 进程退出时 SSE 必然断开。如果已经进入等待重启,这属于正常现象, - // 交给 /api/health 轮询继续判定即可。 + // 交给 /api/version 轮询继续判定即可。 // EventSource 自动重连,恢复服务端保存的当前进度。 }; return es; diff --git a/web/src/lib/api-version.test.cjs b/web/src/lib/api-version.test.cjs new file mode 100644 index 0000000..1883b3a --- /dev/null +++ b/web/src/lib/api-version.test.cjs @@ -0,0 +1,52 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); +const ts = require("typescript"); + +const compiled = ts.transpileModule(fs.readFileSync(path.join(__dirname, "api.ts"), "utf8"), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, +}).outputText; + +function client(fetch) { + const exports = {}; + vm.runInNewContext(compiled, { + exports, + process: { env: {} }, + URLSearchParams, + window: {}, + localStorage: { getItem: () => "test-admin-token" }, + require: (id) => { + if (id === "@/lib/mock/enabled") return { MOCK: false }; + if (id === "@/lib/mock/handler") return { mockHandle: () => assert.fail("unexpected demo request") }; + throw new Error(`Unexpected runtime import: ${id}`); + }, + fetch, + }); + return exports.api; +} + +test("version queries carry the admin token and request fresh results after restart", async () => { + const versions = ["v1.0.0", "v1.0.1"]; + const api = client(async (url, init) => { + assert.equal(url, "/api/version"); + assert.equal(init.headers.Authorization, "Bearer test-admin-token"); + assert.equal(init.cache, "no-store"); + return { ok: true, status: 200, json: async () => ({ version: versions.shift() }) }; + }); + + assert.equal((await api.version()).version, "v1.0.0"); + assert.equal((await api.version()).version, "v1.0.1"); +}); + +test("version queries can resume after a temporary restart connection failure", async () => { + let attempts = 0; + const api = client(async () => { + if (++attempts === 1) throw new TypeError("connection unavailable during restart"); + return { ok: true, status: 200, json: async () => ({ version: "v1.0.1" }) }; + }); + + await assert.rejects(api.version(), /connection unavailable/); + assert.equal((await api.version()).version, "v1.0.1"); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2651113..2282fb2 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -318,8 +318,9 @@ function schedulePayload(input: ScheduleInput) { } export const api = { - // 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。 - health: () => get<{ ok: boolean; service: string; version: string }>("/health"), + // 健康检查受可选 Basic Auth 保护且不返回应用名称或版本;版本号仅向已登录管理员提供。 + health: () => get<{ ok: boolean }>("/health"), + version: () => http<{ version: string }>("/version", { cache: "no-store" }), systemMetrics: () => get("/system/metrics"), // ---- auth ----