vspd/internal/webapi/admin.go
jholdstock 99dc97d6a3 webapi: Abort requests if web cache not ready.
A new middleware aborts any requests which require the data cache if the
cache is not yet initialized. An explicit error is returned so the admin
will be immediately aware what has gone wrong.

Previously, webpages were rendered and JSON responses were sent with
zero values, and with no indication of anything being wrong. This was
sometimes difficult to notice, and even when noticed the cause was not
immediately apparent.
2023-09-19 17:58:59 +01:00

285 lines
8.2 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 (
"encoding/json"
"net/http"
"github.com/decred/vspd/database"
"github.com/decred/vspd/rpc"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
)
// walletStatus describes the current status of a single voting wallet. This is
// used by the admin.html template, and also serialized to JSON for the
// /admin/status endpoint.
type walletStatus struct {
Connected bool `json:"connected"`
InfoError bool `json:"infoerror"`
DaemonConnected bool `json:"daemonconnected"`
VoteVersion uint32 `json:"voteversion"`
Unlocked bool `json:"unlocked"`
Voting bool `json:"voting"`
BestBlockError bool `json:"bestblockerror"`
BestBlockHeight int64 `json:"bestblockheight"`
}
// dcrdStatus describes the current status of the local instance of dcrd used by
// vspd. This is used by the admin.html template, and also serialized to JSON
// for the /admin/status endpoint.
type dcrdStatus struct {
Host string `json:"host"`
Connected bool `json:"connected"`
BestBlockError bool `json:"bestblockerror"`
BestBlockHeight uint32 `json:"bestblockheight"`
}
type searchResult struct {
Hash string
Found bool
Ticket database.Ticket
FeeTxDecoded string
AltSignAddrData *database.AltSignAddrData
VoteChanges map[uint32]database.VoteChangeRecord
MaxVoteChanges int
}
func (w *WebAPI) dcrdStatus(c *gin.Context) dcrdStatus {
hostname := c.MustGet(dcrdHostKey).(string)
status := dcrdStatus{Host: hostname}
dcrdClient := c.MustGet(dcrdKey).(*rpc.DcrdRPC)
dcrdErr := c.MustGet(dcrdErrorKey)
if dcrdErr != nil {
w.log.Errorf("Could not get dcrd client: %v", dcrdErr.(error))
return status
}
status.Connected = true
bestBlock, err := dcrdClient.GetBlockCount()
if err != nil {
w.log.Errorf("Could not get dcrd block count: %v", err)
status.BestBlockError = true
return status
}
status.BestBlockHeight = uint32(bestBlock)
return status
}
func (w *WebAPI) walletStatus(c *gin.Context) map[string]walletStatus {
walletClients := c.MustGet(walletsKey).([]*rpc.WalletRPC)
failedWalletClients := c.MustGet(failedWalletsKey).([]string)
status := make(map[string]walletStatus)
for _, v := range walletClients {
ws := walletStatus{Connected: true}
walletInfo, err := v.WalletInfo()
if err != nil {
w.log.Errorf("dcrwallet.WalletInfo error (wallet=%s): %v", v.String(), err)
ws.InfoError = true
} else {
ws.DaemonConnected = walletInfo.DaemonConnected
ws.VoteVersion = walletInfo.VoteVersion
ws.Unlocked = walletInfo.Unlocked
ws.Voting = walletInfo.Voting
}
height, err := v.GetBestBlockHeight()
if err != nil {
w.log.Errorf("dcrwallet.GetBestBlockHeight error (wallet=%s): %v", v.String(), err)
ws.BestBlockError = true
} else {
ws.BestBlockHeight = height
}
status[v.String()] = ws
}
for _, v := range failedWalletClients {
ws := walletStatus{Connected: false}
status[v] = ws
}
return status
}
// statusJSON is the handler for "GET /admin/status". It returns a JSON object
// describing the current status of voting wallets.
func (w *WebAPI) statusJSON(c *gin.Context) {
httpStatus := http.StatusOK
wallets := w.walletStatus(c)
// Respond with HTTP status 500 if any voting wallets have issues.
for _, wallet := range wallets {
if wallet.InfoError ||
wallet.BestBlockError ||
!wallet.Connected ||
!wallet.DaemonConnected ||
!wallet.Voting ||
!wallet.Unlocked {
httpStatus = http.StatusInternalServerError
break
}
}
dcrd := w.dcrdStatus(c)
// Respond with HTTP status 500 if dcrd has issues.
if !dcrd.Connected || dcrd.BestBlockError {
httpStatus = http.StatusInternalServerError
}
c.AbortWithStatusJSON(httpStatus, gin.H{
"wallets": wallets,
"dcrd": dcrd,
})
}
// adminPage is the handler for "GET /admin".
func (w *WebAPI) adminPage(c *gin.Context) {
cacheData := c.MustGet(cacheKey).(cacheData)
c.HTML(http.StatusOK, "admin.html", gin.H{
"WebApiCache": cacheData,
"WebApiCfg": w.cfg,
"WalletStatus": w.walletStatus(c),
"DcrdStatus": w.dcrdStatus(c),
})
}
// ticketSearch is the handler for "POST /admin/ticket". The hash param will be
// used to retrieve a ticket from the database.
func (w *WebAPI) ticketSearch(c *gin.Context) {
cacheData := c.MustGet(cacheKey).(cacheData)
hash := c.PostForm("hash")
ticket, found, err := w.db.GetTicketByHash(hash)
if err != nil {
w.log.Errorf("db.GetTicketByHash error (ticketHash=%s): %v", hash, err)
c.String(http.StatusInternalServerError, "Error getting ticket from db")
return
}
voteChanges, err := w.db.GetVoteChanges(hash)
if err != nil {
w.log.Errorf("db.GetVoteChanges error (ticketHash=%s): %v", hash, err)
c.String(http.StatusInternalServerError, "Error getting vote changes from db")
return
}
altSignAddrData, err := w.db.AltSignAddrData(hash)
if err != nil {
w.log.Errorf("db.AltSignAddrData error (ticketHash=%s): %v", hash, err)
c.String(http.StatusInternalServerError, "Error getting alt sig from db")
return
}
// Decode the fee tx so it can be displayed human-readable. Fee tx hex may
// be null because it is removed from the DB if the tx is already mined and
// confirmed.
var feeTxDecoded string
if ticket.FeeTxHex != "" {
dcrdClient := c.MustGet(dcrdKey).(*rpc.DcrdRPC)
dcrdErr := c.MustGet(dcrdErrorKey)
if dcrdErr != nil {
w.log.Errorf("Could not get dcrd client: %v", dcrdErr.(error))
c.String(http.StatusInternalServerError, "Could not get dcrd client")
return
}
resp, err := dcrdClient.DecodeRawTransaction(ticket.FeeTxHex)
if err != nil {
w.log.Errorf("dcrd.DecodeRawTransaction error: %w", err)
c.String(http.StatusInternalServerError, "Error decoding fee transaction")
return
}
decoded, err := json.Marshal(resp)
if err != nil {
w.log.Errorf("Unmarshal fee tx error: %w", err)
c.String(http.StatusInternalServerError, "Error unmarshalling fee tx")
return
}
feeTxDecoded = string(decoded)
}
c.HTML(http.StatusOK, "admin.html", gin.H{
"SearchResult": searchResult{
Hash: hash,
Found: found,
Ticket: ticket,
FeeTxDecoded: feeTxDecoded,
AltSignAddrData: altSignAddrData,
VoteChanges: voteChanges,
MaxVoteChanges: w.cfg.MaxVoteChangeRecords,
},
"WebApiCache": cacheData,
"WebApiCfg": w.cfg,
"WalletStatus": w.walletStatus(c),
"DcrdStatus": w.dcrdStatus(c),
})
}
// adminLogin is the handler for "POST /admin". If a valid password is provided,
// the current session will be authenticated as an admin.
func (w *WebAPI) adminLogin(c *gin.Context) {
cacheData := c.MustGet(cacheKey).(cacheData)
password := c.PostForm("password")
if password != w.cfg.AdminPass {
w.log.Warnf("Failed login attempt from %s", c.ClientIP())
c.HTML(http.StatusUnauthorized, "login.html", gin.H{
"WebApiCache": cacheData,
"WebApiCfg": w.cfg,
"FailedLoginMsg": "Incorrect password",
})
return
}
w.setAdminStatus(true, c)
}
// adminLogout is the handler for "POST /admin/logout". The current session will
// have its admin authentication removed.
func (w *WebAPI) adminLogout(c *gin.Context) {
w.setAdminStatus(nil, c)
}
// downloadDatabaseBackup is the handler for "GET /backup". A binary
// representation of the whole database is generated and returned to the client.
func (w *WebAPI) downloadDatabaseBackup(c *gin.Context) {
err := w.db.BackupDB(c.Writer)
if err != nil {
w.log.Errorf("Error backing up database: %v", err)
// Don't write any http body here because Content-Length has already
// been set in db.BackupDB. Status is enough to indicate an error.
c.Status(http.StatusInternalServerError)
}
}
// setAdminStatus stores the authentication status of the current session and
// redirects the client to GET /admin.
func (w *WebAPI) setAdminStatus(admin any, c *gin.Context) {
session := c.MustGet(sessionKey).(*sessions.Session)
session.Values["admin"] = admin
err := session.Save(c.Request, c.Writer)
if err != nil {
w.log.Errorf("Error saving session: %v", err)
c.String(http.StatusInternalServerError, "Error saving session")
return
}
c.Redirect(http.StatusFound, "/admin")
c.Abort()
}