Add localhost resolve HTTP hop with bearer auth and redaction
Implement `keyproxy serve`: POST /v1/resolve (constant-time bearer-token
auth, 4 KiB body cap, mpk- ref validation before any echo) and
GET /healthz. The token is resolved once at startup from the file
backend. Panics are recovered with the panic value discarded and detail
suppressed; auth failures log the remote address only; every log line
naming a ref masks it as <ref>=***. Failures respond with fixed reason
enums (404/400/405/413/500/501/502) that cannot embed material.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
// Package server implements `keyproxy serve`: a localhost HTTP hop that
|
||||
// resolves mpk- refs to material. Consumers authenticate with a bearer
|
||||
// token bootstrapped from the file backend; material crosses the wire at
|
||||
// resolve time only, in memory, never persisted, never logged — every
|
||||
// log line that names a ref masks any value as <ref>=***.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto/subtle"
|
||||
|
||||
"git.knownelement.com/ukrrs/mopac-keyproxy/internal/backend"
|
||||
"git.knownelement.com/ukrrs/mopac-keyproxy/internal/config"
|
||||
)
|
||||
|
||||
// maxBodyBytes bounds resolve requests; bigger bodies are rejected
|
||||
// before parsing (413).
|
||||
const maxBodyBytes = 4 << 10
|
||||
|
||||
// Server is the `keyproxy serve` HTTP hop.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
reg *backend.Registry
|
||||
token string // resolved at startup from the file backend; never logged
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// New resolves the bearer token through the registry (fail-fast: the
|
||||
// auth ref must resolve at startup) and returns the server. The token
|
||||
// value never reaches the log writer; a backend panic during the
|
||||
// startup resolve is converted to a generic error (a panic value may
|
||||
// embed material).
|
||||
func New(cfg *config.Config, reg *backend.Registry, out io.Writer) (*Server, error) {
|
||||
ref := cfg.Refs[cfg.AuthTokenRef]
|
||||
b, ok := reg.Get(ref.Backend)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("auth ref %s: backend %s not registered", cfg.AuthTokenRef, ref.Backend)
|
||||
}
|
||||
token, err := resolveAuthToken(b, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
reg: reg,
|
||||
token: token,
|
||||
logger: log.New(out, "keyproxy: ", log.LstdFlags|log.Lmsgprefix),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveAuthToken resolves the bearer token, converting a backend
|
||||
// panic into a generic error whose text carries no material.
|
||||
func resolveAuthToken(b backend.Backend, ref backend.Ref) (token string, err error) {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
_ = p // never stringified: never logged
|
||||
err = fmt.Errorf("auth ref %s: backend %s: resolve panicked (detail suppressed)", ref.Name, ref.Backend)
|
||||
}
|
||||
}()
|
||||
token, err = b.Resolve(context.Background(), ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auth ref %s: %w", ref.Name, err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Handler builds the HTTP routes: POST /v1/resolve (bearer auth) and
|
||||
// GET /healthz (unauthenticated liveness).
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"status":"ok"}`)
|
||||
})
|
||||
mux.HandleFunc("/v1/resolve", s.resolve)
|
||||
return mux
|
||||
}
|
||||
|
||||
// masked returns the log-safe form of a ref: <ref>=***. Refs are
|
||||
// validated mpk- placeholders before they ever reach a log line, so the
|
||||
// name itself is safe; the =*** form asserts the value stays masked.
|
||||
func masked(ref string) string { return ref + "=***" }
|
||||
|
||||
func (s *Server) resolve(w http.ResponseWriter, r *http.Request) {
|
||||
// Crash-path redaction: if anything below panics, log the tracked
|
||||
// ref/backend (masked, format-validated) and NEVER the panic value
|
||||
// (a panic value may embed material). Stack suppressed for the same
|
||||
// reason.
|
||||
logRef, logBackend := "-", "-"
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
_ = p // never stringified: never logged
|
||||
s.logger.Printf("panic recovered ref=%s backend=%s status=500 detail=suppressed", logRef, logBackend)
|
||||
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||
}
|
||||
}()
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"})
|
||||
return
|
||||
}
|
||||
|
||||
// Auth first, before the body is read: failures log the remote only,
|
||||
// never the presented token.
|
||||
if !s.authorized(r) {
|
||||
s.logger.Printf("auth failure remote=%s status=401", r.RemoteAddr)
|
||||
s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
body, ok := s.readBody(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil || strings.TrimSpace(req.Ref) == "" {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed body (expected {\"ref\":\"mpk-...\"})"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Ref)
|
||||
if !config.ValidRef(name) {
|
||||
// Not echoed: an unparsable "ref" field could hold pasted
|
||||
// material. Only format-validated mpk- refs are ever echoed.
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid ref (expected mpk-<name>)"})
|
||||
return
|
||||
}
|
||||
logRef = masked(name)
|
||||
|
||||
ref, ok := s.cfg.Refs[name]
|
||||
if !ok {
|
||||
s.logger.Printf("resolve ref=%s backend=%s status=404 reason=%s", logRef, logBackend, backend.ReasonUnknownRef)
|
||||
s.writeJSON(w, http.StatusNotFound, map[string]string{"error": "unknown ref", "ref": name})
|
||||
return
|
||||
}
|
||||
logBackend = ref.Backend
|
||||
|
||||
b, ok := s.reg.Get(ref.Backend)
|
||||
if !ok {
|
||||
s.logger.Printf("resolve ref=%s backend=%s status=500 reason=unknown_backend", logRef, logBackend)
|
||||
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "backend not registered", "backend": ref.Backend})
|
||||
return
|
||||
}
|
||||
|
||||
value, err := b.Resolve(r.Context(), ref)
|
||||
if err != nil {
|
||||
rerr := backend.AsResolveError(err, ref, ref.Backend)
|
||||
status := http.StatusBadGateway
|
||||
if rerr.Reason == backend.ReasonNotImplemented {
|
||||
status = http.StatusNotImplemented
|
||||
}
|
||||
// The reason is a fixed enum: it can name the ref and the
|
||||
// backend, never material.
|
||||
s.logger.Printf("resolve ref=%s backend=%s status=%d reason=%s", logRef, logBackend, status, rerr.Reason)
|
||||
s.writeJSON(w, status, map[string]string{
|
||||
"error": "resolution failed",
|
||||
"ref": name,
|
||||
"backend": ref.Backend,
|
||||
"reason": string(rerr.Reason),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("resolve ref=%s backend=%s status=200", logRef, logBackend)
|
||||
s.writeJSON(w, http.StatusOK, map[string]string{"value": value})
|
||||
}
|
||||
|
||||
// authorized checks the bearer token in constant time; the presented
|
||||
// value is never logged and never compared with early exit.
|
||||
func (s *Server) authorized(r *http.Request) bool {
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
||||
return false
|
||||
}
|
||||
presented := h[len(prefix):]
|
||||
return subtle.ConstantTimeCompare([]byte(presented), []byte(s.token)) == 1
|
||||
}
|
||||
|
||||
func (s *Server) readBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
|
||||
if err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unreadable body"})
|
||||
return nil, false
|
||||
}
|
||||
if len(body) > maxBodyBytes {
|
||||
s.writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
|
||||
return nil, false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
func (s *Server) writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// ListenAndServe runs the HTTP hop until ctx is cancelled, then shuts
|
||||
// down gracefully (material is memory-only; shutdown drops it).
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
httpSrv := &http.Server{
|
||||
Addr: s.cfg.Listen,
|
||||
Handler: s.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- httpSrv.ListenAndServe() }()
|
||||
s.logger.Printf("listening addr=%s refs=%d backends=%s auth=%s", s.cfg.Listen, len(s.cfg.Refs), strings.Join(s.reg.Names(), ","), masked(s.cfg.AuthTokenRef))
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return httpSrv.Shutdown(shutdownCtx)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user