// 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" "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 } // tgzEntry holds a cached tgz archive pending download. type tgzEntry struct { filename string data []byte } // WebServer is the standalone web UI server. type WebServer struct { cfg *config.Config vault vaultBackend logger *slog.Logger httpSrv *http.Server staticFS fs.FS tgzCache sync.Map // key: UUID string → *tgzEntry } // 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 }