Phases 11, 12: mcrctl CLI tool and mcr-web UI

Phase 11 implements the admin CLI with dual REST/gRPC transport,
global flags (--server, --grpc, --token, --ca-cert, --json), and
all commands: status, repo list/delete, policy CRUD, audit tail,
gc trigger/status/reconcile, and snapshot.

Phase 12 implements the HTMX web UI with chi router, session-based
auth (HttpOnly/Secure/SameSite=Strict cookies), CSRF protection
(HMAC-SHA256 signed double-submit), and pages for dashboard,
repositories, manifest detail, policy management, and audit log.

Security: CSRF via signed double-submit cookie, session cookies
with HttpOnly/Secure/SameSite=Strict, TLS 1.3 minimum on all
connections, form body size limits via http.MaxBytesReader.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 10:14:38 -07:00
parent 185b68ff6d
commit 593da3975d
23 changed files with 3737 additions and 66 deletions

View File

@@ -1 +0,0 @@
kyle@vade.20490:1773789991

View File

@@ -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.111.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.112.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
---

View File

@@ -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

View File

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

145
cmd/mcrctl/client.go Normal file
View File

@@ -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
}

267
cmd/mcrctl/client_test.go Normal file
View File

@@ -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))
}
}

View File

@@ -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",
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
}

65
cmd/mcrctl/output.go Normal file
View File

@@ -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()
}

184
internal/webserver/auth.go Normal file
View File

@@ -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))
}

View File

@@ -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
}

View File

@@ -0,0 +1,133 @@
// Package webserver implements the MCR web UI server.
//
// It serves HTML pages rendered from Go templates with htmx for
// interactive elements. All data is fetched via gRPC from the main
// mcrsrv API server. Authentication is handled via MCIAS, with session
// tokens stored in secure HttpOnly cookies.
package webserver
import (
"io/fs"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/web"
)
// LoginFunc authenticates a user and returns a bearer token.
type LoginFunc func(username, password string) (token string, expiresIn int, err error)
// Server is the MCR web UI server.
type Server struct {
router chi.Router
templates *templateSet
registry mcrv1.RegistryServiceClient
policy mcrv1.PolicyServiceClient
audit mcrv1.AuditServiceClient
admin mcrv1.AdminServiceClient
loginFn LoginFunc
csrfKey []byte // 32-byte key for HMAC signing
}
// New creates a new web UI server with the given gRPC clients and login function.
func New(
registry mcrv1.RegistryServiceClient,
policy mcrv1.PolicyServiceClient,
audit mcrv1.AuditServiceClient,
admin mcrv1.AdminServiceClient,
loginFn LoginFunc,
csrfKey []byte,
) (*Server, error) {
tmpl, err := loadTemplates()
if err != nil {
return nil, err
}
s := &Server{
templates: tmpl,
registry: registry,
policy: policy,
audit: audit,
admin: admin,
loginFn: loginFn,
csrfKey: csrfKey,
}
s.router = s.buildRouter()
return s, nil
}
// Handler returns the http.Handler for the server.
func (s *Server) Handler() http.Handler {
return s.router
}
// buildRouter sets up the chi router with all routes and middleware.
func (s *Server) buildRouter() chi.Router {
r := chi.NewRouter()
// Global middleware.
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
// Static files (no auth required).
staticFS, err := fs.Sub(web.Content, "static")
if err != nil {
log.Fatalf("webserver: failed to create static sub-filesystem: %v", err)
}
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Public routes (no session required).
r.Get("/login", s.handleLoginPage)
r.Post("/login", s.handleLoginSubmit)
r.Get("/logout", s.handleLogout)
// Protected routes (session required).
r.Group(func(r chi.Router) {
r.Use(s.sessionMiddleware)
r.Get("/", s.handleDashboard)
// Repository routes — name may contain slashes.
r.Get("/repositories", s.handleRepositories)
r.Get("/repositories/*", s.handleRepositoryOrManifest)
// Policy routes (admin — gRPC interceptors enforce this).
r.Get("/policies", s.handlePolicies)
r.Post("/policies", s.handleCreatePolicy)
r.Post("/policies/{id}/toggle", s.handleTogglePolicy)
r.Post("/policies/{id}/delete", s.handleDeletePolicy)
// Audit routes (admin — gRPC interceptors enforce this).
r.Get("/audit", s.handleAudit)
})
return r
}
// handleRepositoryOrManifest dispatches between repository detail and
// manifest detail based on the URL path. This is necessary because
// repository names can contain slashes.
func (s *Server) handleRepositoryOrManifest(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if idx := lastIndex(path, "/manifests/"); idx >= 0 {
s.handleManifestDetail(w, r)
return
}
s.handleRepositoryDetail(w, r)
}
// lastIndex returns the index of the last occurrence of sep in s, or -1.
func lastIndex(s, sep string) int {
for i := len(s) - len(sep); i >= 0; i-- {
if s[i:i+len(sep)] == sep {
return i
}
}
return -1
}

View File

@@ -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")
}
}

View File

@@ -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)
}

6
web/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed templates static
var Content embed.FS

404
web/static/style.css Normal file
View File

@@ -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;
}
}

80
web/templates/audit.html Normal file
View File

@@ -0,0 +1,80 @@
{{define "title"}}Audit Log{{end}}
{{define "content"}}
<h1>Audit Log</h1>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{end}}
<form method="GET" action="/audit" class="filters">
<div class="form-group">
<label for="event_type">Event Type</label>
<select id="event_type" name="event_type">
<option value="">All</option>
<option value="manifest_pushed" {{if eq .FilterType "manifest_pushed"}}selected{{end}}>Manifest Pushed</option>
<option value="manifest_deleted" {{if eq .FilterType "manifest_deleted"}}selected{{end}}>Manifest Deleted</option>
<option value="blob_uploaded" {{if eq .FilterType "blob_uploaded"}}selected{{end}}>Blob Uploaded</option>
<option value="blob_deleted" {{if eq .FilterType "blob_deleted"}}selected{{end}}>Blob Deleted</option>
<option value="repo_deleted" {{if eq .FilterType "repo_deleted"}}selected{{end}}>Repo Deleted</option>
<option value="gc_started" {{if eq .FilterType "gc_started"}}selected{{end}}>GC Started</option>
<option value="gc_completed" {{if eq .FilterType "gc_completed"}}selected{{end}}>GC Completed</option>
<option value="policy_created" {{if eq .FilterType "policy_created"}}selected{{end}}>Policy Created</option>
<option value="policy_updated" {{if eq .FilterType "policy_updated"}}selected{{end}}>Policy Updated</option>
<option value="policy_deleted" {{if eq .FilterType "policy_deleted"}}selected{{end}}>Policy Deleted</option>
</select>
</div>
<div class="form-group">
<label for="repository">Repository</label>
<input type="text" id="repository" name="repository" value="{{.FilterRepo}}" placeholder="e.g. library/nginx">
</div>
<div class="form-group">
<label for="since">Since</label>
<input type="date" id="since" name="since" value="{{.FilterSince}}">
</div>
<div class="form-group">
<label for="until">Until</label>
<input type="date" id="until" name="until" value="{{.FilterUntil}}">
</div>
<button type="submit">Filter</button>
</form>
{{if .Events}}
<table>
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Actor</th>
<th>Repository</th>
<th>Digest</th>
<th>IP Address</th>
</tr>
</thead>
<tbody>
{{range .Events}}
<tr>
<td>{{formatTime .EventTime}}</td>
<td>{{.EventType}}</td>
<td>{{.ActorId}}</td>
<td>{{.Repository}}</td>
<td class="truncated">{{truncate .Digest 24}}</td>
<td>{{.IpAddress}}</td>
</tr>
{{end}}
</tbody>
</table>
<div class="pagination">
{{if gt .Page 1}}
<a href="{{.PrevURL}}">Previous</a>
{{end}}
<span>Page {{.Page}}</span>
{{if .HasNext}}
<a href="{{.NextURL}}">Next</a>
{{end}}
</div>
{{else}}
<p>No audit events found.</p>
{{end}}
{{end}}

View File

@@ -0,0 +1,44 @@
{{define "title"}}Dashboard{{end}}
{{define "content"}}
<h1>Dashboard</h1>
<div class="stats">
<div class="stat-card">
<div class="label">Repositories</div>
<div class="value">{{.RepoCount}}</div>
</div>
<div class="stat-card">
<div class="label">Total Size</div>
<div class="value">{{.TotalSize}}</div>
</div>
</div>
<h2>Recent Activity</h2>
{{if .Events}}
<table>
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Actor</th>
<th>Repository</th>
<th>Digest</th>
</tr>
</thead>
<tbody>
{{range .Events}}
<tr>
<td>{{formatTime .EventTime}}</td>
<td>{{.EventType}}</td>
<td>{{.ActorId}}</td>
<td>{{.Repository}}</td>
<td class="truncated">{{truncate .Digest 24}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No recent activity.</p>
{{end}}
{{end}}

26
web/templates/layout.html Normal file
View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCR - {{template "title" .}}</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
{{if .Session}}
<nav>
<span class="brand">MCR</span>
<a href="/">Dashboard</a>
<a href="/repositories">Repositories</a>
<a href="/policies">Policies</a>
<a href="/audit">Audit</a>
<span class="spacer"></span>
<a href="/logout" class="logout">Logout</a>
</nav>
{{end}}
<div class="container">
{{template "content" .}}
</div>
</body>
</html>

22
web/templates/login.html Normal file
View File

@@ -0,0 +1,22 @@
{{define "title"}}Login{{end}}
{{define "content"}}
<div class="login-container">
<h1>MCR Login</h1>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{end}}
<form method="POST" action="/login">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Sign In</button>
</form>
</div>
{{end}}

View File

@@ -0,0 +1,28 @@
{{define "title"}}Manifest Detail{{end}}
{{define "content"}}
<h1>Manifest Detail</h1>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{else}}
<div class="stats">
<div class="stat-card">
<div class="label">Digest</div>
<div class="value truncated" style="font-size: 0.875rem;">{{.Manifest.Digest}}</div>
</div>
<div class="stat-card">
<div class="label">Media Type</div>
<div class="value" style="font-size: 1rem;">{{.Manifest.MediaType}}</div>
</div>
<div class="stat-card">
<div class="label">Size</div>
<div class="value">{{formatSize .Manifest.Size}}</div>
</div>
</div>
<p><a href="/repositories/{{.RepoName}}">Back to {{.RepoName}}</a></p>
{{end}}
{{end}}

View File

@@ -0,0 +1,92 @@
{{define "title"}}Policies{{end}}
{{define "content"}}
<h1>Policy Rules</h1>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{end}}
<h2>Create Policy Rule</h2>
<form method="POST" action="/policies">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div class="form-row">
<div class="form-group">
<label for="priority">Priority</label>
<input type="number" id="priority" name="priority" value="100" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<input type="text" id="description" name="description" required>
</div>
<div class="form-group">
<label for="effect">Effect</label>
<select id="effect" name="effect">
<option value="allow">Allow</option>
<option value="deny">Deny</option>
</select>
</div>
<div class="form-group">
<label for="actions">Actions (comma-sep)</label>
<input type="text" id="actions" name="actions" placeholder="pull,push">
</div>
<div class="form-group">
<label for="repositories">Repositories (comma-sep)</label>
<input type="text" id="repositories" name="repositories" placeholder="*">
</div>
<button type="submit">Create</button>
</div>
</form>
<div id="policy-table">
{{if .Policies}}
<table>
<thead>
<tr>
<th>ID</th>
<th>Priority</th>
<th>Description</th>
<th>Effect</th>
<th>Actions</th>
<th>Repositories</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Policies}}
<tr id="policy-{{.Id}}">
<td>{{.Id}}</td>
<td>{{.Priority}}</td>
<td>{{.Description}}</td>
<td><span class="badge badge-{{.Effect}}">{{.Effect}}</span></td>
<td>{{joinStrings .Actions ", "}}</td>
<td>{{joinStrings .Repositories ", "}}</td>
<td>
{{if .Enabled}}
<span class="badge badge-enabled">Enabled</span>
{{else}}
<span class="badge badge-disabled">Disabled</span>
{{end}}
</td>
<td>
<form method="POST" action="/policies/{{.Id}}/toggle" style="display:inline;">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit" class="small secondary">
{{if .Enabled}}Disable{{else}}Enable{{end}}
</button>
</form>
<form method="POST" action="/policies/{{.Id}}/delete" style="display:inline;">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit" class="small danger">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No policy rules configured.</p>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,36 @@
{{define "title"}}Repositories{{end}}
{{define "content"}}
<h1>Repositories</h1>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{end}}
{{if .Repositories}}
<table>
<thead>
<tr>
<th>Name</th>
<th>Tags</th>
<th>Manifests</th>
<th>Size</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range .Repositories}}
<tr>
<td><a href="/repositories/{{.Name}}">{{.Name}}</a></td>
<td>{{.TagCount}}</td>
<td>{{.ManifestCount}}</td>
<td>{{formatSize .TotalSize}}</td>
<td>{{formatTime .CreatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No repositories found.</p>
{{end}}
{{end}}

View File

@@ -0,0 +1,74 @@
{{define "title"}}{{.Name}}{{end}}
{{define "content"}}
<h1>{{.Name}}</h1>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{else}}
<div class="stats">
<div class="stat-card">
<div class="label">Total Size</div>
<div class="value">{{formatSize .TotalSize}}</div>
</div>
<div class="stat-card">
<div class="label">Tags</div>
<div class="value">{{len .Tags}}</div>
</div>
<div class="stat-card">
<div class="label">Manifests</div>
<div class="value">{{len .Manifests}}</div>
</div>
</div>
<h2>Tags</h2>
{{if .Tags}}
<table>
<thead>
<tr>
<th>Tag</th>
<th>Digest</th>
</tr>
</thead>
<tbody>
{{range .Tags}}
<tr>
<td>{{.Name}}</td>
<td class="truncated"><a href="/repositories/{{$.Name}}/manifests/{{.Digest}}">{{truncate .Digest 24}}</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No tags.</p>
{{end}}
<h2>Manifests</h2>
{{if .Manifests}}
<table>
<thead>
<tr>
<th>Digest</th>
<th>Media Type</th>
<th>Size</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range .Manifests}}
<tr>
<td class="truncated"><a href="/repositories/{{$.Name}}/manifests/{{.Digest}}">{{truncate .Digest 24}}</a></td>
<td>{{.MediaType}}</td>
<td>{{formatSize .Size}}</td>
<td>{{formatTime .CreatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No manifests.</p>
{{end}}
{{end}}
{{end}}