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.
This commit is contained in:
jholdstock 2023-09-18 17:52:19 +01:00 committed by Jamie Holdstock
parent d1eddafb52
commit 99dc97d6a3
6 changed files with 55 additions and 12 deletions

View File

@ -144,8 +144,10 @@ func (w *WebAPI) statusJSON(c *gin.Context) {
// adminPage is the handler for "GET /admin". // adminPage is the handler for "GET /admin".
func (w *WebAPI) adminPage(c *gin.Context) { func (w *WebAPI) adminPage(c *gin.Context) {
cacheData := c.MustGet(cacheKey).(cacheData)
c.HTML(http.StatusOK, "admin.html", gin.H{ c.HTML(http.StatusOK, "admin.html", gin.H{
"WebApiCache": w.cache.getData(), "WebApiCache": cacheData,
"WebApiCfg": w.cfg, "WebApiCfg": w.cfg,
"WalletStatus": w.walletStatus(c), "WalletStatus": w.walletStatus(c),
"DcrdStatus": w.dcrdStatus(c), "DcrdStatus": w.dcrdStatus(c),
@ -155,6 +157,8 @@ func (w *WebAPI) adminPage(c *gin.Context) {
// ticketSearch is the handler for "POST /admin/ticket". The hash param will be // ticketSearch is the handler for "POST /admin/ticket". The hash param will be
// used to retrieve a ticket from the database. // used to retrieve a ticket from the database.
func (w *WebAPI) ticketSearch(c *gin.Context) { func (w *WebAPI) ticketSearch(c *gin.Context) {
cacheData := c.MustGet(cacheKey).(cacheData)
hash := c.PostForm("hash") hash := c.PostForm("hash")
ticket, found, err := w.db.GetTicketByHash(hash) ticket, found, err := w.db.GetTicketByHash(hash)
@ -218,7 +222,7 @@ func (w *WebAPI) ticketSearch(c *gin.Context) {
VoteChanges: voteChanges, VoteChanges: voteChanges,
MaxVoteChanges: w.cfg.MaxVoteChangeRecords, MaxVoteChanges: w.cfg.MaxVoteChangeRecords,
}, },
"WebApiCache": w.cache.getData(), "WebApiCache": cacheData,
"WebApiCfg": w.cfg, "WebApiCfg": w.cfg,
"WalletStatus": w.walletStatus(c), "WalletStatus": w.walletStatus(c),
"DcrdStatus": w.dcrdStatus(c), "DcrdStatus": w.dcrdStatus(c),
@ -228,12 +232,14 @@ func (w *WebAPI) ticketSearch(c *gin.Context) {
// adminLogin is the handler for "POST /admin". If a valid password is provided, // adminLogin is the handler for "POST /admin". If a valid password is provided,
// the current session will be authenticated as an admin. // the current session will be authenticated as an admin.
func (w *WebAPI) adminLogin(c *gin.Context) { func (w *WebAPI) adminLogin(c *gin.Context) {
cacheData := c.MustGet(cacheKey).(cacheData)
password := c.PostForm("password") password := c.PostForm("password")
if password != w.cfg.AdminPass { if password != w.cfg.AdminPass {
w.log.Warnf("Failed login attempt from %s", c.ClientIP()) w.log.Warnf("Failed login attempt from %s", c.ClientIP())
c.HTML(http.StatusUnauthorized, "login.html", gin.H{ c.HTML(http.StatusUnauthorized, "login.html", gin.H{
"WebApiCache": w.cache.getData(), "WebApiCache": cacheData,
"WebApiCfg": w.cfg, "WebApiCfg": w.cfg,
"FailedLoginMsg": "Incorrect password", "FailedLoginMsg": "Incorrect password",
}) })

View File

@ -30,6 +30,10 @@ type cache struct {
} }
type cacheData struct { type cacheData struct {
// Initialized is set true after all of the below values have been set for
// the first time.
Initialized bool
UpdateTime string UpdateTime string
PubKey string PubKey string
DatabaseSize string DatabaseSize string
@ -45,6 +49,13 @@ type cacheData struct {
MissedProportion float32 MissedProportion float32
} }
func (c *cache) initialized() bool {
c.mtx.RLock()
defer c.mtx.RUnlock()
return c.data.Initialized
}
func (c *cache) getData() cacheData { func (c *cache) getData() cacheData {
c.mtx.RLock() c.mtx.RLock()
defer c.mtx.RUnlock() defer c.mtx.RUnlock()
@ -106,6 +117,7 @@ func (c *cache) update() error {
c.mtx.Lock() c.mtx.Lock()
defer c.mtx.Unlock() defer c.mtx.Unlock()
c.data.Initialized = true
c.data.UpdateTime = dateTime(time.Now().Unix()) c.data.UpdateTime = dateTime(time.Now().Unix())
c.data.DatabaseSize = humanize.Bytes(dbSize) c.data.DatabaseSize = humanize.Bytes(dbSize)
c.data.Voting = voting c.data.Voting = voting

View File

@ -11,8 +11,10 @@ import (
) )
func (w *WebAPI) homepage(c *gin.Context) { func (w *WebAPI) homepage(c *gin.Context) {
cacheData := c.MustGet(cacheKey).(cacheData)
c.HTML(http.StatusOK, "homepage.html", gin.H{ c.HTML(http.StatusOK, "homepage.html", gin.H{
"WebApiCache": w.cache.getData(), "WebApiCache": cacheData,
"WebApiCfg": w.cfg, "WebApiCfg": w.cfg,
}) })
} }

View File

@ -93,15 +93,31 @@ func (w *WebAPI) withSession(store *sessions.CookieStore) gin.HandlerFunc {
} }
} }
// 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() {
const str = "Cache is not yet initialized"
w.log.Errorf(str)
c.String(http.StatusInternalServerError, str)
c.Abort()
return
}
c.Set(cacheKey, w.cache.getData())
}
// requireAdmin will only allow the request to proceed if the current session is // requireAdmin will only allow the request to proceed if the current session is
// authenticated as an admin, otherwise it will render the login template. // authenticated as an admin, otherwise it will render the login template.
func (w *WebAPI) requireAdmin(c *gin.Context) { func (w *WebAPI) requireAdmin(c *gin.Context) {
cacheData := c.MustGet(cacheKey).(cacheData)
session := c.MustGet(sessionKey).(*sessions.Session) session := c.MustGet(sessionKey).(*sessions.Session)
admin := session.Values["admin"] admin := session.Values["admin"]
if admin == nil { if admin == nil {
c.HTML(http.StatusUnauthorized, "login.html", gin.H{ c.HTML(http.StatusUnauthorized, "login.html", gin.H{
"WebApiCache": w.cache.getData(), "WebApiCache": cacheData,
"WebApiCfg": w.cfg, "WebApiCfg": w.cfg,
}) })
c.Abort() c.Abort()

View File

@ -14,7 +14,8 @@ import (
// vspInfo is the handler for "GET /api/v3/vspinfo". // vspInfo is the handler for "GET /api/v3/vspinfo".
func (w *WebAPI) vspInfo(c *gin.Context) { func (w *WebAPI) vspInfo(c *gin.Context) {
cachedStats := w.cache.getData() cachedStats := c.MustGet(cacheKey).(cacheData)
w.sendJSONResponse(types.VspInfoResponse{ w.sendJSONResponse(types.VspInfoResponse{
APIVersions: []int64{3}, APIVersions: []int64{3},
Timestamp: time.Now().Unix(), Timestamp: time.Now().Unix(),

View File

@ -57,6 +57,7 @@ const (
dcrdKey = "DcrdClient" dcrdKey = "DcrdClient"
dcrdHostKey = "DcrdHostname" dcrdHostKey = "DcrdHostname"
dcrdErrorKey = "DcrdClientErr" dcrdErrorKey = "DcrdClientErr"
cacheKey = "Cache"
walletsKey = "WalletClients" walletsKey = "WalletClients"
failedWalletsKey = "FailedWalletClients" failedWalletsKey = "FailedWalletClients"
requestBytesKey = "RequestBytes" requestBytesKey = "RequestBytes"
@ -240,7 +241,7 @@ func (w *WebAPI) router(cookieSecret []byte, dcrd rpc.DcrdConnect, wallets rpc.W
// API routes. // API routes.
api := router.Group("/api/v3") api := router.Group("/api/v3")
api.GET("/vspinfo", w.vspInfo) api.GET("/vspinfo", w.requireWebCache, w.vspInfo)
api.POST("/setaltsignaddr", w.vspMustBeOpen, w.withDcrdClient(dcrd), w.broadcastTicket, w.vspAuth, w.setAltSignAddr) api.POST("/setaltsignaddr", w.vspMustBeOpen, w.withDcrdClient(dcrd), w.broadcastTicket, w.vspAuth, w.setAltSignAddr)
api.POST("/feeaddress", w.vspMustBeOpen, w.withDcrdClient(dcrd), w.broadcastTicket, w.vspAuth, w.feeAddress) api.POST("/feeaddress", w.vspMustBeOpen, w.withDcrdClient(dcrd), w.broadcastTicket, w.vspAuth, w.feeAddress)
api.POST("/ticketstatus", w.withDcrdClient(dcrd), w.vspAuth, w.ticketStatus) api.POST("/ticketstatus", w.withDcrdClient(dcrd), w.vspAuth, w.ticketStatus)
@ -249,7 +250,7 @@ func (w *WebAPI) router(cookieSecret []byte, dcrd rpc.DcrdConnect, wallets rpc.W
// Website routes. // Website routes.
router.GET("", w.homepage) router.GET("", w.requireWebCache, w.homepage)
login := router.Group("/admin").Use( login := router.Group("/admin").Use(
w.withSession(cookieStore), w.withSession(cookieStore),
@ -257,18 +258,23 @@ func (w *WebAPI) router(cookieSecret []byte, dcrd rpc.DcrdConnect, wallets rpc.W
// Limit login attempts to 3 per second. // Limit login attempts to 3 per second.
loginRateLmiter := rateLimit(3, func(c *gin.Context) { loginRateLmiter := rateLimit(3, func(c *gin.Context) {
cacheData := c.MustGet(cacheKey).(cacheData)
w.log.Warnf("Login rate limit exceeded by %s", c.ClientIP()) w.log.Warnf("Login rate limit exceeded by %s", c.ClientIP())
c.HTML(http.StatusTooManyRequests, "login.html", gin.H{ c.HTML(http.StatusTooManyRequests, "login.html", gin.H{
"WebApiCache": w.cache.getData(), "WebApiCache": cacheData,
"WebApiCfg": w.cfg, "WebApiCfg": w.cfg,
"FailedLoginMsg": "Rate limit exceeded", "FailedLoginMsg": "Rate limit exceeded",
}) })
}) })
login.POST("", loginRateLmiter, w.adminLogin) login.POST("", w.requireWebCache, loginRateLmiter, w.adminLogin)
admin := router.Group("/admin").Use( admin := router.Group("/admin").Use(
w.withWalletClients(wallets), w.withSession(cookieStore), w.requireAdmin, w.requireWebCache,
) w.withWalletClients(wallets),
w.withSession(cookieStore),
w.requireAdmin)
admin.GET("", w.withDcrdClient(dcrd), w.adminPage) admin.GET("", w.withDcrdClient(dcrd), w.adminPage)
admin.POST("/ticket", w.withDcrdClient(dcrd), w.ticketSearch) admin.POST("/ticket", w.withDcrdClient(dcrd), w.ticketSearch)
admin.GET("/backup", w.downloadDatabaseBackup) admin.GET("/backup", w.downloadDatabaseBackup)