Implement Phase 1: core framework, operational tooling, and runbook
Core packages: crypto (Argon2id/AES-256-GCM), config (TOML/viper), db (SQLite/migrations), barrier (encrypted storage), seal (state machine with rate-limited unseal), auth (MCIAS integration with token cache), policy (priority-based ACL engine), engine (interface + registry). Server: HTTPS with TLS 1.2+, REST API, auth/admin middleware, htmx web UI (init, unseal, login, dashboard pages). CLI: cobra/viper subcommands (server, init, status, snapshot) with env var override support (METACRYPT_ prefix). Operational tooling: Dockerfile (multi-stage, non-root), docker-compose, hardened systemd units (service + daily backup timer), install script, backup script with retention pruning, production config examples. Runbook covering installation, configuration, daily operations, backup/restore, monitoring, troubleshooting, and security procedures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
109
internal/server/middleware.go
Normal file
109
internal/server/middleware.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const tokenInfoKey contextKey = "tokenInfo"
|
||||
|
||||
// TokenInfoFromContext extracts the validated token info from the request context.
|
||||
func TokenInfoFromContext(ctx context.Context) *auth.TokenInfo {
|
||||
info, _ := ctx.Value(tokenInfoKey).(*auth.TokenInfo)
|
||||
return info
|
||||
}
|
||||
|
||||
// loggingMiddleware logs HTTP requests, stripping sensitive headers.
|
||||
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: 200}
|
||||
next.ServeHTTP(sw, r)
|
||||
s.logger.Info("http request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", sw.status,
|
||||
"duration", time.Since(start),
|
||||
"remote", r.RemoteAddr,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// requireUnseal rejects requests unless the service is unsealed.
|
||||
func (s *Server) requireUnseal(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
state := s.seal.State()
|
||||
switch state {
|
||||
case seal.StateUninitialized:
|
||||
http.Error(w, `{"error":"not initialized"}`, http.StatusPreconditionFailed)
|
||||
return
|
||||
case seal.StateSealed, seal.StateInitializing:
|
||||
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// requireAuth validates the bearer token and injects TokenInfo into context.
|
||||
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.requireUnseal(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := extractToken(r)
|
||||
if token == "" {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := s.auth.ValidateToken(token)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), tokenInfoKey, info)
|
||||
next(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// requireAdmin requires the authenticated user to have admin role.
|
||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
info := TokenInfoFromContext(r.Context())
|
||||
if info == nil || !info.IsAdmin {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func extractToken(r *http.Request) string {
|
||||
// Check Authorization header first.
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
return strings.TrimPrefix(authHeader, "Bearer ")
|
||||
}
|
||||
// Fall back to cookie.
|
||||
cookie, err := r.Cookie("metacrypt_token")
|
||||
if err == nil {
|
||||
return cookie.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
532
internal/server/routes.go
Normal file
532
internal/server/routes.go
Normal file
@@ -0,0 +1,532 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
||||
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
||||
)
|
||||
|
||||
func (s *Server) registerRoutes(mux *http.ServeMux) {
|
||||
// Static files.
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
|
||||
|
||||
// Web UI routes.
|
||||
mux.HandleFunc("/", s.handleWebRoot)
|
||||
mux.HandleFunc("/init", s.handleWebInit)
|
||||
mux.HandleFunc("/unseal", s.handleWebUnseal)
|
||||
mux.HandleFunc("/login", s.handleWebLogin)
|
||||
mux.HandleFunc("/dashboard", s.requireAuthWeb(s.handleWebDashboard))
|
||||
|
||||
// API routes.
|
||||
mux.HandleFunc("/v1/status", s.handleStatus)
|
||||
mux.HandleFunc("/v1/init", s.handleInit)
|
||||
mux.HandleFunc("/v1/unseal", s.handleUnseal)
|
||||
mux.HandleFunc("/v1/seal", s.requireAdmin(s.handleSeal))
|
||||
|
||||
mux.HandleFunc("/v1/auth/login", s.handleLogin)
|
||||
mux.HandleFunc("/v1/auth/logout", s.requireAuth(s.handleLogout))
|
||||
mux.HandleFunc("/v1/auth/tokeninfo", s.requireAuth(s.handleTokenInfo))
|
||||
|
||||
mux.HandleFunc("/v1/engine/mounts", s.requireAuth(s.handleEngineMounts))
|
||||
mux.HandleFunc("/v1/engine/mount", s.requireAdmin(s.handleEngineMount))
|
||||
mux.HandleFunc("/v1/engine/unmount", s.requireAdmin(s.handleEngineUnmount))
|
||||
mux.HandleFunc("/v1/engine/request", s.requireAuth(s.handleEngineRequest))
|
||||
|
||||
mux.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules))
|
||||
mux.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
|
||||
}
|
||||
|
||||
// --- API Handlers ---
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"state": s.seal.State().String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleInit(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Password == "" {
|
||||
http.Error(w, `{"error":"password is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
params := crypto.Argon2Params{
|
||||
Time: s.cfg.Seal.Argon2Time,
|
||||
Memory: s.cfg.Seal.Argon2Memory,
|
||||
Threads: s.cfg.Seal.Argon2Threads,
|
||||
}
|
||||
if err := s.seal.Initialize(r.Context(), []byte(req.Password), params); err != nil {
|
||||
if err == seal.ErrAlreadyInitialized {
|
||||
http.Error(w, `{"error":"already initialized"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
s.logger.Error("init failed", "error", err)
|
||||
http.Error(w, `{"error":"initialization failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"state": s.seal.State().String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUnseal(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.seal.Unseal([]byte(req.Password)); err != nil {
|
||||
switch err {
|
||||
case seal.ErrNotInitialized:
|
||||
http.Error(w, `{"error":"not initialized"}`, http.StatusPreconditionFailed)
|
||||
case seal.ErrInvalidPassword:
|
||||
http.Error(w, `{"error":"invalid password"}`, http.StatusUnauthorized)
|
||||
case seal.ErrRateLimited:
|
||||
http.Error(w, `{"error":"too many attempts, try again later"}`, http.StatusTooManyRequests)
|
||||
case seal.ErrNotSealed:
|
||||
http.Error(w, `{"error":"already unsealed"}`, http.StatusConflict)
|
||||
default:
|
||||
s.logger.Error("unseal failed", "error", err)
|
||||
http.Error(w, `{"error":"unseal failed"}`, http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"state": s.seal.State().String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSeal(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.engines.SealAll(); err != nil {
|
||||
s.logger.Error("seal engines failed", "error", err)
|
||||
}
|
||||
|
||||
if err := s.seal.Seal(); err != nil {
|
||||
s.logger.Error("seal failed", "error", err)
|
||||
http.Error(w, `{"error":"seal failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.auth.ClearCache()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"state": s.seal.State().String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if s.seal.State() != seal.StateUnsealed {
|
||||
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
token, expiresAt, err := s.auth.Login(req.Username, req.Password, req.TOTPCode)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"token": token,
|
||||
"expires_at": expiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
token := extractToken(r)
|
||||
client, err := mcias.New(s.cfg.MCIAS.ServerURL, mcias.Options{
|
||||
CACertPath: s.cfg.MCIAS.CACert,
|
||||
Token: token,
|
||||
})
|
||||
if err == nil {
|
||||
s.auth.Logout(client)
|
||||
}
|
||||
|
||||
// Clear cookie.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "metacrypt_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleTokenInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
info := TokenInfoFromContext(r.Context())
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"username": info.Username,
|
||||
"roles": info.Roles,
|
||||
"is_admin": info.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleEngineMounts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
mounts := s.engines.ListMounts()
|
||||
writeJSON(w, http.StatusOK, mounts)
|
||||
}
|
||||
|
||||
func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Phase 1: no engine types registered yet.
|
||||
http.Error(w, `{"error":"no engine types available in phase 1"}`, http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.engines.Unmount(req.Name); err != nil {
|
||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// Phase 1 stub.
|
||||
http.Error(w, `{"error":"no engine types available in phase 1"}`, http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (s *Server) handlePolicyRules(w http.ResponseWriter, r *http.Request) {
|
||||
info := TokenInfoFromContext(r.Context())
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if !info.IsAdmin {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
rules, err := s.policy.ListRules(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("list policies", "error", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if rules == nil {
|
||||
rules = []policy.Rule{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rules)
|
||||
case http.MethodPost:
|
||||
if !info.IsAdmin {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var rule policy.Rule
|
||||
if err := readJSON(r, &rule); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if rule.ID == "" {
|
||||
http.Error(w, `{"error":"id is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.policy.CreateRule(r.Context(), &rule); err != nil {
|
||||
s.logger.Error("create policy", "error", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, rule)
|
||||
default:
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handlePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
info := TokenInfoFromContext(r.Context())
|
||||
if !info.IsAdmin {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
http.Error(w, `{"error":"id parameter required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
rule, err := s.policy.GetRule(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rule)
|
||||
case http.MethodDelete:
|
||||
if err := s.policy.DeleteRule(r.Context(), id); err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
default:
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Web Handlers ---
|
||||
|
||||
func (s *Server) handleWebRoot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
state := s.seal.State()
|
||||
switch state {
|
||||
case seal.StateUninitialized:
|
||||
http.Redirect(w, r, "/init", http.StatusFound)
|
||||
case seal.StateSealed:
|
||||
http.Redirect(w, r, "/unseal", http.StatusFound)
|
||||
case seal.StateInitializing:
|
||||
http.Redirect(w, r, "/init", http.StatusFound)
|
||||
case seal.StateUnsealed:
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWebInit(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if s.seal.State() != seal.StateUninitialized {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.renderTemplate(w, "init.html", nil)
|
||||
case http.MethodPost:
|
||||
r.ParseForm()
|
||||
password := r.FormValue("password")
|
||||
if password == "" {
|
||||
s.renderTemplate(w, "init.html", map[string]interface{}{"Error": "Password is required"})
|
||||
return
|
||||
}
|
||||
params := crypto.Argon2Params{
|
||||
Time: s.cfg.Seal.Argon2Time,
|
||||
Memory: s.cfg.Seal.Argon2Memory,
|
||||
Threads: s.cfg.Seal.Argon2Threads,
|
||||
}
|
||||
if err := s.seal.Initialize(r.Context(), []byte(password), params); err != nil {
|
||||
s.renderTemplate(w, "init.html", map[string]interface{}{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUnseal(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
state := s.seal.State()
|
||||
if state == seal.StateUninitialized {
|
||||
http.Redirect(w, r, "/init", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if state == seal.StateUnsealed {
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.renderTemplate(w, "unseal.html", nil)
|
||||
case http.MethodPost:
|
||||
r.ParseForm()
|
||||
password := r.FormValue("password")
|
||||
if err := s.seal.Unseal([]byte(password)); err != nil {
|
||||
msg := "Invalid password"
|
||||
if err == seal.ErrRateLimited {
|
||||
msg = "Too many attempts. Please wait 60 seconds."
|
||||
}
|
||||
s.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": msg})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWebLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.seal.State() != seal.StateUnsealed {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.renderTemplate(w, "login.html", nil)
|
||||
case http.MethodPost:
|
||||
r.ParseForm()
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
totpCode := r.FormValue("totp_code")
|
||||
token, _, err := s.auth.Login(username, password, totpCode)
|
||||
if err != nil {
|
||||
s.renderTemplate(w, "login.html", map[string]interface{}{"Error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "metacrypt_token",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWebDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
info := TokenInfoFromContext(r.Context())
|
||||
mounts := s.engines.ListMounts()
|
||||
s.renderTemplate(w, "dashboard.html", map[string]interface{}{
|
||||
"Username": info.Username,
|
||||
"IsAdmin": info.IsAdmin,
|
||||
"Roles": info.Roles,
|
||||
"Mounts": mounts,
|
||||
"State": s.seal.State().String(),
|
||||
})
|
||||
}
|
||||
|
||||
// requireAuthWeb redirects to login for web pages instead of returning 401.
|
||||
func (s *Server) requireAuthWeb(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.seal.State() != seal.StateUnsealed {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
token := extractToken(r)
|
||||
if token == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
info, err := s.auth.ValidateToken(token)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, tokenInfoKey, info)
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
|
||||
tmpl, err := template.ParseFiles(
|
||||
filepath.Join("web", "templates", "layout.html"),
|
||||
filepath.Join("web", "templates", name),
|
||||
)
|
||||
if err != nil {
|
||||
s.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 {
|
||||
s.logger.Error("execute template", "name", name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, v interface{}) error {
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(body, v)
|
||||
}
|
||||
77
internal/server/server.go
Normal file
77
internal/server/server.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Package server implements the HTTP server for Metacrypt.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
||||
)
|
||||
|
||||
// Server is the Metacrypt HTTP server.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
seal *seal.Manager
|
||||
auth *auth.Authenticator
|
||||
policy *policy.Engine
|
||||
engines *engine.Registry
|
||||
httpSrv *http.Server
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new server.
|
||||
func New(cfg *config.Config, sealMgr *seal.Manager, authenticator *auth.Authenticator,
|
||||
policyEngine *policy.Engine, engineRegistry *engine.Registry, logger *slog.Logger) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
seal: sealMgr,
|
||||
auth: authenticator,
|
||||
policy: policyEngine,
|
||||
engines: engineRegistry,
|
||||
logger: logger,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Start starts the HTTPS server.
|
||||
func (s *Server) Start() error {
|
||||
mux := http.NewServeMux()
|
||||
s.registerRoutes(mux)
|
||||
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
},
|
||||
}
|
||||
|
||||
s.httpSrv = &http.Server{
|
||||
Addr: s.cfg.Server.ListenAddr,
|
||||
Handler: s.loggingMiddleware(mux),
|
||||
TLSConfig: tlsCfg,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
s.logger.Info("starting server", "addr", s.cfg.Server.ListenAddr)
|
||||
err := s.httpSrv.ListenAndServeTLS(s.cfg.Server.TLSCert, s.cfg.Server.TLSKey)
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
return s.httpSrv.Shutdown(ctx)
|
||||
}
|
||||
179
internal/server/server_test.go
Normal file
179
internal/server/server_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/db"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
||||
|
||||
// auth is used indirectly via the server
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
|
||||
)
|
||||
|
||||
func setupTestServer(t *testing.T) (*Server, *seal.Manager, *http.ServeMux) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
db.Migrate(database)
|
||||
|
||||
b := barrier.NewAESGCMBarrier(database)
|
||||
sealMgr := seal.NewManager(database, b)
|
||||
sealMgr.CheckInitialized()
|
||||
|
||||
// Auth requires MCIAS client which we can't create in tests easily,
|
||||
// so we pass nil and avoid auth-dependent routes in these tests.
|
||||
authenticator := auth.NewAuthenticator(nil)
|
||||
policyEngine := policy.NewEngine(b)
|
||||
engineRegistry := engine.NewRegistry(b)
|
||||
|
||||
cfg := &config.Config{
|
||||
Server: config.ServerConfig{
|
||||
ListenAddr: ":0",
|
||||
TLSCert: "cert.pem",
|
||||
TLSKey: "key.pem",
|
||||
},
|
||||
Database: config.DatabaseConfig{Path: filepath.Join(dir, "test.db")},
|
||||
MCIAS: config.MCIASConfig{ServerURL: "https://mcias.test"},
|
||||
Seal: config.SealConfig{
|
||||
Argon2Time: 1,
|
||||
Argon2Memory: 64 * 1024,
|
||||
Argon2Threads: 1,
|
||||
},
|
||||
}
|
||||
|
||||
logger := slog.Default()
|
||||
srv := New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
srv.registerRoutes(mux)
|
||||
return srv, sealMgr, mux
|
||||
}
|
||||
|
||||
func TestStatusEndpoint(t *testing.T) {
|
||||
_, _, mux := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status code: got %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["state"] != "uninitialized" {
|
||||
t.Errorf("state: got %q, want %q", resp["state"], "uninitialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitEndpoint(t *testing.T) {
|
||||
_, _, mux := setupTestServer(t)
|
||||
|
||||
body := `{"password":"test-password"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/init", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status code: got %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["state"] != "unsealed" {
|
||||
t.Errorf("state: got %q, want %q", resp["state"], "unsealed")
|
||||
}
|
||||
|
||||
// Second init should fail.
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/v1/init", strings.NewReader(body))
|
||||
w2 := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w2, req2)
|
||||
if w2.Code != http.StatusConflict {
|
||||
t.Errorf("double init: got %d, want %d", w2.Code, http.StatusConflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsealEndpoint(t *testing.T) {
|
||||
_, sealMgr, mux := setupTestServer(t)
|
||||
|
||||
// Initialize first.
|
||||
params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1}
|
||||
sealMgr.Initialize(context.Background(), []byte("password"), params)
|
||||
sealMgr.Seal()
|
||||
|
||||
// Unseal with wrong password.
|
||||
body := `{"password":"wrong"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/unseal", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("wrong password: got %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// Unseal with correct password.
|
||||
body = `{"password":"password"}`
|
||||
req = httptest.NewRequest(http.MethodPost, "/v1/unseal", strings.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("correct password: got %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMethodNotAllowed(t *testing.T) {
|
||||
_, _, mux := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("POST /v1/status: got %d, want %d", w.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootRedirect(t *testing.T) {
|
||||
_, _, mux := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Errorf("root redirect: got %d, want %d", w.Code, http.StatusFound)
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if loc != "/init" {
|
||||
t.Errorf("redirect location: got %q, want /init", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenInfoFromContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if info := TokenInfoFromContext(ctx); info != nil {
|
||||
t.Error("expected nil from empty context")
|
||||
}
|
||||
|
||||
info := &auth.TokenInfo{Username: "test", IsAdmin: true}
|
||||
ctx = context.WithValue(ctx, tokenInfoKey, info)
|
||||
got := TokenInfoFromContext(ctx)
|
||||
if got == nil || got.Username != "test" {
|
||||
t.Error("expected token info from context")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user