feat(admin-console): Stage 1 — pipe + skeleton behind the gateway
Tests · Go / test (push) Successful in 2m0s
Tests · Go / test (push) Successful in 2m0s
Add the server-rendered operator console at /_gm, exposed publicly through the gateway behind the existing admin_accounts Basic Auth. Backend: - new internal/adminconsole package (html/template Renderer, stateless HMAC CSRF signer, embedded stylesheet) - /_gm route group reusing basicauth.Middleware(admin.Service) + a CSRF guard (per-operator token + same-origin check); dashboard landing page - BACKEND_ADMIN_CONSOLE_CSRF_KEY config (per-process random fallback) Gateway: - new "admin" public route class (per-IP rate limit, body + GET/HEAD/POST method limits) classifying /_gm traffic - reverse proxy to the backend /_gm surface, preserving Host and relaying the backend 401 Basic Auth challenge; 502 when the backend is unreachable - GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_* config dev-deploy: - Caddy routes /_gm/* to the gateway - bootstrap admin + stable CSRF key; enable Prometheus /metrics exporters on backend and gateway (forward-compat for a future Prometheus/Grafana stack) Docs: ARCHITECTURE 14.1/16, FUNCTIONAL 10.2.1 (+ru mirror), backend and gateway READMEs, new backend/docs/admin-console.md. Tests: renderer + CSRF unit tests; backend router auth/render/asset/CSRF; gateway classifier, proxy forwarding/Host/401/405/413/429/502.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminConsoleHandlers renders the server-side operator console mounted under
|
||||
// the `/_gm` route group. It wraps the framework-agnostic
|
||||
// adminconsole.Renderer and CSRF signer with the gin glue: the per-page
|
||||
// handlers, the embedded static-asset handler, and the CSRF guard middleware
|
||||
// applied to state-changing requests. Authentication is provided by the shared
|
||||
// admin Basic Auth middleware mounted on the group, so this type assumes the
|
||||
// caller has already been verified.
|
||||
type AdminConsoleHandlers struct {
|
||||
renderer *adminconsole.Renderer
|
||||
csrf *adminconsole.CSRF
|
||||
assets http.Handler
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminConsoleHandlers constructs the console handler set. A nil renderer
|
||||
// falls back to the embedded default templates; a nil csrf falls back to a
|
||||
// fresh per-process random key; a nil logger falls back to zap.NewNop. It
|
||||
// panics only on conditions that are unrecoverable at startup (template parse
|
||||
// failure or crypto/rand failure), both of which indicate a broken build or
|
||||
// host rather than a runtime input.
|
||||
func NewAdminConsoleHandlers(renderer *adminconsole.Renderer, csrf *adminconsole.CSRF, logger *zap.Logger) *AdminConsoleHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
if renderer == nil {
|
||||
renderer = adminconsole.MustNewRenderer()
|
||||
}
|
||||
if csrf == nil {
|
||||
generated, err := adminconsole.NewRandomCSRF()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
csrf = generated
|
||||
}
|
||||
|
||||
assetsFS, err := adminconsole.Assets()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return &AdminConsoleHandlers{
|
||||
renderer: renderer,
|
||||
csrf: csrf,
|
||||
assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))),
|
||||
logger: logger.Named("http.admin.console"),
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard renders the console landing page (GET /_gm and GET /_gm/).
|
||||
func (h *AdminConsoleHandlers) Dashboard() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Asset serves the embedded console static assets under `/_gm/assets/`.
|
||||
func (h *AdminConsoleHandlers) Asset() gin.HandlerFunc {
|
||||
return gin.WrapH(h.assets)
|
||||
}
|
||||
|
||||
// RequireCSRF returns middleware guarding state-changing requests against
|
||||
// cross-site request forgery. Safe methods pass through untouched. For unsafe
|
||||
// methods it requires both a same-origin Origin/Referer header (when the
|
||||
// browser sends one) and a valid per-operator token in the `_csrf` form field;
|
||||
// either check failing yields 403.
|
||||
func (h *AdminConsoleHandlers) RequireCSRF() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if isSafeHTTPMethod(c.Request.Method) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if !sameOriginRequest(c.Request) {
|
||||
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "cross-origin request rejected")
|
||||
return
|
||||
}
|
||||
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||
if !h.csrf.Verify(username, c.PostForm("_csrf")) {
|
||||
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "invalid or missing CSRF token")
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// render composes the data common to every console page (operator name, CSRF
|
||||
// token, active navigation entry) and writes the named page. It renders into an
|
||||
// intermediate buffer so a template failure surfaces as a clean 500 without
|
||||
// emitting a partial document.
|
||||
func (h *AdminConsoleHandlers) render(c *gin.Context, status int, page, activeNav, title string, data any) {
|
||||
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := h.renderer.Render(&buf, page, adminconsole.PageData{
|
||||
Title: title,
|
||||
Username: username,
|
||||
CSRFToken: h.csrf.Token(username),
|
||||
ActiveNav: activeNav,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("render admin console page", zap.String("page", page), zap.Error(err))
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "failed to render page")
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(status, "text/html; charset=utf-8", buf.Bytes())
|
||||
}
|
||||
|
||||
// isSafeHTTPMethod reports whether method is a read-only HTTP method that the
|
||||
// CSRF guard may let through without a token.
|
||||
func isSafeHTTPMethod(method string) bool {
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sameOriginRequest reports whether the request's Origin (or, failing that,
|
||||
// Referer) names the same host as the request itself. A request that carries
|
||||
// neither header is treated as same-origin, leaving the CSRF token as the sole
|
||||
// guard; a malformed or cross-host value is rejected. This relies on the
|
||||
// gateway reverse proxy preserving the inbound Host header.
|
||||
func sameOriginRequest(r *http.Request) bool {
|
||||
source := r.Header.Get("Origin")
|
||||
if source == "" {
|
||||
source = r.Header.Get("Referer")
|
||||
}
|
||||
if source == "" {
|
||||
return true
|
||||
}
|
||||
parsed, err := url.Parse(source)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(parsed.Host, r.Host)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func newConsoleTestRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
handler, err := NewRouter(RouterDependencies{
|
||||
Logger: zap.NewNop(),
|
||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||
AdminConsole: NewAdminConsoleHandlers(nil, adminconsole.NewCSRF([]byte("test-key")), nil),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
func TestAdminConsoleRequiresAuth(t *testing.T) {
|
||||
router := newConsoleTestRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Basic") {
|
||||
t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminConsoleDashboardRenders(t *testing.T) {
|
||||
router := newConsoleTestRouter(t)
|
||||
|
||||
for _, path := range []string{"/_gm", "/_gm/"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s status = %d, want 200; body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("GET %s content-type = %q, want text/html", path, ct)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Dashboard") {
|
||||
t.Errorf("GET %s body missing the dashboard heading", path)
|
||||
}
|
||||
if !strings.Contains(body, "ops") {
|
||||
t.Errorf("GET %s body missing the operator name", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminConsoleServesAsset(t *testing.T) {
|
||||
router := newConsoleTestRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/_gm/assets/console.css", nil)
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("asset status = %d, want 200", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/css") {
|
||||
t.Errorf("asset content-type = %q, want text/css", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminConsoleRequireCSRF(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
||||
console := NewAdminConsoleHandlers(nil, csrf, nil)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) {
|
||||
c.Request = c.Request.WithContext(basicauth.WithUsername(c.Request.Context(), "ops"))
|
||||
c.Next()
|
||||
})
|
||||
engine.Use(console.RequireCSRF())
|
||||
engine.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
engine.POST("/x", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
token := csrf.Token("ops")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
form string
|
||||
origin string
|
||||
host string
|
||||
want int
|
||||
}{
|
||||
{"get is a safe method", http.MethodGet, "", "", "galaxy.lan", http.StatusOK},
|
||||
{"valid token, same origin", http.MethodPost, "_csrf=" + token, "https://galaxy.lan", "galaxy.lan", http.StatusOK},
|
||||
{"valid token, no origin header", http.MethodPost, "_csrf=" + token, "", "galaxy.lan", http.StatusOK},
|
||||
{"missing token", http.MethodPost, "", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden},
|
||||
{"wrong token", http.MethodPost, "_csrf=bogus", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden},
|
||||
{"cross-origin", http.MethodPost, "_csrf=" + token, "https://evil.example", "galaxy.lan", http.StatusForbidden},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var body io.Reader
|
||||
if tc.form != "" {
|
||||
body = strings.NewReader(tc.form)
|
||||
}
|
||||
req := httptest.NewRequest(tc.method, "/x", body)
|
||||
if tc.form != "" {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if tc.origin != "" {
|
||||
req.Header.Set("Origin", tc.origin)
|
||||
}
|
||||
req.Host = tc.host
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.want {
|
||||
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, tc.want, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,13 @@ type RouterDependencies struct {
|
||||
AdminGeo *AdminGeoHandlers
|
||||
InternalSessions *InternalSessionsHandlers
|
||||
InternalUsers *InternalUsersHandlers
|
||||
|
||||
// AdminConsole, when non-nil, mounts the server-rendered operator
|
||||
// console under the `/_gm` route group behind the same admin Basic
|
||||
// Auth verifier as `/api/v1/admin`. A nil value leaves the console
|
||||
// unmounted, which keeps routers built without console wiring (the
|
||||
// contract test, most unit tests) unchanged.
|
||||
AdminConsole *AdminConsoleHandlers
|
||||
}
|
||||
|
||||
// NewRouter constructs the backend gin engine wired with the documented
|
||||
@@ -123,6 +130,7 @@ func NewRouter(deps RouterDependencies) (http.Handler, error) {
|
||||
registerUserRoutes(router, instruments, deps)
|
||||
registerAdminRoutes(router, instruments, deps)
|
||||
registerInternalRoutes(router, instruments, deps)
|
||||
registerAdminConsoleRoutes(router, deps)
|
||||
|
||||
router.NoMethod(func(c *gin.Context) {
|
||||
if allow := allowedMethodsForPath(c.Request.URL.Path); allow != "" {
|
||||
@@ -364,6 +372,24 @@ func registerInternalRoutes(router *gin.Engine, instruments *metrics.Instruments
|
||||
users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal())
|
||||
}
|
||||
|
||||
// registerAdminConsoleRoutes mounts the server-rendered operator console under
|
||||
// `/_gm` when deps.AdminConsole is wired. The group reuses the same admin Basic
|
||||
// Auth verifier as `/api/v1/admin`; the CSRF guard then protects every
|
||||
// state-changing request. A nil AdminConsole leaves the surface unmounted.
|
||||
func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) {
|
||||
if deps.AdminConsole == nil {
|
||||
return
|
||||
}
|
||||
|
||||
group := router.Group("/_gm")
|
||||
group.Use(basicauth.Middleware(deps.AdminVerifier, adminBasicAuthRealm))
|
||||
group.Use(deps.AdminConsole.RequireCSRF())
|
||||
|
||||
group.GET("/assets/*filepath", deps.AdminConsole.Asset())
|
||||
group.GET("", deps.AdminConsole.Dashboard())
|
||||
group.GET("/", deps.AdminConsole.Dashboard())
|
||||
}
|
||||
|
||||
// allowedMethodsForPath returns the comma-separated list of methods
|
||||
// the gin router accepts on requestPath. Only the probe paths declare
|
||||
// a non-empty list so NoMethod can advertise a useful `Allow` header
|
||||
|
||||
Reference in New Issue
Block a user