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:
2026-03-20 10:14:38 -07:00
parent 185b68ff6d
commit 593da3975d
23 changed files with 3737 additions and 66 deletions

View File

@@ -1,10 +1,26 @@
package main
import (
"context"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/internal/auth"
"git.wntrmute.dev/kyle/mcr/internal/config"
"git.wntrmute.dev/kyle/mcr/internal/webserver"
)
var version = "dev"
@@ -24,11 +40,152 @@ func main() {
}
func serverCmd() *cobra.Command {
return &cobra.Command{
var configPath string
cmd := &cobra.Command{
Use: "server",
Short: "Start the web UI server",
RunE: func(_ *cobra.Command, _ []string) error {
return fmt.Errorf("not implemented")
return runServer(configPath)
},
}
cmd.Flags().StringVar(&configPath, "config", "", "path to mcr.toml config file (required)")
if err := cmd.MarkFlagRequired("config"); err != nil {
log.Fatalf("flag setup: %v", err)
}
return cmd
}
func runServer(configPath string) error {
cfg, err := config.Load(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if cfg.Web.ListenAddr == "" {
return fmt.Errorf("config: web.listen_addr is required")
}
if cfg.Web.GRPCAddr == "" {
return fmt.Errorf("config: web.grpc_addr is required")
}
// Connect to mcrsrv gRPC API.
grpcConn, err := dialGRPC(cfg)
if err != nil {
return fmt.Errorf("connect to gRPC server: %w", err)
}
defer func() { _ = grpcConn.Close() }()
// Create gRPC clients.
registryClient := mcrv1.NewRegistryServiceClient(grpcConn)
policyClient := mcrv1.NewPolicyServiceClient(grpcConn)
auditClient := mcrv1.NewAuditServiceClient(grpcConn)
adminClient := mcrv1.NewAdminServiceClient(grpcConn)
// Create MCIAS auth client for login.
authClient, err := auth.NewClient(
cfg.MCIAS.ServerURL,
cfg.MCIAS.CACert,
cfg.MCIAS.ServiceName,
cfg.MCIAS.Tags,
)
if err != nil {
return fmt.Errorf("create auth client: %w", err)
}
loginFn := func(username, password string) (string, int, error) {
return authClient.Login(username, password)
}
// Generate CSRF key.
csrfKey := make([]byte, 32)
if _, err := rand.Read(csrfKey); err != nil {
return fmt.Errorf("generate CSRF key: %w", err)
}
// Create web server.
srv, err := webserver.New(registryClient, policyClient, auditClient, adminClient, loginFn, csrfKey)
if err != nil {
return fmt.Errorf("create web server: %w", err)
}
// Configure TLS.
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
}
cert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)
if err != nil {
return fmt.Errorf("load TLS certificate: %w", err)
}
tlsCfg.Certificates = []tls.Certificate{cert}
httpServer := &http.Server{
Addr: cfg.Web.ListenAddr,
Handler: srv.Handler(),
TLSConfig: tlsCfg,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown on SIGINT/SIGTERM.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
log.Printf("mcr-web listening on %s", cfg.Web.ListenAddr)
// TLS is configured via TLSConfig, so use ServeTLS with empty cert/key
// paths (they are already loaded).
errCh <- httpServer.ListenAndServeTLS("", "")
}()
select {
case err := <-errCh:
return fmt.Errorf("server error: %w", err)
case <-ctx.Done():
log.Println("shutting down mcr-web...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
log.Println("mcr-web stopped")
return nil
}
}
// dialGRPC establishes a gRPC connection to the mcrsrv API server.
func dialGRPC(cfg *config.Config) (*grpc.ClientConn, error) {
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
}
if cfg.Web.CACert != "" {
pem, err := os.ReadFile(cfg.Web.CACert) //nolint:gosec // CA cert path is operator-supplied
if err != nil {
return nil, fmt.Errorf("read CA cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no valid certificates in CA cert file")
}
tlsCfg.RootCAs = pool
}
creds := credentials.NewTLS(tlsCfg)
conn, err := grpc.NewClient(
cfg.Web.GRPCAddr,
grpc.WithTransportCredentials(creds),
grpc.WithDefaultCallOptions(grpc.ForceCodecV2(mcrv1.JSONCodec{})),
)
if err != nil {
return nil, err
}
return conn, nil
}