Files
metacrypt/internal/webserver/server.go
Kyle Isom bbe382dc10 Migrate module path from kyle/ to mc/ org
All import paths updated to git.wntrmute.dev/mc/. Bumps mcdsl to v1.2.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:05:59 -07:00

275 lines
11 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/rand"
"crypto/tls"
"fmt"
"io/fs"
"log/slog"
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/csrf"
"git.wntrmute.dev/mc/mcdsl/web"
"git.wntrmute.dev/mc/metacrypt/internal/config"
webui "git.wntrmute.dev/mc/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 {
// System
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
Close() error
// PKI / CA
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
// Policy
ListPolicies(ctx context.Context, token string) ([]PolicyRule, error)
GetPolicy(ctx context.Context, token, id string) (*PolicyRule, error)
CreatePolicy(ctx context.Context, token string, rule PolicyRule) (*PolicyRule, error)
DeletePolicy(ctx context.Context, token, id string) error
// SSH CA
GetSSHCAPublicKey(ctx context.Context, mount string) (*SSHCAPublicKey, error)
SSHCASignHost(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error)
SSHCASignUser(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error)
ListSSHCAProfiles(ctx context.Context, token, mount string) ([]SSHCAProfileSummary, error)
GetSSHCAProfile(ctx context.Context, token, mount, name string) (*SSHCAProfile, error)
CreateSSHCAProfile(ctx context.Context, token, mount string, req SSHCAProfileRequest) error
UpdateSSHCAProfile(ctx context.Context, token, mount, name string, req SSHCAProfileRequest) error
DeleteSSHCAProfile(ctx context.Context, token, mount, name string) error
ListSSHCACerts(ctx context.Context, token, mount string) ([]SSHCACertSummary, error)
GetSSHCACert(ctx context.Context, token, mount, serial string) (*SSHCACertDetail, error)
RevokeSSHCACert(ctx context.Context, token, mount, serial string) error
DeleteSSHCACert(ctx context.Context, token, mount, serial string) error
GetSSHCAKRL(ctx context.Context, mount string) ([]byte, error)
// Transit
ListTransitKeys(ctx context.Context, token, mount string) ([]TransitKeySummary, error)
GetTransitKey(ctx context.Context, token, mount, name string) (*TransitKeyDetail, error)
CreateTransitKey(ctx context.Context, token, mount, name, keyType string) error
DeleteTransitKey(ctx context.Context, token, mount, name string) error
RotateTransitKey(ctx context.Context, token, mount, name string) error
UpdateTransitKeyConfig(ctx context.Context, token, mount, name string, minDecryptVersion int, allowDeletion bool) error
TrimTransitKey(ctx context.Context, token, mount, name string) (int, error)
TransitEncrypt(ctx context.Context, token, mount, key, plaintext, transitCtx string) (string, error)
TransitDecrypt(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error)
TransitRewrap(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error)
TransitSign(ctx context.Context, token, mount, key, input string) (string, error)
TransitVerify(ctx context.Context, token, mount, key, input, signature string) (bool, error)
TransitHMAC(ctx context.Context, token, mount, key, input string) (string, error)
GetTransitPublicKey(ctx context.Context, token, mount, name string) (string, error)
// User (E2E encryption)
UserRegister(ctx context.Context, token, mount string) (*UserKeyInfo, error)
UserProvision(ctx context.Context, token, mount, username string) (*UserKeyInfo, error)
GetUserPublicKey(ctx context.Context, token, mount, username string) (*UserKeyInfo, error)
ListUsers(ctx context.Context, token, mount string) ([]string, error)
UserEncrypt(ctx context.Context, token, mount, plaintext, metadata string, recipients []string) (string, error)
UserDecrypt(ctx context.Context, token, mount, envelope string) (*UserDecryptResult, error)
UserReEncrypt(ctx context.Context, token, mount, envelope string) (string, error)
UserRotateKey(ctx context.Context, token, mount string) (*UserKeyInfo, error)
UserDeleteUser(ctx context.Context, token, mount, username string) error
}
const userCacheTTL = 5 * time.Minute
// tgzEntry holds a cached tgz archive pending download.
type tgzEntry struct {
filename string
data []byte
}
// cachedUsername holds a resolved UUID→username entry with an expiry.
type cachedUsername struct {
username string
expiresAt time.Time
}
// WebServer is the standalone web UI server.
type WebServer struct {
cfg *config.Config
vault vaultBackend
logger *slog.Logger
httpSrv *http.Server
staticFS fs.FS
csrf *csrf.Protect
tgzCache sync.Map // key: UUID string → *tgzEntry
userCache sync.Map // key: UUID string → *cachedUsername
}
// resolveUser returns the display name for a user ID. If the ID is already a
// human-readable username (i.e. not a UUID), it is returned unchanged. When the
// webserver has an MCIAS client configured it will look up unknown IDs and cache
// the result; otherwise the raw ID is returned as a fallback.
func (ws *WebServer) resolveUser(id string) string {
if id == "" {
return id
}
if v, ok := ws.userCache.Load(id); ok {
if entry := v.(*cachedUsername); time.Now().Before(entry.expiresAt) {
ws.logger.Info("webserver: resolved user ID from cache", "id", id, "username", entry.username)
return entry.username
}
}
// TODO: re-enable MCIAS account lookup once mcias client library is
// published with proper Go module tags. For now, return the raw ID.
return id
}
// 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, cfg.Web.VaultSNI, 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)
}
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("webserver: generate CSRF secret: %w", err)
}
ws := &WebServer{
cfg: cfg,
vault: vault,
logger: logger,
staticFS: staticFS,
csrf: csrf.New(secret, "metacrypt_csrf", "csrf_token"),
}
if tok := cfg.MCIAS.ServiceToken; tok != "" {
a, err := mcdslauth.New(mcdslauth.Config{
ServerURL: cfg.MCIAS.ServerURL,
CACert: cfg.MCIAS.CACert,
}, logger)
if err != nil {
logger.Warn("webserver: failed to create auth client for service token validation", "error", err)
} else {
info, err := a.ValidateToken(tok)
switch {
case err != nil:
logger.Warn("webserver: MCIAS service token validation failed", "error", err)
default:
logger.Info("webserver: MCIAS service token valid", "username", info.Username, "roles", info.Roles)
}
}
}
return ws, 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)
r.Use(ws.csrf.Middleware)
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.VersionTLS13}
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{}) {
web.RenderTemplate(w, webui.FS, name, data, ws.csrf.TemplateFunc(w))
}
func extractCookie(r *http.Request) string {
return web.GetSessionToken(r, "metacrypt_token")
}