mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-11 05:27:47 +00:00
fix(api): hold bannedUsersMu across reload and release flock on all paths
The read-error path in loadBannedUsers returned while it held the shared file lock. One transient read failure blocked every later ban write for the process lifetime. The reload also applied a stale snapshot over a ban that landed between the read and the replace. Acquire bannedUsersMu first, then the file lock, for the whole read and replace. Release both locks with defer on every path. A deterministic test proves the lock is released after a read error.
This commit is contained in:
@@ -21,9 +21,11 @@ var bannedUsersIDs sync.Map // key: userID string, value: reason string
|
|||||||
|
|
||||||
// bannedUsersMu serializes in-memory banned-set mutations (admin Store/Delete)
|
// bannedUsersMu serializes in-memory banned-set mutations (admin Store/Delete)
|
||||||
// against the periodic file reload's clear-then-rebuild, and keeps the file
|
// against the periodic file reload's clear-then-rebuild, and keeps the file
|
||||||
// write atomic with the map change. Lock ordering: always acquire
|
// read/write atomic with the map change. Lock ordering: always acquire
|
||||||
// bannedUsersMu BEFORE the file lock; loadBannedUsers releases its file read
|
// bannedUsersMu BEFORE the file lock. loadBannedUsers holds bannedUsersMu for
|
||||||
// lock before applying the snapshot to honour that order and avoid deadlock.
|
// its whole read+replace, so a reload can never apply a stale snapshot over a
|
||||||
|
// write that apiBanUser/apiUnbanUser made in between, and it matches the
|
||||||
|
// mutex-then-file order those handlers use around storeBannedUsers.
|
||||||
var bannedUsersMu sync.Mutex
|
var bannedUsersMu sync.Mutex
|
||||||
|
|
||||||
// authMiddleware provides API key authentication for admin endpoints
|
// authMiddleware provides API key authentication for admin endpoints
|
||||||
@@ -353,6 +355,22 @@ func loadBannedUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hold bannedUsersMu across the file read AND the in-memory replace below.
|
||||||
|
// apiBanUser/apiUnbanUser also acquire bannedUsersMu before their file
|
||||||
|
// write, so this keeps the documented mutex-before-file-lock order and
|
||||||
|
// stops a write landing between our read and our replace from being
|
||||||
|
// overwritten by this reload's now-stale snapshot.
|
||||||
|
//
|
||||||
|
// This holds bannedUsersMu across lockFileRead below, which can block for
|
||||||
|
// up to 30s under cross-process flock contention (see lockFileRead's own
|
||||||
|
// timeout). Because apiBanUser/apiUnbanUser take the same mutex, an admin
|
||||||
|
// ban/unban request can queue behind a reload for that long. This is an
|
||||||
|
// accepted trade: it buys atomicity between the in-memory map and the
|
||||||
|
// on-disk file, and reloads are infrequent enough that the worst case is
|
||||||
|
// rare in practice.
|
||||||
|
bannedUsersMu.Lock()
|
||||||
|
defer bannedUsersMu.Unlock()
|
||||||
|
|
||||||
fileLock := flock.New(fmt.Sprintf("%s.lock", cfg.Api.BannedUsersFile))
|
fileLock := flock.New(fmt.Sprintf("%s.lock", cfg.Api.BannedUsersFile))
|
||||||
if err := lockFileRead(fileLock); err != nil {
|
if err := lockFileRead(fileLock); err != nil {
|
||||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||||
@@ -361,6 +379,15 @@ func loadBannedUsers() {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := fileLock.Unlock(); err != nil {
|
||||||
|
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "Failed to unlock file",
|
||||||
|
Pairs: map[string]any{"error": err.Error()},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
data, err := os.ReadFile(cfg.Api.BannedUsersFile)
|
data, err := os.ReadFile(cfg.Api.BannedUsersFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||||
@@ -370,17 +397,6 @@ func loadBannedUsers() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release the read lock BEFORE mutating the in-memory set. replaceBannedUsers
|
|
||||||
// takes bannedUsersMu; acquiring the file read lock and bannedUsersMu in the
|
|
||||||
// file->mutex order would invert the mutex->file order used by the admin
|
|
||||||
// write path (apiBanUser/apiUnbanUser) and could deadlock.
|
|
||||||
if err := fileLock.Unlock(); err != nil {
|
|
||||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
|
||||||
Message: "Failed to unlock file",
|
|
||||||
Pairs: map[string]any{"error": err.Error()},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var newBannedUsers map[string]string
|
var newBannedUsers map[string]string
|
||||||
if err := json.Unmarshal(data, &newBannedUsers); err != nil {
|
if err := json.Unmarshal(data, &newBannedUsers); err != nil {
|
||||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||||
@@ -390,7 +406,7 @@ func loadBannedUsers() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceBannedUsers(newBannedUsers)
|
replaceBannedUsersLocked(newBannedUsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// snapshotBannedUsers returns a plain map copy of the current banned users.
|
// snapshotBannedUsers returns a plain map copy of the current banned users.
|
||||||
@@ -414,6 +430,12 @@ func replaceBannedUsers(newUsers map[string]string) {
|
|||||||
// (apiBanUser/apiUnbanUser) or a reload can clobber a just-applied ban.
|
// (apiBanUser/apiUnbanUser) or a reload can clobber a just-applied ban.
|
||||||
bannedUsersMu.Lock()
|
bannedUsersMu.Lock()
|
||||||
defer bannedUsersMu.Unlock()
|
defer bannedUsersMu.Unlock()
|
||||||
|
replaceBannedUsersLocked(newUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceBannedUsersLocked does the work of replaceBannedUsers without
|
||||||
|
// acquiring bannedUsersMu. Callers must already hold bannedUsersMu.
|
||||||
|
func replaceBannedUsersLocked(newUsers map[string]string) {
|
||||||
bannedUsersIDs.Range(func(k, _ any) bool {
|
bannedUsersIDs.Range(func(k, _ any) bool {
|
||||||
bannedUsersIDs.Delete(k)
|
bannedUsersIDs.Delete(k)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofrs/flock"
|
||||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -262,3 +263,38 @@ func TestBannedUsersConcurrentModifyAndReload(t *testing.T) {
|
|||||||
t.Fatal("kept-user ban was lost after concurrent modify + reload")
|
t.Fatal("kept-user ban was lost after concurrent modify + reload")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoadBannedUsersReleasesLockOnReadError is a regression test for the
|
||||||
|
// file-lock leak: a read error inside loadBannedUsers (after the shared file
|
||||||
|
// lock is taken) must still release the lock. Before the fix, the error
|
||||||
|
// return at the os.ReadFile check skipped the unlock, so the flock's open fd
|
||||||
|
// stayed shared-locked for the process lifetime, and every later
|
||||||
|
// storeBannedUsers (which needs the exclusive lock) would block and fail.
|
||||||
|
func TestLoadBannedUsersReleasesLockOnReadError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// A directory in place of the banned-users file makes os.Stat report the
|
||||||
|
// path exists (skipping the create-file branch) while os.ReadFile fails.
|
||||||
|
file := filepath.Join(dir, "banned.json")
|
||||||
|
if err := os.Mkdir(file, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origCfg := cfg
|
||||||
|
cfg = &config{Logger: libpack_logger.New()}
|
||||||
|
cfg.Api.BannedUsersFile = file
|
||||||
|
defer func() { cfg = origCfg }()
|
||||||
|
|
||||||
|
loadBannedUsers() // must hit the os.ReadFile error path and return
|
||||||
|
|
||||||
|
// If the shared read lock leaked, a fresh non-blocking exclusive TryLock
|
||||||
|
// on the same lock file fails instead of succeeding immediately.
|
||||||
|
fileLock := flock.New(fmt.Sprintf("%s.lock", file))
|
||||||
|
locked, err := fileLock.TryLock()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TryLock returned an error: %v", err)
|
||||||
|
}
|
||||||
|
if !locked {
|
||||||
|
t.Fatal("exclusive lock unavailable after loadBannedUsers read error - shared lock leaked")
|
||||||
|
}
|
||||||
|
_ = fileLock.Unlock()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user