Admins can now revoke or delete certificate records from the cert detail
page in the web UI. Revoked certificates display a [REVOKED] badge and
show revocation metadata (time and actor). Deletion redirects to the
issuer page.
The REST API gains three new authenticated endpoints that mirror the
gRPC surface:
GET /v1/ca/{mount}/cert/{serial} (auth required)
POST /v1/ca/{mount}/cert/{serial}/revoke (admin only)
DELETE /v1/ca/{mount}/cert/{serial} (admin only)
The CA engine stores revocation state (revoked, revoked_at, revoked_by)
directly in the existing CertRecord barrier entry. The proto CertRecord
message is extended with the same three fields (field numbers 10–12).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
173 lines
5.4 KiB
Go
173 lines
5.4 KiB
Go
// Package webserver implements the standalone web UI server for Metacrypt.
|
|
// It communicates with the vault over gRPC and renders server-side HTML.
|
|
package webserver
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
|
webui "git.wntrmute.dev/kyle/metacrypt/web"
|
|
)
|
|
|
|
// vaultBackend is the interface used by WebServer to communicate with the vault.
|
|
// It is satisfied by *VaultClient and can be replaced with a mock in tests.
|
|
type vaultBackend interface {
|
|
Status(ctx context.Context) (string, error)
|
|
Init(ctx context.Context, password string) error
|
|
Unseal(ctx context.Context, password string) error
|
|
Login(ctx context.Context, username, password, totpCode string) (string, error)
|
|
ValidateToken(ctx context.Context, token string) (*TokenInfo, error)
|
|
ListMounts(ctx context.Context, token string) ([]MountInfo, error)
|
|
Mount(ctx context.Context, token, name, engineType string, config map[string]interface{}) error
|
|
GetRootCert(ctx context.Context, mount string) ([]byte, error)
|
|
GetIssuerCert(ctx context.Context, mount, issuer string) ([]byte, error)
|
|
ImportRoot(ctx context.Context, token, mount, certPEM, keyPEM string) error
|
|
CreateIssuer(ctx context.Context, token string, req CreateIssuerRequest) error
|
|
ListIssuers(ctx context.Context, token, mount string) ([]string, error)
|
|
IssueCert(ctx context.Context, token string, req IssueCertRequest) (*IssuedCert, error)
|
|
SignCSR(ctx context.Context, token string, req SignCSRRequest) (*SignedCert, error)
|
|
GetCert(ctx context.Context, token, mount, serial string) (*CertDetail, error)
|
|
ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error)
|
|
RevokeCert(ctx context.Context, token, mount, serial string) error
|
|
DeleteCert(ctx context.Context, token, mount, serial string) error
|
|
Close() error
|
|
}
|
|
|
|
// WebServer is the standalone web UI server.
|
|
type WebServer struct {
|
|
cfg *config.Config
|
|
vault vaultBackend
|
|
logger *slog.Logger
|
|
httpSrv *http.Server
|
|
staticFS fs.FS
|
|
}
|
|
|
|
// New creates a new WebServer. It dials the vault gRPC endpoint.
|
|
func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) {
|
|
logger.Info("connecting to vault", "addr", cfg.Web.VaultGRPC, "ca_cert", cfg.Web.VaultCACert)
|
|
vault, err := NewVaultClient(cfg.Web.VaultGRPC, cfg.Web.VaultCACert, logger)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("webserver: connect to vault: %w", err)
|
|
}
|
|
logger.Info("vault connection ready", "addr", cfg.Web.VaultGRPC)
|
|
|
|
staticFS, err := fs.Sub(webui.FS, "static")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("webserver: static FS: %w", err)
|
|
}
|
|
|
|
return &WebServer{
|
|
cfg: cfg,
|
|
vault: vault,
|
|
logger: logger,
|
|
staticFS: staticFS,
|
|
}, nil
|
|
}
|
|
|
|
// loggingMiddleware logs each incoming HTTP request.
|
|
func (ws *WebServer) loggingMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
lw := &loggingResponseWriter{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(lw, r)
|
|
ws.logger.Info("request",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", lw.status,
|
|
"duration", time.Since(start),
|
|
"remote_addr", r.RemoteAddr,
|
|
)
|
|
})
|
|
}
|
|
|
|
// loggingResponseWriter wraps http.ResponseWriter to capture the status code.
|
|
type loggingResponseWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (lw *loggingResponseWriter) WriteHeader(code int) {
|
|
lw.status = code
|
|
lw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
// Unwrap returns the underlying ResponseWriter so that http.ResponseController
|
|
// can reach it to set deadlines and perform other extended operations.
|
|
func (lw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
|
return lw.ResponseWriter
|
|
}
|
|
|
|
// Start starts the web server. It blocks until the server is closed.
|
|
func (ws *WebServer) Start() error {
|
|
r := chi.NewRouter()
|
|
r.Use(ws.loggingMiddleware)
|
|
ws.registerRoutes(r)
|
|
|
|
ws.httpSrv = &http.Server{
|
|
Addr: ws.cfg.Web.ListenAddr,
|
|
Handler: r,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
ws.logger.Info("starting web server", "addr", ws.cfg.Web.ListenAddr)
|
|
|
|
if ws.cfg.Web.TLSCert != "" && ws.cfg.Web.TLSKey != "" {
|
|
ws.httpSrv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
|
err := ws.httpSrv.ListenAndServeTLS(ws.cfg.Web.TLSCert, ws.cfg.Web.TLSKey)
|
|
if err != nil && err != http.ErrServerClosed {
|
|
return fmt.Errorf("webserver: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
err := ws.httpSrv.ListenAndServe()
|
|
if err != nil && err != http.ErrServerClosed {
|
|
return fmt.Errorf("webserver: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Shutdown gracefully shuts down the web server.
|
|
func (ws *WebServer) Shutdown(ctx context.Context) error {
|
|
_ = ws.vault.Close()
|
|
if ws.httpSrv != nil {
|
|
return ws.httpSrv.Shutdown(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ws *WebServer) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
|
|
tmpl, err := template.ParseFS(webui.FS,
|
|
"templates/layout.html",
|
|
"templates/"+name,
|
|
)
|
|
if err != nil {
|
|
ws.logger.Error("parse template", "name", name, "error", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
|
ws.logger.Error("execute template", "name", name, "error", err)
|
|
}
|
|
}
|
|
|
|
func extractCookie(r *http.Request) string {
|
|
c, err := r.Cookie("metacrypt_token")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return c.Value
|
|
}
|