The web UI connects to the vault API via gRPC using the Docker compose service name (e.g., "metacrypt:9443"), but the vault's TLS certificate has SANs for "crypt.metacircular.net" and "localhost". The new vault_sni config field overrides the TLS ServerName so certificate verification succeeds despite the hostname mismatch. Also updates metacrypt-rift.toml with vault_sni and temporarily binds the web UI port to 0.0.0.0 for direct access until mc-proxy is deployed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
295 lines
11 KiB
Go
295 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/tls"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
|
"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 {
|
|
// 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 *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
|
|
}
|
|
}
|
|
// 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)
|
|
}
|
|
|
|
ws := &WebServer{
|
|
cfg: cfg,
|
|
vault: vault,
|
|
logger: logger,
|
|
staticFS: staticFS,
|
|
csrf: newCSRFProtect(),
|
|
}
|
|
|
|
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{}) {
|
|
csrfToken := ws.csrf.setToken(w)
|
|
|
|
funcMap := template.FuncMap{
|
|
"csrfField": func() template.HTML {
|
|
return template.HTML(fmt.Sprintf(
|
|
`<input type="hidden" name="%s" value="%s">`,
|
|
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
|
|
}
|