From 25be5b47e4c12bd4cd7ec7c3aeeba8a9aca0054e Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sun, 5 Mar 2023 22:57:51 -0700 Subject: [PATCH 1/3] allow default-token and per-subscription tokens in client.yml --- client/client.yml | 12 +- client/config.go | 3 + client/config_test.go | 22 +++ cmd/publish.go | 37 ++--- cmd/publish_test.go | 153 ++++++++++++++++++++- cmd/subscribe.go | 48 +++++-- cmd/subscribe_test.go | 306 ++++++++++++++++++++++++++++++++++++++++++ docs/releases.md | 17 +++ docs/subscribe/cli.md | 10 +- server/server.go | 6 +- user/manager_test.go | 23 ---- 11 files changed, 572 insertions(+), 65 deletions(-) create mode 100644 cmd/subscribe_test.go diff --git a/client/client.yml b/client/client.yml index d3ba2722..1b81b80d 100644 --- a/client/client.yml +++ b/client/client.yml @@ -5,10 +5,12 @@ # # default-host: https://ntfy.sh -# Default username and password will be used with "ntfy publish" if no credentials are provided on command line -# Default username and password will be used with "ntfy subscribe" if no credentials are provided in subscription below -# For an empty password, use empty double-quotes ("") -# +# Default credentials will be used with "ntfy publish" and "ntfy subscribe" if no other credentials are provided. +# You can set a default token to use or a default user:password combination, but not both. For an empty password, +# use empty double-quotes ("") + +# default-token: + # default-user: # default-password: @@ -30,6 +32,8 @@ # command: 'notify-send "$m"' # user: phill # password: mypass +# - topic: token_topic +# token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 # # Variables: # Variable Aliases Description diff --git a/client/config.go b/client/config.go index b2efc1d0..edb52121 100644 --- a/client/config.go +++ b/client/config.go @@ -15,11 +15,13 @@ type Config struct { DefaultHost string `yaml:"default-host"` DefaultUser string `yaml:"default-user"` DefaultPassword *string `yaml:"default-password"` + DefaultToken string `yaml:"default-token"` DefaultCommand string `yaml:"default-command"` Subscribe []struct { Topic string `yaml:"topic"` User string `yaml:"user"` Password *string `yaml:"password"` + Token string `yaml:"token"` Command string `yaml:"command"` If map[string]string `yaml:"if"` } `yaml:"subscribe"` @@ -31,6 +33,7 @@ func NewConfig() *Config { DefaultHost: DefaultBaseURL, DefaultUser: "", DefaultPassword: nil, + DefaultToken: "", DefaultCommand: "", Subscribe: nil, } diff --git a/client/config_test.go b/client/config_test.go index 0a71c3bb..f22e6b20 100644 --- a/client/config_test.go +++ b/client/config_test.go @@ -116,3 +116,25 @@ subscribe: require.Equal(t, "phil", conf.Subscribe[0].User) require.Nil(t, conf.Subscribe[0].Password) } + +func TestConfig_DefaultToken(t *testing.T) { + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(` +default-host: http://localhost +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +subscribe: + - topic: mytopic +`), 0600)) + + conf, err := client.LoadConfig(filename) + require.Nil(t, err) + require.Equal(t, "http://localhost", conf.DefaultHost) + require.Equal(t, "", conf.DefaultUser) + require.Nil(t, conf.DefaultPassword) + require.Equal(t, "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", conf.DefaultToken) + require.Equal(t, 1, len(conf.Subscribe)) + require.Equal(t, "mytopic", conf.Subscribe[0].Topic) + require.Equal(t, "", conf.Subscribe[0].User) + require.Nil(t, conf.Subscribe[0].Password) + require.Equal(t, "", conf.Subscribe[0].Token) +} diff --git a/cmd/publish.go b/cmd/publish.go index 21578d34..b70ef6f4 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -154,25 +154,28 @@ func execPublish(c *cli.Context) error { } if token != "" { options = append(options, client.WithBearerAuth(token)) - } - if user != "" { - var pass string - parts := strings.SplitN(user, ":", 2) - if len(parts) == 2 { - user = parts[0] - pass = parts[1] - } else { - fmt.Fprint(c.App.ErrWriter, "Enter Password: ") - p, err := util.ReadPassword(c.App.Reader) - if err != nil { - return err + } else { + if user != "" { + var pass string + parts := strings.SplitN(user, ":", 2) + if len(parts) == 2 { + user = parts[0] + pass = parts[1] + } else { + fmt.Fprint(c.App.ErrWriter, "Enter Password: ") + p, err := util.ReadPassword(c.App.Reader) + if err != nil { + return err + } + pass = string(p) + fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) } - pass = string(p) - fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) + options = append(options, client.WithBasicAuth(user, pass)) + } else if conf.DefaultToken != "" { + options = append(options, client.WithBearerAuth(conf.DefaultToken)) + } else if conf.DefaultUser != "" && conf.DefaultPassword != nil { + options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) } - options = append(options, client.WithBasicAuth(user, pass)) - } else if token == "" && conf.DefaultUser != "" && conf.DefaultPassword != nil { - options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) } if pid > 0 { newMessage, err := waitForProcess(pid) diff --git a/cmd/publish_test.go b/cmd/publish_test.go index 6fe2d000..a254f47d 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -5,8 +5,11 @@ import ( "github.com/stretchr/testify/require" "heckel.io/ntfy/test" "heckel.io/ntfy/util" + "net/http" + "net/http/httptest" "os" "os/exec" + "path/filepath" "strconv" "strings" "testing" @@ -130,7 +133,7 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) { require.Equal(t, `command failed: does-not-exist-no-really "really though", error: exec: "does-not-exist-no-really": executable file not found in $PATH`, err.Error()) // Tests with NTFY_TOPIC set //// - require.Nil(t, os.Setenv("NTFY_TOPIC", topic)) + t.Setenv("NTFY_TOPIC", topic) // Test: Successful command with NTFY_TOPIC app, _, stdout, _ = newTestApp() @@ -147,3 +150,151 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) { m = toMessage(t, stdout.String()) require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message) } + +func TestCLI_Publish_Default_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_UserPass_CLI_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_Token_CLI_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_Token_CLI_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_FAKETOKEN01234567890FAKETOKEN +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_UserPass_CLI_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: fakepass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Token_And_UserPass(t *testing.T) { + app, _, _, _ := newTestApp() + err := app.Run([]string{"ntfy", "publish", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"}) + require.Error(t, err) + require.Equal(t, "cannot set both --user and --token", err.Error()) +} diff --git a/cmd/subscribe.go b/cmd/subscribe.go index bbc6fb33..36a4e48c 100644 --- a/cmd/subscribe.go +++ b/cmd/subscribe.go @@ -30,6 +30,7 @@ var flagsSubscribe = append( &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"}, &cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"}, &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, + &cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"}, &cli.BoolFlag{Name: "from-config", Aliases: []string{"from_config", "C"}, Usage: "read subscriptions from config file (service mode)"}, &cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"}, &cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"}, @@ -97,11 +98,18 @@ func execSubscribe(c *cli.Context) error { cl := client.New(conf) since := c.String("since") user := c.String("user") + token := c.String("token") poll := c.Bool("poll") scheduled := c.Bool("scheduled") fromConfig := c.Bool("from-config") topic := c.Args().Get(0) command := c.Args().Get(1) + + // Checks + if user != "" && token != "" { + return errors.New("cannot set both --user and --token") + } + if !fromConfig { conf.Subscribe = nil // wipe if --from-config not passed } @@ -109,6 +117,9 @@ func execSubscribe(c *cli.Context) error { if since != "" { options = append(options, client.WithSince(since)) } + if token != "" { + options = append(options, client.WithBearerAuth(token)) + } if user != "" { var pass string parts := strings.SplitN(user, ":", 2) @@ -175,21 +186,32 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, for filter, value := range s.If { topicOptions = append(topicOptions, client.WithFilter(filter, value)) } - var user string - var password *string - if s.User != "" { - user = s.User - } else if conf.DefaultUser != "" { - user = conf.DefaultUser + + // check for subscription token then subscription user:pass + var authSet bool + if s.Token != "" { + topicOptions = append(topicOptions, client.WithBearerAuth(s.Token)) + authSet = true + } else { + if s.User != "" && s.Password != nil { + topicOptions = append(topicOptions, client.WithBasicAuth(s.User, *s.Password)) + authSet = true + } } - if s.Password != nil { - password = s.Password - } else if conf.DefaultPassword != nil { - password = conf.DefaultPassword - } - if user != "" && password != nil { - topicOptions = append(topicOptions, client.WithBasicAuth(user, *password)) + + // if no subscription token nor subscription user:pass, check for default token then default user:pass + if !authSet { + if conf.DefaultToken != "" { + topicOptions = append(topicOptions, client.WithBearerAuth(conf.DefaultToken)) + authSet = true + } else { + if conf.DefaultUser != "" && conf.DefaultPassword != nil { + topicOptions = append(topicOptions, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) + authSet = true + } + } } + subscriptionID := cl.Subscribe(s.Topic, topicOptions...) if s.Command != "" { cmds[subscriptionID] = s.Command diff --git a/cmd/subscribe_test.go b/cmd/subscribe_test.go new file mode 100644 index 00000000..26ab55ea --- /dev/null +++ b/cmd/subscribe_test.go @@ -0,0 +1,306 @@ +package cmd + +import ( + "fmt" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestCLI_Subscribe_Default_UserPass_Subscription_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +subscribe: + - topic: mytopic + token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) + // Sleep to give the app time to subscribe + time.Sleep(time.Millisecond * 100) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_Subscription_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +subscribe: + - topic: mytopic + user: philipp + password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) + // Sleep to give the app time to subscribe + time.Sleep(time.Millisecond * 100) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_Subscription_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_FAKETOKEN01234567890FAKETOKEN +subscribe: + - topic: mytopic + token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) + // Sleep to give the app time to subscribe + time.Sleep(time.Millisecond * 100) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_UserPass_Subscription_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: fake +default-password: password +subscribe: + - topic: mytopic + user: philipp + password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) + // Sleep to give the app time to subscribe + time.Sleep(time.Millisecond * 100) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_Subscription_Empty(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +subscribe: + - topic: mytopic +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) + // Sleep to give the app time to subscribe + time.Sleep(time.Millisecond * 100) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_UserPass_Subscription_Empty(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +subscribe: + - topic: mytopic +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) + // Sleep to give the app time to subscribe + time.Sleep(time.Millisecond * 100) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Empty_Subscription_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +subscribe: + - topic: mytopic + token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) + // Sleep to give the app time to subscribe + time.Sleep(time.Millisecond * 100) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Empty_Subscription_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +subscribe: + - topic: mytopic + user: philipp + password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) + // Sleep to give the app time to subscribe + time.Sleep(time.Millisecond * 100) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_CLI_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_FAKETOKEN0123456789FAKETOKEN +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic"}) + // Sleep to give the app time to subscribe + time.Sleep(time.Millisecond * 100) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_CLI_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename, "--user", "philipp:mypass", "mytopic"}) + // Sleep to give the app time to subscribe + time.Sleep(time.Millisecond * 100) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Token_And_UserPass(t *testing.T) { + app, _, _, _ := newTestApp() + err := app.Run([]string{"ntfy", "subscribe", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"}) + require.Error(t, err) + require.Equal(t, "cannot set both --user and --token", err.Error()) +} diff --git a/docs/releases.md b/docs/releases.md index d341f9e5..c0aef2df 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,6 +2,23 @@ Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). +## ntfy server v2.1.3 (UNRELEASED) + +**Features:** + +* You can now use tokens in `client.yml` for publishing and subscribing ([#653](https://github.com/binwiederhier/ntfy/issues/653), thanks to [@wunter8](https://github.com/wunter8)) + +## ntfy Android app v1.16.1 (UNRELEASED) + +**Features:** + +* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)) +* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8)) + +**Bug fixes + maintenance:** + +* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)) + ## ntfy server v2.1.2 Released March 4, 2023 diff --git a/docs/subscribe/cli.md b/docs/subscribe/cli.md index f1f9e760..59cfc8e7 100644 --- a/docs/subscribe/cli.md +++ b/docs/subscribe/cli.md @@ -254,13 +254,13 @@ I hope this shows how powerful this command is. Here's a short video that demons
Execute all the things
-If most (or all) of your subscription usernames, passwords, and commands are the same, you can specify a `default-user`, `default-password`, and `default-command` at the top of the -`client.yml`. If a subscription does not specify a username/password to use or does not have a command, the defaults will be used, otherwise, the subscription settings will -override the defaults. +If most (or all) of your subscriptions use the same credentials, you can set defaults in `client.yml`. Use `default-user` and `default-password` or `default-token` (but not both). +You can also specify a `default-command` that will run when a message is received. If a subscription does not include credentials to use or does not have a command, the defaults +will be used, otherwise, the subscription settings will override the defaults. !!! warning - Because the `default-user` and `default-password` will be sent for each topic that does not have its own username/password (even if the topic does not require authentication), - be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password. + Because the `default-user`, `default-password`, and `default-token` will be sent for each topic that does not have its own username/password (even if the topic does not + require authentication), be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password. ### Using the systemd service You can use the `ntfy-client` systemd service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service)) diff --git a/server/server.go b/server/server.go index 2397ba36..e1d2bc28 100644 --- a/server/server.go +++ b/server/server.go @@ -1622,6 +1622,7 @@ func (s *Server) autorizeTopic(next handleFunc, perm user.Permission) handleFunc // maybeAuthenticate reads the "Authorization" header and will try to authenticate the user // if it is set. // +// - If auth-db is not configured, immediately return an IP-based visitor // - If the header is not set or not supported (anything non-Basic and non-Bearer), // an IP-based visitor is returned // - If the header is set, authenticate will be called to check the username/password (Basic auth), @@ -1633,13 +1634,14 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) { // Read "Authorization" header value, and exit out early if it's not set ip := extractIPAddress(r, s.config.BehindProxy) vip := s.visitor(ip, nil) + if s.userManager == nil { + return vip, nil + } header, err := readAuthHeader(r) if err != nil { return vip, err } else if !supportedAuthHeader(header) { return vip, nil - } else if s.userManager == nil { - return vip, errHTTPUnauthorized } // If we're trying to auth, check the rate limiter first if !vip.AuthAllowed() { diff --git a/user/manager_test.go b/user/manager_test.go index f242af71..cd2e1032 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -133,29 +133,6 @@ func TestManager_AddUser_And_Query(t *testing.T) { require.Equal(t, u.ID, u3.ID) } -func TestManager_Authenticate_Timing(t *testing.T) { - a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval) - require.Nil(t, a.AddUser("user", "pass", RoleAdmin)) - - // Timing a correct attempt - start := time.Now().UnixMilli() - _, err := a.Authenticate("user", "pass") - require.Nil(t, err) - require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) - - // Timing an incorrect attempt - start = time.Now().UnixMilli() - _, err = a.Authenticate("user", "INCORRECT") - require.Equal(t, ErrUnauthenticated, err) - require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) - - // Timing a non-existing user attempt - start = time.Now().UnixMilli() - _, err = a.Authenticate("DOES-NOT-EXIST", "hithere") - require.Equal(t, ErrUnauthenticated, err) - require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) -} - func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) { a := newTestManager(t, PermissionDenyAll) From 1797dec2ba9e9831304abad8826ee48594ba4751 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Mon, 6 Mar 2023 18:14:52 -0700 Subject: [PATCH 2/3] include auth headers with using ntfy sub --poll --from-config --- client/config.go | 29 ++++++++++-------- cmd/subscribe.go | 47 +++++++++++++++-------------- cmd/subscribe_test.go | 70 +++++++++++++++++++++++-------------------- 3 files changed, 78 insertions(+), 68 deletions(-) diff --git a/client/config.go b/client/config.go index edb52121..d4337d47 100644 --- a/client/config.go +++ b/client/config.go @@ -12,19 +12,22 @@ const ( // Config is the config struct for a Client type Config struct { - DefaultHost string `yaml:"default-host"` - DefaultUser string `yaml:"default-user"` - DefaultPassword *string `yaml:"default-password"` - DefaultToken string `yaml:"default-token"` - DefaultCommand string `yaml:"default-command"` - Subscribe []struct { - Topic string `yaml:"topic"` - User string `yaml:"user"` - Password *string `yaml:"password"` - Token string `yaml:"token"` - Command string `yaml:"command"` - If map[string]string `yaml:"if"` - } `yaml:"subscribe"` + DefaultHost string `yaml:"default-host"` + DefaultUser string `yaml:"default-user"` + DefaultPassword *string `yaml:"default-password"` + DefaultToken string `yaml:"default-token"` + DefaultCommand string `yaml:"default-command"` + Subscribe []Subscribe `yaml:"subscribe"` +} + +// Subscribe is the struct for a Subscription within Config +type Subscribe struct { + Topic string `yaml:"topic"` + User string `yaml:"user"` + Password *string `yaml:"password"` + Token string `yaml:"token"` + Command string `yaml:"command"` + If map[string]string `yaml:"if"` } // NewConfig creates a new Config struct for a Client diff --git a/cmd/subscribe.go b/cmd/subscribe.go index 36a4e48c..dae06ae2 100644 --- a/cmd/subscribe.go +++ b/cmd/subscribe.go @@ -156,6 +156,9 @@ func execSubscribe(c *cli.Context) error { func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error { for _, s := range conf.Subscribe { // may be nil + if auth := maybeAddAuthHeader(s, conf); auth != nil { + options = append(options, auth) + } if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil { return err } @@ -187,29 +190,8 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, topicOptions = append(topicOptions, client.WithFilter(filter, value)) } - // check for subscription token then subscription user:pass - var authSet bool - if s.Token != "" { - topicOptions = append(topicOptions, client.WithBearerAuth(s.Token)) - authSet = true - } else { - if s.User != "" && s.Password != nil { - topicOptions = append(topicOptions, client.WithBasicAuth(s.User, *s.Password)) - authSet = true - } - } - - // if no subscription token nor subscription user:pass, check for default token then default user:pass - if !authSet { - if conf.DefaultToken != "" { - topicOptions = append(topicOptions, client.WithBearerAuth(conf.DefaultToken)) - authSet = true - } else { - if conf.DefaultUser != "" && conf.DefaultPassword != nil { - topicOptions = append(topicOptions, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) - authSet = true - } - } + if auth := maybeAddAuthHeader(s, conf); auth != nil { + topicOptions = append(topicOptions, auth) } subscriptionID := cl.Subscribe(s.Topic, topicOptions...) @@ -236,6 +218,25 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, return nil } +func maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption { + // check for subscription token then subscription user:pass + if s.Token != "" { + return client.WithBearerAuth(s.Token) + } + if s.User != "" && s.Password != nil { + return client.WithBasicAuth(s.User, *s.Password) + } + + // if no subscription token nor subscription user:pass, check for default token then default user:pass + if conf.DefaultToken != "" { + return client.WithBearerAuth(conf.DefaultToken) + } + if conf.DefaultUser != "" && conf.DefaultPassword != nil { + return client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword) + } + return nil +} + func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) { if command != "" { runCommand(c, command, m) diff --git a/cmd/subscribe_test.go b/cmd/subscribe_test.go index 26ab55ea..a22b0c97 100644 --- a/cmd/subscribe_test.go +++ b/cmd/subscribe_test.go @@ -9,7 +9,6 @@ import ( "path/filepath" "strings" "testing" - "time" ) func TestCLI_Subscribe_Default_UserPass_Subscription_Token(t *testing.T) { @@ -35,9 +34,7 @@ subscribe: app, _, stdout, _ := newTestApp() - go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) - // Sleep to give the app time to subscribe - time.Sleep(time.Millisecond * 100) + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } @@ -65,9 +62,7 @@ subscribe: app, _, stdout, _ := newTestApp() - go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) - // Sleep to give the app time to subscribe - time.Sleep(time.Millisecond * 100) + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } @@ -94,9 +89,7 @@ subscribe: app, _, stdout, _ := newTestApp() - go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) - // Sleep to give the app time to subscribe - time.Sleep(time.Millisecond * 100) + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } @@ -125,9 +118,7 @@ subscribe: app, _, stdout, _ := newTestApp() - go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) - // Sleep to give the app time to subscribe - time.Sleep(time.Millisecond * 100) + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } @@ -153,9 +144,7 @@ subscribe: app, _, stdout, _ := newTestApp() - go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) - // Sleep to give the app time to subscribe - time.Sleep(time.Millisecond * 100) + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } @@ -182,9 +171,7 @@ subscribe: app, _, stdout, _ := newTestApp() - go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) - // Sleep to give the app time to subscribe - time.Sleep(time.Millisecond * 100) + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } @@ -210,9 +197,7 @@ subscribe: app, _, stdout, _ := newTestApp() - go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) - // Sleep to give the app time to subscribe - time.Sleep(time.Millisecond * 100) + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } @@ -239,9 +224,7 @@ subscribe: app, _, stdout, _ := newTestApp() - go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename}) - // Sleep to give the app time to subscribe - time.Sleep(time.Millisecond * 100) + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } @@ -265,9 +248,7 @@ default-token: tk_FAKETOKEN0123456789FAKETOKEN app, _, stdout, _ := newTestApp() - go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic"}) - // Sleep to give the app time to subscribe - time.Sleep(time.Millisecond * 100) + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic"})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } @@ -291,16 +272,41 @@ default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 app, _, stdout, _ := newTestApp() - go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename, "--user", "philipp:mypass", "mytopic"}) - // Sleep to give the app time to subscribe - time.Sleep(time.Millisecond * 100) + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass", "mytopic"})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_Subscription_Token_CLI_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_FAKETOKEN01234567890FAKETOKEN +subscribe: + - topic: mytopic + token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass"})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Token_And_UserPass(t *testing.T) { app, _, _, _ := newTestApp() - err := app.Run([]string{"ntfy", "subscribe", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"}) + err := app.Run([]string{"ntfy", "subscribe", "--poll", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"}) require.Error(t, err) require.Equal(t, "cannot set both --user and --token", err.Error()) } From 758eb3f3711daa47c323bb1fafbcafe044c2458a Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Mon, 6 Mar 2023 18:31:24 -0700 Subject: [PATCH 3/3] update release docs --- docs/releases.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index c0aef2df..ea4c3e27 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -8,6 +8,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * You can now use tokens in `client.yml` for publishing and subscribing ([#653](https://github.com/binwiederhier/ntfy/issues/653), thanks to [@wunter8](https://github.com/wunter8)) +**Bug fixes + maintenance:** + +* `ntfy sub --poll --from-config` will now include authentication headers from client.yml (if applicable) ([#658](https://github.com/binwiederhier/ntfy/issues/658), thanks to [@wunter8](https://github.com/wunter8)) + ## ntfy Android app v1.16.1 (UNRELEASED) **Features:**