Add the first concrete engine implementation: a CA (PKI) engine that generates
a self-signed root CA at mount time, issues scoped intermediate CAs ("issuers"),
and signs leaf certificates using configurable profiles (server, client, peer).
Engine framework updates:
- Add CallerInfo struct for auth context in engine requests
- Add config parameter to Engine.Initialize for mount-time configuration
- Export Mount.Engine field; add GetEngine/GetMount on Registry
CA engine (internal/engine/ca/):
- Two-tier PKI: root CA → issuers → leaf certificates
- 10 operations: get-root, get-chain, get-issuer, create/delete/list issuers,
issue, get-cert, list-certs, renew
- Certificate profiles with user-overridable TTL, key usages, and key algorithm
- Private keys never stored in barrier; zeroized from memory on seal
- Supports ECDSA, RSA, and Ed25519 key types via goutils/certlib/certgen
Server routes:
- Wire up engine mount/request handlers (replace Phase 1 stubs)
- Add public PKI routes (/v1/pki/{mount}/ca, /ca/chain, /issuer/{name})
for unauthenticated TLS trust bootstrapping
Also includes: ARCHITECTURE.md, deploy config updates, operational tooling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
684 lines
20 KiB
Go
684 lines
20 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"html/template"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
|
|
|
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
|
|
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
|
"git.wntrmute.dev/kyle/metacrypt/internal/engine/ca"
|
|
"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))
|
|
|
|
// Public PKI routes (no auth required, but must be unsealed).
|
|
mux.HandleFunc("GET /v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot))
|
|
mux.HandleFunc("GET /v1/pki/{mount}/ca/chain", s.requireUnseal(s.handlePKIChain))
|
|
mux.HandleFunc("GET /v1/pki/{mount}/issuer/{name}", s.requireUnseal(s.handlePKIIssuer))
|
|
|
|
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"`
|
|
Config map[string]interface{} `json:"config"`
|
|
}
|
|
if err := readJSON(r, &req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Name == "" || req.Type == "" {
|
|
http.Error(w, `{"error":"name and type are required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := s.engines.Mount(r.Context(), req.Name, engine.EngineType(req.Type), req.Config); err != nil {
|
|
s.logger.Error("mount engine", "name", req.Name, "type", req.Type, "error", err)
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var req struct {
|
|
Mount string `json:"mount"`
|
|
Operation string `json:"operation"`
|
|
Path string `json:"path"`
|
|
Data map[string]interface{} `json:"data"`
|
|
}
|
|
if err := readJSON(r, &req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Mount == "" || req.Operation == "" {
|
|
http.Error(w, `{"error":"mount and operation are required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
info := TokenInfoFromContext(r.Context())
|
|
engReq := &engine.Request{
|
|
Operation: req.Operation,
|
|
Path: req.Path,
|
|
Data: req.Data,
|
|
CallerInfo: &engine.CallerInfo{
|
|
Username: info.Username,
|
|
Roles: info.Roles,
|
|
IsAdmin: info.IsAdmin,
|
|
},
|
|
}
|
|
|
|
resp, err := s.engines.HandleRequest(r.Context(), req.Mount, engReq)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
// Map known errors to appropriate status codes.
|
|
switch {
|
|
case errors.Is(err, engine.ErrMountNotFound):
|
|
status = http.StatusNotFound
|
|
case strings.Contains(err.Error(), "forbidden"):
|
|
status = http.StatusForbidden
|
|
case strings.Contains(err.Error(), "authentication required"):
|
|
status = http.StatusUnauthorized
|
|
case strings.Contains(err.Error(), "not found"):
|
|
status = http.StatusNotFound
|
|
}
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, status)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp.Data)
|
|
}
|
|
|
|
// --- Public PKI Handlers ---
|
|
|
|
func (s *Server) handlePKIRoot(w http.ResponseWriter, r *http.Request) {
|
|
mountName := r.PathValue("mount")
|
|
caEng, err := s.getCAEngine(mountName)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
certPEM, err := caEng.GetRootCertPEM()
|
|
if err != nil {
|
|
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/x-pem-file")
|
|
w.Write(certPEM)
|
|
}
|
|
|
|
func (s *Server) handlePKIChain(w http.ResponseWriter, r *http.Request) {
|
|
mountName := r.PathValue("mount")
|
|
issuerName := r.URL.Query().Get("issuer")
|
|
if issuerName == "" {
|
|
http.Error(w, `{"error":"issuer query parameter required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
caEng, err := s.getCAEngine(mountName)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
chainPEM, err := caEng.GetChainPEM(issuerName)
|
|
if err != nil {
|
|
if errors.Is(err, ca.ErrIssuerNotFound) {
|
|
http.Error(w, `{"error":"issuer not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/x-pem-file")
|
|
w.Write(chainPEM)
|
|
}
|
|
|
|
func (s *Server) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
|
|
mountName := r.PathValue("mount")
|
|
issuerName := r.PathValue("name")
|
|
|
|
caEng, err := s.getCAEngine(mountName)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
certPEM, err := caEng.GetIssuerCertPEM(issuerName)
|
|
if err != nil {
|
|
if errors.Is(err, ca.ErrIssuerNotFound) {
|
|
http.Error(w, `{"error":"issuer not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/x-pem-file")
|
|
w.Write(certPEM)
|
|
}
|
|
|
|
func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) {
|
|
mount, err := s.engines.GetMount(mountName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if mount.Type != engine.EngineTypeCA {
|
|
return nil, errors.New("mount is not a CA engine")
|
|
}
|
|
caEng, ok := mount.Engine.(*ca.CAEngine)
|
|
if !ok {
|
|
return nil, errors.New("mount is not a CA engine")
|
|
}
|
|
return caEng, nil
|
|
}
|
|
|
|
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)
|
|
}
|