// 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" "sync" "time" "github.com/go-chi/chi/v5" mcias "git.wntrmute.dev/kyle/mcias/clients/go" "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 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 Close() 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 mcias *mcias.Client // optional; nil when no service_token is configured logger *slog.Logger httpSrv *http.Server staticFS fs.FS csrf *csrfProtect 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 } } if ws.mcias == nil { ws.logger.Warn("webserver: no MCIAS client available, cannot resolve user ID", "id", id) return id } ws.logger.Info("webserver: looking up user ID via MCIAS", "id", id) acct, err := ws.mcias.GetAccount(id) if err != nil { ws.logger.Warn("webserver: failed to resolve user ID", "id", id, "error", err) return id } ws.logger.Info("webserver: resolved user ID", "id", id, "username", acct.Username) ws.userCache.Store(id, &cachedUsername{ username: acct.Username, expiresAt: time.Now().Add(userCacheTTL), }) return acct.Username } // 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) } ws := &WebServer{ cfg: cfg, vault: vault, logger: logger, staticFS: staticFS, csrf: newCSRFProtect(), } if tok := cfg.MCIAS.ServiceToken; tok != "" { mc, err := mcias.New(cfg.MCIAS.ServerURL, mcias.Options{ CACertPath: cfg.MCIAS.CACert, Token: tok, }) if err != nil { logger.Warn("webserver: failed to create MCIAS client for user resolution", "error", err) } else { claims, err := mc.ValidateToken(tok) switch { case err != nil: logger.Warn("webserver: MCIAS service token validation failed", "error", err) case !claims.Valid: logger.Warn("webserver: MCIAS service token is invalid or expired") default: logger.Info("webserver: MCIAS service token valid", "sub", claims.Sub, "roles", claims.Roles) ws.mcias = mc } } } 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{}) { csrfToken := ws.csrf.setToken(w) funcMap := template.FuncMap{ "csrfField": func() template.HTML { return template.HTML(fmt.Sprintf( ``, csrfFieldName, template.HTMLEscapeString(csrfToken), )) }, } tmpl, err := template.New("").Funcs(funcMap).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 }