// 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") }