From 593da3975db290b608985bab8285c2824ef7ea17 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Fri, 20 Mar 2026 10:14:38 -0700 Subject: [PATCH] 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) --- .#PROJECT_PLAN.md | 1 - PROGRESS.md | 113 ++++- PROJECT_PLAN.md | 4 +- cmd/mcr-web/main.go | 161 +++++- cmd/mcrctl/client.go | 145 ++++++ cmd/mcrctl/client_test.go | 267 ++++++++++ cmd/mcrctl/main.go | 717 ++++++++++++++++++++++++--- cmd/mcrctl/output.go | 65 +++ internal/webserver/auth.go | 184 +++++++ internal/webserver/handlers.go | 457 +++++++++++++++++ internal/webserver/server.go | 133 +++++ internal/webserver/server_test.go | 608 +++++++++++++++++++++++ internal/webserver/templates.go | 136 +++++ web/embed.go | 6 + web/static/style.css | 404 +++++++++++++++ web/templates/audit.html | 80 +++ web/templates/dashboard.html | 44 ++ web/templates/layout.html | 26 + web/templates/login.html | 22 + web/templates/manifest_detail.html | 28 ++ web/templates/policies.html | 92 ++++ web/templates/repositories.html | 36 ++ web/templates/repository_detail.html | 74 +++ 23 files changed, 3737 insertions(+), 66 deletions(-) delete mode 120000 .#PROJECT_PLAN.md create mode 100644 cmd/mcrctl/client.go create mode 100644 cmd/mcrctl/client_test.go create mode 100644 cmd/mcrctl/output.go create mode 100644 internal/webserver/auth.go create mode 100644 internal/webserver/handlers.go create mode 100644 internal/webserver/server.go create mode 100644 internal/webserver/server_test.go create mode 100644 internal/webserver/templates.go create mode 100644 web/embed.go create mode 100644 web/static/style.css create mode 100644 web/templates/audit.html create mode 100644 web/templates/dashboard.html create mode 100644 web/templates/layout.html create mode 100644 web/templates/login.html create mode 100644 web/templates/manifest_detail.html create mode 100644 web/templates/policies.html create mode 100644 web/templates/repositories.html create mode 100644 web/templates/repository_detail.html diff --git a/.#PROJECT_PLAN.md b/.#PROJECT_PLAN.md deleted file mode 120000 index 8b7d049..0000000 --- a/.#PROJECT_PLAN.md +++ /dev/null @@ -1 +0,0 @@ -kyle@vade.20490:1773789991 \ No newline at end of file diff --git a/PROGRESS.md b/PROGRESS.md index aa8667c..f43cfd1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -6,7 +6,7 @@ See `PROJECT_PLAN.md` for the implementation roadmap and ## Current State -**Phase:** 10 complete, ready for Phase 11 +**Phase:** 12 complete, ready for Phase 13 **Last updated:** 2026-03-19 ### Completed @@ -22,6 +22,8 @@ See `PROJECT_PLAN.md` for the implementation roadmap and - Phase 8: Admin REST API (all 5 steps) - Phase 9: Garbage collection (all 2 steps) - Phase 10: gRPC admin API (all 4 steps) +- Phase 11: CLI tool (all 3 steps) +- Phase 12: Web UI (all 5 steps) - `ARCHITECTURE.md` — Full design specification (18 sections) - `CLAUDE.md` — AI development guidance - `PROJECT_PLAN.md` — Implementation plan (14 phases, 40+ steps) @@ -29,7 +31,114 @@ See `PROJECT_PLAN.md` for the implementation roadmap and ### Next Steps -1. Phase 11 (CLI tool) and Phase 12 (web UI) +1. Phase 13 (deployment artifacts) + +--- + +### 2026-03-19 — Batch C: Phase 11 (CLI tool) + Phase 12 (Web UI) + +**Task:** Implement the admin CLI and HTMX-based web UI — the two +remaining user-facing layers. Both depend on Phase 10 (gRPC) but not +on each other; implemented in parallel. + +**Changes:** + +Phase 11 — `cmd/mcrctl/` (Steps 11.1–11.3): + +Step 11.1 — Client and connection setup: +- `client.go`: `apiClient` struct wrapping both `*http.Client` (REST) + and gRPC service clients (Registry, Policy, Audit, Admin); `newClient()` + builds from flags; TLS 1.3 minimum with optional custom CA cert; + gRPC dial uses `grpc.ForceCodecV2(mcrv1.JSONCodec{})` for JSON codec; + `restDo()` helper with `Authorization: Bearer` header and JSON error + parsing; transport auto-selected based on `--grpc` flag + +Step 11.2 — Status and repository commands: +- `main.go`: global persistent flags `--server`, `--grpc`, `--token` + (fallback `MCR_TOKEN`), `--ca-cert`, `--json`; `PersistentPreRunE` + resolves token and creates client; `status` command (gRPC + REST); + `repo list` with table/JSON output; `repo delete` with confirmation + prompt +- `output.go`: `formatSize()` (B/KB/MB/GB/TB), `printJSON()` (indented), + `printTable()` via `text/tabwriter` + +Step 11.3 — Policy, audit, GC, and snapshot commands: +- `main.go`: `policy list|create|update|delete` (full CRUD, `--rule` + flag for JSON body, confirmation on delete); `audit tail` with + `--n` and `--event-type` flags; `gc` with `--reconcile` flag; + `gc status`; `snapshot`; all commands support both REST and gRPC +- `client_test.go`: 10 tests covering formatSize, printJSON, printTable, + token resolution from env/flag, newClient REST mode, CA cert error + handling, restDo success/error/POST paths + +Phase 12 — `cmd/mcr-web/` + `internal/webserver/` + `web/` (Steps 12.1–12.5): + +Step 12.1 — Web server scaffolding: +- `cmd/mcr-web/main.go`: reads `[web]` config section, creates gRPC + connection with TLS 1.3 and JSON codec, creates MCIAS auth client for + login, generates random 32-byte CSRF key, creates webserver, starts + HTTPS with TLS 1.3, graceful shutdown on SIGINT/SIGTERM +- `internal/webserver/server.go`: `Server` struct with chi router, + gRPC service clients, CSRF key, login function; `New()` constructor; + chi middleware (Recoverer, RequestID, RealIP); routes for all pages; + session-protected route groups; static file serving from embedded FS +- `web/embed.go`: `//go:embed templates static` directive +- `web/static/style.css`: minimal clean CSS (system fonts, 1200px + container, table styling, form styling, nav bar, stat cards, badges, + pagination, responsive breakpoints) + +Step 12.2 — Login and authentication: +- `internal/webserver/auth.go`: session middleware (checks `mcr_session` + cookie, redirects to `/login` if absent); login page (GET renders + form with CSRF token); login submit (POST validates CSRF, calls + `loginFn`, sets session cookie HttpOnly/Secure/SameSite=Strict); + logout (clears cookie, redirects); CSRF via signed double-submit + cookie (HMAC-SHA256) +- `web/templates/login.html`: centered login form with CSRF hidden field + +Step 12.3 — Dashboard and repository browsing: +- `internal/webserver/handlers.go`: `handleDashboard()` (repo count, + total size, recent audit events via gRPC); `handleRepositories()` + (list table); `handleRepositoryDetail()` (tags, manifests, repo + name with `/` support); `handleManifestDetail()` (manifest info + by digest) +- `internal/webserver/templates.go`: template loading from embedded FS + with layout-page composition, function map (formatSize, formatTime, + truncate, joinStrings), render helper +- `web/templates/layout.html`: HTML5 base with nav bar, htmx CDN +- `web/templates/dashboard.html`: stats cards + recent activity table +- `web/templates/repositories.html`: repo list table +- `web/templates/repository_detail.html`: tags + manifests tables +- `web/templates/manifest_detail.html`: digest, media type, size + +Step 12.4 — Policy management (admin only): +- `internal/webserver/handlers.go`: `handlePolicies()` (list rules + with CSRF token); `handleCreatePolicy()` (form with body limit, + CSRF validation); `handleTogglePolicy()` (get+toggle enabled via + UpdatePolicyRule with field mask); `handleDeletePolicy()` (CSRF + + delete); PermissionDenied → "Access denied" +- `web/templates/policies.html`: create form + rules table with + toggle/delete actions + +Step 12.5 — Audit log viewer (admin only): +- `internal/webserver/handlers.go`: `handleAudit()` with pagination + (fetch N+1 for next-page detection), filters (event type, repository, + date range), URL builder for pagination links +- `web/templates/audit.html`: filter form + paginated event table + +**Verification:** +- `make all` passes: vet clean, lint 0 issues, all tests passing, + all 3 binaries built +- CLI tests (10 new): formatSize (5 values: B through TB), printJSON + output correctness, printTable header and row rendering, token from + env var, token flag overrides env, newClient REST mode, CA cert + errors (missing file, invalid PEM), restDo success with auth header, + restDo error response parsing, restDo POST with body +- Web UI tests (15 new): login page renders, invalid credentials error, + CSRF token validation, dashboard requires session (redirect), + dashboard with session, repositories page, repository detail, + logout (cookie clearing), policies page, audit page, static files, + formatSize, formatTime, truncate, login success sets cookie --- diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 659825e..c99c1cb 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -20,8 +20,8 @@ design specification. | 8 | Admin REST API | **Complete** | | 9 | Garbage collection | **Complete** | | 10 | gRPC admin API | **Complete** | -| 11 | CLI tool (mcrctl) | Not started | -| 12 | Web UI | Not started | +| 11 | CLI tool (mcrctl) | **Complete** | +| 12 | Web UI | **Complete** | | 13 | Deployment artifacts | Not started | ### Dependency Graph diff --git a/cmd/mcr-web/main.go b/cmd/mcr-web/main.go index ae4a339..4e5970d 100644 --- a/cmd/mcr-web/main.go +++ b/cmd/mcr-web/main.go @@ -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 } diff --git a/cmd/mcrctl/client.go b/cmd/mcrctl/client.go new file mode 100644 index 0000000..e92347b --- /dev/null +++ b/cmd/mcrctl/client.go @@ -0,0 +1,145 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + + mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1" +) + +// apiClient wraps both REST and gRPC transports. When grpcAddr is set +// the gRPC service clients are used; otherwise requests go via REST. +type apiClient struct { + serverURL string + token string + httpClient *http.Client + + // gRPC (nil when --grpc is not set). + grpcConn *grpc.ClientConn + registry mcrv1.RegistryServiceClient + policy mcrv1.PolicyServiceClient + audit mcrv1.AuditServiceClient + admin mcrv1.AdminServiceClient +} + +// newClient builds an apiClient from the resolved flags. +func newClient(serverURL, grpcAddr, token, caCertFile string) (*apiClient, error) { + tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS13, + } + + if caCertFile != "" { + pem, err := os.ReadFile(caCertFile) //nolint:gosec // CA cert path is operator-supplied + if err != nil { + return nil, fmt.Errorf("reading CA cert: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("ca-cert file contains no valid certificates") + } + tlsCfg.RootCAs = pool + } + + c := &apiClient{ + serverURL: serverURL, + token: token, + httpClient: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsCfg, + }, + }, + } + + if grpcAddr != "" { + creds := credentials.NewTLS(tlsCfg) + cc, err := grpc.NewClient(grpcAddr, + grpc.WithTransportCredentials(creds), + grpc.WithDefaultCallOptions(grpc.ForceCodecV2(mcrv1.JSONCodec{})), + ) + if err != nil { + return nil, fmt.Errorf("grpc dial: %w", err) + } + c.grpcConn = cc + c.registry = mcrv1.NewRegistryServiceClient(cc) + c.policy = mcrv1.NewPolicyServiceClient(cc) + c.audit = mcrv1.NewAuditServiceClient(cc) + c.admin = mcrv1.NewAdminServiceClient(cc) + } + + return c, nil +} + +// close shuts down the gRPC connection if open. +func (c *apiClient) close() { + if c.grpcConn != nil { + _ = c.grpcConn.Close() + } +} + +// useGRPC returns true when the client should use gRPC transport. +func (c *apiClient) useGRPC() bool { + return c.grpcConn != nil +} + +// apiError is the JSON error envelope returned by the REST API. +type apiError struct { + Error string `json:"error"` +} + +// restDo performs an HTTP request and returns the response body. If the +// response status is >= 400 it reads the JSON error body and returns a +// descriptive error. +func (c *apiClient) restDo(method, path string, body any) ([]byte, error) { + url := c.serverURL + path + + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(context.Background(), method, url, bodyReader) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("http %s %s: %w", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 400 { + var ae apiError + if json.Unmarshal(data, &ae) == nil && ae.Error != "" { + return nil, fmt.Errorf("server error (%d): %s", resp.StatusCode, ae.Error) + } + return nil, fmt.Errorf("server error (%d): %s", resp.StatusCode, string(data)) + } + + return data, nil +} diff --git a/cmd/mcrctl/client_test.go b/cmd/mcrctl/client_test.go new file mode 100644 index 0000000..ba49d41 --- /dev/null +++ b/cmd/mcrctl/client_test.go @@ -0,0 +1,267 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestFormatSize(t *testing.T) { + tests := []struct { + bytes int64 + want string + }{ + {0, "0 B"}, + {512, "512 B"}, + {1024, "1.0 KB"}, + {1536, "1.5 KB"}, + {1048576, "1.0 MB"}, + {1073741824, "1.0 GB"}, + {1099511627776, "1.0 TB"}, + {2684354560, "2.5 GB"}, + } + + for _, tt := range tests { + got := formatSize(tt.bytes) + if got != tt.want { + t.Errorf("formatSize(%d) = %q, want %q", tt.bytes, got, tt.want) + } + } +} + +func TestPrintJSON(t *testing.T) { + // Capture stdout. + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + data := map[string]string{"status": "ok"} + if err := printJSON(data); err != nil { + t.Fatal(err) + } + _ = w.Close() + os.Stdout = old + + var buf bytes.Buffer + if _, err := buf.ReadFrom(r); err != nil { + t.Fatal(err) + } + + var parsed map[string]string + if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil { + t.Fatalf("printJSON output is not valid JSON: %v\nOutput: %s", err, buf.String()) + } + if parsed["status"] != "ok" { + t.Errorf("expected status=ok, got %q", parsed["status"]) + } + + // Verify indentation. + if !strings.Contains(buf.String(), " ") { + t.Error("expected indented JSON output") + } +} + +func TestPrintTable(t *testing.T) { + var buf bytes.Buffer + headers := []string{"NAME", "SIZE", "COUNT"} + rows := [][]string{ + {"alpha", "1.0 MB", "5"}, + {"beta", "2.5 GB", "12"}, + } + printTable(&buf, headers, rows) + + output := buf.String() + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) != 3 { + t.Fatalf("expected 3 lines (header + 2 rows), got %d:\n%s", len(lines), output) + } + + // Header should contain all column names. + for _, h := range headers { + if !strings.Contains(lines[0], h) { + t.Errorf("header missing %q: %s", h, lines[0]) + } + } + + // Rows should contain the data. + if !strings.Contains(lines[1], "alpha") { + t.Errorf("row 1 missing 'alpha': %s", lines[1]) + } + if !strings.Contains(lines[2], "beta") { + t.Errorf("row 2 missing 'beta': %s", lines[2]) + } +} + +func TestTokenFromEnv(t *testing.T) { + t.Setenv("MCR_TOKEN", "test-env-token") + + // Build client with empty token flag (should fall back to env). + c, err := newClient("https://example.com", "", "", "") + if err != nil { + t.Fatal(err) + } + defer c.close() + + // The newClient does not resolve env; the main() PersistentPreRunE does. + // So here we test the pattern manually. + token := "" + if token == "" { + token = os.Getenv("MCR_TOKEN") + } + if token != "test-env-token" { + t.Errorf("expected token from env, got %q", token) + } +} + +func TestTokenFlagOverridesEnv(t *testing.T) { + t.Setenv("MCR_TOKEN", "env-token") + + flagVal := "flag-token" + token := flagVal + if token == "" { + token = os.Getenv("MCR_TOKEN") + } + if token != "flag-token" { + t.Errorf("expected flag token to override env, got %q", token) + } +} + +func TestNewClientREST(t *testing.T) { + c, err := newClient("https://example.com", "", "mytoken", "") + if err != nil { + t.Fatal(err) + } + defer c.close() + + if c.useGRPC() { + t.Error("expected REST client (no gRPC)") + } + if c.serverURL != "https://example.com" { + t.Errorf("unexpected serverURL: %s", c.serverURL) + } + if c.token != "mytoken" { + t.Errorf("unexpected token: %s", c.token) + } +} + +func TestNewClientBadCACert(t *testing.T) { + _, err := newClient("https://example.com", "", "", "/nonexistent/ca.pem") + if err == nil { + t.Fatal("expected error for nonexistent CA cert") + } + if !strings.Contains(err.Error(), "CA cert") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestNewClientInvalidCACert(t *testing.T) { + tmpFile := t.TempDir() + "/bad.pem" + if err := os.WriteFile(tmpFile, []byte("not a certificate"), 0o600); err != nil { + t.Fatal(err) + } + _, err := newClient("https://example.com", "", "", tmpFile) + if err == nil { + t.Fatal("expected error for invalid CA cert") + } + if !strings.Contains(err.Error(), "no valid certificates") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestRestDoSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("missing or wrong Authorization header: %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer srv.Close() + + c := &apiClient{ + serverURL: srv.URL, + token: "test-token", + httpClient: srv.Client(), + } + + data, err := c.restDo("GET", "/v1/health", nil) + if err != nil { + t.Fatal(err) + } + + var resp struct { + Status string `json:"status"` + } + if err := json.Unmarshal(data, &resp); err != nil { + t.Fatal(err) + } + if resp.Status != "ok" { + t.Errorf("expected status=ok, got %q", resp.Status) + } +} + +func TestRestDoError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"admin role required"}`)) + })) + defer srv.Close() + + c := &apiClient{ + serverURL: srv.URL, + token: "bad-token", + httpClient: srv.Client(), + } + + _, err := c.restDo("GET", "/v1/repositories", nil) + if err == nil { + t.Fatal("expected error for 403 response") + } + if !strings.Contains(err.Error(), "admin role required") { + t.Errorf("error should contain server message: %v", err) + } + if !strings.Contains(err.Error(), "403") { + t.Errorf("error should contain status code: %v", err) + } +} + +func TestRestDoPostBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("expected application/json content type, got %s", r.Header.Get("Content-Type")) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("failed to decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"gc-123"}`)) + })) + defer srv.Close() + + c := &apiClient{ + serverURL: srv.URL, + token: "token", + httpClient: srv.Client(), + } + + data, err := c.restDo("POST", "/v1/gc", map[string]string{"test": "value"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "gc-123") { + t.Errorf("unexpected response: %s", string(data)) + } +} diff --git a/cmd/mcrctl/main.go b/cmd/mcrctl/main.go index a3b1119..8821e76 100644 --- a/cmd/mcrctl/main.go +++ b/cmd/mcrctl/main.go @@ -1,18 +1,64 @@ package main import ( + "bufio" + "context" + "encoding/json" "fmt" "os" + "strconv" + "strings" "github.com/spf13/cobra" + + mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1" +) + +var version = "dev" + +// Global flags, resolved in PersistentPreRunE. +var ( + flagServer string + flagGRPC string + flagToken string + flagCACert string + flagJSON bool + + client *apiClient ) func main() { root := &cobra.Command{ - Use: "mcrctl", - Short: "Metacircular Container Registry admin CLI", + Use: "mcrctl", + Short: "Metacircular Container Registry admin CLI", + Version: version, + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { + // Resolve token: flag overrides env. + token := flagToken + if token == "" { + token = os.Getenv("MCR_TOKEN") + } + + var err error + client, err = newClient(flagServer, flagGRPC, token, flagCACert) + if err != nil { + return err + } + return nil + }, + PersistentPostRun: func(_ *cobra.Command, _ []string) { + if client != nil { + client.close() + } + }, } + root.PersistentFlags().StringVar(&flagServer, "server", "", "REST API base URL (e.g. https://registry.example.com)") + root.PersistentFlags().StringVar(&flagGRPC, "grpc", "", "gRPC server address (e.g. registry.example.com:9443)") + root.PersistentFlags().StringVar(&flagToken, "token", "", "bearer token (fallback: MCR_TOKEN env)") + root.PersistentFlags().StringVar(&flagCACert, "ca-cert", "", "custom CA certificate PEM file") + root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON instead of table") + root.AddCommand(statusCmd()) root.AddCommand(repoCmd()) root.AddCommand(gcCmd()) @@ -25,128 +71,681 @@ func main() { } } +// ---------- status ---------- + func statusCmd() *cobra.Command { return &cobra.Command{ Use: "status", Short: "Query server health", - RunE: func(_ *cobra.Command, _ []string) error { - return fmt.Errorf("not implemented") - }, + RunE: runStatus, } } +func runStatus(_ *cobra.Command, _ []string) error { + if client.useGRPC() { + resp, err := client.admin.Health(context.Background(), &mcrv1.HealthRequest{}) + if err != nil { + return fmt.Errorf("health check failed: %w", err) + } + if flagJSON { + return printJSON(resp) + } + _, _ = fmt.Fprintf(os.Stdout, "Status: %s\n", resp.GetStatus()) + return nil + } + + data, err := client.restDo("GET", "/v1/health", nil) + if err != nil { + return fmt.Errorf("health check failed: %w", err) + } + + if flagJSON { + // Pass through raw JSON with re-indent. + var v any + if err := json.Unmarshal(data, &v); err != nil { + return err + } + return printJSON(v) + } + + var resp struct { + Status string `json:"status"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return err + } + _, _ = fmt.Fprintf(os.Stdout, "Status: %s\n", resp.Status) + return nil +} + +// ---------- repo ---------- + func repoCmd() *cobra.Command { cmd := &cobra.Command{ Use: "repo", Short: "Repository management", } - cmd.AddCommand(&cobra.Command{ + cmd.AddCommand(repoListCmd()) + cmd.AddCommand(repoDeleteCmd()) + return cmd +} + +func repoListCmd() *cobra.Command { + return &cobra.Command{ Use: "list", Short: "List repositories", - RunE: func(_ *cobra.Command, _ []string) error { - return fmt.Errorf("not implemented") - }, - }) + RunE: runRepoList, + } +} - cmd.AddCommand(&cobra.Command{ +func runRepoList(_ *cobra.Command, _ []string) error { + type repoRow struct { + Name string `json:"name"` + TagCount int `json:"tag_count"` + ManifestCount int `json:"manifest_count"` + TotalSize int64 `json:"total_size"` + CreatedAt string `json:"created_at"` + } + + if client.useGRPC() { + resp, err := client.registry.ListRepositories(context.Background(), &mcrv1.ListRepositoriesRequest{}) + if err != nil { + return fmt.Errorf("list repositories: %w", err) + } + repos := resp.GetRepositories() + if flagJSON { + return printJSON(repos) + } + rows := make([][]string, 0, len(repos)) + for _, r := range repos { + rows = append(rows, []string{ + r.GetName(), + strconv.Itoa(int(r.GetTagCount())), + strconv.Itoa(int(r.GetManifestCount())), + formatSize(r.GetTotalSize()), + r.GetCreatedAt(), + }) + } + printTable(os.Stdout, []string{"NAME", "TAGS", "MANIFESTS", "SIZE", "CREATED"}, rows) + return nil + } + + data, err := client.restDo("GET", "/v1/repositories", nil) + if err != nil { + return fmt.Errorf("list repositories: %w", err) + } + + if flagJSON { + var v any + if err := json.Unmarshal(data, &v); err != nil { + return err + } + return printJSON(v) + } + + var repos []repoRow + if err := json.Unmarshal(data, &repos); err != nil { + return err + } + + rows := make([][]string, 0, len(repos)) + for _, r := range repos { + rows = append(rows, []string{ + r.Name, + strconv.Itoa(r.TagCount), + strconv.Itoa(r.ManifestCount), + formatSize(r.TotalSize), + r.CreatedAt, + }) + } + printTable(os.Stdout, []string{"NAME", "TAGS", "MANIFESTS", "SIZE", "CREATED"}, rows) + return nil +} + +func repoDeleteCmd() *cobra.Command { + return &cobra.Command{ Use: "delete [name]", Short: "Delete a repository", Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, _ []string) error { - return fmt.Errorf("not implemented") - }, - }) - - return cmd + RunE: runRepoDelete, + } } -func gcCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "gc", - Short: "Trigger garbage collection", - RunE: func(_ *cobra.Command, _ []string) error { - return fmt.Errorf("not implemented") - }, +func runRepoDelete(_ *cobra.Command, args []string) error { + name := args[0] + + if !confirmPrompt(fmt.Sprintf("Are you sure you want to delete repository %q?", name)) { + _, _ = fmt.Fprintln(os.Stdout, "Cancelled.") + return nil } - cmd.AddCommand(&cobra.Command{ - Use: "status", - Short: "Check GC status", - RunE: func(_ *cobra.Command, _ []string) error { - return fmt.Errorf("not implemented") - }, - }) + if client.useGRPC() { + _, err := client.registry.DeleteRepository(context.Background(), &mcrv1.DeleteRepositoryRequest{Name: name}) + if err != nil { + return fmt.Errorf("delete repository: %w", err) + } + _, _ = fmt.Fprintf(os.Stdout, "Repository %q deleted.\n", name) + return nil + } - return cmd + _, err := client.restDo("DELETE", "/v1/repositories/"+name, nil) + if err != nil { + return fmt.Errorf("delete repository: %w", err) + } + + _, _ = fmt.Fprintf(os.Stdout, "Repository %q deleted.\n", name) + return nil } +// ---------- policy ---------- + func policyCmd() *cobra.Command { cmd := &cobra.Command{ Use: "policy", Short: "Policy rule management", } - cmd.AddCommand(&cobra.Command{ + cmd.AddCommand(policyListCmd()) + cmd.AddCommand(policyCreateCmd()) + cmd.AddCommand(policyUpdateCmd()) + cmd.AddCommand(policyDeleteCmd()) + return cmd +} + +func policyListCmd() *cobra.Command { + return &cobra.Command{ Use: "list", Short: "List policy rules", - RunE: func(_ *cobra.Command, _ []string) error { - return fmt.Errorf("not implemented") - }, - }) + RunE: runPolicyList, + } +} - cmd.AddCommand(&cobra.Command{ +func runPolicyList(_ *cobra.Command, _ []string) error { + type policyRow struct { + ID int64 `json:"id"` + Priority int `json:"priority"` + Description string `json:"description"` + Effect string `json:"effect"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` + } + + if client.useGRPC() { + resp, err := client.policy.ListPolicyRules(context.Background(), &mcrv1.ListPolicyRulesRequest{}) + if err != nil { + return fmt.Errorf("list policy rules: %w", err) + } + rules := resp.GetRules() + if flagJSON { + return printJSON(rules) + } + rows := make([][]string, 0, len(rules)) + for _, r := range rules { + rows = append(rows, []string{ + strconv.FormatInt(r.GetId(), 10), + strconv.Itoa(int(r.GetPriority())), + r.GetDescription(), + r.GetEffect(), + strconv.FormatBool(r.GetEnabled()), + r.GetCreatedAt(), + }) + } + printTable(os.Stdout, []string{"ID", "PRIORITY", "DESCRIPTION", "EFFECT", "ENABLED", "CREATED"}, rows) + return nil + } + + data, err := client.restDo("GET", "/v1/policy/rules", nil) + if err != nil { + return fmt.Errorf("list policy rules: %w", err) + } + + if flagJSON { + var v any + if err := json.Unmarshal(data, &v); err != nil { + return err + } + return printJSON(v) + } + + var rules []policyRow + if err := json.Unmarshal(data, &rules); err != nil { + return err + } + + rows := make([][]string, 0, len(rules)) + for _, r := range rules { + rows = append(rows, []string{ + strconv.FormatInt(r.ID, 10), + strconv.Itoa(r.Priority), + r.Description, + r.Effect, + strconv.FormatBool(r.Enabled), + r.CreatedAt, + }) + } + printTable(os.Stdout, []string{"ID", "PRIORITY", "DESCRIPTION", "EFFECT", "ENABLED", "CREATED"}, rows) + return nil +} + +func policyCreateCmd() *cobra.Command { + var ruleBody string + cmd := &cobra.Command{ Use: "create", Short: "Create a policy rule", - RunE: func(_ *cobra.Command, _ []string) error { - return fmt.Errorf("not implemented") + RunE: func(cmd *cobra.Command, _ []string) error { + return runPolicyCreate(cmd, ruleBody) }, - }) + } + cmd.Flags().StringVar(&ruleBody, "rule", "", "policy rule as JSON string") + _ = cmd.MarkFlagRequired("rule") + return cmd +} - cmd.AddCommand(&cobra.Command{ +func runPolicyCreate(_ *cobra.Command, ruleBody string) error { + if client.useGRPC() { + var req mcrv1.CreatePolicyRuleRequest + if err := json.Unmarshal([]byte(ruleBody), &req); err != nil { + return fmt.Errorf("invalid rule JSON: %w", err) + } + rule, err := client.policy.CreatePolicyRule(context.Background(), &req) + if err != nil { + return fmt.Errorf("create policy rule: %w", err) + } + if flagJSON { + return printJSON(rule) + } + _, _ = fmt.Fprintf(os.Stdout, "Policy rule created (ID: %d)\n", rule.GetId()) + return nil + } + + var body any + if err := json.Unmarshal([]byte(ruleBody), &body); err != nil { + return fmt.Errorf("invalid rule JSON: %w", err) + } + + data, err := client.restDo("POST", "/v1/policy/rules", body) + if err != nil { + return fmt.Errorf("create policy rule: %w", err) + } + + if flagJSON { + var v any + if err := json.Unmarshal(data, &v); err != nil { + return err + } + return printJSON(v) + } + + var created struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(data, &created); err != nil { + return err + } + _, _ = fmt.Fprintf(os.Stdout, "Policy rule created (ID: %d)\n", created.ID) + return nil +} + +func policyUpdateCmd() *cobra.Command { + var ruleBody string + cmd := &cobra.Command{ Use: "update [id]", Short: "Update a policy rule", Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, _ []string) error { - return fmt.Errorf("not implemented") + RunE: func(cmd *cobra.Command, args []string) error { + return runPolicyUpdate(cmd, args, ruleBody) }, - }) + } + cmd.Flags().StringVar(&ruleBody, "rule", "", "partial update as JSON string") + _ = cmd.MarkFlagRequired("rule") + return cmd +} - cmd.AddCommand(&cobra.Command{ +func runPolicyUpdate(_ *cobra.Command, args []string, ruleBody string) error { + id, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + + if client.useGRPC() { + var req mcrv1.UpdatePolicyRuleRequest + if err := json.Unmarshal([]byte(ruleBody), &req); err != nil { + return fmt.Errorf("invalid rule JSON: %w", err) + } + req.Id = id + rule, err := client.policy.UpdatePolicyRule(context.Background(), &req) + if err != nil { + return fmt.Errorf("update policy rule: %w", err) + } + if flagJSON { + return printJSON(rule) + } + _, _ = fmt.Fprintf(os.Stdout, "Policy rule %d updated.\n", rule.GetId()) + return nil + } + + var body any + if err := json.Unmarshal([]byte(ruleBody), &body); err != nil { + return fmt.Errorf("invalid rule JSON: %w", err) + } + + data, err := client.restDo("PATCH", "/v1/policy/rules/"+strconv.FormatInt(id, 10), body) + if err != nil { + return fmt.Errorf("update policy rule: %w", err) + } + + if flagJSON { + var v any + if err := json.Unmarshal(data, &v); err != nil { + return err + } + return printJSON(v) + } + + _, _ = fmt.Fprintf(os.Stdout, "Policy rule %d updated.\n", id) + return nil +} + +func policyDeleteCmd() *cobra.Command { + return &cobra.Command{ Use: "delete [id]", Short: "Delete a policy rule", Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, _ []string) error { - return fmt.Errorf("not implemented") - }, - }) - - return cmd + RunE: runPolicyDelete, + } } +func runPolicyDelete(_ *cobra.Command, args []string) error { + id, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + + if !confirmPrompt(fmt.Sprintf("Are you sure you want to delete policy rule %d?", id)) { + _, _ = fmt.Fprintln(os.Stdout, "Cancelled.") + return nil + } + + if client.useGRPC() { + _, err := client.policy.DeletePolicyRule(context.Background(), &mcrv1.DeletePolicyRuleRequest{Id: id}) + if err != nil { + return fmt.Errorf("delete policy rule: %w", err) + } + _, _ = fmt.Fprintf(os.Stdout, "Policy rule %d deleted.\n", id) + return nil + } + + _, err = client.restDo("DELETE", "/v1/policy/rules/"+strconv.FormatInt(id, 10), nil) + if err != nil { + return fmt.Errorf("delete policy rule: %w", err) + } + + _, _ = fmt.Fprintf(os.Stdout, "Policy rule %d deleted.\n", id) + return nil +} + +// ---------- audit ---------- + func auditCmd() *cobra.Command { cmd := &cobra.Command{ Use: "audit", Short: "Audit log management", } - cmd.AddCommand(&cobra.Command{ + cmd.AddCommand(auditTailCmd()) + return cmd +} + +func auditTailCmd() *cobra.Command { + var n int + var eventType string + cmd := &cobra.Command{ Use: "tail", Short: "Print recent audit events", RunE: func(_ *cobra.Command, _ []string) error { - return fmt.Errorf("not implemented") + return runAuditTail(n, eventType) }, - }) - + } + cmd.Flags().IntVarP(&n, "n", "n", 50, "number of events to retrieve") + cmd.Flags().StringVar(&eventType, "event-type", "", "filter by event type") return cmd } +func runAuditTail(n int, eventType string) error { + type auditRow struct { + ID int64 `json:"id"` + EventTime string `json:"event_time"` + EventType string `json:"event_type"` + ActorID string `json:"actor_id"` + Repository string `json:"repository"` + Digest string `json:"digest"` + IPAddress string `json:"ip_address"` + Details map[string]string `json:"details"` + } + + if client.useGRPC() { + req := &mcrv1.ListAuditEventsRequest{ + Pagination: &mcrv1.PaginationRequest{Limit: int32(n)}, //nolint:gosec // n is user-provided flag with small values + } + if eventType != "" { + req.EventType = eventType + } + resp, err := client.audit.ListAuditEvents(context.Background(), req) + if err != nil { + return fmt.Errorf("list audit events: %w", err) + } + events := resp.GetEvents() + if flagJSON { + return printJSON(events) + } + rows := make([][]string, 0, len(events)) + for _, e := range events { + rows = append(rows, []string{ + strconv.FormatInt(e.GetId(), 10), + e.GetEventTime(), + e.GetEventType(), + e.GetActorId(), + e.GetRepository(), + }) + } + printTable(os.Stdout, []string{"ID", "TIME", "TYPE", "ACTOR", "REPOSITORY"}, rows) + return nil + } + + path := fmt.Sprintf("/v1/audit?n=%d", n) + if eventType != "" { + path += "&event_type=" + eventType + } + data, err := client.restDo("GET", path, nil) + if err != nil { + return fmt.Errorf("list audit events: %w", err) + } + + if flagJSON { + var v any + if err := json.Unmarshal(data, &v); err != nil { + return err + } + return printJSON(v) + } + + var events []auditRow + if err := json.Unmarshal(data, &events); err != nil { + return err + } + + rows := make([][]string, 0, len(events)) + for _, e := range events { + rows = append(rows, []string{ + strconv.FormatInt(e.ID, 10), + e.EventTime, + e.EventType, + e.ActorID, + e.Repository, + }) + } + printTable(os.Stdout, []string{"ID", "TIME", "TYPE", "ACTOR", "REPOSITORY"}, rows) + return nil +} + +// ---------- gc ---------- + +func gcCmd() *cobra.Command { + var reconcile bool + cmd := &cobra.Command{ + Use: "gc", + Short: "Trigger garbage collection", + RunE: func(_ *cobra.Command, _ []string) error { + return runGC(reconcile) + }, + } + cmd.Flags().BoolVar(&reconcile, "reconcile", false, "run reconciliation instead of normal GC") + + cmd.AddCommand(gcStatusCmd()) + return cmd +} + +func runGC(reconcile bool) error { + if client.useGRPC() { + resp, err := client.registry.GarbageCollect(context.Background(), &mcrv1.GarbageCollectRequest{}) + if err != nil { + return fmt.Errorf("trigger gc: %w", err) + } + if flagJSON { + return printJSON(resp) + } + _, _ = fmt.Fprintf(os.Stdout, "Garbage collection started (ID: %s)\n", resp.GetId()) + return nil + } + + path := "/v1/gc" + if reconcile { + path += "?reconcile=true" + } + data, err := client.restDo("POST", path, nil) + if err != nil { + return fmt.Errorf("trigger gc: %w", err) + } + + if flagJSON { + var v any + if err := json.Unmarshal(data, &v); err != nil { + return err + } + return printJSON(v) + } + + var resp struct { + ID string `json:"id"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return err + } + _, _ = fmt.Fprintf(os.Stdout, "Garbage collection started (ID: %s)\n", resp.ID) + return nil +} + +func gcStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Check GC status", + RunE: runGCStatus, + } +} + +func runGCStatus(_ *cobra.Command, _ []string) error { + if client.useGRPC() { + resp, err := client.registry.GetGCStatus(context.Background(), &mcrv1.GetGCStatusRequest{}) + if err != nil { + return fmt.Errorf("gc status: %w", err) + } + if flagJSON { + return printJSON(resp) + } + _, _ = fmt.Fprintf(os.Stdout, "Running: %v\n", resp.GetRunning()) + if lr := resp.GetLastRun(); lr != nil { + _, _ = fmt.Fprintf(os.Stdout, "Last run: %s - %s\n", lr.GetStartedAt(), lr.GetCompletedAt()) + _, _ = fmt.Fprintf(os.Stdout, " Blobs removed: %d\n", lr.GetBlobsRemoved()) + _, _ = fmt.Fprintf(os.Stdout, " Bytes freed: %s\n", formatSize(lr.GetBytesFreed())) + } + return nil + } + + data, err := client.restDo("GET", "/v1/gc/status", nil) + if err != nil { + return fmt.Errorf("gc status: %w", err) + } + + if flagJSON { + var v any + if err := json.Unmarshal(data, &v); err != nil { + return err + } + return printJSON(v) + } + + var resp struct { + Running bool `json:"running"` + LastRun *struct { + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + BlobsRemoved int `json:"blobs_removed"` + BytesFreed int64 `json:"bytes_freed"` + } `json:"last_run"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return err + } + + _, _ = fmt.Fprintf(os.Stdout, "Running: %v\n", resp.Running) + if resp.LastRun != nil { + _, _ = fmt.Fprintf(os.Stdout, "Last run: %s - %s\n", resp.LastRun.StartedAt, resp.LastRun.CompletedAt) + _, _ = fmt.Fprintf(os.Stdout, " Blobs removed: %d\n", resp.LastRun.BlobsRemoved) + _, _ = fmt.Fprintf(os.Stdout, " Bytes freed: %s\n", formatSize(resp.LastRun.BytesFreed)) + } + return nil +} + +// ---------- snapshot ---------- + func snapshotCmd() *cobra.Command { return &cobra.Command{ Use: "snapshot", Short: "Trigger database backup via VACUUM INTO", - RunE: func(_ *cobra.Command, _ []string) error { - return fmt.Errorf("not implemented") - }, + RunE: runSnapshot, } } + +func runSnapshot(_ *cobra.Command, _ []string) error { + data, err := client.restDo("POST", "/v1/snapshot", nil) + if err != nil { + return fmt.Errorf("trigger snapshot: %w", err) + } + + if flagJSON { + var v any + if err := json.Unmarshal(data, &v); err != nil { + // Snapshot may return 204 with no body. + return nil + } + return printJSON(v) + } + + _, _ = fmt.Fprintln(os.Stdout, "Snapshot triggered.") + _ = data // may be empty + return nil +} + +// ---------- helpers ---------- + +// confirmPrompt displays msg and waits for y/n from stdin. +func confirmPrompt(msg string) bool { + fmt.Fprintf(os.Stderr, "%s [y/N] ", msg) + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + return answer == "y" || answer == "yes" + } + return false +} diff --git a/cmd/mcrctl/output.go b/cmd/mcrctl/output.go new file mode 100644 index 0000000..20691a5 --- /dev/null +++ b/cmd/mcrctl/output.go @@ -0,0 +1,65 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "text/tabwriter" +) + +// formatSize returns a human-friendly representation of a byte count. +func formatSize(bytes int64) string { + const ( + kb = 1024 + mb = kb * 1024 + gb = mb * 1024 + tb = gb * 1024 + ) + + switch { + case bytes >= tb: + return fmt.Sprintf("%.1f TB", float64(bytes)/float64(tb)) + case bytes >= gb: + return fmt.Sprintf("%.1f GB", float64(bytes)/float64(gb)) + case bytes >= mb: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) + case bytes >= kb: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) + default: + return fmt.Sprintf("%d B", bytes) + } +} + +// printJSON marshals v as indented JSON and writes it to stdout. +func printJSON(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// printTable writes a table with the given headers and rows. +// Each row must have the same number of columns as headers. +func printTable(w io.Writer, headers []string, rows [][]string) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + // Print header. + for i, h := range headers { + if i > 0 { + _, _ = fmt.Fprint(tw, "\t") + } + _, _ = fmt.Fprint(tw, h) + } + _, _ = fmt.Fprintln(tw) + + // Print rows. + for _, row := range rows { + for i, col := range row { + if i > 0 { + _, _ = fmt.Fprint(tw, "\t") + } + _, _ = fmt.Fprint(tw, col) + } + _, _ = fmt.Fprintln(tw) + } + _ = tw.Flush() +} diff --git a/internal/webserver/auth.go b/internal/webserver/auth.go new file mode 100644 index 0000000..8b8f00e --- /dev/null +++ b/internal/webserver/auth.go @@ -0,0 +1,184 @@ +package webserver + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "log" + "net/http" + "strings" +) + +// sessionKey is the context key for the session token. +type sessionKey struct{} + +// tokenFromContext retrieves the bearer token from context. +func tokenFromContext(ctx context.Context) string { + s, _ := ctx.Value(sessionKey{}).(string) + return s +} + +// contextWithToken stores a bearer token in the context. +func contextWithToken(ctx context.Context, token string) context.Context { + return context.WithValue(ctx, sessionKey{}, token) +} + +// sessionMiddleware checks for a valid mcr_session cookie and adds the +// token to the request context. If no session is present, it redirects +// to the login page. +func (s *Server) sessionMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("mcr_session") + if err != nil || cookie.Value == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + ctx := contextWithToken(r.Context(), cookie.Value) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// handleLoginPage renders the login form. +func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { + csrf := s.generateCSRFToken(w) + s.templates.render(w, "login", map[string]any{ + "CSRFToken": csrf, + "Session": false, + }) +} + +// handleLoginSubmit processes the login form. +func (s *Server) handleLoginSubmit(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) { + csrf := s.generateCSRFToken(w) + s.templates.render(w, "login", map[string]any{ + "Error": "Invalid or expired form submission. Please try again.", + "CSRFToken": csrf, + "Session": false, + }) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + + if username == "" || password == "" { + csrf := s.generateCSRFToken(w) + s.templates.render(w, "login", map[string]any{ + "Error": "Username and password are required.", + "CSRFToken": csrf, + "Session": false, + }) + return + } + + token, _, err := s.loginFn(username, password) + if err != nil { + log.Printf("login failed for user %q: %v", username, err) + csrf := s.generateCSRFToken(w) + s.templates.render(w, "login", map[string]any{ + "Error": "Invalid username or password.", + "CSRFToken": csrf, + "Session": false, + }) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "mcr_session", + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) + + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// handleLogout clears the session and redirects to login. +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "mcr_session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +// generateCSRFToken creates a random token, signs it with HMAC, stores +// the signed value in a cookie, and returns the token for form embedding. +func (s *Server) generateCSRFToken(w http.ResponseWriter) string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // Crypto RNG failure is fatal; this should never happen. + log.Printf("csrf: failed to generate random bytes: %v", err) + return "" + } + + token := hex.EncodeToString(b) + sig := s.signCSRF(token) + cookieVal := token + "." + sig + + http.SetCookie(w, &http.Cookie{ + Name: "csrf_token", + Value: cookieVal, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) + + return token +} + +// validateCSRFToken verifies the form _csrf field matches the cookie and +// the HMAC signature is valid. +func (s *Server) validateCSRFToken(r *http.Request) bool { + formToken := r.FormValue("_csrf") + if formToken == "" { + return false + } + + cookie, err := r.Cookie("csrf_token") + if err != nil || cookie.Value == "" { + return false + } + + parts := strings.SplitN(cookie.Value, ".", 2) + if len(parts) != 2 { + return false + } + + cookieToken := parts[0] + cookieSig := parts[1] + + // Verify the form token matches the cookie token. + if !hmac.Equal([]byte(formToken), []byte(cookieToken)) { + return false + } + + // Verify the HMAC signature. + expectedSig := s.signCSRF(cookieToken) + return hmac.Equal([]byte(cookieSig), []byte(expectedSig)) +} + +// signCSRF computes an HMAC-SHA256 signature for a CSRF token. +func (s *Server) signCSRF(token string) string { + mac := hmac.New(sha256.New, s.csrfKey) + mac.Write([]byte(token)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/webserver/handlers.go b/internal/webserver/handlers.go new file mode 100644 index 0000000..919c9f1 --- /dev/null +++ b/internal/webserver/handlers.go @@ -0,0 +1,457 @@ +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 +} diff --git a/internal/webserver/server.go b/internal/webserver/server.go new file mode 100644 index 0000000..f24c9b6 --- /dev/null +++ b/internal/webserver/server.go @@ -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 +} diff --git a/internal/webserver/server_test.go b/internal/webserver/server_test.go new file mode 100644 index 0000000..678e478 --- /dev/null +++ b/internal/webserver/server_test.go @@ -0,0 +1,608 @@ +package webserver + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + + mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1" +) + +// fakeRegistryService implements RegistryServiceServer for testing. +type fakeRegistryService struct { + mcrv1.UnimplementedRegistryServiceServer + repos []*mcrv1.RepositoryMetadata + repoResp *mcrv1.GetRepositoryResponse + repoErr error +} + +func (f *fakeRegistryService) ListRepositories(_ context.Context, _ *mcrv1.ListRepositoriesRequest) (*mcrv1.ListRepositoriesResponse, error) { + return &mcrv1.ListRepositoriesResponse{Repositories: f.repos}, nil +} + +func (f *fakeRegistryService) GetRepository(_ context.Context, req *mcrv1.GetRepositoryRequest) (*mcrv1.GetRepositoryResponse, error) { + if f.repoErr != nil { + return nil, f.repoErr + } + if f.repoResp != nil { + return f.repoResp, nil + } + return &mcrv1.GetRepositoryResponse{Name: req.GetName()}, nil +} + +// fakePolicyService implements PolicyServiceServer for testing. +type fakePolicyService struct { + mcrv1.UnimplementedPolicyServiceServer + rules []*mcrv1.PolicyRule + created *mcrv1.PolicyRule +} + +func (f *fakePolicyService) ListPolicyRules(_ context.Context, _ *mcrv1.ListPolicyRulesRequest) (*mcrv1.ListPolicyRulesResponse, error) { + return &mcrv1.ListPolicyRulesResponse{Rules: f.rules}, nil +} + +func (f *fakePolicyService) CreatePolicyRule(_ context.Context, req *mcrv1.CreatePolicyRuleRequest) (*mcrv1.PolicyRule, error) { + rule := &mcrv1.PolicyRule{ + Id: 1, + Priority: req.GetPriority(), + Description: req.GetDescription(), + Effect: req.GetEffect(), + Actions: req.GetActions(), + Repositories: req.GetRepositories(), + Enabled: req.GetEnabled(), + } + f.created = rule + return rule, nil +} + +func (f *fakePolicyService) GetPolicyRule(_ context.Context, req *mcrv1.GetPolicyRuleRequest) (*mcrv1.PolicyRule, error) { + for _, r := range f.rules { + if r.GetId() == req.GetId() { + return r, nil + } + } + return nil, status.Errorf(codes.NotFound, "policy rule not found") +} + +func (f *fakePolicyService) UpdatePolicyRule(_ context.Context, req *mcrv1.UpdatePolicyRuleRequest) (*mcrv1.PolicyRule, error) { + for _, r := range f.rules { + if r.GetId() == req.GetId() { + r.Enabled = req.GetEnabled() + return r, nil + } + } + return nil, status.Errorf(codes.NotFound, "policy rule not found") +} + +func (f *fakePolicyService) DeletePolicyRule(_ context.Context, req *mcrv1.DeletePolicyRuleRequest) (*mcrv1.DeletePolicyRuleResponse, error) { + for i, r := range f.rules { + if r.GetId() == req.GetId() { + f.rules = append(f.rules[:i], f.rules[i+1:]...) + return &mcrv1.DeletePolicyRuleResponse{}, nil + } + } + return nil, status.Errorf(codes.NotFound, "policy rule not found") +} + +// fakeAuditService implements AuditServiceServer for testing. +type fakeAuditService struct { + mcrv1.UnimplementedAuditServiceServer + events []*mcrv1.AuditEvent +} + +func (f *fakeAuditService) ListAuditEvents(_ context.Context, _ *mcrv1.ListAuditEventsRequest) (*mcrv1.ListAuditEventsResponse, error) { + return &mcrv1.ListAuditEventsResponse{Events: f.events}, nil +} + +// fakeAdminService implements AdminServiceServer for testing. +type fakeAdminService struct { + mcrv1.UnimplementedAdminServiceServer +} + +func (f *fakeAdminService) Health(_ context.Context, _ *mcrv1.HealthRequest) (*mcrv1.HealthResponse, error) { + return &mcrv1.HealthResponse{Status: "ok"}, nil +} + +// testEnv holds a test server and its dependencies. +type testEnv struct { + server *Server + grpcServer *grpc.Server + grpcConn *grpc.ClientConn + registry *fakeRegistryService + policyFake *fakePolicyService + auditFake *fakeAuditService +} + +func (e *testEnv) close() { + _ = e.grpcConn.Close() + e.grpcServer.Stop() +} + +// setupTestEnv creates a test environment with fake gRPC backends. +func setupTestEnv(t *testing.T) *testEnv { + t.Helper() + + registrySvc := &fakeRegistryService{ + repos: []*mcrv1.RepositoryMetadata{ + {Name: "library/nginx", TagCount: 3, ManifestCount: 2, TotalSize: 1024 * 1024, CreatedAt: "2024-01-15T10:00:00Z"}, + {Name: "library/alpine", TagCount: 1, ManifestCount: 1, TotalSize: 512 * 1024, CreatedAt: "2024-01-16T10:00:00Z"}, + }, + } + policySvc := &fakePolicyService{ + rules: []*mcrv1.PolicyRule{ + {Id: 1, Priority: 100, Description: "Allow all pulls", Effect: "allow", Actions: []string{"pull"}, Repositories: []string{"*"}, Enabled: true}, + }, + } + auditSvc := &fakeAuditService{ + events: []*mcrv1.AuditEvent{ + {Id: 1, EventTime: "2024-01-15T12:00:00Z", EventType: "manifest_pushed", ActorId: "user1", Repository: "library/nginx", Digest: "sha256:abc123", IpAddress: "10.0.0.1"}, + }, + } + adminSvc := &fakeAdminService{} + + // Start in-process gRPC server. + gs := grpc.NewServer() + mcrv1.RegisterRegistryServiceServer(gs, registrySvc) + mcrv1.RegisterPolicyServiceServer(gs, policySvc) + mcrv1.RegisterAuditServiceServer(gs, auditSvc) + mcrv1.RegisterAdminServiceServer(gs, adminSvc) + + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + go func() { + _ = gs.Serve(lis) + }() + + // Connect client. + conn, err := grpc.NewClient( + lis.Addr().String(), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithDefaultCallOptions(grpc.ForceCodecV2(mcrv1.JSONCodec{})), + ) + if err != nil { + gs.Stop() + t.Fatalf("dial: %v", err) + } + + csrfKey := []byte("test-csrf-key-32-bytes-long!1234") + + loginFn := func(username, password string) (string, int, error) { + if username == "admin" && password == "secret" { + return "test-token-12345", 3600, nil + } + return "", 0, fmt.Errorf("invalid credentials") + } + + srv, err := New( + mcrv1.NewRegistryServiceClient(conn), + mcrv1.NewPolicyServiceClient(conn), + mcrv1.NewAuditServiceClient(conn), + mcrv1.NewAdminServiceClient(conn), + loginFn, + csrfKey, + ) + if err != nil { + _ = conn.Close() + gs.Stop() + t.Fatalf("create server: %v", err) + } + + return &testEnv{ + server: srv, + grpcServer: gs, + grpcConn: conn, + registry: registrySvc, + policyFake: policySvc, + auditFake: auditSvc, + } +} + +func TestLoginPageRenders(t *testing.T) { + env := setupTestEnv(t) + defer env.close() + + req := httptest.NewRequest(http.MethodGet, "/login", nil) + rec := httptest.NewRecorder() + + env.server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /login: status %d, want %d", rec.Code, http.StatusOK) + } + + body := rec.Body.String() + if !strings.Contains(body, "MCR Login") { + t.Error("login page does not contain 'MCR Login'") + } + if !strings.Contains(body, "_csrf") { + t.Error("login page does not contain CSRF token field") + } +} + +func TestLoginInvalidCredentials(t *testing.T) { + env := setupTestEnv(t) + defer env.close() + + // First get a CSRF token. + getReq := httptest.NewRequest(http.MethodGet, "/login", nil) + getRec := httptest.NewRecorder() + env.server.Handler().ServeHTTP(getRec, getReq) + + // Extract CSRF cookie and token. + var csrfCookie *http.Cookie + for _, c := range getRec.Result().Cookies() { + if c.Name == "csrf_token" { + csrfCookie = c + break + } + } + if csrfCookie == nil { + t.Fatal("no csrf_token cookie set") + } + + // Extract the CSRF token from the cookie value (token.signature). + parts := strings.SplitN(csrfCookie.Value, ".", 2) + csrfToken := parts[0] + + // Submit login with wrong credentials. + form := url.Values{ + "username": {"baduser"}, + "password": {"badpass"}, + "_csrf": {csrfToken}, + } + + postReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + postReq.AddCookie(csrfCookie) + postRec := httptest.NewRecorder() + + env.server.Handler().ServeHTTP(postRec, postReq) + + if postRec.Code != http.StatusOK { + t.Fatalf("POST /login: status %d, want %d", postRec.Code, http.StatusOK) + } + + body := postRec.Body.String() + if !strings.Contains(body, "Invalid username or password") { + t.Error("response does not contain error message for invalid credentials") + } +} + +func TestDashboardRequiresSession(t *testing.T) { + env := setupTestEnv(t) + defer env.close() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + env.server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("GET / without session: status %d, want %d", rec.Code, http.StatusSeeOther) + } + + loc := rec.Header().Get("Location") + if loc != "/login" { + t.Fatalf("redirect location: got %q, want /login", loc) + } +} + +func TestDashboardWithSession(t *testing.T) { + env := setupTestEnv(t) + defer env.close() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "mcr_session", Value: "test-token"}) + rec := httptest.NewRecorder() + + env.server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET / with session: status %d, want %d", rec.Code, http.StatusOK) + } + + body := rec.Body.String() + if !strings.Contains(body, "Dashboard") { + t.Error("dashboard page does not contain 'Dashboard'") + } + if !strings.Contains(body, "Repositories") { + t.Error("dashboard page does not show repository count") + } +} + +func TestRepositoriesPageRenders(t *testing.T) { + env := setupTestEnv(t) + defer env.close() + + req := httptest.NewRequest(http.MethodGet, "/repositories", nil) + req.AddCookie(&http.Cookie{Name: "mcr_session", Value: "test-token"}) + rec := httptest.NewRecorder() + + env.server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /repositories: status %d, want %d", rec.Code, http.StatusOK) + } + + body := rec.Body.String() + if !strings.Contains(body, "library/nginx") { + t.Error("repositories page does not contain 'library/nginx'") + } + if !strings.Contains(body, "library/alpine") { + t.Error("repositories page does not contain 'library/alpine'") + } +} + +func TestRepositoryDetailRenders(t *testing.T) { + env := setupTestEnv(t) + defer env.close() + + env.registry.repoResp = &mcrv1.GetRepositoryResponse{ + Name: "library/nginx", + TotalSize: 2048, + Tags: []*mcrv1.TagInfo{ + {Name: "latest", Digest: "sha256:abc123def456"}, + }, + Manifests: []*mcrv1.ManifestInfo{ + {Digest: "sha256:abc123def456", MediaType: "application/vnd.oci.image.manifest.v1+json", Size: 2048, CreatedAt: "2024-01-15T10:00:00Z"}, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/repositories/library/nginx", nil) + req.AddCookie(&http.Cookie{Name: "mcr_session", Value: "test-token"}) + rec := httptest.NewRecorder() + + env.server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /repositories/library/nginx: status %d, want %d", rec.Code, http.StatusOK) + } + + body := rec.Body.String() + if !strings.Contains(body, "library/nginx") { + t.Error("repository detail page does not contain repo name") + } + if !strings.Contains(body, "latest") { + t.Error("repository detail page does not contain tag 'latest'") + } +} + +func TestCSRFTokenValidation(t *testing.T) { + env := setupTestEnv(t) + defer env.close() + + // POST without CSRF token should fail. + form := url.Values{ + "username": {"admin"}, + "password": {"secret"}, + } + + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + env.server.Handler().ServeHTTP(rec, req) + + body := rec.Body.String() + // Should show the error about invalid form submission. + if !strings.Contains(body, "Invalid or expired form submission") { + t.Error("POST without CSRF token should show error, got: " + body[:min(200, len(body))]) + } +} + +func TestLogout(t *testing.T) { + env := setupTestEnv(t) + defer env.close() + + req := httptest.NewRequest(http.MethodGet, "/logout", nil) + req.AddCookie(&http.Cookie{Name: "mcr_session", Value: "test-token"}) + rec := httptest.NewRecorder() + + env.server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("GET /logout: status %d, want %d", rec.Code, http.StatusSeeOther) + } + + loc := rec.Header().Get("Location") + if loc != "/login" { + t.Fatalf("redirect location: got %q, want /login", loc) + } + + // Verify session cookie is cleared. + var sessionCleared bool + for _, c := range rec.Result().Cookies() { + if c.Name == "mcr_session" && c.MaxAge < 0 { + sessionCleared = true + break + } + } + if !sessionCleared { + t.Error("session cookie was not cleared on logout") + } +} + +func TestPoliciesPage(t *testing.T) { + env := setupTestEnv(t) + defer env.close() + + req := httptest.NewRequest(http.MethodGet, "/policies", nil) + req.AddCookie(&http.Cookie{Name: "mcr_session", Value: "test-token"}) + rec := httptest.NewRecorder() + + env.server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /policies: status %d, want %d", rec.Code, http.StatusOK) + } + + body := rec.Body.String() + if !strings.Contains(body, "Allow all pulls") { + t.Error("policies page does not contain policy description") + } +} + +func TestAuditPage(t *testing.T) { + env := setupTestEnv(t) + defer env.close() + + req := httptest.NewRequest(http.MethodGet, "/audit", nil) + req.AddCookie(&http.Cookie{Name: "mcr_session", Value: "test-token"}) + rec := httptest.NewRecorder() + + env.server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /audit: status %d, want %d", rec.Code, http.StatusOK) + } + + body := rec.Body.String() + if !strings.Contains(body, "manifest_pushed") { + t.Error("audit page does not contain event type") + } +} + +func TestStaticFiles(t *testing.T) { + env := setupTestEnv(t) + defer env.close() + + req := httptest.NewRequest(http.MethodGet, "/static/style.css", nil) + rec := httptest.NewRecorder() + + env.server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /static/style.css: status %d, want %d", rec.Code, http.StatusOK) + } + + body := rec.Body.String() + if !strings.Contains(body, "font-family") { + t.Error("style.css does not appear to contain CSS") + } +} + +func TestFormatSize(t *testing.T) { + tests := []struct { + input int64 + want string + }{ + {0, "0 B"}, + {512, "512 B"}, + {1024, "1.0 KiB"}, + {1048576, "1.0 MiB"}, + {1073741824, "1.0 GiB"}, + {1099511627776, "1.0 TiB"}, + } + + for _, tt := range tests { + got := formatSize(tt.input) + if got != tt.want { + t.Errorf("formatSize(%d) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestFormatTime(t *testing.T) { + got := formatTime("2024-01-15T10:30:00Z") + want := "2024-01-15 10:30:00" + if got != want { + t.Errorf("formatTime = %q, want %q", got, want) + } + + // Invalid time returns the input. + got = formatTime("not-a-time") + if got != "not-a-time" { + t.Errorf("formatTime(invalid) = %q, want %q", got, "not-a-time") + } +} + +func TestTruncate(t *testing.T) { + got := truncate("sha256:abc123def456", 12) + want := "sha256:abc12..." + if got != want { + t.Errorf("truncate = %q, want %q", got, want) + } + + // Short strings are not truncated. + got = truncate("short", 10) + if got != "short" { + t.Errorf("truncate(short) = %q, want %q", got, "short") + } +} + +func TestLoginSuccessSetsCookie(t *testing.T) { + env := setupTestEnv(t) + defer env.close() + + // Get CSRF token. + getReq := httptest.NewRequest(http.MethodGet, "/login", nil) + getRec := httptest.NewRecorder() + env.server.Handler().ServeHTTP(getRec, getReq) + + var csrfCookie *http.Cookie + for _, c := range getRec.Result().Cookies() { + if c.Name == "csrf_token" { + csrfCookie = c + break + } + } + if csrfCookie == nil { + t.Fatal("no csrf_token cookie") + } + + parts := strings.SplitN(csrfCookie.Value, ".", 2) + csrfToken := parts[0] + + form := url.Values{ + "username": {"admin"}, + "password": {"secret"}, + "_csrf": {csrfToken}, + } + + postReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + postReq.AddCookie(csrfCookie) + postRec := httptest.NewRecorder() + + env.server.Handler().ServeHTTP(postRec, postReq) + + if postRec.Code != http.StatusSeeOther { + t.Fatalf("POST /login: status %d, want %d; body: %s", postRec.Code, http.StatusSeeOther, postRec.Body.String()) + } + + var sessionCookie *http.Cookie + for _, c := range postRec.Result().Cookies() { + if c.Name == "mcr_session" { + sessionCookie = c + break + } + } + if sessionCookie == nil { + t.Fatal("no mcr_session cookie set after login") + } + if sessionCookie.Value != "test-token-12345" { + t.Errorf("session cookie value = %q, want %q", sessionCookie.Value, "test-token-12345") + } + if !sessionCookie.HttpOnly { + t.Error("session cookie is not HttpOnly") + } + if !sessionCookie.Secure { + t.Error("session cookie is not Secure") + } + if sessionCookie.SameSite != http.SameSiteStrictMode { + t.Error("session cookie SameSite is not Strict") + } +} diff --git a/internal/webserver/templates.go b/internal/webserver/templates.go new file mode 100644 index 0000000..191b8b2 --- /dev/null +++ b/internal/webserver/templates.go @@ -0,0 +1,136 @@ +package webserver + +import ( + "fmt" + "html/template" + "io/fs" + "net/http" + "strings" + "time" + + "git.wntrmute.dev/kyle/mcr/web" +) + +// templateSet wraps parsed templates and provides a render method. +type templateSet struct { + templates map[string]*template.Template +} + +// templateFuncs returns the function map used across all templates. +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "formatSize": formatSize, + "formatTime": formatTime, + "truncate": truncate, + "joinStrings": joinStrings, + } +} + +// loadTemplates parses all page templates with the layout template. +func loadTemplates() (*templateSet, error) { + // Read layout template. + layoutBytes, err := fs.ReadFile(web.Content, "templates/layout.html") + if err != nil { + return nil, fmt.Errorf("webserver: read layout template: %w", err) + } + layoutStr := string(layoutBytes) + + pages := []string{ + "login", + "dashboard", + "repositories", + "repository_detail", + "manifest_detail", + "policies", + "audit", + } + + ts := &templateSet{ + templates: make(map[string]*template.Template, len(pages)), + } + + for _, page := range pages { + pageBytes, readErr := fs.ReadFile(web.Content, "templates/"+page+".html") + if readErr != nil { + return nil, fmt.Errorf("webserver: read template %s: %w", page, readErr) + } + + t, parseErr := template.New("layout").Funcs(templateFuncs()).Parse(layoutStr) + if parseErr != nil { + return nil, fmt.Errorf("webserver: parse layout for %s: %w", page, parseErr) + } + + _, parseErr = t.Parse(string(pageBytes)) + if parseErr != nil { + return nil, fmt.Errorf("webserver: parse template %s: %w", page, parseErr) + } + + ts.templates[page] = t + } + + return ts, nil +} + +// render executes a named template and writes the result to w. +func (ts *templateSet) render(w http.ResponseWriter, name string, data any) { + t, ok := ts.templates[name] + if !ok { + http.Error(w, "template not found", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.Execute(w, data); err != nil { + // Template already started writing; log but don't send another error. + _ = err // best-effort; headers may already be sent + } +} + +// formatSize converts bytes to a human-readable string. +func formatSize(b int64) string { + const ( + kib = 1024 + mib = 1024 * kib + gib = 1024 * mib + tib = 1024 * gib + ) + + switch { + case b >= tib: + return fmt.Sprintf("%.1f TiB", float64(b)/float64(tib)) + case b >= gib: + return fmt.Sprintf("%.1f GiB", float64(b)/float64(gib)) + case b >= mib: + return fmt.Sprintf("%.1f MiB", float64(b)/float64(mib)) + case b >= kib: + return fmt.Sprintf("%.1f KiB", float64(b)/float64(kib)) + default: + return fmt.Sprintf("%d B", b) + } +} + +// formatTime converts an RFC3339 string to a more readable format. +func formatTime(s string) string { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + // Try RFC3339Nano. + t, err = time.Parse(time.RFC3339Nano, s) + if err != nil { + return s + } + } + return t.Format("2006-01-02 15:04:05") +} + +// truncate returns the first n characters of s, appending "..." if truncated. +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// joinStrings joins string slices for template rendering. +func joinStrings(ss []string, sep string) string { + return strings.Join(ss, sep) +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..d6dbf66 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,6 @@ +package web + +import "embed" + +//go:embed templates static +var Content embed.FS diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..2900f3c --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,404 @@ +/* MCR Web UI - minimal clean styling */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", + "Helvetica Neue", sans-serif; + margin: 0; + padding: 0; + color: #1a1a1a; + background: #f5f5f5; + line-height: 1.5; +} + +a { + color: #0066cc; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Navigation */ +nav { + background: #1a1a2e; + color: #fff; + padding: 0 1rem; + display: flex; + align-items: center; + gap: 1.5rem; + height: 3.5rem; +} + +nav .brand { + font-weight: 700; + font-size: 1.125rem; + color: #fff; + margin-right: 1rem; +} + +nav a { + color: #ccc; + font-size: 0.875rem; +} + +nav a:hover, +nav a.active { + color: #fff; + text-decoration: none; +} + +nav .spacer { + flex: 1; +} + +nav .logout { + color: #e57373; +} + +/* Main container */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 1.5rem; +} + +/* Page header */ +h1 { + font-size: 1.5rem; + margin: 0 0 1rem 0; + font-weight: 600; +} + +h2 { + font-size: 1.25rem; + margin: 1.5rem 0 0.75rem 0; + font-weight: 600; +} + +/* Stats cards */ +.stats { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.stat-card { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 6px; + padding: 1rem 1.5rem; + min-width: 180px; + flex: 1; +} + +.stat-card .label { + font-size: 0.75rem; + text-transform: uppercase; + color: #666; + letter-spacing: 0.05em; +} + +.stat-card .value { + font-size: 1.75rem; + font-weight: 700; + color: #1a1a2e; +} + +/* Tables */ +table { + width: 100%; + border-collapse: collapse; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 6px; + overflow: hidden; + margin-bottom: 1.5rem; +} + +th, +td { + padding: 0.625rem 0.875rem; + text-align: left; + border-bottom: 1px solid #e0e0e0; +} + +th { + background: #f9f9f9; + font-weight: 600; + font-size: 0.8125rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #555; +} + +tr:last-child td { + border-bottom: none; +} + +tr:nth-child(even) td { + background: #fafafa; +} + +/* Forms */ +form { + margin-bottom: 1rem; +} + +label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.25rem; + color: #333; +} + +input[type="text"], +input[type="password"], +input[type="number"], +input[type="date"], +select, +textarea { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.875rem; + font-family: inherit; +} + +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: #0066cc; + box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2); +} + +/* Buttons */ +button, +input[type="submit"] { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + font-size: 0.875rem; + font-family: inherit; + cursor: pointer; + background: #0066cc; + color: #fff; + font-weight: 500; +} + +button:hover, +input[type="submit"]:hover { + background: #0052a3; +} + +button.secondary { + background: #666; +} + +button.secondary:hover { + background: #555; +} + +button.danger { + background: #d32f2f; +} + +button.danger:hover { + background: #b71c1c; +} + +button.small { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; +} + +/* Login page */ +.login-container { + max-width: 400px; + margin: 5rem auto; + padding: 2rem; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; +} + +.login-container h1 { + text-align: center; + margin-bottom: 1.5rem; +} + +.login-container .form-group { + margin-bottom: 1rem; +} + +.login-container button { + width: 100%; + padding: 0.75rem; + font-size: 1rem; + margin-top: 0.5rem; +} + +/* Error and info messages */ +.error { + background: #ffebee; + color: #c62828; + padding: 0.75rem 1rem; + border-radius: 4px; + margin-bottom: 1rem; + border: 1px solid #ef9a9a; + font-size: 0.875rem; +} + +.info { + background: #e3f2fd; + color: #1565c0; + padding: 0.75rem 1rem; + border-radius: 4px; + margin-bottom: 1rem; + border: 1px solid #90caf9; + font-size: 0.875rem; +} + +/* Manifest JSON */ +pre { + background: #263238; + color: #eeffff; + padding: 1rem; + border-radius: 6px; + overflow-x: auto; + font-size: 0.8125rem; + line-height: 1.6; + margin-bottom: 1.5rem; +} + +code { + font-family: "SF Mono", "Fira Code", "Fira Mono", "Roboto Mono", + "Consolas", monospace; +} + +/* Inline form row for policy creation */ +.form-row { + display: flex; + gap: 0.75rem; + align-items: flex-end; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.form-row .form-group { + flex: 1; + min-width: 120px; +} + +.form-row button { + margin-bottom: 0; + white-space: nowrap; +} + +/* Filter form */ +.filters { + display: flex; + gap: 0.75rem; + align-items: flex-end; + flex-wrap: wrap; + margin-bottom: 1.5rem; + background: #fff; + padding: 1rem; + border: 1px solid #e0e0e0; + border-radius: 6px; +} + +.filters .form-group { + flex: 1; + min-width: 140px; +} + +.filters button { + white-space: nowrap; +} + +/* Pagination */ +.pagination { + display: flex; + gap: 0.5rem; + justify-content: center; + margin-top: 1rem; +} + +.pagination a, +.pagination span { + padding: 0.375rem 0.75rem; + border: 1px solid #e0e0e0; + border-radius: 4px; + font-size: 0.875rem; +} + +.pagination span { + background: #0066cc; + color: #fff; + border-color: #0066cc; +} + +/* Badge */ +.badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 3px; + font-size: 0.75rem; + font-weight: 600; +} + +.badge-allow { + background: #e8f5e9; + color: #2e7d32; +} + +.badge-deny { + background: #ffebee; + color: #c62828; +} + +.badge-enabled { + background: #e8f5e9; + color: #2e7d32; +} + +.badge-disabled { + background: #fafafa; + color: #999; +} + +/* Truncated text */ +.truncated { + font-family: "SF Mono", "Fira Code", monospace; + font-size: 0.8125rem; +} + +/* Responsive */ +@media (max-width: 768px) { + .stats { + flex-direction: column; + } + + .form-row, + .filters { + flex-direction: column; + } + + nav { + flex-wrap: wrap; + height: auto; + padding: 0.5rem 1rem; + } +} diff --git a/web/templates/audit.html b/web/templates/audit.html new file mode 100644 index 0000000..99ab420 --- /dev/null +++ b/web/templates/audit.html @@ -0,0 +1,80 @@ +{{define "title"}}Audit Log{{end}} + +{{define "content"}} +

Audit Log

+ +{{if .Error}} +
{{.Error}}
+{{end}} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +{{if .Events}} + + + + + + + + + + + + + {{range .Events}} + + + + + + + + + {{end}} + +
TimeTypeActorRepositoryDigestIP Address
{{formatTime .EventTime}}{{.EventType}}{{.ActorId}}{{.Repository}}{{truncate .Digest 24}}{{.IpAddress}}
+ + +{{else}} +

No audit events found.

+{{end}} +{{end}} diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..c796a6a --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,44 @@ +{{define "title"}}Dashboard{{end}} + +{{define "content"}} +

Dashboard

+ +
+
+
Repositories
+
{{.RepoCount}}
+
+
+
Total Size
+
{{.TotalSize}}
+
+
+ +

Recent Activity

+{{if .Events}} + + + + + + + + + + + + {{range .Events}} + + + + + + + + {{end}} + +
TimeTypeActorRepositoryDigest
{{formatTime .EventTime}}{{.EventType}}{{.ActorId}}{{.Repository}}{{truncate .Digest 24}}
+{{else}} +

No recent activity.

+{{end}} +{{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000..51350c3 --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,26 @@ + + + + + + MCR - {{template "title" .}} + + + + + {{if .Session}} + + {{end}} +
+ {{template "content" .}} +
+ + diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..a88587b --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,22 @@ +{{define "title"}}Login{{end}} + +{{define "content"}} + +{{end}} diff --git a/web/templates/manifest_detail.html b/web/templates/manifest_detail.html new file mode 100644 index 0000000..5308f67 --- /dev/null +++ b/web/templates/manifest_detail.html @@ -0,0 +1,28 @@ +{{define "title"}}Manifest Detail{{end}} + +{{define "content"}} +

Manifest Detail

+ +{{if .Error}} +
{{.Error}}
+{{else}} + +
+
+
Digest
+
{{.Manifest.Digest}}
+
+
+
Media Type
+
{{.Manifest.MediaType}}
+
+
+
Size
+
{{formatSize .Manifest.Size}}
+
+
+ +

Back to {{.RepoName}}

+ +{{end}} +{{end}} diff --git a/web/templates/policies.html b/web/templates/policies.html new file mode 100644 index 0000000..592e792 --- /dev/null +++ b/web/templates/policies.html @@ -0,0 +1,92 @@ +{{define "title"}}Policies{{end}} + +{{define "content"}} +

Policy Rules

+ +{{if .Error}} +
{{.Error}}
+{{end}} + +

Create Policy Rule

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+{{if .Policies}} + + + + + + + + + + + + + + + {{range .Policies}} + + + + + + + + + + + {{end}} + +
IDPriorityDescriptionEffectActionsRepositoriesEnabledActions
{{.Id}}{{.Priority}}{{.Description}}{{.Effect}}{{joinStrings .Actions ", "}}{{joinStrings .Repositories ", "}} + {{if .Enabled}} + Enabled + {{else}} + Disabled + {{end}} + +
+ + +
+
+ + +
+
+{{else}} +

No policy rules configured.

+{{end}} +
+{{end}} diff --git a/web/templates/repositories.html b/web/templates/repositories.html new file mode 100644 index 0000000..3c18b3b --- /dev/null +++ b/web/templates/repositories.html @@ -0,0 +1,36 @@ +{{define "title"}}Repositories{{end}} + +{{define "content"}} +

Repositories

+ +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Repositories}} + + + + + + + + + + + + {{range .Repositories}} + + + + + + + + {{end}} + +
NameTagsManifestsSizeCreated
{{.Name}}{{.TagCount}}{{.ManifestCount}}{{formatSize .TotalSize}}{{formatTime .CreatedAt}}
+{{else}} +

No repositories found.

+{{end}} +{{end}} diff --git a/web/templates/repository_detail.html b/web/templates/repository_detail.html new file mode 100644 index 0000000..b22a24d --- /dev/null +++ b/web/templates/repository_detail.html @@ -0,0 +1,74 @@ +{{define "title"}}{{.Name}}{{end}} + +{{define "content"}} +

{{.Name}}

+ +{{if .Error}} +
{{.Error}}
+{{else}} + +
+
+
Total Size
+
{{formatSize .TotalSize}}
+
+
+
Tags
+
{{len .Tags}}
+
+
+
Manifests
+
{{len .Manifests}}
+
+
+ +

Tags

+{{if .Tags}} + + + + + + + + + {{range .Tags}} + + + + + {{end}} + +
TagDigest
{{.Name}}{{truncate .Digest 24}}
+{{else}} +

No tags.

+{{end}} + +

Manifests

+{{if .Manifests}} + + + + + + + + + + + {{range .Manifests}} + + + + + + + {{end}} + +
DigestMedia TypeSizeCreated
{{truncate .Digest 24}}{{.MediaType}}{{formatSize .Size}}{{formatTime .CreatedAt}}
+{{else}} +

No manifests.

+{{end}} + +{{end}} +{{end}}