mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-16 01:26:07 +00:00
fbad71d620
Second-pass review of the 54-commit self-correcting audit. Each item below was confirmed by reading the surrounding source (and, where practical, the pre-fix code) before being changed; regression tests are included for every behavioral fix. Concurrency: - eventbus: Bus.Subscribe called wg.Add with no synchronization against a concurrent Bus.Stop's wg.Wait, a real "WaitGroup misuse" panic risk (e.g. a Telegram-bot settings save racing panel shutdown/restart). Stop now flips a mu-guarded `stopped` flag before waiting, and Subscribe checks it under the same lock, so Add and Wait can no longer race. Security: - login_limiter: evictForRoom's fallback eviction picked an arbitrary map key, including ones still under an active cooldown - an attacker flooding /login with fresh usernames could evict their own (or anyone's) blocked record and reset the lockout. The fallback now skips actively-blocked records, only falling back to an unconditional evict if the map is somehow entirely full of active blocks (preserves the hard memory cap). Subscription-endpoint panics (reachable by any client hitting /sub): - internal/sub/service.go: applyPathAndHostParams/Obj (ws/httpupgrade/xhttp with no path settings object) and the TLS alpn readers in three places used unchecked type assertions - exactly the bug classabab7cd0patched elsewhere in the same switch statements, just not these call sites. - internal/sub/json_service.go, clash_service.go: the externalProxy loops in the JSON and Clash generators used unchecked assertions on a legacy/admin-supplied field (missing "port", non-object entry, etc.). - internal/sub/json_service.go: realityData's shortId/serverName selection could assert a non-string array element. Other correctness: - client_traffic.go: ResetAllTraffics (touched by3eb214d0) still skipped clearing NodeClientTraffic node-sync baselines, unlike its sibling reset paths in the same file - a node's next sync would re-add pre-reset delta on top of the freshly-zeroed counter. - inbound_traffic.go: the traffic-tick tx's Commit/Rollback errors were silently discarded; now logged so a backend-level commit failure (e.g. an aborted Postgres tx from a best-effort helper) doesn't masquerade as a successful tick. - outbound_subscription.go: the new subscriptionFetchClient doc comment was wedged between fetchAndStore's existing comment and fetchAndStore itself, leaving fetchAndStore undocumented and the comment describing the wrong function. Convention cleanup: - Removed narrative // comments added by the audit that violate this repo's no-inline-comment rule (mostly narrating the specific bug/fix rather than a lasting contract, and mostly on new Test functions, which this repo's existing tests never comment) - calibrated against this exact codebase's own pre-existing comment style so legitimate godoc-style doc comments were left alone.
176 lines
4.7 KiB
Go
176 lines
4.7 KiB
Go
package eventbus
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
)
|
|
|
|
// DefaultBufferSize is the number of events the bus can hold before Publish starts dropping.
|
|
const DefaultBufferSize = 256
|
|
|
|
// subscriberQueueSize bounds how many undelivered events a single subscriber may
|
|
// hold before the newest are dropped. Each subscriber drains its own queue on a
|
|
// dedicated worker goroutine, so a slow subscriber can neither stall delivery to
|
|
// the others nor make the bus spawn an unbounded number of goroutines.
|
|
const subscriberQueueSize = 64
|
|
|
|
// subscriber pairs an ID with its event handler and the per-subscriber worker
|
|
// state used to deliver events to it serially, without blocking the dispatch loop.
|
|
type subscriber struct {
|
|
id string
|
|
handler func(Event)
|
|
queue chan Event
|
|
quit chan struct{}
|
|
}
|
|
|
|
// Bus is a minimal in-process pub/sub event bus backed by a buffered channel.
|
|
// Producers call Publish (non-blocking) and every event is fanned out to all
|
|
// subscribers; per-event filtering is the subscriber's responsibility.
|
|
type Bus struct {
|
|
ch chan Event
|
|
subs []*subscriber
|
|
mu sync.RWMutex
|
|
done chan struct{}
|
|
wg sync.WaitGroup
|
|
stopped bool
|
|
}
|
|
|
|
// New creates a Bus with the given buffer size. Use 0 for DefaultBufferSize.
|
|
func New(bufSize int) *Bus {
|
|
if bufSize <= 0 {
|
|
bufSize = DefaultBufferSize
|
|
}
|
|
b := &Bus{
|
|
ch: make(chan Event, bufSize),
|
|
done: make(chan struct{}),
|
|
}
|
|
b.wg.Add(1)
|
|
go b.dispatch()
|
|
return b
|
|
}
|
|
|
|
// Subscribe registers a handler that receives every published event on its own
|
|
// worker goroutine. The id is used for Unsubscribe; it must be unique across
|
|
// active subscribers. Subscribing with an already-registered id replaces the
|
|
// previous subscriber, stopping its worker.
|
|
func (b *Bus) Subscribe(id string, handler func(Event)) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.stopped {
|
|
return
|
|
}
|
|
for i, s := range b.subs {
|
|
if s.id == id {
|
|
close(s.quit)
|
|
b.subs = append(b.subs[:i], b.subs[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
s := &subscriber{
|
|
id: id,
|
|
handler: handler,
|
|
queue: make(chan Event, subscriberQueueSize),
|
|
quit: make(chan struct{}),
|
|
}
|
|
b.subs = append(b.subs, s)
|
|
b.wg.Add(1)
|
|
go b.runWorker(s)
|
|
}
|
|
|
|
// Unsubscribe removes a subscriber by id and stops its worker. Safe to call with an unknown id.
|
|
func (b *Bus) Unsubscribe(id string) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
for i, s := range b.subs {
|
|
if s.id == id {
|
|
close(s.quit)
|
|
b.subs = append(b.subs[:i], b.subs[i+1:]...)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Publish sends an event to all subscribers. Non-blocking — if the buffer is
|
|
// full the event is dropped and a warning is logged.
|
|
func (b *Bus) Publish(e Event) {
|
|
if e.Timestamp.IsZero() {
|
|
e.Timestamp = time.Now()
|
|
}
|
|
select {
|
|
case b.ch <- e:
|
|
default:
|
|
logger.Warning("eventbus: buffer full, dropping event ", e.Type)
|
|
}
|
|
}
|
|
|
|
// dispatch is the fan-out loop. It reads events from the channel and hands each
|
|
// one to every subscriber's queue with a non-blocking send, so a subscriber
|
|
// whose handler blocks on network I/O (the email and Telegram notifiers can
|
|
// block for tens of seconds) can neither stall delivery of unrelated, higher-
|
|
// value events such as xray.crash or node.down, nor force the bus to spawn an
|
|
// unbounded number of goroutines under load. A subscriber whose queue is full
|
|
// drops the event, keeping the bus non-blocking and its memory bounded.
|
|
func (b *Bus) dispatch() {
|
|
defer b.wg.Done()
|
|
for {
|
|
select {
|
|
case e, ok := <-b.ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
b.mu.RLock()
|
|
for _, s := range b.subs {
|
|
select {
|
|
case s.queue <- e:
|
|
default:
|
|
logger.Warning("eventbus: subscriber ", s.id, " queue full, dropping ", e.Type)
|
|
}
|
|
}
|
|
b.mu.RUnlock()
|
|
case <-b.done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// runWorker delivers queued events to one subscriber serially, so a subscriber
|
|
// never runs concurrently with itself and observes events in publication order.
|
|
func (b *Bus) runWorker(s *subscriber) {
|
|
defer b.wg.Done()
|
|
for {
|
|
select {
|
|
case e := <-s.queue:
|
|
safeCall(s.handler, e)
|
|
case <-s.quit:
|
|
return
|
|
case <-b.done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// safeCall invokes handler with panic recovery.
|
|
func safeCall(fn func(Event), e Event) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
logger.Errorf("eventbus: subscriber panicked on %s: %v", e.Type, r)
|
|
}
|
|
}()
|
|
fn(e)
|
|
}
|
|
|
|
// Stop shuts down the bus: the dispatch loop and every subscriber worker exit
|
|
// after finishing any handler already in progress, and any events still buffered
|
|
// or queued may be dropped. Safe to call once. After Stop returns, Subscribe is
|
|
// a no-op — this also keeps Subscribe's wg.Add from ever racing with Wait below,
|
|
// since both are serialized through mu.
|
|
func (b *Bus) Stop() {
|
|
b.mu.Lock()
|
|
b.stopped = true
|
|
b.mu.Unlock()
|
|
close(b.done)
|
|
b.wg.Wait()
|
|
}
|