205 lines
6.7 KiB
Go
205 lines
6.7 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/rsa"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
blog "github.com/letsencrypt/boulder/log"
|
|
)
|
|
|
|
// RequestEvent is a structured record of the metadata we care about for a
|
|
// single web request. It is generated when a request is received, passed to
|
|
// the request handler which can populate its fields as appropriate, and then
|
|
// logged when the request completes.
|
|
type RequestEvent struct {
|
|
// These fields are not rendered in JSON; instead, they are rendered
|
|
// whitespace-separated ahead of the JSON. This saves bytes in the logs since
|
|
// we don't have to include field names, quotes, or commas -- all of these
|
|
// fields are known to not include whitespace.
|
|
Method string `json:"-"`
|
|
Endpoint string `json:"-"`
|
|
Requester int64 `json:"-"`
|
|
Code int `json:"-"`
|
|
Latency float64 `json:"-"`
|
|
RealIP string `json:"-"`
|
|
|
|
Slug string `json:",omitempty"`
|
|
InternalErrors []string `json:",omitempty"`
|
|
Error string `json:",omitempty"`
|
|
UserAgent string `json:"ua,omitempty"`
|
|
// Origin is sent by the browser from XHR-based clients.
|
|
Origin string `json:",omitempty"`
|
|
Extra map[string]interface{} `json:",omitempty"`
|
|
|
|
// For endpoints that create objects, the ID of the newly created object.
|
|
Created string `json:",omitempty"`
|
|
|
|
// For challenge and authorization GETs and POSTs:
|
|
// the status of the authorization at the time the request began.
|
|
Status string `json:",omitempty"`
|
|
// The DNS name, if there is a single relevant name, for instance
|
|
// in an authorization or challenge request.
|
|
DNSName string `json:",omitempty"`
|
|
// The set of DNS names, if there are potentially multiple relevant
|
|
// names, for instance in a new-order, finalize, or revoke request.
|
|
DNSNames []string `json:",omitempty"`
|
|
|
|
// For challenge POSTs, the challenge type.
|
|
ChallengeType string `json:",omitempty"`
|
|
|
|
// suppressed controls whether this event will be logged when the request
|
|
// completes. If true, no log line will be emitted. Can only be set by
|
|
// calling .Suppress(); automatically unset by adding an internal error.
|
|
suppressed bool `json:"-"`
|
|
}
|
|
|
|
// AddError formats the given message with the given args and appends it to the
|
|
// list of internal errors that have occurred as part of handling this event.
|
|
// If the RequestEvent has been suppressed, this un-suppresses it.
|
|
func (e *RequestEvent) AddError(msg string, args ...interface{}) {
|
|
e.InternalErrors = append(e.InternalErrors, fmt.Sprintf(msg, args...))
|
|
e.suppressed = false
|
|
}
|
|
|
|
// Suppress causes the RequestEvent to not be logged at all when the request
|
|
// is complete. This is a no-op if an internal error has been added to the event
|
|
// (logging errors takes precedence over suppressing output).
|
|
func (e *RequestEvent) Suppress() {
|
|
if len(e.InternalErrors) == 0 {
|
|
e.suppressed = true
|
|
}
|
|
}
|
|
|
|
type WFEHandlerFunc func(context.Context, *RequestEvent, http.ResponseWriter, *http.Request)
|
|
|
|
func (f WFEHandlerFunc) ServeHTTP(e *RequestEvent, w http.ResponseWriter, r *http.Request) {
|
|
f(r.Context(), e, w, r)
|
|
}
|
|
|
|
type wfeHandler interface {
|
|
ServeHTTP(e *RequestEvent, w http.ResponseWriter, r *http.Request)
|
|
}
|
|
|
|
type TopHandler struct {
|
|
wfe wfeHandler
|
|
log blog.Logger
|
|
}
|
|
|
|
func NewTopHandler(log blog.Logger, wfe wfeHandler) *TopHandler {
|
|
return &TopHandler{
|
|
wfe: wfe,
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
// responseWriterWithStatus satisfies http.ResponseWriter, but keeps track of the
|
|
// status code for logging.
|
|
type responseWriterWithStatus struct {
|
|
http.ResponseWriter
|
|
code int
|
|
}
|
|
|
|
// WriteHeader stores a status code for generating stats.
|
|
func (r *responseWriterWithStatus) WriteHeader(code int) {
|
|
r.code = code
|
|
r.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (th *TopHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
// Check that this header is well-formed, since we assume it is when logging.
|
|
realIP := r.Header.Get("X-Real-IP")
|
|
if net.ParseIP(realIP) == nil {
|
|
realIP = "0.0.0.0"
|
|
}
|
|
|
|
logEvent := &RequestEvent{
|
|
RealIP: realIP,
|
|
Method: r.Method,
|
|
UserAgent: r.Header.Get("User-Agent"),
|
|
Origin: r.Header.Get("Origin"),
|
|
Extra: make(map[string]interface{}),
|
|
}
|
|
// We specifically override the default r.Context() because we would prefer
|
|
// for clients to not be able to cancel our operations in arbitrary places.
|
|
// Instead we start a new context, and apply timeouts in our various RPCs.
|
|
// TODO(go1.22?): Use context.Detach()
|
|
span := trace.SpanFromContext(r.Context())
|
|
ctx := trace.ContextWithSpan(context.Background(), span)
|
|
r = r.WithContext(ctx)
|
|
|
|
// Some clients will send a HTTP Host header that includes the default port
|
|
// for the scheme that they are using. Previously when we were fronted by
|
|
// Akamai they would rewrite the header and strip out the unnecessary port,
|
|
// now that they are not in our request path we need to strip these ports out
|
|
// ourselves.
|
|
//
|
|
// The main reason we want to strip these ports out is so that when this header
|
|
// is sent to the /directory endpoint we don't reply with directory URLs that
|
|
// also contain these ports.
|
|
//
|
|
// We unconditionally strip :443 even when r.TLS is nil because the WFE2
|
|
// may be deployed HTTP-only behind another service that terminates HTTPS on
|
|
// its behalf.
|
|
r.Host = strings.TrimSuffix(r.Host, ":443")
|
|
r.Host = strings.TrimSuffix(r.Host, ":80")
|
|
|
|
begin := time.Now()
|
|
rwws := &responseWriterWithStatus{w, 0}
|
|
defer func() {
|
|
logEvent.Code = rwws.code
|
|
if logEvent.Code == 0 {
|
|
// If we haven't explicitly set a status code golang will set it
|
|
// to 200 itself when writing to the wire
|
|
logEvent.Code = http.StatusOK
|
|
}
|
|
logEvent.Latency = time.Since(begin).Seconds()
|
|
th.logEvent(logEvent)
|
|
}()
|
|
th.wfe.ServeHTTP(logEvent, rwws, r)
|
|
}
|
|
|
|
func (th *TopHandler) logEvent(logEvent *RequestEvent) {
|
|
if logEvent.suppressed {
|
|
return
|
|
}
|
|
var msg string
|
|
jsonEvent, err := json.Marshal(logEvent)
|
|
if err != nil {
|
|
th.log.AuditErrf("failed to marshal logEvent - %s - %#v", msg, err)
|
|
return
|
|
}
|
|
th.log.Infof("%s %s %d %d %d %s JSON=%s",
|
|
logEvent.Method, logEvent.Endpoint, logEvent.Requester, logEvent.Code,
|
|
int(logEvent.Latency*1000), logEvent.RealIP, jsonEvent)
|
|
}
|
|
|
|
// GetClientAddr returns a comma-separated list of HTTP clients involved in
|
|
// making this request, starting with the original requester and ending with the
|
|
// remote end of our TCP connection (which is typically our own proxy).
|
|
func GetClientAddr(r *http.Request) string {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
return xff + "," + r.RemoteAddr
|
|
}
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
func KeyTypeToString(pub crypto.PublicKey) string {
|
|
switch pk := pub.(type) {
|
|
case *rsa.PublicKey:
|
|
return fmt.Sprintf("RSA %d", pk.N.BitLen())
|
|
case *ecdsa.PublicKey:
|
|
return fmt.Sprintf("ECDSA %s", pk.Params().Name)
|
|
}
|
|
return "unknown"
|
|
}
|