So much logging
parent
ab955d4d1c
commit
7845eb0124
|
@ -8,5 +8,5 @@ any outside service. All data is exclusively used to make the service function p
|
||||||
I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
|
I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
|
||||||
[FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version.
|
[FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version.
|
||||||
|
|
||||||
The web server does not log or otherwise store request paths, remote IP addresses or even topics or messages,
|
For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics
|
||||||
aside from a short on-disk cache to support service restarts.
|
or messages, though typically this is turned off.
|
||||||
|
|
|
@ -13,6 +13,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||||
|
|
||||||
## ntfy server v1.25.0 (UNRELEASED)
|
## ntfy server v1.25.0 (UNRELEASED)
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Advanced logging, with different log levels and hot reloading of the log level (no ticket)
|
||||||
|
|
||||||
**Bugs**:
|
**Bugs**:
|
||||||
|
|
||||||
* Respect Firebase "quota exceeded" response for topics, block Firebase publishing for user for 10min ([#289](https://github.com/binwiederhier/ntfy/issues/289))
|
* Respect Firebase "quota exceeded" response for topics, block Firebase publishing for user for 10min ([#289](https://github.com/binwiederhier/ntfy/issues/289))
|
||||||
|
@ -27,6 +31,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||||
* [Examples](examples.md) for [Home Assistant](https://www.home-assistant.io/) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@poblabs](https://github.com/poblabs))
|
* [Examples](examples.md) for [Home Assistant](https://www.home-assistant.io/) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@poblabs](https://github.com/poblabs))
|
||||||
* Install instructions for [NixOS/Nix](https://ntfy.sh/docs/install/#nixos-nix) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@arjan-s](https://github.com/arjan-s))
|
* Install instructions for [NixOS/Nix](https://ntfy.sh/docs/install/#nixos-nix) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@arjan-s](https://github.com/arjan-s))
|
||||||
* Clarify `poll_request` wording for [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) ([#300](https://github.com/binwiederhier/ntfy/issues/300), thanks to [@prabirshrestha](https://github.com/prabirshrestha) for reporting)
|
* Clarify `poll_request` wording for [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) ([#300](https://github.com/binwiederhier/ntfy/issues/300), thanks to [@prabirshrestha](https://github.com/prabirshrestha) for reporting)
|
||||||
|
* Example for using ntfy with docker-compose.yml without root privileges ([#304](https://github.com/binwiederhier/ntfy/pull/304), thanks to [@ksurl](https://github.com/ksurl))
|
||||||
|
|
||||||
**Additional translations:**
|
**Additional translations:**
|
||||||
|
|
||||||
|
|
39
log/log.go
39
log/log.go
|
@ -11,7 +11,8 @@ type Level int
|
||||||
|
|
||||||
// Well known log levels
|
// Well known log levels
|
||||||
const (
|
const (
|
||||||
DebugLevel Level = iota
|
TraceLevel Level = iota
|
||||||
|
DebugLevel
|
||||||
InfoLevel
|
InfoLevel
|
||||||
WarnLevel
|
WarnLevel
|
||||||
ErrorLevel
|
ErrorLevel
|
||||||
|
@ -19,6 +20,8 @@ const (
|
||||||
|
|
||||||
func (l Level) String() string {
|
func (l Level) String() string {
|
||||||
switch l {
|
switch l {
|
||||||
|
case TraceLevel:
|
||||||
|
return "TRACE"
|
||||||
case DebugLevel:
|
case DebugLevel:
|
||||||
return "DEBUG"
|
return "DEBUG"
|
||||||
case InfoLevel:
|
case InfoLevel:
|
||||||
|
@ -36,7 +39,12 @@ var (
|
||||||
mu = &sync.Mutex{}
|
mu = &sync.Mutex{}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Debug prints the given message, if the current log level is DEBUG
|
// Trace prints the given message, if the current log level is TRACE
|
||||||
|
func Trace(message string, v ...interface{}) {
|
||||||
|
logIf(TraceLevel, message, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug prints the given message, if the current log level is DEBUG or lower
|
||||||
func Debug(message string, v ...interface{}) {
|
func Debug(message string, v ...interface{}) {
|
||||||
logIf(DebugLevel, message, v...)
|
logIf(DebugLevel, message, v...)
|
||||||
}
|
}
|
||||||
|
@ -78,20 +86,37 @@ func SetLevel(newLevel Level) {
|
||||||
// ToLevel converts a string to a Level. It returns InfoLevel if the string
|
// ToLevel converts a string to a Level. It returns InfoLevel if the string
|
||||||
// does not match any known log levels.
|
// does not match any known log levels.
|
||||||
func ToLevel(s string) Level {
|
func ToLevel(s string) Level {
|
||||||
switch strings.ToLower(s) {
|
switch strings.ToUpper(s) {
|
||||||
case "debug":
|
case "TRACE":
|
||||||
|
return TraceLevel
|
||||||
|
case "DEBUG":
|
||||||
return DebugLevel
|
return DebugLevel
|
||||||
case "info":
|
case "INFO":
|
||||||
return InfoLevel
|
return InfoLevel
|
||||||
case "warn", "warning":
|
case "WARN", "WARNING":
|
||||||
return WarnLevel
|
return WarnLevel
|
||||||
case "error":
|
case "ERROR":
|
||||||
return ErrorLevel
|
return ErrorLevel
|
||||||
default:
|
default:
|
||||||
return InfoLevel
|
return InfoLevel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loggable returns true if the given log level is lower or equal to the current log level
|
||||||
|
func Loggable(l Level) bool {
|
||||||
|
return CurrentLevel() <= l
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTrace returns true if the current log level is TraceLevel
|
||||||
|
func IsTrace() bool {
|
||||||
|
return Loggable(TraceLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDebug returns true if the current log level is DebugLevel or below
|
||||||
|
func IsDebug() bool {
|
||||||
|
return Loggable(DebugLevel)
|
||||||
|
}
|
||||||
|
|
||||||
func logIf(l Level, message string, v ...interface{}) {
|
func logIf(l Level, message string, v ...interface{}) {
|
||||||
if CurrentLevel() <= l {
|
if CurrentLevel() <= l {
|
||||||
log.Printf(l.String()+" "+message, v...)
|
log.Printf(l.String()+" "+message, v...)
|
||||||
|
|
170
server/server.go
170
server/server.go
|
@ -32,22 +32,22 @@ import (
|
||||||
|
|
||||||
// Server is the main server, providing the UI and API for ntfy
|
// Server is the main server, providing the UI and API for ntfy
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config *Config
|
config *Config
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
httpsServer *http.Server
|
httpsServer *http.Server
|
||||||
unixListener net.Listener
|
unixListener net.Listener
|
||||||
smtpServer *smtp.Server
|
smtpServer *smtp.Server
|
||||||
smtpBackend *smtpBackend
|
smtpServerBackend *smtpBackend
|
||||||
topics map[string]*topic
|
smtpSender mailer
|
||||||
visitors map[string]*visitor
|
topics map[string]*topic
|
||||||
firebaseClient *firebaseClient
|
visitors map[string]*visitor
|
||||||
mailer mailer
|
firebaseClient *firebaseClient
|
||||||
messages int64
|
messages int64
|
||||||
auth auth.Auther
|
auth auth.Auther
|
||||||
messageCache *messageCache
|
messageCache *messageCache
|
||||||
fileCache *fileCache
|
fileCache *fileCache
|
||||||
closeChan chan bool
|
closeChan chan bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleFunc extends the normal http.HandlerFunc to be able to easily return errors
|
// handleFunc extends the normal http.HandlerFunc to be able to easily return errors
|
||||||
|
@ -147,7 +147,7 @@ func New(conf *Config) (*Server, error) {
|
||||||
messageCache: messageCache,
|
messageCache: messageCache,
|
||||||
fileCache: fileCache,
|
fileCache: fileCache,
|
||||||
firebaseClient: firebaseClient,
|
firebaseClient: firebaseClient,
|
||||||
mailer: mailer,
|
smtpSender: mailer,
|
||||||
topics: topics,
|
topics: topics,
|
||||||
auth: auther,
|
auth: auther,
|
||||||
visitors: make(map[string]*visitor),
|
visitors: make(map[string]*visitor),
|
||||||
|
@ -246,14 +246,14 @@ func (s *Server) Stop() {
|
||||||
|
|
||||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||||
v := s.visitor(r)
|
v := s.visitor(r)
|
||||||
log.Debug("%s HTTP %s %s", v.ip, r.Method, r.URL.Path)
|
log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
|
||||||
if err := s.handleInternal(w, r, v); err != nil {
|
if err := s.handleInternal(w, r, v); err != nil {
|
||||||
if websocket.IsWebSocketUpgrade(r) {
|
if websocket.IsWebSocketUpgrade(r) {
|
||||||
isNormalError := websocket.IsCloseError(err, websocket.CloseAbnormalClosure) || strings.Contains(err.Error(), "i/o timeout")
|
isNormalError := strings.Contains(err.Error(), "i/o timeout")
|
||||||
if isNormalError {
|
if isNormalError {
|
||||||
log.Debug("%s WS %s %s - %s", v.ip, r.Method, r.URL.Path, err.Error())
|
log.Debug("%s WebSocket error (this error is okay, it happens a lot): %s", logHTTPPrefix(v, r), err.Error())
|
||||||
} else {
|
} else {
|
||||||
log.Warn("%s WS %s %s - %s", v.ip, r.Method, r.URL.Path, err.Error())
|
log.Warn("%s WebSocket error: %s", logHTTPPrefix(v, r), err.Error())
|
||||||
}
|
}
|
||||||
return // Do not attempt to write to upgraded connection
|
return // Do not attempt to write to upgraded connection
|
||||||
}
|
}
|
||||||
|
@ -261,13 +261,12 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||||
if !ok {
|
if !ok {
|
||||||
httpErr = errHTTPInternalError
|
httpErr = errHTTPInternalError
|
||||||
}
|
}
|
||||||
isNormalError := httpErr.Code == 404
|
isNormalError := httpErr.HTTPCode == http.StatusNotFound
|
||||||
if isNormalError {
|
if isNormalError {
|
||||||
log.Debug("%s HTTP %s %s - %d - %d - %s", v.ip, r.Method, r.URL.Path, httpErr.HTTPCode, httpErr.Code, err.Error())
|
log.Debug("%s Connection closed with HTTP %d (ntfy error %d): %s", logHTTPPrefix(v, r), httpErr.HTTPCode, httpErr.Code, err.Error())
|
||||||
} else {
|
} else {
|
||||||
log.Info("%s HTTP %s %s - %d - %d - %s", v.ip, r.Method, r.URL.Path, httpErr.HTTPCode, httpErr.Code, err.Error())
|
log.Info("%s Connection closed with HTTP %d (ntfy error %d): %s", logHTTPPrefix(v, r), httpErr.HTTPCode, httpErr.Code, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||||
w.WriteHeader(httpErr.HTTPCode)
|
w.WriteHeader(httpErr.HTTPCode)
|
||||||
|
@ -444,8 +443,11 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||||
m.Message = emptyMessageBody
|
m.Message = emptyMessageBody
|
||||||
}
|
}
|
||||||
delayed := m.Time > time.Now().Unix()
|
delayed := m.Time > time.Now().Unix()
|
||||||
log.Debug("%s Received message: ev=%s, body=%d bytes, delayed=%t, fb=%t, cache=%t, up=%t, email=%s",
|
log.Debug("%s Received message: event=%s, body=%d byte(s), delayed=%t, firebase=%t, cache=%t, up=%t, email=%s",
|
||||||
logPrefix(v, m), m.Event, len(body.PeekedBytes), delayed, firebase, cache, unifiedpush, email)
|
logMessagePrefix(v, m), m.Event, len(m.Message), delayed, firebase, cache, unifiedpush, email)
|
||||||
|
if log.IsTrace() {
|
||||||
|
log.Trace("%s Message body: %s", logMessagePrefix(v, m), maybeMarshalJSON(m))
|
||||||
|
}
|
||||||
if !delayed {
|
if !delayed {
|
||||||
if err := t.Publish(v, m); err != nil {
|
if err := t.Publish(v, m); err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -453,14 +455,14 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||||
if s.firebaseClient != nil && firebase {
|
if s.firebaseClient != nil && firebase {
|
||||||
go s.sendToFirebase(v, m)
|
go s.sendToFirebase(v, m)
|
||||||
}
|
}
|
||||||
if s.mailer != nil && email != "" {
|
if s.smtpSender != nil && email != "" {
|
||||||
go s.sendEmail(v, m, email)
|
go s.sendEmail(v, m, email)
|
||||||
}
|
}
|
||||||
if s.config.UpstreamBaseURL != "" {
|
if s.config.UpstreamBaseURL != "" {
|
||||||
go s.forwardPollRequest(v, m)
|
go s.forwardPollRequest(v, m)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Debug("%s Message delayed, will process later", logPrefix(v, m))
|
log.Debug("%s Message delayed, will process later", logMessagePrefix(v, m))
|
||||||
}
|
}
|
||||||
if cache {
|
if cache {
|
||||||
if err := s.messageCache.AddMessage(m); err != nil {
|
if err := s.messageCache.AddMessage(m); err != nil {
|
||||||
|
@ -479,16 +481,16 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) sendToFirebase(v *visitor, m *message) {
|
func (s *Server) sendToFirebase(v *visitor, m *message) {
|
||||||
log.Debug("%s Publishing to Firebase", logPrefix(v, m))
|
log.Debug("%s Publishing to Firebase", logMessagePrefix(v, m))
|
||||||
if err := s.firebaseClient.Send(v, m); err != nil {
|
if err := s.firebaseClient.Send(v, m); err != nil {
|
||||||
log.Warn("%s Unable to publish to Firebase: %v", logPrefix(v, m), err.Error())
|
log.Warn("%s Unable to publish to Firebase: %v", logMessagePrefix(v, m), err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) sendEmail(v *visitor, m *message, email string) {
|
func (s *Server) sendEmail(v *visitor, m *message, email string) {
|
||||||
log.Debug("%s Sending email to %s", logPrefix(v, m), email)
|
log.Debug("%s Sending email to %s", logMessagePrefix(v, m), email)
|
||||||
if err := s.mailer.Send(v.ip, email, m); err != nil {
|
if err := s.smtpSender.Send(v, m, email); err != nil {
|
||||||
log.Warn("%s Unable to send email: %v", logPrefix(v, m), err.Error())
|
log.Warn("%s Unable to send email: %v", logMessagePrefix(v, m), err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -496,10 +498,10 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||||
topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
|
topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
|
||||||
topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL)))
|
topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL)))
|
||||||
forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash)
|
forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash)
|
||||||
log.Debug("%s Publishing poll request to %s", logPrefix(v, m), forwardURL)
|
log.Debug("%s Publishing poll request to %s", logMessagePrefix(v, m), forwardURL)
|
||||||
req, err := http.NewRequest("POST", forwardURL, strings.NewReader(""))
|
req, err := http.NewRequest("POST", forwardURL, strings.NewReader(""))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("%s Unable to publish poll request: %v", logPrefix(v, m), err.Error())
|
log.Warn("%s Unable to publish poll request: %v", logMessagePrefix(v, m), err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Header.Set("X-Poll-ID", m.ID)
|
req.Header.Set("X-Poll-ID", m.ID)
|
||||||
|
@ -508,10 +510,10 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||||
}
|
}
|
||||||
response, err := httpClient.Do(req)
|
response, err := httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("%s Unable to publish poll request: %v", logPrefix(v, m), err.Error())
|
log.Warn("%s Unable to publish poll request: %v", logMessagePrefix(v, m), err.Error())
|
||||||
return
|
return
|
||||||
} else if response.StatusCode != http.StatusOK {
|
} else if response.StatusCode != http.StatusOK {
|
||||||
log.Warn("%s Unable to publish poll request, unexpected HTTP status: %d", logPrefix(v, m), response.StatusCode)
|
log.Warn("%s Unable to publish poll request, unexpected HTTP status: %d", logMessagePrefix(v, m), response.StatusCode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -553,7 +555,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||||
return false, false, "", false, errHTTPTooManyRequestsLimitEmails
|
return false, false, "", false, errHTTPTooManyRequestsLimitEmails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.mailer == nil && email != "" {
|
if s.smtpSender == nil && email != "" {
|
||||||
return false, false, "", false, errHTTPBadRequestEmailDisabled
|
return false, false, "", false, errHTTPBadRequestEmailDisabled
|
||||||
}
|
}
|
||||||
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
||||||
|
@ -627,7 +629,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||||
// If file.txt is > message limit, treat it as an attachment
|
// If file.txt is > message limit, treat it as an attachment
|
||||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
|
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
|
||||||
if m.Event == pollRequestEvent { // Case 1
|
if m.Event == pollRequestEvent { // Case 1
|
||||||
return nil
|
return s.handleBodyDiscard(body)
|
||||||
} else if unifiedpush {
|
} else if unifiedpush {
|
||||||
return s.handleBodyAsMessageAutoDetect(m, body) // Case 2
|
return s.handleBodyAsMessageAutoDetect(m, body) // Case 2
|
||||||
} else if m.Attachment != nil && m.Attachment.URL != "" {
|
} else if m.Attachment != nil && m.Attachment.URL != "" {
|
||||||
|
@ -640,6 +642,12 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 6
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 6
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
|
||||||
|
_, err := io.Copy(io.Discard, body)
|
||||||
|
_ = body.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error {
|
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error {
|
||||||
if utf8.Valid(body.PeekedBytes) {
|
if utf8.Valid(body.PeekedBytes) {
|
||||||
m.Message = string(body.PeekedBytes) // Do not trim
|
m.Message = string(body.PeekedBytes) // Do not trim
|
||||||
|
@ -739,6 +747,8 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *v
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *visitor, contentType string, encoder messageEncoder) error {
|
func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *visitor, contentType string, encoder messageEncoder) error {
|
||||||
|
log.Debug("%s HTTP stream connection opened", logHTTPPrefix(v, r))
|
||||||
|
defer log.Debug("%s HTTP stream connection closed", logHTTPPrefix(v, r))
|
||||||
if err := v.SubscriptionAllowed(); err != nil {
|
if err := v.SubscriptionAllowed(); err != nil {
|
||||||
return errHTTPTooManyRequestsLimitSubscriptions
|
return errHTTPTooManyRequestsLimitSubscriptions
|
||||||
}
|
}
|
||||||
|
@ -795,6 +805,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||||
case <-r.Context().Done():
|
case <-r.Context().Done():
|
||||||
return nil
|
return nil
|
||||||
case <-time.After(s.config.KeepaliveInterval):
|
case <-time.After(s.config.KeepaliveInterval):
|
||||||
|
log.Trace("%s Sending keepalive message", logHTTPPrefix(v, r))
|
||||||
v.Keepalive()
|
v.Keepalive()
|
||||||
if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
|
if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
|
||||||
return err
|
return err
|
||||||
|
@ -811,6 +822,8 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||||
return errHTTPTooManyRequestsLimitSubscriptions
|
return errHTTPTooManyRequestsLimitSubscriptions
|
||||||
}
|
}
|
||||||
defer v.RemoveSubscription()
|
defer v.RemoveSubscription()
|
||||||
|
log.Debug("%s WebSocket connection opened", logHTTPPrefix(v, r))
|
||||||
|
defer log.Debug("%s WebSocket connection closed", logHTTPPrefix(v, r))
|
||||||
topics, topicsStr, err := s.topicsFromPath(r.URL.Path)
|
topics, topicsStr, err := s.topicsFromPath(r.URL.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -840,6 +853,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
conn.SetPongHandler(func(appData string) error {
|
conn.SetPongHandler(func(appData string) error {
|
||||||
|
log.Trace("%s Received WebSocket pong", logHTTPPrefix(v, r))
|
||||||
return conn.SetReadDeadline(time.Now().Add(pongWait))
|
return conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
})
|
})
|
||||||
for {
|
for {
|
||||||
|
@ -856,6 +870,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||||
if err := conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil {
|
if err := conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
log.Trace("%s Sending WebSocket ping", logHTTPPrefix(v, r))
|
||||||
return conn.WriteMessage(websocket.PingMessage, nil)
|
return conn.WriteMessage(websocket.PingMessage, nil)
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
|
@ -901,8 +916,9 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = g.Wait()
|
err = g.Wait()
|
||||||
if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||||
return nil // Normal closures are not errors
|
log.Trace("%s WebSocket connection closed: %s", logHTTPPrefix(v, r), err.Error())
|
||||||
|
return nil // Normal closures are not errors; note: "1006 (abnormal closure)" is treated as normal, because people disconnect a lot
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -1025,12 +1041,15 @@ func (s *Server) updateStatsAndPrune() {
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
// Expire visitors from rate visitors map
|
// Expire visitors from rate visitors map
|
||||||
|
staleVisitors := 0
|
||||||
for ip, v := range s.visitors {
|
for ip, v := range s.visitors {
|
||||||
if v.Stale() {
|
if v.Stale() {
|
||||||
log.Debug("Deleting stale visitor %s", v.ip)
|
log.Debug("Deleting stale visitor %s", v.ip)
|
||||||
delete(s.visitors, ip)
|
delete(s.visitors, ip)
|
||||||
|
staleVisitors++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log.Debug("Manager: Deleted %d stale visitor(s)", staleVisitors)
|
||||||
|
|
||||||
// Delete expired attachments
|
// Delete expired attachments
|
||||||
if s.fileCache != nil {
|
if s.fileCache != nil {
|
||||||
|
@ -1038,20 +1057,20 @@ func (s *Server) updateStatsAndPrune() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Error retrieving expired attachments: %s", err.Error())
|
log.Warn("Error retrieving expired attachments: %s", err.Error())
|
||||||
} else if len(ids) > 0 {
|
} else if len(ids) > 0 {
|
||||||
log.Debug("Deleting expired attachments: %v", ids)
|
log.Debug("Manager: Deleting expired attachments: %v", ids)
|
||||||
if err := s.fileCache.Remove(ids...); err != nil {
|
if err := s.fileCache.Remove(ids...); err != nil {
|
||||||
log.Warn("Error deleting attachments: %s", err.Error())
|
log.Warn("Error deleting attachments: %s", err.Error())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Debug("No expired attachments to delete")
|
log.Debug("Manager: No expired attachments to delete")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune message cache
|
// Prune message cache
|
||||||
olderThan := time.Now().Add(-1 * s.config.CacheDuration)
|
olderThan := time.Now().Add(-1 * s.config.CacheDuration)
|
||||||
log.Debug("Pruning messages older tha %v", olderThan)
|
log.Debug("Manager: Pruning messages older than %s", olderThan.Format("2006-01-02 15:04:05"))
|
||||||
if err := s.messageCache.Prune(olderThan); err != nil {
|
if err := s.messageCache.Prune(olderThan); err != nil {
|
||||||
log.Warn("Error pruning cache: %s", err.Error())
|
log.Warn("Manager: Error pruning cache: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune old topics, remove subscriptions without subscribers
|
// Prune old topics, remove subscriptions without subscribers
|
||||||
|
@ -1060,7 +1079,7 @@ func (s *Server) updateStatsAndPrune() {
|
||||||
subs := t.Subscribers()
|
subs := t.Subscribers()
|
||||||
msgs, err := s.messageCache.MessageCount(t.ID)
|
msgs, err := s.messageCache.MessageCount(t.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Cannot get stats for topic %s: %s", t.ID, err.Error())
|
log.Warn("Manager: Cannot get stats for topic %s: %s", t.ID, err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if msgs == 0 && subs == 0 {
|
if msgs == 0 && subs == 0 {
|
||||||
|
@ -1072,19 +1091,25 @@ func (s *Server) updateStatsAndPrune() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mail stats
|
// Mail stats
|
||||||
var mailSuccess, mailFailure int64
|
var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64
|
||||||
if s.smtpBackend != nil {
|
if s.smtpServerBackend != nil {
|
||||||
mailSuccess, mailFailure = s.smtpBackend.Counts()
|
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
|
||||||
|
}
|
||||||
|
var sentMailTotal, sentMailSuccess, sentMailFailure int64
|
||||||
|
if s.smtpSender != nil {
|
||||||
|
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print stats
|
// Print stats
|
||||||
log.Info("Stats: %d message(s) published, %d in cache, %d successful mails, %d failed, %d topic(s) active, %d subscriber(s), %d visitor(s)",
|
log.Info("Stats: %d messages published, %d in cache, %d topic(s) active, %d subscriber(s), %d visitor(s), %d mails received (%d successful, %d failed), %d mails sent (%d successful, %d failed)",
|
||||||
s.messages, messages, mailSuccess, mailFailure, len(s.topics), subscribers, len(s.visitors))
|
s.messages, messages, len(s.topics), subscribers, len(s.visitors),
|
||||||
|
receivedMailTotal, receivedMailSuccess, receivedMailFailure,
|
||||||
|
sentMailTotal, sentMailSuccess, sentMailFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) runSMTPServer() error {
|
func (s *Server) runSMTPServer() error {
|
||||||
s.smtpBackend = newMailBackend(s.config, s.handle)
|
s.smtpServerBackend = newMailBackend(s.config, s.handle)
|
||||||
s.smtpServer = smtp.NewServer(s.smtpBackend)
|
s.smtpServer = smtp.NewServer(s.smtpServerBackend)
|
||||||
s.smtpServer.Addr = s.config.SMTPServerListen
|
s.smtpServer.Addr = s.config.SMTPServerListen
|
||||||
s.smtpServer.Domain = s.config.SMTPServerDomain
|
s.smtpServer.Domain = s.config.SMTPServerDomain
|
||||||
s.smtpServer.ReadTimeout = 10 * time.Second
|
s.smtpServer.ReadTimeout = 10 * time.Second
|
||||||
|
@ -1099,7 +1124,6 @@ func (s *Server) runManager() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-time.After(s.config.ManagerInterval):
|
case <-time.After(s.config.ManagerInterval):
|
||||||
log.Debug("Running manager")
|
|
||||||
s.updateStatsAndPrune()
|
s.updateStatsAndPrune()
|
||||||
case <-s.closeChan:
|
case <-s.closeChan:
|
||||||
return
|
return
|
||||||
|
@ -1107,19 +1131,6 @@ func (s *Server) runManager() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) runDelayedSender() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-time.After(s.config.DelayedSenderInterval):
|
|
||||||
if err := s.sendDelayedMessages(); err != nil {
|
|
||||||
log.Warn("error sending scheduled messages: %s", err.Error())
|
|
||||||
}
|
|
||||||
case <-s.closeChan:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) runFirebaseKeepaliver() {
|
func (s *Server) runFirebaseKeepaliver() {
|
||||||
if s.firebaseClient == nil {
|
if s.firebaseClient == nil {
|
||||||
return
|
return
|
||||||
|
@ -1137,6 +1148,19 @@ func (s *Server) runFirebaseKeepaliver() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) runDelayedSender() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-time.After(s.config.DelayedSenderInterval):
|
||||||
|
if err := s.sendDelayedMessages(); err != nil {
|
||||||
|
log.Warn("Error sending delayed messages: %s", err.Error())
|
||||||
|
}
|
||||||
|
case <-s.closeChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) sendDelayedMessages() error {
|
func (s *Server) sendDelayedMessages() error {
|
||||||
messages, err := s.messageCache.MessagesDue()
|
messages, err := s.messageCache.MessagesDue()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -1145,7 +1169,7 @@ func (s *Server) sendDelayedMessages() error {
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
v := s.visitorFromIP(m.Sender)
|
v := s.visitorFromIP(m.Sender)
|
||||||
if err := s.sendDelayedMessage(v, m); err != nil {
|
if err := s.sendDelayedMessage(v, m); err != nil {
|
||||||
log.Warn("%s Error sending delayed message: %s", logPrefix(v, m), err.Error())
|
log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
@ -1154,13 +1178,13 @@ func (s *Server) sendDelayedMessages() error {
|
||||||
func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
|
func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
log.Debug("%s Sending delayed message", logPrefix(v, m))
|
log.Debug("%s Sending delayed message", logMessagePrefix(v, m))
|
||||||
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
|
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
|
||||||
if ok {
|
if ok {
|
||||||
go func() {
|
go func() {
|
||||||
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler
|
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler
|
||||||
if err := t.Publish(v, m); err != nil {
|
if err := t.Publish(v, m); err != nil {
|
||||||
log.Warn("%s Unable to publish message: %v", logPrefix(v, m), err.Error())
|
log.Warn("%s Unable to publish message: %v", logMessagePrefix(v, m), err.Error())
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
@ -1333,7 +1357,3 @@ func (s *Server) visitorFromIP(ip string) *visitor {
|
||||||
v.Keepalive()
|
v.Keepalive()
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func logPrefix(v *visitor, m *message) string {
|
|
||||||
return fmt.Sprintf("%s/%s/%s", v.ip, m.Topic, m.ID)
|
|
||||||
}
|
|
||||||
|
|
|
@ -51,6 +51,7 @@
|
||||||
# cache-file: <filename>
|
# cache-file: <filename>
|
||||||
# cache-duration: "12h"
|
# cache-duration: "12h"
|
||||||
|
|
||||||
|
|
||||||
# If set, access to the ntfy server and API can be controlled on a granular level using
|
# If set, access to the ntfy server and API can be controlled on a granular level using
|
||||||
# the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs.
|
# the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs.
|
||||||
#
|
#
|
||||||
|
@ -179,7 +180,10 @@
|
||||||
# visitor-attachment-total-size-limit: "100M"
|
# visitor-attachment-total-size-limit: "100M"
|
||||||
# visitor-attachment-daily-bandwidth-limit: "500M"
|
# visitor-attachment-daily-bandwidth-limit: "500M"
|
||||||
|
|
||||||
# Log level, can be DEBUG, INFO, WARN or ERROR
|
# Log level, can be TRACE, DEBUG, INFO, WARN or ERROR
|
||||||
# This option can be hot-reloaded by calling "kill -HUP $pid" or "systemctl reload ntfy".
|
# This option can be hot-reloaded by calling "kill -HUP $pid" or "systemctl reload ntfy".
|
||||||
#
|
#
|
||||||
|
# Be aware that DEBUG (and particularly TRACE) can be VERY CHATTY. Only turn them on for
|
||||||
|
# debugging purposes, or your disk will fill up quickly.
|
||||||
|
#
|
||||||
# log-level: INFO
|
# log-level: INFO
|
||||||
|
|
|
@ -4,14 +4,13 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
firebase "firebase.google.com/go/v4"
|
firebase "firebase.google.com/go/v4"
|
||||||
"firebase.google.com/go/v4/messaging"
|
"firebase.google.com/go/v4/messaging"
|
||||||
|
"fmt"
|
||||||
"google.golang.org/api/option"
|
"google.golang.org/api/option"
|
||||||
"heckel.io/ntfy/auth"
|
"heckel.io/ntfy/auth"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -45,9 +44,12 @@ func (c *firebaseClient) Send(v *visitor, m *message) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if log.IsTrace() {
|
||||||
|
log.Trace("%s Firebase message: %s", logMessagePrefix(v, m), maybeMarshalJSON(fbm))
|
||||||
|
}
|
||||||
err = c.sender.Send(fbm)
|
err = c.sender.Send(fbm)
|
||||||
if err == errFirebaseQuotaExceeded {
|
if err == errFirebaseQuotaExceeded {
|
||||||
log.Printf("[%s] FB quota exceeded for topic %s, temporarily denying FB access to visitor", v.ip, m.Topic)
|
log.Warn("%s Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor", logMessagePrefix(v, m))
|
||||||
v.FirebaseTemporarilyDeny()
|
v.FirebaseTemporarilyDeny()
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -477,7 +477,7 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) {
|
||||||
|
|
||||||
func TestServer_PublishInvalidTopic(t *testing.T) {
|
func TestServer_PublishInvalidTopic(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
s.mailer = &testMailer{}
|
s.smtpSender = &testMailer{}
|
||||||
response := request(t, s, "PUT", "/docs", "fail", nil)
|
response := request(t, s, "PUT", "/docs", "fail", nil)
|
||||||
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
||||||
}
|
}
|
||||||
|
@ -743,13 +743,17 @@ type testMailer struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *testMailer) Send(from, to string, m *message) error {
|
func (t *testMailer) Send(v *visitor, m *message, to string) error {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
t.count++
|
t.count++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *testMailer) Counts() (total int64, success int64, failure int64) {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
func (t *testMailer) Count() int {
|
func (t *testMailer) Count() int {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
|
@ -795,7 +799,7 @@ func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
|
||||||
|
|
||||||
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
s.mailer = &testMailer{}
|
s.smtpSender = &testMailer{}
|
||||||
for i := 0; i < 16; i++ {
|
for i := 0; i < 16; i++ {
|
||||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||||
"E-Mail": "test@example.com",
|
"E-Mail": "test@example.com",
|
||||||
|
@ -812,7 +816,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
s.mailer = &testMailer{}
|
s.smtpSender = &testMailer{}
|
||||||
for i := 0; i < 16; i++ {
|
for i := 0; i < 16; i++ {
|
||||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||||
"E-Mail": "test@example.com",
|
"E-Mail": "test@example.com",
|
||||||
|
@ -838,7 +842,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
||||||
|
|
||||||
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
|
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
s.mailer = &testMailer{}
|
s.smtpSender = &testMailer{}
|
||||||
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
||||||
"E-Mail": "test@example.com",
|
"E-Mail": "test@example.com",
|
||||||
"Delay": "20 min",
|
"Delay": "20 min",
|
||||||
|
@ -956,7 +960,7 @@ func TestServer_PublishAsJSON(t *testing.T) {
|
||||||
func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
|
func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
|
||||||
mailer := &testMailer{}
|
mailer := &testMailer{}
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
s.mailer = mailer
|
s.smtpSender = mailer
|
||||||
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
|
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
|
||||||
response := request(t, s, "PUT", "/", body, nil)
|
response := request(t, s, "PUT", "/", body, nil)
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
|
@ -4,33 +4,62 @@ import (
|
||||||
_ "embed" // required by go:embed
|
_ "embed" // required by go:embed
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"mime"
|
"mime"
|
||||||
"net"
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mailer interface {
|
type mailer interface {
|
||||||
Send(from, to string, m *message) error
|
Send(v *visitor, m *message, to string) error
|
||||||
|
Counts() (total int64, success int64, failure int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
type smtpSender struct {
|
type smtpSender struct {
|
||||||
config *Config
|
config *Config
|
||||||
|
success int64
|
||||||
|
failure int64
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSender) Send(senderIP, to string, m *message) error {
|
func (s *smtpSender) Send(v *visitor, m *message, to string) error {
|
||||||
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
|
return s.withCount(v, m, func() error {
|
||||||
|
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
message, err := formatMail(s.config.BaseURL, v.ip, s.config.SMTPSenderFrom, to, m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
||||||
|
log.Debug("%s Sending mail: via=%s, user=%s, pass=***, to=%s", logMessagePrefix(v, m), s.config.SMTPSenderAddr, s.config.SMTPSenderUser, to)
|
||||||
|
log.Trace("%s Mail body: %s", logMessagePrefix(v, m), message)
|
||||||
|
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSender) Counts() (total int64, success int64, failure int64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.success + s.failure, s.success, s.failure
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error {
|
||||||
|
err := fn()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
log.Debug("%s Sending mail failed: %s", logMessagePrefix(v, m), err.Error())
|
||||||
|
s.failure++
|
||||||
|
} else {
|
||||||
|
s.success++
|
||||||
}
|
}
|
||||||
message, err := formatMail(s.config.BaseURL, senderIP, s.config.SMTPSenderFrom, to, m)
|
return err
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
|
||||||
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) {
|
func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) {
|
||||||
|
|
|
@ -5,9 +5,11 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/emersion/go-smtp"
|
"github.com/emersion/go-smtp"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
@ -40,36 +42,41 @@ func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Reques
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
||||||
return &smtpSession{backend: b, remoteAddr: state.RemoteAddr.String()}, nil
|
log.Debug("%s Incoming mail, login with user %s", logSMTPPrefix(state), username)
|
||||||
|
return &smtpSession{backend: b, state: state}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
||||||
return &smtpSession{backend: b, remoteAddr: state.RemoteAddr.String()}, nil
|
log.Debug("%s Incoming mail, anonymous login", logSMTPPrefix(state))
|
||||||
|
return &smtpSession{backend: b, state: state}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *smtpBackend) Counts() (success int64, failure int64) {
|
func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
return b.success, b.failure
|
return b.success + b.failure, b.success, b.failure
|
||||||
}
|
}
|
||||||
|
|
||||||
// smtpSession is returned after EHLO.
|
// smtpSession is returned after EHLO.
|
||||||
type smtpSession struct {
|
type smtpSession struct {
|
||||||
backend *smtpBackend
|
backend *smtpBackend
|
||||||
remoteAddr string
|
state *smtp.ConnectionState
|
||||||
topic string
|
topic string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) AuthPlain(username, password string) error {
|
func (s *smtpSession) AuthPlain(username, password string) error {
|
||||||
|
log.Debug("%s AUTH PLAIN (with username %s)", logSMTPPrefix(s.state), username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
|
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
|
||||||
|
log.Debug("%s MAIL FROM: %s (with options: %#v)", logSMTPPrefix(s.state), from, opts)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) Rcpt(to string) error {
|
func (s *smtpSession) Rcpt(to string) error {
|
||||||
|
log.Debug("%s RCPT TO: %s", logSMTPPrefix(s.state), to)
|
||||||
return s.withFailCount(func() error {
|
return s.withFailCount(func() error {
|
||||||
conf := s.backend.config
|
conf := s.backend.config
|
||||||
addressList, err := mail.ParseAddressList(to)
|
addressList, err := mail.ParseAddressList(to)
|
||||||
|
@ -106,6 +113,11 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if log.IsTrace() {
|
||||||
|
log.Trace("%s DATA: %s", logSMTPPrefix(s.state), string(b))
|
||||||
|
} else if log.IsDebug() {
|
||||||
|
log.Debug("%s DATA: %d byte(s)", logSMTPPrefix(s.state), len(b))
|
||||||
|
}
|
||||||
msg, err := mail.ReadMessage(bytes.NewReader(b))
|
msg, err := mail.ReadMessage(bytes.NewReader(b))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -143,10 +155,18 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) publishMessage(m *message) error {
|
func (s *smtpSession) publishMessage(m *message) error {
|
||||||
|
// Extract remote address (for rate limiting)
|
||||||
|
remoteAddr, _, err := net.SplitHostPort(s.state.RemoteAddr.String())
|
||||||
|
if err != nil {
|
||||||
|
remoteAddr = s.state.RemoteAddr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call HTTP handler with fake HTTP request
|
||||||
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
|
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
|
||||||
req, err := http.NewRequest("PUT", url, strings.NewReader(m.Message))
|
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
|
||||||
req.RemoteAddr = s.remoteAddr // rate limiting!!
|
req.RequestURI = "/" + m.Topic // just for the logs
|
||||||
req.Header.Set("X-Forwarded-For", s.remoteAddr)
|
req.RemoteAddr = remoteAddr // rate limiting!!
|
||||||
|
req.Header.Set("X-Forwarded-For", remoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -176,6 +196,9 @@ func (s *smtpSession) withFailCount(fn func() error) error {
|
||||||
s.backend.mu.Lock()
|
s.backend.mu.Lock()
|
||||||
defer s.backend.mu.Unlock()
|
defer s.backend.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Almost all of these errors are parse errors, and user input errors.
|
||||||
|
// We do not want to spam the log with WARN messages.
|
||||||
|
log.Debug("%s Incoming mail error: %s", logSMTPPrefix(s.state), err.Error())
|
||||||
s.backend.failure++
|
s.backend.failure++
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -47,14 +47,14 @@ func (t *topic) Publish(v *visitor, m *message) error {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
if len(t.subscribers) > 0 {
|
if len(t.subscribers) > 0 {
|
||||||
log.Debug("%s Forwarding to %d subscriber(s)", logPrefix(v, m), len(t.subscribers))
|
log.Debug("%s Forwarding to %d subscriber(s)", logMessagePrefix(v, m), len(t.subscribers))
|
||||||
for _, s := range t.subscribers {
|
for _, s := range t.subscribers {
|
||||||
if err := s(v, m); err != nil {
|
if err := s(v, m); err != nil {
|
||||||
log.Warn("%s Error forwarding to subscriber", logPrefix(v, m))
|
log.Warn("%s Error forwarding to subscriber", logMessagePrefix(v, m))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Debug("%s No subscribers, not forwarding", logPrefix(v, m))
|
log.Trace("%s No stream or WebSocket subscribers, not forwarding", logMessagePrefix(v, m))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
@ -40,3 +43,30 @@ func readQueryParam(r *http.Request, names ...string) string {
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logMessagePrefix(v *visitor, m *message) string {
|
||||||
|
return fmt.Sprintf("%s/%s/%s", v.ip, m.Topic, m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logHTTPPrefix(v *visitor, r *http.Request) string {
|
||||||
|
requestURI := r.RequestURI
|
||||||
|
if requestURI == "" {
|
||||||
|
requestURI = r.URL.Path
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s HTTP %s %s", v.ip, r.Method, requestURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logSMTPPrefix(state *smtp.ConnectionState) string {
|
||||||
|
return fmt.Sprintf("%s/%s SMTP", state.Hostname, state.RemoteAddr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func maybeMarshalJSON(v interface{}) string {
|
||||||
|
messageJSON, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "<cannot serialize>"
|
||||||
|
}
|
||||||
|
if len(messageJSON) > 5000 {
|
||||||
|
return string(messageJSON)[:5000]
|
||||||
|
}
|
||||||
|
return string(messageJSON)
|
||||||
|
}
|
||||||
|
|
|
@ -110,7 +110,7 @@
|
||||||
<p>
|
<p>
|
||||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"></a>
|
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"></a>
|
||||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"></a>
|
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"></a>
|
||||||
<a href="https://github.com/binwiederhier/ntfy/issues/4"><img src="static/img/badge-appstore.png"></a>
|
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="static/img/badge-appstore.png"></a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Here's a video showing the app in action:
|
Here's a video showing the app in action:
|
||||||
|
|
Loading…
Reference in New Issue