auth: instancelock, GetRefreshTokenForRenew, FOR UPDATE
This commit is contained in:
parent
216aed7ca9
commit
e0844760d8
6 changed files with 242 additions and 23 deletions
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/handlers"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/instancelock"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/migration"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/registration"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/service"
|
||||
|
|
@ -97,6 +98,15 @@ func main() {
|
|||
return
|
||||
}
|
||||
|
||||
// Acquire an exclusive instance lock to prevent two agent processes
|
||||
// from sharing the same config.json and renewal state. The lock is
|
||||
// released when this process exits (fd closes on os.Exit too).
|
||||
unlock, err := instancelock.Acquire()
|
||||
if err != nil {
|
||||
log.Fatalf("[FATAL] instance_lock_failed another_instance_running error=%v", err)
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
// Check if registered
|
||||
if !cfg.IsRegistered() {
|
||||
log.Fatal("Agent not registered. Run with -register flag first.")
|
||||
|
|
|
|||
37
agent/internal/instancelock/lock.go
Normal file
37
agent/internal/instancelock/lock.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Package instancelock prevents multiple agent processes from running
|
||||
// concurrently on the same host by holding an exclusive lock on a
|
||||
// well-known path (Unix: flock on /var/lib/.../agent.lock; Windows:
|
||||
// named kernel mutex Global\RedFlagAgent_v1 + per-user file lock).
|
||||
// The lock is released when the process exits (the OS cleans up).
|
||||
// If the lock cannot be acquired, the instance should exit immediately
|
||||
// rather than share config.json and renewal state.
|
||||
package instancelock
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LockPath returns the well-known path for the Unix instance lockfile.
|
||||
// Windows uses a named kernel mutex instead; this function is only called
|
||||
// from lock_unix.go and is referenced here so the package compiles on
|
||||
// Windows.
|
||||
func LockPath() string {
|
||||
return filepath.Join("/var/lib/redflag/agent/state", "agent.lock")
|
||||
}
|
||||
|
||||
// Acquire attempts to acquire an exclusive instance lock. It returns a
|
||||
// release function and nil on success, or an error if another agent
|
||||
// process is already running on this host.
|
||||
//
|
||||
// The release function must be called on graceful shutdown.
|
||||
// On process crash the OS releases the lock automatically:
|
||||
// - Unix: the kernel closes the flock fd on process exit.
|
||||
// - Windows: the kernel transitions the mutex to "abandoned" state;
|
||||
// a crashed owner's mutex is acquired cleanly by the next waiter.
|
||||
func Acquire() (release func(), err error) {
|
||||
return acquireLock()
|
||||
}
|
||||
|
||||
// noopRelease is a safe no-op for platforms that don't need cleanup.
|
||||
func noopRelease() {}
|
||||
|
||||
45
agent/internal/instancelock/lock_unix.go
Normal file
45
agent/internal/instancelock/lock_unix.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//go:build linux || darwin || freebsd
|
||||
|
||||
package instancelock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// acquireLock opens (or creates) the lockfile and takes an exclusive
|
||||
// flock. The lock is released when the process exits (the kernel
|
||||
// closes the fd, which releases the flock).
|
||||
func acquireLock() (func(), error) {
|
||||
path := LockPath()
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("instancelock: mkdir %s: %w", dir, err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instancelock: open %s: %w", path, err)
|
||||
}
|
||||
|
||||
fd := int(f.Fd())
|
||||
if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("instancelock: %s is locked by another process: %w", path, err)
|
||||
}
|
||||
|
||||
// Write our PID so operators can see who holds the lock.
|
||||
_, _ = f.WriteAt([]byte(fmt.Sprintf("%d\n", os.Getpid())), 0)
|
||||
_ = f.Truncate(64)
|
||||
|
||||
release := func() {
|
||||
_ = f.Close()
|
||||
// Don't remove the file — leaving it is harmless and prevents a
|
||||
// TOCTOU race where a fresh open might get an unlocked fd before
|
||||
// we flock it.
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
127
agent/internal/instancelock/lock_windows.go
Normal file
127
agent/internal/instancelock/lock_windows.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
//go:build windows
|
||||
|
||||
package instancelock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
// mu guards handle — the OS mutex handle must be closed exactly once,
|
||||
// but it must outlive the caller's release function (it's process-wide).
|
||||
mu sync.Mutex
|
||||
handle windows.Handle
|
||||
)
|
||||
|
||||
// lockName is the well-known kernel-object name. The Global\ prefix makes
|
||||
// it visible across all sessions (including the service session), which is
|
||||
// necessary because the agent may run both as a service and as a console
|
||||
// process under different sessions on the same host.
|
||||
const lockName = `Global\RedFlagAgent_v1`
|
||||
|
||||
// acquireLock opens a named kernel mutex and attempts to acquire it
|
||||
// without blocking. If the mutex is held by another process, we fail
|
||||
// immediately. If it doesn't exist, CreateMutex creates it for us.
|
||||
//
|
||||
// Unlike the Unix flock (which lives on an fd scoped to the file system
|
||||
// path), a Windows mutex is purely a kernel object — there is no file
|
||||
// to write a PID or leak. The name is the key: any process on the system
|
||||
// (service, console, WSL bridge) that opens the same name gets the same
|
||||
// mutex object.
|
||||
func acquireLock() (func(), error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// If we already hold the lock in this process (shouldn't happen in
|
||||
// normal use, but guard it), don't re-acquire.
|
||||
if handle != 0 {
|
||||
return noopRelease, nil
|
||||
}
|
||||
|
||||
name, err := windows.UTF16PtrFromString(lockName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instancelock: utf16: %w", err)
|
||||
}
|
||||
|
||||
// CreateMutex opens or creates the named mutex. It does NOT set the
|
||||
// initial ownership — we do that with WaitForSingleObject below.
|
||||
h, err := windows.CreateMutex(nil, false, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instancelock: CreateMutex: %w", err)
|
||||
}
|
||||
|
||||
// Attempt to acquire with zero timeout (non-blocking).
|
||||
// WAIT_OBJECT_0 (0) = we got it.
|
||||
// WAIT_ABANDONED (128) = previous owner died holding it — it's ours.
|
||||
// WAIT_TIMEOUT (258) = someone else has it.
|
||||
switch waitResult, _ := windows.WaitForSingleObject(h, 0); waitResult {
|
||||
case 0, windows.WAIT_ABANDONED:
|
||||
handle = h
|
||||
case 258: // WAIT_TIMEOUT
|
||||
_ = windows.CloseHandle(h)
|
||||
return nil, fmt.Errorf("instancelock: another agent process is already running on this host (locked mutex: %s)", lockName)
|
||||
default:
|
||||
_ = windows.CloseHandle(h)
|
||||
return nil, fmt.Errorf("instancelock: WaitForSingleObject failed on %s", lockName)
|
||||
}
|
||||
|
||||
// Release is called on graceful shutdown. If the process crashes or
|
||||
// is killed, the kernel releases the mutex automatically — unlike
|
||||
// Unix where flock is tied to the fd and the fd closes on exit, a
|
||||
// Windows mutex held by a dead thread transitions to "abandoned"
|
||||
// and the next waiter acquires it cleanly.
|
||||
release := func() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if handle != 0 {
|
||||
_ = windows.ReleaseMutex(handle)
|
||||
_ = windows.CloseHandle(handle)
|
||||
handle = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Also create a per-user lockfile under APPDATA to catch the case
|
||||
// where the same user runs two console-mode agents without going
|
||||
// through the SCM. The kernel mutex covers cross-session; the file
|
||||
// lock covers same-user-duplicates where both instances open the
|
||||
// same file.
|
||||
localAppData := os.Getenv("LOCALAPPDATA")
|
||||
if localAppData != "" {
|
||||
lockFilePath := filepath.Join(localAppData, "RedFlag", "agent.lock")
|
||||
|
||||
// Best-effort: if we can't write it, the kernel mutex still
|
||||
// protects us across all sessions.
|
||||
_ = os.MkdirAll(filepath.Dir(lockFilePath), 0755)
|
||||
if f, fErr := os.OpenFile(lockFilePath, os.O_RDWR|os.O_CREATE, 0644); fErr == nil {
|
||||
if lErr := lockFile(f); lErr == nil {
|
||||
// Extend release to also close the file.
|
||||
prevRelease := release
|
||||
release = func() {
|
||||
_ = f.Close()
|
||||
prevRelease()
|
||||
}
|
||||
} else {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// lockFile takes an advisory lock on an *os.File using LockFileEx (the
|
||||
// Windows equivalent of flock). This is belt-and-suspenders with the
|
||||
// kernel mutex — both must be acquired for the lock to count.
|
||||
func lockFile(f *os.File) error {
|
||||
ol := &windows.Overlapped{}
|
||||
return windows.LockFileEx(
|
||||
windows.Handle(f.Fd()),
|
||||
windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY,
|
||||
0, 1, 0, ol,
|
||||
)
|
||||
}
|
||||
|
|
@ -1277,19 +1277,14 @@ func (h *AgentHandler) RenewToken(c *gin.Context) {
|
|||
}
|
||||
defer renewTx.Rollback()
|
||||
|
||||
// Look up the presented token. We deliberately do NOT filter on `revoked` or
|
||||
// `consumed_at` here: reuse detection needs to see a spent token to react to it.
|
||||
tokenHash := queries.HashRefreshToken(req.RefreshToken)
|
||||
var refreshToken queries.RefreshToken
|
||||
validateQuery := `
|
||||
SELECT id, agent_id, token_hash, expires_at, created_at, last_used_at, revoked,
|
||||
family_id, superseded_by, consumed_at
|
||||
FROM refresh_tokens
|
||||
WHERE agent_id = $1 AND token_hash = $2
|
||||
`
|
||||
if err := renewTx.Get(&refreshToken, validateQuery, req.AgentID, tokenHash); err != nil {
|
||||
// Look up the presented token inside the transaction with FOR UPDATE to
|
||||
// serialize concurrent renewals. Returns the full rotation state (consumed_at,
|
||||
// superseded_by, family_id) so the handler can distinguish first-use, grace
|
||||
// recovery, and reuse from a single row.
|
||||
refreshToken, err := h.refreshTokenQueries.GetRefreshTokenForRenew(renewTx, req.AgentID, req.RefreshToken)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [server] [auth] token_renewal_failed agent_id=%s error=%v", req.AgentID, err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1354,12 +1349,8 @@ func (h *AgentHandler) RenewToken(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// A revoked token presented while the family is alive is anomalous → reuse.
|
||||
if refreshToken.Revoked {
|
||||
revokeFamily("revoked_token_replayed")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token revoked"})
|
||||
return
|
||||
}
|
||||
// Expiry check: the query returns non-revoked tokens, but the expiry
|
||||
// could lapse between fetch and the rotation below.
|
||||
if time.Now().UTC().After(refreshToken.ExpiresAt) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token expired"})
|
||||
return
|
||||
|
|
|
|||
|
|
@ -69,20 +69,29 @@ func (q *RefreshTokenQueries) CreateRefreshToken(agentID uuid.UUID, token string
|
|||
return err
|
||||
}
|
||||
|
||||
// ValidateRefreshToken checks if a refresh token is valid
|
||||
func (q *RefreshTokenQueries) ValidateRefreshToken(agentID uuid.UUID, token string) (*RefreshToken, error) {
|
||||
// GetRefreshTokenForRenew looks up a refresh token for the renewal handler,
|
||||
// locking the row with FOR UPDATE to serialize concurrent renewal attempts.
|
||||
// It accepts a sqlx.Ext (pass a *sqlx.Tx so the lock participates in the
|
||||
// renewal transaction). It does NOT filter on consumed_at — the handler's
|
||||
// state machine needs to see consumed tokens to distinguish accept-previous-
|
||||
// once grace from theft. Revoked tokens are filtered out.
|
||||
//
|
||||
// This replaced ValidateRefreshToken (zero callers) when rotation landed.
|
||||
func (q *RefreshTokenQueries) GetRefreshTokenForRenew(qe sqlx.Ext, agentID uuid.UUID, token string) (*RefreshToken, error) {
|
||||
tokenHash := HashRefreshToken(token)
|
||||
|
||||
query := `
|
||||
SELECT id, agent_id, token_hash, expires_at, created_at, last_used_at, revoked
|
||||
SELECT id, agent_id, token_hash, expires_at, created_at, last_used_at, revoked,
|
||||
family_id, superseded_by, consumed_at
|
||||
FROM refresh_tokens
|
||||
WHERE agent_id = $1 AND token_hash = $2 AND NOT revoked
|
||||
FOR UPDATE
|
||||
`
|
||||
|
||||
var refreshToken RefreshToken
|
||||
err := q.db.Get(&refreshToken, query, agentID, tokenHash)
|
||||
err := sqlx.Get(qe, &refreshToken, query, agentID, tokenHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh token not found or invalid: %w", err)
|
||||
return nil, fmt.Errorf("refresh token not found or revoked: %w", err)
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
|
|
|
|||
Loading…
Reference in a new issue