Merge remote-tracking branch 'origin/main' into cluster-drain

# Conflicts:
#	cluster/cluster_mesh_test.go
#	server/server.go
#	server/server_cluster_test.go
This commit is contained in:
binwiederhier committed 2026-10-24 18:06:11 +00:00
commit 70781daf2d
27 files changed
+1362 -392

No files matched your search

+80 -52
View File
@@ -270,6 +270,40 @@ func New(conf *Config) (*Server, error) {
CallFormat: conf.TwilioCallFormat,
BuildVersion: conf.BuildVersion,
})
var banner *ban.Service
if conf.BanFile != "" {
banner = ban.NewService(&ban.Config{
File: conf.BanFile,
Window: conf.BanWindow,
Threshold: conf.BanThreshold,
Weights: conf.BanWeights,
PrefixBitsIPv4: conf.VisitorPrefixBitsIPv4,
PrefixBitsIPv6: conf.VisitorPrefixBitsIPv6,
})
}
// Peers reach this node on the dedicated cluster listener, never on the public ones
advertiseURL := conf.ClusterAdvertiseURL
if advertiseURL == "" && conf.ClusterListen != "" {
advertiseURL = "http://" + conf.ClusterListen
}
s := &Server{
config: conf,
db: pool,
messageCache: messageCache,
webPush: wp,
attachment: attachmentStore,
twilio: twilioClient,
mailer: sender,
ban: banner,
topics: topics,
messages: messages,
messagesHistory: []int64{messages},
messagesFlushed: messages, // The loaded total is already persisted; only new publishes are deltas
visitors: make(map[string]*visitor),
stripe: stripe,
streams: newStreamRegistry(),
}
s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration)
var userManager *user.Manager
if conf.AuthFile != "" || pool != nil {
authConfig := &user.Config{
@@ -285,6 +319,8 @@ func New(conf *Config) (*Server, error) {
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
AccessCacheEnabled: conf.AuthAccessCacheEnabled,
AccessCacheReloadInterval: conf.AuthAccessCacheReloadInterval,
AccessCacheChangedFunc: s.reauthorizeSubscribers,
AccessWrittenFunc: s.broadcastAccessChanged,
}
if pool != nil {
userManager, err = user.NewPostgresManager(pool, authConfig)
@@ -309,57 +345,22 @@ func New(conf *Config) (*Server, error) {
}
firebaseClient = newFirebaseClient(sender, auther)
}
var banner *ban.Service
if conf.BanFile != "" {
banner = ban.NewService(&ban.Config{
File: conf.BanFile,
Window: conf.BanWindow,
Threshold: conf.BanThreshold,
Weights: conf.BanWeights,
PrefixBitsIPv4: conf.VisitorPrefixBitsIPv4,
PrefixBitsIPv6: conf.VisitorPrefixBitsIPv6,
})
}
// Peers reach this node on the dedicated cluster listener, never on the public ones
advertiseURL := conf.ClusterAdvertiseURL
if advertiseURL == "" && conf.ClusterListen != "" {
advertiseURL = "http://" + conf.ClusterListen
}
s := &Server{
config: conf,
db: pool,
messageCache: messageCache,
webPush: wp,
attachment: attachmentStore,
firebaseClient: firebaseClient,
twilio: twilioClient,
mailer: sender,
ban: banner,
topics: topics,
userManager: userManager,
messages: messages,
messagesHistory: []int64{messages},
messagesFlushed: messages, // The loaded total is already persisted; only new publishes are deltas
visitors: make(map[string]*visitor),
stripe: stripe,
streams: newStreamRegistry(),
}
// Everything from here on needs the server itself: the price cache calls a method on it, and
// the cluster delivers peer messages through callbacks into it (which is how the cluster
// package stays independent of the server)
s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration)
s.userManager = userManager
s.firebaseClient = firebaseClient
if pool != nil {
metrics.SetDBPoolsFunc(s.dbPoolStats)
}
s.cluster, err = cluster.New(&cluster.Config{
Enabled: conf.ClusterListen != "", // Setting experimental-cluster-listen implicitly enables clustering
NodeID: cluster.NodeID(conf.ClusterNodeID),
AdvertiseURL: advertiseURL,
Role: conf.ClusterRole,
Secret: conf.ClusterSecret,
BatchLinger: conf.ClusterBatchLinger,
IsolatedFunc: s.maybeCloseLocalSubscribers,
MaxMessageBytes: int64(conf.MessageSizeLimit)*4 + 1024, // Envelope overhead over the raw message
Enabled: conf.ClusterListen != "", // Setting experimental-cluster-listen implicitly enables clustering
NodeID: cluster.NodeID(conf.ClusterNodeID),
AdvertiseURL: advertiseURL,
Role: conf.ClusterRole,
Secret: conf.ClusterSecret,
BatchLinger: conf.ClusterBatchLinger,
UsersDeletedFunc: s.handleUsersDeleted,
AccessChangedFunc: s.handlePeerAccessChanged,
IsolatedFunc: s.maybeCloseLocalSubscribers,
MaxMessageBytes: int64(conf.MessageSizeLimit)*4 + 1024, // Envelope overhead over the raw message
}, pool, s.deliverFromBus)
if err != nil {
return nil, err
@@ -408,6 +409,11 @@ func createAttachmentStore(conf *Config, messageCache *message.Cache) (*attachme
return nil, nil
}
// testHookBeforeSubscribe, if non-nil, is called by the subscribe handlers after the request was
// authorized and before its stream subscribes. Tests use it to land an access change in between;
// it is always nil in production.
var testHookBeforeSubscribe func()
// Run executes the main server. It listens on HTTP (+ HTTPS, if configured), and starts
// a manager go routine to print stats and prune messages.
func (s *Server) Run() error {
@@ -1646,17 +1652,23 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
w.Header().Set("Incremental", "?1") // Ask proxies to forward the stream without buffering (RFC 10036)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if testHookBeforeSubscribe != nil {
testHookBeforeSubscribe()
}
streamID := s.streams.Add(cancel)
defer s.streams.Remove(streamID)
subscriberIDs := make([]int, 0)
for _, t := range topics {
subscriberIDs = append(subscriberIDs, t.Subscribe(sub, v.MaybeUserID(), cancel))
subscriberIDs = append(subscriberIDs, t.Subscribe(sub, v.User(), cancel))
}
defer func() {
for i, subscriberID := range subscriberIDs {
topics[i].Unsubscribe(subscriberID) // Order!
}
}()
if err := s.authorizeSubscribed(v, topics); err != nil {
return err
}
if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message
return err
}
@@ -1799,17 +1811,23 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
}
return s.sendOldMessages(w, topics, since, scheduled, v, sub)
}
if testHookBeforeSubscribe != nil {
testHookBeforeSubscribe()
}
streamID := s.streams.Add(cancel)
defer s.streams.Remove(streamID)
subscriberIDs := make([]int, 0)
for _, t := range topics {
subscriberIDs = append(subscriberIDs, t.Subscribe(sub, v.MaybeUserID(), cancel))
subscriberIDs = append(subscriberIDs, t.Subscribe(sub, v.User(), cancel))
}
defer func() {
for i, subscriberID := range subscriberIDs {
topics[i].Unsubscribe(subscriberID) // Order!
}
}()
if err := s.authorizeSubscribed(v, topics); err != nil {
return err
}
if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message
return err
}
@@ -2068,14 +2086,12 @@ func (s *Server) topicFromID(v *visitor, id string) (*topic, error) {
// topicsFromPattern returns a list of topics matching the given pattern, but it does not create them.
func (s *Server) topicsFromPattern(pattern string) ([]*topic, error) {
s.mu.RLock()
defer s.mu.RUnlock()
patternRegexp, err := regexp.Compile("^" + strings.ReplaceAll(pattern, "*", ".*") + "$")
if err != nil {
return nil, err
}
topics := make([]*topic, 0)
for _, t := range s.topics {
for _, t := range s.allTopics() {
if patternRegexp.MatchString(t.ID) {
topics = append(topics, t)
}
@@ -2083,6 +2099,18 @@ func (s *Server) topicsFromPattern(pattern string) ([]*topic, error) {
return topics, nil
}
// allTopics returns all topics. Callers that scan them (~313k on ntfy.sh) do so without s.mu held,
// which every request that creates a visitor or topic needs.
func (s *Server) allTopics() []*topic {
s.mu.RLock()
defer s.mu.RUnlock()
topics := make([]*topic, 0, len(s.topics))
for _, t := range s.topics {
topics = append(topics, t)
}
return topics
}
func (s *Server) runSMTPServer() error {
s.smtpServerBackend = newMailBackend(s.config, s.handle)
s.smtpServer = smtp.NewServer(s.smtpServerBackend)