Separate web UI into standalone metacrypt-web binary
The vault server holds in-memory unsealed state (KEK, engine keys) that is lost on restart, requiring a full unseal ceremony. Previously the web UI ran inside the vault process, so any UI change forced a restart and re-unseal. This change extracts the web UI into a separate metacrypt-web binary that communicates with the vault over an authenticated gRPC connection. The web server carries no sealed state and can be restarted freely. - gen/metacrypt/v1/: generated Go bindings from proto/metacrypt/v1/ - internal/grpcserver/: full gRPC server implementation (System, Auth, Engine, PKI, Policy, ACME services) with seal/auth/admin interceptors - internal/webserver/: web server with gRPC vault client; templates embedded via web/embed.go (no runtime web/ directory needed) - cmd/metacrypt-web/: standalone binary entry point - internal/config: added [web] section (listen_addr, vault_grpc, etc.) - internal/server/routes.go: removed all web UI routes and handlers - cmd/metacrypt/server.go: starts gRPC server alongside HTTP server - Deploy: Dockerfile builds both binaries, docker-compose adds metacrypt-web service, new metacrypt-web.service systemd unit, Makefile gains proto/metacrypt-web targets Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
112
internal/webserver/server.go
Normal file
112
internal/webserver/server.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// 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"
|
||||
|
||||
webui "git.wntrmute.dev/kyle/metacrypt/web"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
||||
)
|
||||
|
||||
// WebServer is the standalone web UI server.
|
||||
type WebServer struct {
|
||||
cfg *config.Config
|
||||
vault *VaultClient
|
||||
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) {
|
||||
vault, err := NewVaultClient(cfg.Web.VaultGRPC, cfg.Web.VaultCACert)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webserver: connect to vault: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Start starts the web server. It blocks until the server is closed.
|
||||
func (ws *WebServer) Start() error {
|
||||
r := chi.NewRouter()
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user