Files
metacrypt/internal/webserver/server.go
Kyle Isom 74e35ce63e Add certificate detail page and tests
- Add cert detail page with metadata display and download link
- Change cert issuance to return tgz with key.pem and cert.pem
- Add handleCertDetail and handleCertDownload handlers
- Extract vaultBackend interface for testability
- Add table-driven tests for cert detail handlers

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-15 13:24:05 -07:00

165 lines
5.1 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)
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)
}
// 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
}