diff --git a/.gitignore b/.gitignore index 78c2659..04372e2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ cover.out *.test .DS_Store + +# 프론트 번들은 커밋하지 않는다 — index.html 셸만 두고 make web-build 가 채운다. +internal/webui/assets/* +!internal/webui/assets/.gitignore +!internal/webui/assets/index.html diff --git a/Makefile b/Makefile index be54e2e..2f1df6c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all fmt vet test build clean +.PHONY: all fmt vet test build clean web-build # 외부 의존성 0 이 이 제품의 전제다 — go.mod 에 require 가 늘면 "차트 하나 = # 파드 하나 = 완결" 이 성립하지 않는다. dep-check 가 그것을 지킨다. @@ -26,3 +26,13 @@ build: clean: rm -rf dist cover.out + +# 프론트를 빌드한다. vite 의 outDir 이 internal/webui/assets 라 산출물이 바로 +# embed 대상에 떨어진다 — 복사 단계를 두면 emptyOutDir 와 순서가 엉켜 방금 +# 만든 번들을 지운다(실제로 그렇게 한 번 깨뜨렸다). +# +# 번들은 커밋하지 않고 index.html 셸만 커밋한다. 빌드 전에는 셸이 참조하는 +# 해시 번들이 없으므로 Go 테스트가 그 상태를 인식해 관련 검사를 건너뛴다 — +# 그래야 프론트를 빌드하지 않은 개발자의 `make all` 이 실패하지 않는다. +web-build: + cd web && pnpm install --frozen-lockfile && pnpm build diff --git a/internal/apiserver/server.go b/internal/apiserver/server.go index 9abe4cd..bc4ea3d 100644 --- a/internal/apiserver/server.go +++ b/internal/apiserver/server.go @@ -30,6 +30,7 @@ import ( "github.com/KeiaiLab/nodevitals-observatory/internal/rules" "github.com/KeiaiLab/nodevitals-observatory/internal/storage" "github.com/KeiaiLab/nodevitals-observatory/internal/tsdb" + "github.com/KeiaiLab/nodevitals-observatory/internal/webui" ) // queryWindow 는 /api/v1/query 의 평가창 폭이다 — [eval-queryWindow, eval]. @@ -75,6 +76,9 @@ func NewServer(d Deps) http.Handler { mux.Handle("POST /api/v1/auth/login", d.Auth.LoginHandler()) mux.Handle("POST /api/v1/auth/logout", d.Auth.LogoutHandler()) mux.HandleFunc("GET /api/", handleUnknownAPI) + // 콘솔 정적 자산. 데이터가 0 이라 인증 예외이고, 위 구체 패턴들이 + // ServeMux 우선순위로 먼저 매치되므로 이 catch-all 이 API 를 삼키지 않는다. + mux.Handle("GET /", webui.Handler()) // 보호 — 질의 q := storage.New(d.DB) diff --git a/internal/webui/assets/index.html b/internal/webui/assets/index.html new file mode 100644 index 0000000..ffe206e --- /dev/null +++ b/internal/webui/assets/index.html @@ -0,0 +1,13 @@ + + + + + + NodeVitals Observatory + + + + +
+ + diff --git a/internal/webui/webui.go b/internal/webui/webui.go new file mode 100644 index 0000000..ea74b32 --- /dev/null +++ b/internal/webui/webui.go @@ -0,0 +1,146 @@ +// Package webui 는 nodevitals-observatory 관제 콘솔의 빌드 산출 정적 자산을 +// embed.FS 로 담아 서빙한다. 데이터는 전부 인증 뒤 /api/v1/* 가 낸다 — 이 +// 패키지는 마크업·번들만 서빙한다. M5: React 콘솔(web/, Vite 빌드)이 +// 클라이언트 라우팅(React Router)을 쓰므로, `/assets/*` 는 정적 자산 전용으로 +// 서빙하고 그 외 비-API 경로는 index.html 로 폴백한다(m5-design.md §2 D2) — +// 새로고침·직접 URL 진입(/overview, /explorer 등)에서도 클라이언트 라우터가 +// 문서를 받도록 보장한다. +package webui + +import ( + "embed" + "io/fs" + "net/http" + "strings" +) + +//go:embed assets +var assets embed.FS + +// FS 는 assets 디렉터리를 루트로 한 서브트리를 반환한다(테스트·외부 소비용). +// go:embed 는 빌드 시점에 고정되므로 fs.Sub 실패는 임베드 자체가 깨졌다는 +// 뜻이라 panic 으로 즉시 드러낸다 — 런타임에 조용히 넘어가면 안 되는 불변식. +func FS() fs.FS { + sub, err := fs.Sub(assets, "assets") + if err != nil { + panic("webui: assets 서브트리 구성 실패: " + err.Error()) + } + return sub +} + +// securityHeaders 는 콘솔 응답에 심층방어 헤더를 붙인다. CSP 로 self 외 +// 스크립트 실행을 원천 차단하고(외부 CDN 0 과 정합) 클릭재킹·MIME 스니핑을 +// 막는다(m4-design.md 보안 적대검토 F-SEC-6 반영). +// +// style-src 만 'unsafe-inline' 으로 완화한다 — m5-design.md D3 이 예고한 +// "인라인 style 주입이 실제로 필요해지는 시점"이 왔다: Radix 의 스크롤 락 +// (react-remove-scroll)이