All import paths updated to git.wntrmute.dev/mc/. Bumps mcdsl to v1.2.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
275 lines
11 KiB
Go
275 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/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")
|
|
}
|