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>
458 lines
12 KiB
Go
458 lines
12 KiB
Go
package webserver
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/metadata"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// grpcContext creates a context with the bearer token from the session
|
|
// attached as gRPC outgoing metadata.
|
|
func grpcContext(r *http.Request) context.Context {
|
|
token := tokenFromContext(r.Context())
|
|
return metadata.AppendToOutgoingContext(r.Context(), "authorization", "Bearer "+token)
|
|
}
|
|
|
|
// handleDashboard renders the dashboard with repo stats and recent activity.
|
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
ctx := grpcContext(r)
|
|
|
|
repos, err := s.registry.ListRepositories(ctx, &mcrv1.ListRepositoriesRequest{})
|
|
if err != nil {
|
|
s.renderError(w, "dashboard", "Failed to load repositories.", err)
|
|
return
|
|
}
|
|
|
|
var repoCount int
|
|
var totalSize int64
|
|
for _, repo := range repos.GetRepositories() {
|
|
repoCount++
|
|
totalSize += repo.GetTotalSize()
|
|
}
|
|
|
|
// Fetch recent audit events for dashboard activity.
|
|
var events []*mcrv1.AuditEvent
|
|
auditResp, auditErr := s.audit.ListAuditEvents(ctx, &mcrv1.ListAuditEventsRequest{
|
|
Pagination: &mcrv1.PaginationRequest{Limit: 10},
|
|
})
|
|
if auditErr == nil {
|
|
events = auditResp.GetEvents()
|
|
}
|
|
// If audit fails with PermissionDenied, just show no events (user is not admin).
|
|
|
|
s.templates.render(w, "dashboard", map[string]any{
|
|
"Session": true,
|
|
"RepoCount": repoCount,
|
|
"TotalSize": formatSize(totalSize),
|
|
"Events": events,
|
|
})
|
|
}
|
|
|
|
// handleRepositories renders the repository list.
|
|
func (s *Server) handleRepositories(w http.ResponseWriter, r *http.Request) {
|
|
ctx := grpcContext(r)
|
|
|
|
resp, err := s.registry.ListRepositories(ctx, &mcrv1.ListRepositoriesRequest{})
|
|
if err != nil {
|
|
s.renderError(w, "repositories", "Failed to load repositories.", err)
|
|
return
|
|
}
|
|
|
|
s.templates.render(w, "repositories", map[string]any{
|
|
"Session": true,
|
|
"Repositories": resp.GetRepositories(),
|
|
})
|
|
}
|
|
|
|
// handleRepositoryDetail renders a single repository's tags and manifests.
|
|
func (s *Server) handleRepositoryDetail(w http.ResponseWriter, r *http.Request) {
|
|
name := extractRepoName(r)
|
|
if name == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
ctx := grpcContext(r)
|
|
|
|
resp, err := s.registry.GetRepository(ctx, &mcrv1.GetRepositoryRequest{Name: name})
|
|
if err != nil {
|
|
s.templates.render(w, "repository_detail", map[string]any{
|
|
"Session": true,
|
|
"Name": name,
|
|
"Error": grpcErrorMessage(err),
|
|
})
|
|
return
|
|
}
|
|
|
|
s.templates.render(w, "repository_detail", map[string]any{
|
|
"Session": true,
|
|
"Name": resp.GetName(),
|
|
"Tags": resp.GetTags(),
|
|
"Manifests": resp.GetManifests(),
|
|
"TotalSize": resp.GetTotalSize(),
|
|
})
|
|
}
|
|
|
|
// handleManifestDetail renders details for a specific manifest.
|
|
func (s *Server) handleManifestDetail(w http.ResponseWriter, r *http.Request) {
|
|
// URL format: /repositories/{name}/manifests/{digest}
|
|
// The name can contain slashes, so we parse manually.
|
|
path := r.URL.Path
|
|
const manifestsPrefix = "/manifests/"
|
|
idx := strings.LastIndex(path, manifestsPrefix)
|
|
if idx < 0 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
digest := path[idx+len(manifestsPrefix):]
|
|
repoPath := path[len("/repositories/"):idx]
|
|
|
|
if repoPath == "" || digest == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
ctx := grpcContext(r)
|
|
|
|
resp, err := s.registry.GetRepository(ctx, &mcrv1.GetRepositoryRequest{Name: repoPath})
|
|
if err != nil {
|
|
s.templates.render(w, "manifest_detail", map[string]any{
|
|
"Session": true,
|
|
"RepoName": repoPath,
|
|
"Error": grpcErrorMessage(err),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Find the specific manifest.
|
|
var manifest *mcrv1.ManifestInfo
|
|
for _, m := range resp.GetManifests() {
|
|
if m.GetDigest() == digest {
|
|
manifest = m
|
|
break
|
|
}
|
|
}
|
|
|
|
if manifest == nil {
|
|
s.templates.render(w, "manifest_detail", map[string]any{
|
|
"Session": true,
|
|
"RepoName": repoPath,
|
|
"Error": "Manifest not found.",
|
|
})
|
|
return
|
|
}
|
|
|
|
s.templates.render(w, "manifest_detail", map[string]any{
|
|
"Session": true,
|
|
"RepoName": repoPath,
|
|
"Manifest": manifest,
|
|
})
|
|
}
|
|
|
|
// handlePolicies renders the policy list and create form.
|
|
func (s *Server) handlePolicies(w http.ResponseWriter, r *http.Request) {
|
|
ctx := grpcContext(r)
|
|
csrf := s.generateCSRFToken(w)
|
|
|
|
resp, err := s.policy.ListPolicyRules(ctx, &mcrv1.ListPolicyRulesRequest{})
|
|
if err != nil {
|
|
s.templates.render(w, "policies", map[string]any{
|
|
"Session": true,
|
|
"CSRFToken": csrf,
|
|
"Error": grpcErrorMessage(err),
|
|
})
|
|
return
|
|
}
|
|
|
|
s.templates.render(w, "policies", map[string]any{
|
|
"Session": true,
|
|
"CSRFToken": csrf,
|
|
"Policies": resp.GetRules(),
|
|
})
|
|
}
|
|
|
|
// handleCreatePolicy processes the policy creation form.
|
|
func (s *Server) handleCreatePolicy(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB limit
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !s.validateCSRFToken(r) {
|
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
priority, _ := strconv.ParseInt(r.FormValue("priority"), 10, 32)
|
|
actions := splitCSV(r.FormValue("actions"))
|
|
repos := splitCSV(r.FormValue("repositories"))
|
|
|
|
ctx := grpcContext(r)
|
|
|
|
_, err := s.policy.CreatePolicyRule(ctx, &mcrv1.CreatePolicyRuleRequest{
|
|
Priority: int32(priority),
|
|
Description: r.FormValue("description"),
|
|
Effect: r.FormValue("effect"),
|
|
Actions: actions,
|
|
Repositories: repos,
|
|
Enabled: true,
|
|
})
|
|
if err != nil {
|
|
csrf := s.generateCSRFToken(w)
|
|
s.templates.render(w, "policies", map[string]any{
|
|
"Session": true,
|
|
"CSRFToken": csrf,
|
|
"Error": grpcErrorMessage(err),
|
|
})
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/policies", http.StatusSeeOther)
|
|
}
|
|
|
|
// handleTogglePolicy toggles a policy rule's enabled state.
|
|
func (s *Server) handleTogglePolicy(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB limit
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !s.validateCSRFToken(r) {
|
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
idStr := extractPolicyID(r.URL.Path, "/toggle")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "invalid policy ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := grpcContext(r)
|
|
|
|
// Get current state.
|
|
rule, err := s.policy.GetPolicyRule(ctx, &mcrv1.GetPolicyRuleRequest{Id: id})
|
|
if err != nil {
|
|
http.Error(w, grpcErrorMessage(err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Toggle the enabled field.
|
|
_, err = s.policy.UpdatePolicyRule(ctx, &mcrv1.UpdatePolicyRuleRequest{
|
|
Id: id,
|
|
Enabled: !rule.GetEnabled(),
|
|
UpdateMask: []string{"enabled"},
|
|
// Carry forward required fields.
|
|
Priority: rule.GetPriority(),
|
|
Description: rule.GetDescription(),
|
|
Effect: rule.GetEffect(),
|
|
Actions: rule.GetActions(),
|
|
Repositories: rule.GetRepositories(),
|
|
})
|
|
if err != nil {
|
|
http.Error(w, grpcErrorMessage(err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/policies", http.StatusSeeOther)
|
|
}
|
|
|
|
// handleDeletePolicy deletes a policy rule.
|
|
func (s *Server) handleDeletePolicy(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB limit
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !s.validateCSRFToken(r) {
|
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
idStr := extractPolicyID(r.URL.Path, "/delete")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "invalid policy ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := grpcContext(r)
|
|
|
|
_, err = s.policy.DeletePolicyRule(ctx, &mcrv1.DeletePolicyRuleRequest{Id: id})
|
|
if err != nil {
|
|
http.Error(w, grpcErrorMessage(err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/policies", http.StatusSeeOther)
|
|
}
|
|
|
|
// handleAudit renders the audit log with filters and pagination.
|
|
func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) {
|
|
ctx := grpcContext(r)
|
|
|
|
q := r.URL.Query()
|
|
eventType := q.Get("event_type")
|
|
repo := q.Get("repository")
|
|
since := q.Get("since")
|
|
until := q.Get("until")
|
|
pageStr := q.Get("page")
|
|
|
|
page := 1
|
|
if pageStr != "" {
|
|
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
|
page = p
|
|
}
|
|
}
|
|
|
|
const pageSize int32 = 50
|
|
offset := int32(page-1) * pageSize
|
|
|
|
req := &mcrv1.ListAuditEventsRequest{
|
|
Pagination: &mcrv1.PaginationRequest{
|
|
Limit: pageSize + 1, // fetch one extra to detect next page
|
|
Offset: offset,
|
|
},
|
|
EventType: eventType,
|
|
Repository: repo,
|
|
Since: since,
|
|
Until: until,
|
|
}
|
|
|
|
resp, err := s.audit.ListAuditEvents(ctx, req)
|
|
if err != nil {
|
|
s.templates.render(w, "audit", map[string]any{
|
|
"Session": true,
|
|
"Error": grpcErrorMessage(err),
|
|
"FilterType": eventType,
|
|
"FilterRepo": repo,
|
|
"FilterSince": since,
|
|
"FilterUntil": until,
|
|
"Page": page,
|
|
})
|
|
return
|
|
}
|
|
|
|
events := resp.GetEvents()
|
|
hasNext := len(events) > int(pageSize)
|
|
if hasNext {
|
|
events = events[:pageSize]
|
|
}
|
|
|
|
// Build pagination URLs.
|
|
buildURL := func(p int) string {
|
|
v := url.Values{}
|
|
if eventType != "" {
|
|
v.Set("event_type", eventType)
|
|
}
|
|
if repo != "" {
|
|
v.Set("repository", repo)
|
|
}
|
|
if since != "" {
|
|
v.Set("since", since)
|
|
}
|
|
if until != "" {
|
|
v.Set("until", until)
|
|
}
|
|
v.Set("page", strconv.Itoa(p))
|
|
return "/audit?" + v.Encode()
|
|
}
|
|
|
|
s.templates.render(w, "audit", map[string]any{
|
|
"Session": true,
|
|
"Events": events,
|
|
"FilterType": eventType,
|
|
"FilterRepo": repo,
|
|
"FilterSince": since,
|
|
"FilterUntil": until,
|
|
"Page": page,
|
|
"HasNext": hasNext,
|
|
"PrevURL": buildURL(page - 1),
|
|
"NextURL": buildURL(page + 1),
|
|
})
|
|
}
|
|
|
|
// renderError renders a template with an error message derived from a gRPC error.
|
|
func (s *Server) renderError(w http.ResponseWriter, tmpl, fallback string, err error) {
|
|
msg := fallback
|
|
if st, ok := status.FromError(err); ok {
|
|
if st.Code() == codes.PermissionDenied {
|
|
msg = "Access denied."
|
|
}
|
|
}
|
|
s.templates.render(w, tmpl, map[string]any{
|
|
"Session": true,
|
|
"Error": msg,
|
|
})
|
|
}
|
|
|
|
// grpcErrorMessage extracts a human-readable message from a gRPC error.
|
|
func grpcErrorMessage(err error) string {
|
|
if st, ok := status.FromError(err); ok {
|
|
if st.Code() == codes.PermissionDenied {
|
|
return "Access denied."
|
|
}
|
|
return st.Message()
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
// extractRepoName extracts the repository name from the URL path.
|
|
// The name may contain slashes (e.g., "library/nginx").
|
|
// URL format: /repositories/{name...}
|
|
func extractRepoName(r *http.Request) string {
|
|
path := r.URL.Path
|
|
prefix := "/repositories/"
|
|
if !strings.HasPrefix(path, prefix) {
|
|
return ""
|
|
}
|
|
name := path[len(prefix):]
|
|
|
|
// Strip trailing slash.
|
|
name = strings.TrimRight(name, "/")
|
|
|
|
// If the path contains /manifests/, extract only the repo name part.
|
|
if idx := strings.Index(name, "/manifests/"); idx >= 0 {
|
|
name = name[:idx]
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
// extractPolicyID extracts the policy ID from paths like /policies/{id}/toggle
|
|
// or /policies/{id}/delete.
|
|
func extractPolicyID(path, suffix string) string {
|
|
path = strings.TrimSuffix(path, suffix)
|
|
path = strings.TrimPrefix(path, "/policies/")
|
|
return path
|
|
}
|
|
|
|
// splitCSV splits a comma-separated string, trimming whitespace.
|
|
func splitCSV(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
result := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
result = append(result, p)
|
|
}
|
|
}
|
|
return result
|
|
}
|