hush/vendor/github.com/redis/go-redis/v9/maintnotifications/manager.go
jx12n 4d9a26498e hush: one-time secret links the server cannot read
Paste a secret, get a link, send it. The first person to open it and press
Reveal sees the secret; the link dies at that moment. The recipient needs a
browser and nothing else — no account, no client, no installed tooling.

The server cannot read what it stores. AES-256-GCM happens in the browser and
the key lives in the URL fragment, which browsers never transmit, so hushd
holds ciphertext and no key material. That is a property of where the key sits
rather than a promise about our conduct, which is why there is deliberately no
endpoint accepting a plaintext secret and no server-side-encryption fallback:
two guarantees behind one URL would be worse than one honest guarantee.

Three decisions carry the design:

  * GET /s/{id} touches NO storage, not even to check existence. Slack, Teams,
    WhatsApp, iMessage and Outlook Safe Links all fetch a URL before a human
    sees it, so destroying on GET would destroy most secrets in transit and the
    recipient's "already used" would be indistinguishable from interception.
    Only POST /reveal consumes. Bot user-agent detection is an arms race;
    removing the side effect from GET is not. Pinned by
    TestGettingTheRevealPageNeverConsumesTheSecret.
  * Destruction is one Redis GETDEL, which is atomic. GET-then-DEL has a window
    where two simultaneous readers both win, and for a one-time secret that
    window is the product. The store contract demands atomicity and the same
    concurrency test runs against both implementations.
  * Missing, already-revealed, expired and evicted are ONE indistinguishable
    410. Separating them would confirm to a prober that a given link was real.

The secret id IS the capability, so secret.ID is a struct whose every
accidental path — %v, %s, String(), slog, json.Marshal — emits a redacted
handle or refuses, and the raw value needs an explicit Value(). The first
version tried to prevent leaks by implementing no String() at all; its own test
caught that Go's fmt prints unexported fields anyway, so forbidding the method
had removed the control rather than the leak.

Operationally: structured JSON on stdout in the fleet's wire format, which
Vector already collects with no annotation; six hush_* metrics on the chassis
registry with no id, IP or path in any label; five alert rules wired into
vmalert. The public Ingress enumerates /, /s/ and /api/ so /metrics, /healthz
and /readyz share the port but are unreachable from the internet — no
basic-auth middleware to maintain and get wrong.

Dependencies are vendored because go-chassis is private: the Woodpecker test
step and the in-cluster Kaniko build both run -mod=vendor with GOPROXY=off and
hold no git credential.

cmd/hush-mcp is a stdio MCP server doing the same client-side crypto locally,
so using hush from an agent preserves the same guarantee as using it from a
browser.
2026-09-03 00:08:38 -06:00

554 lines
20 KiB
Go

package maintnotifications
import (
"context"
"errors"
"fmt"
"net"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/interfaces"
"github.com/redis/go-redis/v9/internal/maintnotifications/logs"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/push"
)
// Push notification type constants for maintenance
const (
NotificationMoving = "MOVING" // Per-connection handoff notification
NotificationMigrating = "MIGRATING" // Per-connection migration start notification - relaxes timeouts
NotificationMigrated = "MIGRATED" // Per-connection migration complete notification - clears relaxed timeouts
NotificationFailingOver = "FAILING_OVER" // Per-connection failover start notification - relaxes timeouts
NotificationFailedOver = "FAILED_OVER" // Per-connection failover complete notification - clears relaxed timeouts
NotificationSMigrating = "SMIGRATING" // Cluster slot migrating notification - relaxes timeouts
NotificationSMigrated = "SMIGRATED" // Cluster slot migrated notification - unrelaxes timeouts and triggers cluster state reload
)
// maintenanceNotificationTypes contains all notification types that maintenance handles
var maintenanceNotificationTypes = []string{
NotificationMoving,
NotificationMigrating,
NotificationMigrated,
NotificationFailingOver,
NotificationFailedOver,
NotificationSMigrating,
NotificationSMigrated,
}
// NotificationHook is called before and after notification processing
// PreHook can modify the notification and return false to skip processing
// PostHook is called after successful processing
type NotificationHook interface {
PreHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}) ([]interface{}, bool)
PostHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}, result error)
}
// MovingOperationKey provides a unique key for tracking MOVING operations
// that combines sequence ID with connection identifier to handle duplicate
// sequence IDs across multiple connections to the same node.
type MovingOperationKey struct {
SeqID int64 // Sequence ID from MOVING notification
ConnID uint64 // Unique connection identifier
}
// String returns a string representation of the key for debugging
func (k MovingOperationKey) String() string {
return fmt.Sprintf("seq:%d-conn:%d", k.SeqID, k.ConnID)
}
// Manager provides a simplified upgrade functionality with hooks and atomic state.
type Manager struct {
client interfaces.ClientInterface
config *Config
options interfaces.OptionsInterface
pool pool.Pooler
// MOVING operation tracking - using sync.Map for better concurrent performance
activeMovingOps sync.Map // map[MovingOperationKey]*MovingOperation
// SMIGRATED notification deduplication - tracks processed SeqIDs
// Multiple connections may receive the same SMIGRATED notification
processedSMigratedSeqIDs sync.Map // map[int64]bool
// Atomic state tracking - no locks needed for state queries
activeOperationCount atomic.Int64 // Number of active operations
closed atomic.Bool // Manager closed state
// shutdownTimeout bounds each pool hook's Shutdown during Close. A field
// (not a constant) so tests can exercise the failed-Close-then-retry
// path without waiting out the real budget.
shutdownTimeout time.Duration
// Notification hooks for extensibility
hooks []NotificationHook
hooksMu sync.RWMutex // Protects hooks slice
poolHooksRef *PoolHook
// additionalPoolHooks are pool hooks bound to pools other than the primary
// one (e.g. a dedicated pipeline connection pool). Each is an independent
// *PoolHook bound to its own pool because the hook's failed-handoff removal
// target (HandoffRequest.Pool) is taken from the hook's single pool field,
// so one hook cannot safely serve two pools. They share this Manager as
// their operations manager, keeping MOVING/MIGRATING tracking centralized.
additionalPoolHooks []additionalPoolHook
// Connections that successfully enabled maintnotifications. These need to be
// retired before the pool-level listeners are removed.
maintNotificationsConns sync.Map // connID -> *pool.Conn
// Cluster state reload callback for SMIGRATED notifications.
// Stored atomically because it is set from the OnNewNode hook while a node
// client is being created and read from the SMIGRATED push handler on that
// node's connections, which can overlap during connection init.
clusterStateReloadCallback atomic.Pointer[ClusterStateReloadCallback]
}
// MovingOperation tracks an active MOVING operation.
type MovingOperation struct {
SeqID int64
NewEndpoint string
StartTime time.Time
Deadline time.Time
}
// ClusterStateReloadCallback is a callback function that triggers cluster state reload.
// This is used by node clients to notify their parent ClusterClient about SMIGRATED notifications.
// The hostPort parameter indicates the destination node (e.g., "127.0.0.1:6379").
// The slotRanges parameter contains the migrated slots (e.g., ["1234", "5000-6000"]).
// Currently, implementations typically reload the entire cluster state, but in the future
// this could be optimized to reload only the specific slots.
type ClusterStateReloadCallback func(ctx context.Context, hostPort string, slotRanges []string)
// NewManager creates a new simplified manager.
func NewManager(client interfaces.ClientInterface, pool pool.Pooler, config *Config) (*Manager, error) {
if client == nil {
return nil, ErrInvalidClient
}
hm := &Manager{
client: client,
pool: pool,
options: client.GetOptions(),
config: config.Clone(),
hooks: make([]NotificationHook, 0),
shutdownTimeout: 10 * time.Second,
}
// Set up push notification handling
if err := hm.setupPushNotifications(); err != nil {
return nil, err
}
return hm, nil
}
// GetPoolHook creates a pool hook with a custom dialer.
func (hm *Manager) InitPoolHook(baseDialer func(context.Context, string, string) (net.Conn, error)) {
poolHook := hm.createPoolHook(baseDialer)
hm.pool.AddPoolHook(poolHook)
}
// additionalPoolHook pairs a pool hook with the pool it was attached to so the
// manager can shut it down and detach it on Close.
type additionalPoolHook struct {
pool pool.Pooler
hook *PoolHook
}
// InitPoolHookForPool attaches a maintnotifications pool hook to an additional
// pool (e.g. a client's dedicated pipeline connection pool). A fresh, independent
// *PoolHook is created and bound to the given pool so that connections which fail
// handoff are removed from the correct pool — the hook's removal target is its own
// single pool field, so the primary hook cannot be reused for a second pool. The
// new hook shares this Manager as its operations manager, so MOVING/MIGRATING
// tracking and notification handling stay centralized across both pools.
func (hm *Manager) InitPoolHookForPool(p pool.Pooler, baseDialer func(context.Context, string, string) (net.Conn, error)) {
if p == nil {
return
}
poolSize := 0
network := ""
if hm.options != nil {
poolSize = hm.options.GetPoolSize()
network = hm.options.GetNetwork()
}
hook := NewPoolHookWithPoolSize(baseDialer, network, hm.config, hm, poolSize)
hook.SetPool(p)
// The closed check, the append, AND the AddPoolHook must all be atomic with
// respect to Close's snapshot: hold the lock across all three so either Close
// runs first (closed==true here, so we neither register nor attach) or we
// register+attach first (Close's snapshot then includes this hook and tears
// it down). Attaching outside the lock left a window where Close could
// snapshot/tear-down between the append and the attach, then AddPoolHook
// would re-attach to a closed manager's pool — leaking an active hook.
// p.AddPoolHook is lock-free (atomic swap on the pool's own hook manager) and
// never calls back into this manager, so holding hooksMu across it is safe.
hm.hooksMu.Lock()
defer hm.hooksMu.Unlock()
if hm.closed.Load() {
return
}
hm.additionalPoolHooks = append(hm.additionalPoolHooks, additionalPoolHook{pool: p, hook: hook})
p.AddPoolHook(hook)
}
// hookForConn returns the pool hook that owns cn's pool: an additional hook
// when the connection came from a secondary pool (e.g. a client's dedicated
// pipeline pool), the primary hook otherwise. Handoffs must be queued through
// the owning hook — the HandoffRequest carries that hook's pool, and a failed
// handoff removes the connection from it, so queuing a pipeline-pool
// connection on the primary hook would close the connection without freeing
// its slot in the pipeline pool's bookkeeping.
func (hm *Manager) hookForConn(cn *pool.Conn) *PoolHook {
if cn == nil {
return hm.poolHooksRef
}
name := cn.PoolName()
if name == "" {
return hm.poolHooksRef
}
hm.hooksMu.RLock()
defer hm.hooksMu.RUnlock()
for _, ah := range hm.additionalPoolHooks {
// Pooler does not expose the name; the concrete pool does. A Pooler
// implementation without it simply never matches and falls through to
// the primary hook — the pre-existing behavior.
if np, ok := ah.pool.(interface{ Name() string }); ok && np.Name() == name {
return ah.hook
}
}
return hm.poolHooksRef
}
// setupPushNotifications sets up push notification handling by registering with the client's processor.
func (hm *Manager) setupPushNotifications() error {
processor := hm.client.GetPushProcessor()
if processor == nil {
return ErrInvalidClient // Client doesn't support push notifications
}
// Create our notification handler
handler := &NotificationHandler{manager: hm, operationsManager: hm}
// Register handlers for all upgrade notifications with the client's processor
for _, notificationType := range maintenanceNotificationTypes {
if err := processor.RegisterHandler(notificationType, handler, true); err != nil {
return errors.New(logs.FailedToRegisterHandler(notificationType, err))
}
}
return nil
}
// TrackMovingOperationWithConnID starts a new MOVING operation with a specific connection ID.
func (hm *Manager) TrackMovingOperationWithConnID(ctx context.Context, newEndpoint string, deadline time.Time, seqID int64, connID uint64) error {
// Create composite key
key := MovingOperationKey{
SeqID: seqID,
ConnID: connID,
}
// Create MOVING operation record
movingOp := &MovingOperation{
SeqID: seqID,
NewEndpoint: newEndpoint,
StartTime: time.Now(),
Deadline: deadline,
}
// Use LoadOrStore for atomic check-and-set operation
if _, loaded := hm.activeMovingOps.LoadOrStore(key, movingOp); loaded {
// Duplicate MOVING notification, ignore
if internal.LogLevel.DebugOrAbove() { // Debug level
internal.Logger.Printf(context.Background(), logs.DuplicateMovingOperation(connID, newEndpoint, seqID))
}
return nil
}
if internal.LogLevel.DebugOrAbove() { // Debug level
internal.Logger.Printf(context.Background(), logs.TrackingMovingOperation(connID, newEndpoint, seqID))
}
// Increment active operation count atomically
hm.activeOperationCount.Add(1)
return nil
}
// UntrackOperationWithConnID completes a MOVING operation with a specific connection ID.
func (hm *Manager) UntrackOperationWithConnID(seqID int64, connID uint64) {
// Create composite key
key := MovingOperationKey{
SeqID: seqID,
ConnID: connID,
}
// Remove from active operations atomically
if _, loaded := hm.activeMovingOps.LoadAndDelete(key); loaded {
if internal.LogLevel.DebugOrAbove() { // Debug level
internal.Logger.Printf(context.Background(), logs.UntrackingMovingOperation(connID, seqID))
}
// Decrement active operation count only if operation existed
hm.activeOperationCount.Add(-1)
} else {
if internal.LogLevel.DebugOrAbove() { // Debug level
internal.Logger.Printf(context.Background(), logs.OperationNotTracked(connID, seqID))
}
}
}
// GetActiveMovingOperations returns active operations with composite keys.
// WARNING: This method creates a new map and copies all operations on every call.
// Use sparingly, especially in hot paths or high-frequency logging.
func (hm *Manager) GetActiveMovingOperations() map[MovingOperationKey]*MovingOperation {
result := make(map[MovingOperationKey]*MovingOperation)
// Iterate over sync.Map to build result
hm.activeMovingOps.Range(func(key, value interface{}) bool {
k := key.(MovingOperationKey)
op := value.(*MovingOperation)
// Create a copy to avoid sharing references
result[k] = &MovingOperation{
SeqID: op.SeqID,
NewEndpoint: op.NewEndpoint,
StartTime: op.StartTime,
Deadline: op.Deadline,
}
return true // Continue iteration
})
return result
}
// IsHandoffInProgress returns true if any handoff is in progress.
// Uses atomic counter for lock-free operation.
func (hm *Manager) IsHandoffInProgress() bool {
return hm.activeOperationCount.Load() > 0
}
// GetActiveOperationCount returns the number of active operations.
// Uses atomic counter for lock-free operation.
func (hm *Manager) GetActiveOperationCount() int64 {
return hm.activeOperationCount.Load()
}
// MarkSMigratedSeqIDProcessed attempts to mark a SMIGRATED SeqID as processed.
// Returns true if this is the first time processing this SeqID (should process),
// false if it was already processed (should skip).
// This prevents duplicate processing when multiple connections receive the same notification.
func (hm *Manager) MarkSMigratedSeqIDProcessed(seqID int64) bool {
_, alreadyProcessed := hm.processedSMigratedSeqIDs.LoadOrStore(seqID, true)
return !alreadyProcessed // Return true if NOT already processed
}
// TrackMaintNotificationsConn records a connection that successfully enabled
// maintnotifications so it can be retired if the feature is later disabled for
// the pool.
func (hm *Manager) TrackMaintNotificationsConn(cn *pool.Conn) {
if cn == nil {
return
}
hm.maintNotificationsConns.Store(cn.GetID(), cn)
}
// UntrackMaintNotificationsConn removes a connection from the enabled
// maintnotifications set.
func (hm *Manager) UntrackMaintNotificationsConn(connID uint64) {
hm.maintNotificationsConns.Delete(connID)
}
func (hm *Manager) maintNotificationsConnSnapshot() []*pool.Conn {
var conns []*pool.Conn
hm.maintNotificationsConns.Range(func(_, value interface{}) bool {
if cn, ok := value.(*pool.Conn); ok {
conns = append(conns, cn)
}
return true
})
return conns
}
func (hm *Manager) retireMaintNotificationsConns(ctx context.Context) {
conns := hm.maintNotificationsConnSnapshot()
if len(conns) == 0 {
return
}
// Tracked connections can live in the primary pool OR in any additional
// pool this manager attached a hook to (e.g. a client's dedicated pipeline
// connection pool — its conns run initConn and are tracked exactly like
// primary ones). Retire through every pool: RetireConns skips connections
// a pool does not own, so offering the full snapshot to each pool is safe.
// Missing the additional pools left pipeline connections in service with
// maintnotifications enabled but no hook attached after a runtime
// downgrade — pushes on them were silently dropped.
pools := make([]pool.Pooler, 0, 1+len(hm.additionalPoolHooks))
if hm.pool != nil {
pools = append(pools, hm.pool)
}
hm.hooksMu.RLock()
for _, ah := range hm.additionalPoolHooks {
if ah.pool != nil {
pools = append(pools, ah.pool)
}
}
hm.hooksMu.RUnlock()
for _, pl := range pools {
if retirer, ok := pl.(pool.ConnRetirer); ok {
retirer.RetireConns(ctx, conns, pool.CloseReasonMaintNotificationsDisabled)
continue
}
for _, cn := range conns {
_ = pl.CloseConn(ctx, cn, pool.CloseReasonMaintNotificationsDisabled, pool.MetricStateIdle)
}
}
}
// Close closes the manager.
func (hm *Manager) Close() error {
// Use atomic operation for thread-safe close check
if !hm.closed.CompareAndSwap(false, true) {
return nil // Already closed
}
// Retire connections that enabled maintnotifications before removing the
// pool-level listeners that process those push notifications.
hm.retireMaintNotificationsConns(context.Background())
// Shutdown the pool hook if it exists
if hm.poolHooksRef != nil {
// Use a timeout to prevent hanging indefinitely
shutdownCtx, cancel := context.WithTimeout(context.Background(), hm.shutdownTimeout)
defer cancel()
err := hm.poolHooksRef.Shutdown(shutdownCtx)
if err != nil {
// was not able to close pool hook, keep closed state false
hm.closed.Store(false)
return err
}
// Remove the pool hook from the pool
if hm.pool != nil {
hm.pool.RemovePoolHook(hm.poolHooksRef)
}
}
// Shutdown and detach any hooks bound to additional pools (e.g. a dedicated
// pipeline pool). Snapshot under the lock so we don't iterate concurrently
// with a registering InitPoolHookForPool; Shutdown itself runs unlocked.
hm.hooksMu.Lock()
additional := hm.additionalPoolHooks
hm.additionalPoolHooks = nil
hm.hooksMu.Unlock()
for i, ah := range additional {
shutdownCtx, cancel := context.WithTimeout(context.Background(), hm.shutdownTimeout)
err := ah.hook.Shutdown(shutdownCtx)
cancel()
if err != nil {
// Could not cleanly shut down this hook. Put it and the ones not
// yet processed back so a retried Close still sees them, then stay
// open so the caller can retry, matching the primary-hook behavior
// above. Hooks before i already shut down and detached.
hm.hooksMu.Lock()
remaining := make([]additionalPoolHook, 0, len(additional)-i+len(hm.additionalPoolHooks))
remaining = append(remaining, additional[i:]...)
remaining = append(remaining, hm.additionalPoolHooks...)
hm.additionalPoolHooks = remaining
hm.hooksMu.Unlock()
hm.closed.Store(false)
return err
}
if ah.pool != nil {
ah.pool.RemovePoolHook(ah.hook)
}
}
// Clear all active operations
hm.activeMovingOps.Range(func(key, value interface{}) bool {
hm.activeMovingOps.Delete(key)
return true
})
// Reset counter
hm.activeOperationCount.Store(0)
return nil
}
// GetState returns current state using atomic counter for lock-free operation.
func (hm *Manager) GetState() State {
if hm.activeOperationCount.Load() > 0 {
return StateMoving
}
return StateIdle
}
// processPreHooks calls all pre-hooks and returns the modified notification and whether to continue processing.
func (hm *Manager) processPreHooks(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}) ([]interface{}, bool) {
hm.hooksMu.RLock()
defer hm.hooksMu.RUnlock()
currentNotification := notification
for _, hook := range hm.hooks {
modifiedNotification, shouldContinue := hook.PreHook(ctx, notificationCtx, notificationType, currentNotification)
if !shouldContinue {
return modifiedNotification, false
}
currentNotification = modifiedNotification
}
return currentNotification, true
}
// processPostHooks calls all post-hooks with the processing result.
func (hm *Manager) processPostHooks(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}, result error) {
hm.hooksMu.RLock()
defer hm.hooksMu.RUnlock()
for _, hook := range hm.hooks {
hook.PostHook(ctx, notificationCtx, notificationType, notification, result)
}
}
// createPoolHook creates a pool hook with this manager already set.
func (hm *Manager) createPoolHook(baseDialer func(context.Context, string, string) (net.Conn, error)) *PoolHook {
if hm.poolHooksRef != nil {
return hm.poolHooksRef
}
// Get pool size from client options for better worker defaults
poolSize := 0
if hm.options != nil {
poolSize = hm.options.GetPoolSize()
}
hm.poolHooksRef = NewPoolHookWithPoolSize(baseDialer, hm.options.GetNetwork(), hm.config, hm, poolSize)
hm.poolHooksRef.SetPool(hm.pool)
return hm.poolHooksRef
}
func (hm *Manager) AddNotificationHook(notificationHook NotificationHook) {
hm.hooksMu.Lock()
defer hm.hooksMu.Unlock()
hm.hooks = append(hm.hooks, notificationHook)
}
// SetClusterStateReloadCallback sets the callback function that will be called when a SMIGRATED notification is received.
// This allows node clients to notify their parent ClusterClient to reload cluster state.
func (hm *Manager) SetClusterStateReloadCallback(callback ClusterStateReloadCallback) {
hm.clusterStateReloadCallback.Store(&callback)
}
// TriggerClusterStateReload calls the cluster state reload callback if it's set.
// This is called when a SMIGRATED notification is received.
func (hm *Manager) TriggerClusterStateReload(ctx context.Context, hostPort string, slotRanges []string) {
if cb := hm.clusterStateReloadCallback.Load(); cb != nil {
(*cb)(ctx, hostPort, slotRanges)
}
}