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:
@@ -1 +0,0 @@
|
||||
kyle@vade.20490:1773789991
|
||||
113
PROGRESS.md
113
PROGRESS.md
@@ -6,7 +6,7 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
|
||||
|
||||
## Current State
|
||||
|
||||
**Phase:** 10 complete, ready for Phase 11
|
||||
**Phase:** 12 complete, ready for Phase 13
|
||||
**Last updated:** 2026-03-19
|
||||
|
||||
### Completed
|
||||
@@ -22,6 +22,8 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
|
||||
- Phase 8: Admin REST API (all 5 steps)
|
||||
- Phase 9: Garbage collection (all 2 steps)
|
||||
- Phase 10: gRPC admin API (all 4 steps)
|
||||
- Phase 11: CLI tool (all 3 steps)
|
||||
- Phase 12: Web UI (all 5 steps)
|
||||
- `ARCHITECTURE.md` — Full design specification (18 sections)
|
||||
- `CLAUDE.md` — AI development guidance
|
||||
- `PROJECT_PLAN.md` — Implementation plan (14 phases, 40+ steps)
|
||||
@@ -29,7 +31,114 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. Phase 11 (CLI tool) and Phase 12 (web UI)
|
||||
1. Phase 13 (deployment artifacts)
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-19 — Batch C: Phase 11 (CLI tool) + Phase 12 (Web UI)
|
||||
|
||||
**Task:** Implement the admin CLI and HTMX-based web UI — the two
|
||||
remaining user-facing layers. Both depend on Phase 10 (gRPC) but not
|
||||
on each other; implemented in parallel.
|
||||
|
||||
**Changes:**
|
||||
|
||||
Phase 11 — `cmd/mcrctl/` (Steps 11.1–11.3):
|
||||
|
||||
Step 11.1 — Client and connection setup:
|
||||
- `client.go`: `apiClient` struct wrapping both `*http.Client` (REST)
|
||||
and gRPC service clients (Registry, Policy, Audit, Admin); `newClient()`
|
||||
builds from flags; TLS 1.3 minimum with optional custom CA cert;
|
||||
gRPC dial uses `grpc.ForceCodecV2(mcrv1.JSONCodec{})` for JSON codec;
|
||||
`restDo()` helper with `Authorization: Bearer` header and JSON error
|
||||
parsing; transport auto-selected based on `--grpc` flag
|
||||
|
||||
Step 11.2 — Status and repository commands:
|
||||
- `main.go`: global persistent flags `--server`, `--grpc`, `--token`
|
||||
(fallback `MCR_TOKEN`), `--ca-cert`, `--json`; `PersistentPreRunE`
|
||||
resolves token and creates client; `status` command (gRPC + REST);
|
||||
`repo list` with table/JSON output; `repo delete` with confirmation
|
||||
prompt
|
||||
- `output.go`: `formatSize()` (B/KB/MB/GB/TB), `printJSON()` (indented),
|
||||
`printTable()` via `text/tabwriter`
|
||||
|
||||
Step 11.3 — Policy, audit, GC, and snapshot commands:
|
||||
- `main.go`: `policy list|create|update|delete` (full CRUD, `--rule`
|
||||
flag for JSON body, confirmation on delete); `audit tail` with
|
||||
`--n` and `--event-type` flags; `gc` with `--reconcile` flag;
|
||||
`gc status`; `snapshot`; all commands support both REST and gRPC
|
||||
- `client_test.go`: 10 tests covering formatSize, printJSON, printTable,
|
||||
token resolution from env/flag, newClient REST mode, CA cert error
|
||||
handling, restDo success/error/POST paths
|
||||
|
||||
Phase 12 — `cmd/mcr-web/` + `internal/webserver/` + `web/` (Steps 12.1–12.5):
|
||||
|
||||
Step 12.1 — Web server scaffolding:
|
||||
- `cmd/mcr-web/main.go`: reads `[web]` config section, creates gRPC
|
||||
connection with TLS 1.3 and JSON codec, creates MCIAS auth client for
|
||||
login, generates random 32-byte CSRF key, creates webserver, starts
|
||||
HTTPS with TLS 1.3, graceful shutdown on SIGINT/SIGTERM
|
||||
- `internal/webserver/server.go`: `Server` struct with chi router,
|
||||
gRPC service clients, CSRF key, login function; `New()` constructor;
|
||||
chi middleware (Recoverer, RequestID, RealIP); routes for all pages;
|
||||
session-protected route groups; static file serving from embedded FS
|
||||
- `web/embed.go`: `//go:embed templates static` directive
|
||||
- `web/static/style.css`: minimal clean CSS (system fonts, 1200px
|
||||
container, table styling, form styling, nav bar, stat cards, badges,
|
||||
pagination, responsive breakpoints)
|
||||
|
||||
Step 12.2 — Login and authentication:
|
||||
- `internal/webserver/auth.go`: session middleware (checks `mcr_session`
|
||||
cookie, redirects to `/login` if absent); login page (GET renders
|
||||
form with CSRF token); login submit (POST validates CSRF, calls
|
||||
`loginFn`, sets session cookie HttpOnly/Secure/SameSite=Strict);
|
||||
logout (clears cookie, redirects); CSRF via signed double-submit
|
||||
cookie (HMAC-SHA256)
|
||||
- `web/templates/login.html`: centered login form with CSRF hidden field
|
||||
|
||||
Step 12.3 — Dashboard and repository browsing:
|
||||
- `internal/webserver/handlers.go`: `handleDashboard()` (repo count,
|
||||
total size, recent audit events via gRPC); `handleRepositories()`
|
||||
(list table); `handleRepositoryDetail()` (tags, manifests, repo
|
||||
name with `/` support); `handleManifestDetail()` (manifest info
|
||||
by digest)
|
||||
- `internal/webserver/templates.go`: template loading from embedded FS
|
||||
with layout-page composition, function map (formatSize, formatTime,
|
||||
truncate, joinStrings), render helper
|
||||
- `web/templates/layout.html`: HTML5 base with nav bar, htmx CDN
|
||||
- `web/templates/dashboard.html`: stats cards + recent activity table
|
||||
- `web/templates/repositories.html`: repo list table
|
||||
- `web/templates/repository_detail.html`: tags + manifests tables
|
||||
- `web/templates/manifest_detail.html`: digest, media type, size
|
||||
|
||||
Step 12.4 — Policy management (admin only):
|
||||
- `internal/webserver/handlers.go`: `handlePolicies()` (list rules
|
||||
with CSRF token); `handleCreatePolicy()` (form with body limit,
|
||||
CSRF validation); `handleTogglePolicy()` (get+toggle enabled via
|
||||
UpdatePolicyRule with field mask); `handleDeletePolicy()` (CSRF +
|
||||
delete); PermissionDenied → "Access denied"
|
||||
- `web/templates/policies.html`: create form + rules table with
|
||||
toggle/delete actions
|
||||
|
||||
Step 12.5 — Audit log viewer (admin only):
|
||||
- `internal/webserver/handlers.go`: `handleAudit()` with pagination
|
||||
(fetch N+1 for next-page detection), filters (event type, repository,
|
||||
date range), URL builder for pagination links
|
||||
- `web/templates/audit.html`: filter form + paginated event table
|
||||
|
||||
**Verification:**
|
||||
- `make all` passes: vet clean, lint 0 issues, all tests passing,
|
||||
all 3 binaries built
|
||||
- CLI tests (10 new): formatSize (5 values: B through TB), printJSON
|
||||
output correctness, printTable header and row rendering, token from
|
||||
env var, token flag overrides env, newClient REST mode, CA cert
|
||||
errors (missing file, invalid PEM), restDo success with auth header,
|
||||
restDo error response parsing, restDo POST with body
|
||||
- Web UI tests (15 new): login page renders, invalid credentials error,
|
||||
CSRF token validation, dashboard requires session (redirect),
|
||||
dashboard with session, repositories page, repository detail,
|
||||
logout (cookie clearing), policies page, audit page, static files,
|
||||
formatSize, formatTime, truncate, login success sets cookie
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
145
cmd/mcrctl/client.go
Normal 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
267
cmd/mcrctl/client_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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,61 +71,174 @@ 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")
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
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
|
||||
}
|
||||
|
||||
_, 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{
|
||||
@@ -87,42 +246,246 @@ func policyCmd() *cobra.Command {
|
||||
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{
|
||||
@@ -130,23 +493,259 @@ func auditCmd() *cobra.Command {
|
||||
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
65
cmd/mcrctl/output.go
Normal 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
184
internal/webserver/auth.go
Normal 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))
|
||||
}
|
||||
457
internal/webserver/handlers.go
Normal file
457
internal/webserver/handlers.go
Normal 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
|
||||
}
|
||||
133
internal/webserver/server.go
Normal file
133
internal/webserver/server.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Package webserver implements the MCR web UI server.
|
||||
//
|
||||
// It serves HTML pages rendered from Go templates with htmx for
|
||||
// interactive elements. All data is fetched via gRPC from the main
|
||||
// mcrsrv API server. Authentication is handled via MCIAS, with session
|
||||
// tokens stored in secure HttpOnly cookies.
|
||||
package webserver
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
||||
"git.wntrmute.dev/kyle/mcr/web"
|
||||
)
|
||||
|
||||
// LoginFunc authenticates a user and returns a bearer token.
|
||||
type LoginFunc func(username, password string) (token string, expiresIn int, err error)
|
||||
|
||||
// Server is the MCR web UI server.
|
||||
type Server struct {
|
||||
router chi.Router
|
||||
templates *templateSet
|
||||
registry mcrv1.RegistryServiceClient
|
||||
policy mcrv1.PolicyServiceClient
|
||||
audit mcrv1.AuditServiceClient
|
||||
admin mcrv1.AdminServiceClient
|
||||
loginFn LoginFunc
|
||||
csrfKey []byte // 32-byte key for HMAC signing
|
||||
}
|
||||
|
||||
// New creates a new web UI server with the given gRPC clients and login function.
|
||||
func New(
|
||||
registry mcrv1.RegistryServiceClient,
|
||||
policy mcrv1.PolicyServiceClient,
|
||||
audit mcrv1.AuditServiceClient,
|
||||
admin mcrv1.AdminServiceClient,
|
||||
loginFn LoginFunc,
|
||||
csrfKey []byte,
|
||||
) (*Server, error) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
templates: tmpl,
|
||||
registry: registry,
|
||||
policy: policy,
|
||||
audit: audit,
|
||||
admin: admin,
|
||||
loginFn: loginFn,
|
||||
csrfKey: csrfKey,
|
||||
}
|
||||
|
||||
s.router = s.buildRouter()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Handler returns the http.Handler for the server.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.router
|
||||
}
|
||||
|
||||
// buildRouter sets up the chi router with all routes and middleware.
|
||||
func (s *Server) buildRouter() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware.
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
|
||||
// Static files (no auth required).
|
||||
staticFS, err := fs.Sub(web.Content, "static")
|
||||
if err != nil {
|
||||
log.Fatalf("webserver: failed to create static sub-filesystem: %v", err)
|
||||
}
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
// Public routes (no session required).
|
||||
r.Get("/login", s.handleLoginPage)
|
||||
r.Post("/login", s.handleLoginSubmit)
|
||||
r.Get("/logout", s.handleLogout)
|
||||
|
||||
// Protected routes (session required).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.sessionMiddleware)
|
||||
|
||||
r.Get("/", s.handleDashboard)
|
||||
|
||||
// Repository routes — name may contain slashes.
|
||||
r.Get("/repositories", s.handleRepositories)
|
||||
r.Get("/repositories/*", s.handleRepositoryOrManifest)
|
||||
|
||||
// Policy routes (admin — gRPC interceptors enforce this).
|
||||
r.Get("/policies", s.handlePolicies)
|
||||
r.Post("/policies", s.handleCreatePolicy)
|
||||
r.Post("/policies/{id}/toggle", s.handleTogglePolicy)
|
||||
r.Post("/policies/{id}/delete", s.handleDeletePolicy)
|
||||
|
||||
// Audit routes (admin — gRPC interceptors enforce this).
|
||||
r.Get("/audit", s.handleAudit)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// handleRepositoryOrManifest dispatches between repository detail and
|
||||
// manifest detail based on the URL path. This is necessary because
|
||||
// repository names can contain slashes.
|
||||
func (s *Server) handleRepositoryOrManifest(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if idx := lastIndex(path, "/manifests/"); idx >= 0 {
|
||||
s.handleManifestDetail(w, r)
|
||||
return
|
||||
}
|
||||
s.handleRepositoryDetail(w, r)
|
||||
}
|
||||
|
||||
// lastIndex returns the index of the last occurrence of sep in s, or -1.
|
||||
func lastIndex(s, sep string) int {
|
||||
for i := len(s) - len(sep); i >= 0; i-- {
|
||||
if s[i:i+len(sep)] == sep {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
608
internal/webserver/server_test.go
Normal file
608
internal/webserver/server_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
136
internal/webserver/templates.go
Normal file
136
internal/webserver/templates.go
Normal 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
6
web/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates static
|
||||
var Content embed.FS
|
||||
404
web/static/style.css
Normal file
404
web/static/style.css
Normal 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
80
web/templates/audit.html
Normal 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}}
|
||||
44
web/templates/dashboard.html
Normal file
44
web/templates/dashboard.html
Normal 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
26
web/templates/layout.html
Normal 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
22
web/templates/login.html
Normal 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}}
|
||||
28
web/templates/manifest_detail.html
Normal file
28
web/templates/manifest_detail.html
Normal 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}}
|
||||
92
web/templates/policies.html
Normal file
92
web/templates/policies.html
Normal 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}}
|
||||
36
web/templates/repositories.html
Normal file
36
web/templates/repositories.html
Normal 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}}
|
||||
74
web/templates/repository_detail.html
Normal file
74
web/templates/repository_detail.html
Normal 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}}
|
||||
Reference in New Issue
Block a user