vspd/internal/webapi/middleware.go
jholdstock c7ebe28501 webapi: Try cache update before returning error.
Rather than immediately returning an error if the cache is not
initialized, attempt to initialize it first. Without this change there
is only an attempt to initialize the cache once per minute.
2023-09-19 17:58:59 +01:00

443 lines
14 KiB
Go

// Copyright (c) 2020-2023 The Decred developers
// Use of this source code is governed by an ISC
// license that can be found in the LICENSE file.
package webapi
import (
"bytes"
"errors"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/decred/dcrd/blockchain/stake/v5"
"github.com/decred/vspd/rpc"
"github.com/decred/vspd/types/v2"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/gorilla/sessions"
"github.com/jrick/wsrpc/v2"
"golang.org/x/time/rate"
)
// This is a hard-coded string from the securecookie library.
const invalidCookieErr = "securecookie: the value is not valid"
// rateLimit middleware limits how many requests each client IP can submit per
// second. If the limit is exceeded the limitExceeded handler will be executed
// and the context will be aborted.
func rateLimit(limit rate.Limit, limitExceeded gin.HandlerFunc) gin.HandlerFunc {
var limitersMtx sync.Mutex
limiters := make(map[string]*rate.Limiter)
// Clear limiters every hour so they arent accumulating infinitely.
go func() {
for {
<-time.After(time.Hour)
limitersMtx.Lock()
limiters = make(map[string]*rate.Limiter)
limitersMtx.Unlock()
}
}()
return func(c *gin.Context) {
limitersMtx.Lock()
defer limitersMtx.Unlock()
// Create a limiter for this IP if one does not exist.
if _, ok := limiters[c.ClientIP()]; !ok {
limiters[c.ClientIP()] = rate.NewLimiter(limit, 1)
}
// Check if this IP exceeds limit.
if !limiters[c.ClientIP()].Allow() {
limitExceeded(c)
c.Abort()
}
}
}
// withSession middleware adds a gorilla session to the request context for
// downstream handlers to make use of. Sessions are used by admin pages to
// maintain authentication status.
func (w *WebAPI) withSession(store *sessions.CookieStore) gin.HandlerFunc {
return func(c *gin.Context) {
session, err := store.Get(c.Request, "vspd-session")
if err != nil {
// "value is not valid" occurs if the cookie secret changes. This is
// common during development (eg. when using the test harness) but
// it should not occur in production.
if strings.Contains(err.Error(), invalidCookieErr) {
w.log.Warn("Cookie secret has changed. Generating new session.")
// Persist the newly generated session.
err = store.Save(c.Request, c.Writer, session)
if err != nil {
w.log.Errorf("Error saving session: %v", err)
c.String(http.StatusInternalServerError, "Error saving session")
c.Abort()
return
}
} else {
w.log.Errorf("Session error: %v", err)
c.String(http.StatusInternalServerError, "Error getting session")
c.Abort()
return
}
}
c.Set(sessionKey, session)
}
}
// requireWebCache will only allow the request to proceed if the web API cache
// has been initialized with data, otherwise it will return a 500 Internal
// Server Error.
func (w *WebAPI) requireWebCache(c *gin.Context) {
if !w.cache.initialized() {
// Try to initialize it now.
err := w.cache.update()
if err != nil {
w.log.Errorf("Failed to initialize cache: %v", err)
c.String(http.StatusInternalServerError, "Cache is not initialized")
c.Abort()
return
}
}
c.Set(cacheKey, w.cache.getData())
}
// requireAdmin will only allow the request to proceed if the current session is
// authenticated as an admin, otherwise it will render the login template.
func (w *WebAPI) requireAdmin(c *gin.Context) {
cacheData := c.MustGet(cacheKey).(cacheData)
session := c.MustGet(sessionKey).(*sessions.Session)
admin := session.Values["admin"]
if admin == nil {
c.HTML(http.StatusUnauthorized, "login.html", gin.H{
"WebApiCache": cacheData,
"WebApiCfg": w.cfg,
})
c.Abort()
return
}
}
// withDcrdClient middleware adds a dcrd client to the request context for
// downstream handlers to make use of.
func (w *WebAPI) withDcrdClient(dcrd rpc.DcrdConnect) gin.HandlerFunc {
return func(c *gin.Context) {
client, hostname, err := dcrd.Client()
// Don't handle the error here, add it to the context and let downstream
// handlers decide what to do with it.
c.Set(dcrdKey, client)
c.Set(dcrdHostKey, hostname)
c.Set(dcrdErrorKey, err)
}
}
// withWalletClients middleware attempts to add voting wallet clients to the
// request context for downstream handlers to make use of. Downstream handlers
// must handle the case where no wallet clients are connected.
func (w *WebAPI) withWalletClients(wallets rpc.WalletConnect) gin.HandlerFunc {
return func(c *gin.Context) {
clients, failedConnections := wallets.Clients()
if len(clients) == 0 {
w.log.Error("Could not connect to any wallets")
} else if len(failedConnections) > 0 {
w.log.Errorf("Failed to connect to %d wallet(s), proceeding with only %d",
len(failedConnections), len(clients))
}
c.Set(walletsKey, clients)
c.Set(failedWalletsKey, failedConnections)
}
}
// drainAndReplaceBody will read and return the body of the provided request. It
// replaces the request reader with an identical one so it can be used again.
func drainAndReplaceBody(req *http.Request) ([]byte, error) {
reqBytes, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
req.Body.Close()
req.Body = io.NopCloser(bytes.NewBuffer(reqBytes))
return reqBytes, nil
}
func (w *WebAPI) vspMustBeOpen(c *gin.Context) {
if w.cfg.VspClosed {
w.sendError(types.ErrVspClosed, c)
return
}
}
// broadcastTicket will ensure that the local dcrd instance is aware of the
// provided ticket.
// Ticket hash, ticket hex, and parent hex are parsed from the request body and
// validated. They are broadcast to the network using SendRawTransaction if dcrd
// is not aware of them.
func (w *WebAPI) broadcastTicket(c *gin.Context) {
const funcName = "broadcastTicket"
// Read request bytes.
reqBytes, err := drainAndReplaceBody(c.Request)
if err != nil {
w.log.Warnf("%s: Error reading request (clientIP=%s): %v", funcName, c.ClientIP(), err)
w.sendErrorWithMsg(err.Error(), types.ErrBadRequest, c)
return
}
// Parse request to ensure ticket hash/hex and parent hex are included.
var request struct {
TicketHex string `json:"tickethex" binding:"required"`
TicketHash string `json:"tickethash" binding:"required"`
ParentHex string `json:"parenthex" binding:"required"`
}
if err := binding.JSON.BindBody(reqBytes, &request); err != nil {
w.log.Warnf("%s: Bad request (clientIP=%s): %v", funcName, c.ClientIP(), err)
w.sendErrorWithMsg(err.Error(), types.ErrBadRequest, c)
return
}
// Ensure the provided ticket hex is a valid ticket.
msgTx, err := decodeTransaction(request.TicketHex)
if err != nil {
w.log.Errorf("%s: Failed to decode ticket hex (ticketHash=%s): %v",
funcName, request.TicketHash, err)
w.sendErrorWithMsg("cannot decode ticket hex", types.ErrBadRequest, c)
return
}
err = isValidTicket(msgTx)
if err != nil {
w.log.Warnf("%s: Invalid ticket (clientIP=%s, ticketHash=%s): %v",
funcName, c.ClientIP(), request.TicketHash, err)
w.sendError(types.ErrInvalidTicket, c)
return
}
// Ensure hex matches hash.
if msgTx.TxHash().String() != request.TicketHash {
w.log.Warnf("%s: Ticket hex/hash mismatch (clientIP=%s, ticketHash=%s)",
funcName, c.ClientIP(), request.TicketHash)
w.sendErrorWithMsg("ticket hex does not match hash", types.ErrBadRequest, c)
return
}
// Ensure the provided parent hex is a valid tx.
parentTx, err := decodeTransaction(request.ParentHex)
if err != nil {
w.log.Errorf("%s: Failed to decode parent hex (ticketHash=%s): %v", funcName, request.TicketHash, err)
w.sendErrorWithMsg("cannot decode parent hex", types.ErrBadRequest, c)
return
}
parentHash := parentTx.TxHash()
// Check if local dcrd already knows the parent tx.
dcrdClient := c.MustGet(dcrdKey).(*rpc.DcrdRPC)
dcrdErr := c.MustGet(dcrdErrorKey)
if dcrdErr != nil {
w.log.Errorf("%s: Could not get dcrd client: %v", funcName, dcrdErr.(error))
w.sendError(types.ErrInternalError, c)
return
}
_, err = dcrdClient.GetRawTransaction(parentHash.String())
if err != nil {
var e *wsrpc.Error
if errors.As(err, &e) && e.Code == rpc.ErrNoTxInfo {
// ErrNoTxInfo means local dcrd is not aware of the parent. We have
// the hex, so we can broadcast it here.
// Before broadcasting, check that the provided parent hex is
// actually the parent of the ticket.
var found bool
for _, txIn := range msgTx.TxIn {
if !txIn.PreviousOutPoint.Hash.IsEqual(&parentHash) {
continue
}
found = true
break
}
if !found {
w.log.Errorf("%s: Invalid ticket parent (ticketHash=%s)", funcName, request.TicketHash)
w.sendErrorWithMsg("invalid ticket parent", types.ErrBadRequest, c)
return
}
w.log.Debugf("%s: Broadcasting parent tx %s (ticketHash=%s)", funcName, parentHash, request.TicketHash)
err = dcrdClient.SendRawTransaction(request.ParentHex)
if err != nil {
w.log.Errorf("%s: dcrd.SendRawTransaction for parent tx failed (ticketHash=%s): %v",
funcName, request.TicketHash, err)
w.sendError(types.ErrCannotBroadcastTicket, c)
return
}
} else {
w.log.Errorf("%s: dcrd.GetRawTransaction for ticket parent failed (ticketHash=%s): %v",
funcName, request.TicketHash, err)
w.sendError(types.ErrInternalError, c)
return
}
}
// Check if local dcrd already knows the ticket.
_, err = dcrdClient.GetRawTransaction(request.TicketHash)
if err == nil {
// No error means dcrd already knows the ticket, we are done here.
return
}
// ErrNoTxInfo means local dcrd is not aware of the ticket. We have the
// hex, so we can broadcast it here.
var e *wsrpc.Error
if errors.As(err, &e) && e.Code == rpc.ErrNoTxInfo {
w.log.Debugf("%s: Broadcasting ticket (ticketHash=%s)", funcName, request.TicketHash)
err = dcrdClient.SendRawTransaction(request.TicketHex)
if err != nil {
w.log.Errorf("%s: dcrd.SendRawTransaction for ticket failed (ticketHash=%s): %v",
funcName, request.TicketHash, err)
w.sendError(types.ErrCannotBroadcastTicket, c)
return
}
} else {
w.log.Errorf("%s: dcrd.GetRawTransaction for ticket failed (ticketHash=%s): %v",
funcName, request.TicketHash, err)
w.sendError(types.ErrInternalError, c)
return
}
}
// vspAuth middleware reads the request body and extracts the ticket hash. The
// commitment address for the ticket is retrieved from the database if it is
// known, or it is retrieved from the chain if not.
// The middleware errors out if the VSP-Client-Signature header of the request
// does not contain the request body signed with the commitment address.
// Ticket information is added to the request context for downstream handlers to
// use.
func (w *WebAPI) vspAuth(c *gin.Context) {
const funcName = "vspAuth"
// Read request bytes.
reqBytes, err := drainAndReplaceBody(c.Request)
if err != nil {
w.log.Warnf("%s: Error reading request (clientIP=%s): %v", funcName, c.ClientIP(), err)
w.sendErrorWithMsg(err.Error(), types.ErrBadRequest, c)
return
}
// Add request bytes to request context for downstream handlers to reuse.
// Necessary because the request body reader can only be used once.
c.Set(requestBytesKey, reqBytes)
// Parse request and ensure there is a ticket hash included.
var request struct {
TicketHash string `json:"tickethash" binding:"required"`
}
if err := binding.JSON.BindBody(reqBytes, &request); err != nil {
w.log.Warnf("%s: Bad request (clientIP=%s): %v", funcName, c.ClientIP(), err)
w.sendErrorWithMsg(err.Error(), types.ErrBadRequest, c)
return
}
hash := request.TicketHash
// Before hitting the db or any RPC, ensure this is a valid ticket hash.
err = validateTicketHash(hash)
if err != nil {
w.log.Errorf("%s: Bad request (clientIP=%s): %v", funcName, c.ClientIP(), err)
w.sendErrorWithMsg("invalid ticket hash", types.ErrBadRequest, c)
return
}
// Check if this ticket already appears in the database.
ticket, ticketFound, err := w.db.GetTicketByHash(hash)
if err != nil {
w.log.Errorf("%s: db.GetTicketByHash error (ticketHash=%s): %v", funcName, hash, err)
w.sendError(types.ErrInternalError, c)
return
}
var commitmentAddress string
if ticketFound {
// The commitment address is already known if the ticket already exists
// in the database.
commitmentAddress = ticket.CommitmentAddress
} else {
// Otherwise the commitment address must be retrieved from the chain
// using dcrd.
dcrdClient := c.MustGet(dcrdKey).(*rpc.DcrdRPC)
dcrdErr := c.MustGet(dcrdErrorKey)
if dcrdErr != nil {
w.log.Errorf("%s: Could not get dcrd client (clientIP=%s, ticketHash=%s): %v",
funcName, c.ClientIP(), hash, dcrdErr.(error))
w.sendError(types.ErrInternalError, c)
return
}
rawTx, err := dcrdClient.GetRawTransaction(hash)
if err != nil {
w.log.Errorf("%s: dcrd.GetRawTransaction for ticket failed (clientIP=%s, ticketHash=%s): %v",
funcName, c.ClientIP(), hash, err)
w.sendError(types.ErrInternalError, c)
return
}
msgTx, err := decodeTransaction(rawTx.Hex)
if err != nil {
w.log.Errorf("%s: Failed to decode ticket hex (clientIP=%s, ticketHash=%s): %v",
funcName, c.ClientIP(), hash, err)
w.sendError(types.ErrInternalError, c)
return
}
err = isValidTicket(msgTx)
if err != nil {
w.log.Errorf("%s: Invalid ticket (clientIP=%s, ticketHash=%s)",
funcName, c.ClientIP(), hash)
w.sendError(types.ErrInvalidTicket, c)
return
}
addr, err := stake.AddrFromSStxPkScrCommitment(msgTx.TxOut[1].PkScript, w.cfg.Network)
if err != nil {
w.log.Errorf("%s: AddrFromSStxPkScrCommitment error (clientIP=%s, ticketHash=%s): %v",
funcName, c.ClientIP(), hash, err)
w.sendError(types.ErrInternalError, c)
return
}
commitmentAddress = addr.String()
}
// Ensure a signature is provided.
signature := c.GetHeader("VSP-Client-Signature")
if signature == "" {
w.log.Warnf("%s: No VSP-Client-Signature header (clientIP=%s)", funcName, c.ClientIP())
w.sendErrorWithMsg("no VSP-Client-Signature header", types.ErrBadRequest, c)
return
}
// Validate request signature to ensure ticket ownership.
err = validateSignature(hash, commitmentAddress, signature, string(reqBytes), w.db, w.cfg.Network)
if err != nil {
w.log.Errorf("%s: Couldn't validate signature (clientIP=%s, ticketHash=%s): %v",
funcName, c.ClientIP(), hash, err)
w.sendError(types.ErrBadSignature, c)
return
}
// Add ticket information to context so downstream handlers don't need
// to access the db for it.
c.Set(ticketKey, ticket)
c.Set(knownTicketKey, ticketFound)
c.Set(commitmentAddressKey, commitmentAddress)
}