Watch
1
0
Fork
You've already forked RedFlag
0

auth: instancelock, GetRefreshTokenForRenew, FOR UPDATE

This commit is contained in:
Fimeg 2026-05-29 13:08:12 -04:00
commit e0844760d8
6 changed files with 242 additions and 23 deletions

View file

@ -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.")

View 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() {}

View 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
}

View 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,
)
}

View file

@ -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

View file

@ -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