Phases 11, 12: mcrctl CLI tool and mcr-web UI
Phase 11 implements the admin CLI with dual REST/gRPC transport, global flags (--server, --grpc, --token, --ca-cert, --json), and all commands: status, repo list/delete, policy CRUD, audit tail, gc trigger/status/reconcile, and snapshot. Phase 12 implements the HTMX web UI with chi router, session-based auth (HttpOnly/Secure/SameSite=Strict cookies), CSRF protection (HMAC-SHA256 signed double-submit), and pages for dashboard, repositories, manifest detail, policy management, and audit log. Security: CSRF via signed double-submit cookie, session cookies with HttpOnly/Secure/SameSite=Strict, TLS 1.3 minimum on all connections, form body size limits via http.MaxBytesReader. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
133
internal/webserver/server.go
Normal file
133
internal/webserver/server.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Package webserver implements the MCR web UI server.
|
||||
//
|
||||
// It serves HTML pages rendered from Go templates with htmx for
|
||||
// interactive elements. All data is fetched via gRPC from the main
|
||||
// mcrsrv API server. Authentication is handled via MCIAS, with session
|
||||
// tokens stored in secure HttpOnly cookies.
|
||||
package webserver
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
||||
"git.wntrmute.dev/kyle/mcr/web"
|
||||
)
|
||||
|
||||
// LoginFunc authenticates a user and returns a bearer token.
|
||||
type LoginFunc func(username, password string) (token string, expiresIn int, err error)
|
||||
|
||||
// Server is the MCR web UI server.
|
||||
type Server struct {
|
||||
router chi.Router
|
||||
templates *templateSet
|
||||
registry mcrv1.RegistryServiceClient
|
||||
policy mcrv1.PolicyServiceClient
|
||||
audit mcrv1.AuditServiceClient
|
||||
admin mcrv1.AdminServiceClient
|
||||
loginFn LoginFunc
|
||||
csrfKey []byte // 32-byte key for HMAC signing
|
||||
}
|
||||
|
||||
// New creates a new web UI server with the given gRPC clients and login function.
|
||||
func New(
|
||||
registry mcrv1.RegistryServiceClient,
|
||||
policy mcrv1.PolicyServiceClient,
|
||||
audit mcrv1.AuditServiceClient,
|
||||
admin mcrv1.AdminServiceClient,
|
||||
loginFn LoginFunc,
|
||||
csrfKey []byte,
|
||||
) (*Server, error) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
templates: tmpl,
|
||||
registry: registry,
|
||||
policy: policy,
|
||||
audit: audit,
|
||||
admin: admin,
|
||||
loginFn: loginFn,
|
||||
csrfKey: csrfKey,
|
||||
}
|
||||
|
||||
s.router = s.buildRouter()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Handler returns the http.Handler for the server.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.router
|
||||
}
|
||||
|
||||
// buildRouter sets up the chi router with all routes and middleware.
|
||||
func (s *Server) buildRouter() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware.
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
|
||||
// Static files (no auth required).
|
||||
staticFS, err := fs.Sub(web.Content, "static")
|
||||
if err != nil {
|
||||
log.Fatalf("webserver: failed to create static sub-filesystem: %v", err)
|
||||
}
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
// Public routes (no session required).
|
||||
r.Get("/login", s.handleLoginPage)
|
||||
r.Post("/login", s.handleLoginSubmit)
|
||||
r.Get("/logout", s.handleLogout)
|
||||
|
||||
// Protected routes (session required).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.sessionMiddleware)
|
||||
|
||||
r.Get("/", s.handleDashboard)
|
||||
|
||||
// Repository routes — name may contain slashes.
|
||||
r.Get("/repositories", s.handleRepositories)
|
||||
r.Get("/repositories/*", s.handleRepositoryOrManifest)
|
||||
|
||||
// Policy routes (admin — gRPC interceptors enforce this).
|
||||
r.Get("/policies", s.handlePolicies)
|
||||
r.Post("/policies", s.handleCreatePolicy)
|
||||
r.Post("/policies/{id}/toggle", s.handleTogglePolicy)
|
||||
r.Post("/policies/{id}/delete", s.handleDeletePolicy)
|
||||
|
||||
// Audit routes (admin — gRPC interceptors enforce this).
|
||||
r.Get("/audit", s.handleAudit)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// handleRepositoryOrManifest dispatches between repository detail and
|
||||
// manifest detail based on the URL path. This is necessary because
|
||||
// repository names can contain slashes.
|
||||
func (s *Server) handleRepositoryOrManifest(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if idx := lastIndex(path, "/manifests/"); idx >= 0 {
|
||||
s.handleManifestDetail(w, r)
|
||||
return
|
||||
}
|
||||
s.handleRepositoryDetail(w, r)
|
||||
}
|
||||
|
||||
// lastIndex returns the index of the last occurrence of sep in s, or -1.
|
||||
func lastIndex(s, sep string) int {
|
||||
for i := len(s) - len(sep); i >= 0; i-- {
|
||||
if s[i:i+len(sep)] == sep {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
Reference in New Issue
Block a user