From f266afa1de871cfe21850448218ab7c4461d3307 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 18 Dec 2021 14:43:27 -0500 Subject: [PATCH] WIP CLI --- client/client.go | 34 ++++++---- client/client.yml | 18 +++++ client/config.go | 20 ++++++ cmd/app.go | 20 ++---- cmd/publish.go | 9 ++- cmd/subscribe.go | 136 ++++++++++++++++++++++++++++--------- config/config.go | 4 +- config/config.yml | 14 +--- config/ntfy-client.service | 12 ++++ go.mod | 2 +- util/util.go | 5 ++ util/util_test.go | 9 +++ 12 files changed, 209 insertions(+), 74 deletions(-) create mode 100644 client/client.yml create mode 100644 client/config.go create mode 100644 config/ntfy-client.service diff --git a/client/client.go b/client/client.go index 86a7a4e0..31e7c894 100644 --- a/client/client.go +++ b/client/client.go @@ -12,10 +12,6 @@ import ( "time" ) -const ( - DefaultBaseURL = "https://ntfy.sh" -) - const ( MessageEvent = "message" KeepaliveEvent = "keepalive" @@ -23,8 +19,8 @@ const ( ) type Client struct { - BaseURL string Messages chan *Message + config *Config subscriptions map[string]*subscription mu sync.Mutex } @@ -34,7 +30,6 @@ type Message struct { Event string Time int64 Topic string - BaseURL string TopicURL string Message string Title string @@ -47,11 +42,10 @@ type subscription struct { cancel context.CancelFunc } -var DefaultClient = New() - -func New() *Client { +func New(config *Config) *Client { return &Client{ Messages: make(chan *Message), + config: config, subscriptions: make(map[string]*subscription), } } @@ -73,11 +67,12 @@ func (c *Client) Publish(topicURL, message string, options ...PublishOption) err return err } -func (c *Client) Poll(topicURL string, options ...SubscribeOption) ([]*Message, error) { +func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) { ctx := context.Background() messages := make([]*Message, 0) msgChan := make(chan *Message) errChan := make(chan error) + topicURL := c.expandTopicURL(topic) go func() { err := performSubscribeRequest(ctx, msgChan, topicURL, options...) close(msgChan) @@ -89,20 +84,23 @@ func (c *Client) Poll(topicURL string, options ...SubscribeOption) ([]*Message, return messages, <-errChan } -func (c *Client) Subscribe(topicURL string, options ...SubscribeOption) { +func (c *Client) Subscribe(topic string, options ...SubscribeOption) string { c.mu.Lock() defer c.mu.Unlock() + topicURL := c.expandTopicURL(topic) if _, ok := c.subscriptions[topicURL]; ok { - return + return topicURL } ctx, cancel := context.WithCancel(context.Background()) c.subscriptions[topicURL] = &subscription{cancel} go handleSubscribeConnLoop(ctx, c.Messages, topicURL, options...) + return topicURL } -func (c *Client) Unsubscribe(topicURL string) { +func (c *Client) Unsubscribe(topic string) { c.mu.Lock() defer c.mu.Unlock() + topicURL := c.expandTopicURL(topic) sub, ok := c.subscriptions[topicURL] if !ok { return @@ -111,6 +109,15 @@ func (c *Client) Unsubscribe(topicURL string) { return } +func (c *Client) expandTopicURL(topic string) string { + if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") { + return topic + } else if strings.Contains(topic, "/") { + return fmt.Sprintf("https://%s", topic) + } + return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic) +} + func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL string, options ...SubscribeOption) { for { if err := performSubscribeRequest(ctx, msgChan, topicURL, options...); err != nil { @@ -147,7 +154,6 @@ func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicUR if err := json.NewDecoder(strings.NewReader(line)).Decode(&m); err != nil { return err } - m.BaseURL = strings.TrimSuffix(topicURL, "/"+m.Topic) // FIXME hack! m.TopicURL = topicURL m.Raw = line msgChan <- m diff --git a/client/client.yml b/client/client.yml new file mode 100644 index 00000000..c65a2f3a --- /dev/null +++ b/client/client.yml @@ -0,0 +1,18 @@ +# ntfy client config file + +# Base URL used to expand short topic names in the "ntfy publish" and "ntfy subscribe" commands. +# If you self-host a ntfy server, you'll likely want to change this. +# +# default-host: https://ntfy.sh + +# Subscriptions to topics and their actions. This option is only used by the "ntfy subscribe --from-config" +# command. +# +# Here's a (hopefully self-explanatory) example: +# subscribe: +# - topic: mytopic +# exec: /usr/local/bin/mytopic-triggered.sh +# - topic: myserver.com/anothertopic +# exec: 'echo "$message"' +# +# subscribe: diff --git a/client/config.go b/client/config.go new file mode 100644 index 00000000..863d2fe6 --- /dev/null +++ b/client/config.go @@ -0,0 +1,20 @@ +package client + +const ( + DefaultBaseURL = "https://ntfy.sh" +) + +type Config struct { + DefaultHost string + Subscribe []struct { + Topic string + Exec string + } +} + +func NewConfig() *Config { + return &Config{ + DefaultHost: DefaultBaseURL, + Subscribe: nil, + } +} diff --git a/cmd/app.go b/cmd/app.go index 9d99710d..571cbecc 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -5,13 +5,16 @@ import ( "fmt" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" - "heckel.io/ntfy/client" "heckel.io/ntfy/util" - "log" "os" "strings" ) +var ( + defaultClientRootConfigFile = "/etc/ntfy/client.yml" + defaultClientUserConfigFile = "~/.config/ntfy/client.yml" +) + // New creates a new CLI application func New() *cli.App { return &cli.App{ @@ -35,8 +38,8 @@ func New() *cli.App { } func execMainApp(c *cli.Context) error { - log.Printf("\x1b[1;33mDeprecation notice: Please run the server using 'ntfy serve'; see 'ntfy -h' for help.\x1b[0m") - log.Printf("\x1b[1;33mThis way of running the server will be removed March 2022. See https://ntfy.sh/docs/deprecations/ for details.\x1b[0m") + fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mDeprecation notice: Please run the server using 'ntfy serve'; see 'ntfy -h' for help.\x1b[0m") + fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mThis way of running the server will be removed March 2022. See https://ntfy.sh/docs/deprecations/ for details.\x1b[0m") return execServe(c) } @@ -58,15 +61,6 @@ func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFu } } -func expandTopicURL(s string) string { - if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") { - return s - } else if strings.Contains(s, "/") { - return fmt.Sprintf("https://%s", s) - } - return fmt.Sprintf("%s/%s", client.DefaultBaseURL, s) -} - func collapseTopicURL(s string) string { return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") } diff --git a/cmd/publish.go b/cmd/publish.go index 5211bb65..2328328f 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -46,7 +46,7 @@ func execPublish(c *cli.Context) error { delay := c.String("delay") noCache := c.Bool("no-cache") noFirebase := c.Bool("no-firebase") - topicURL := expandTopicURL(c.Args().Get(0)) + topic := c.Args().Get(0) message := "" if c.NArg() > 1 { message = strings.Join(c.Args().Slice()[1:], " ") @@ -70,5 +70,10 @@ func execPublish(c *cli.Context) error { if noFirebase { options = append(options, client.WithNoFirebase()) } - return client.DefaultClient.Publish(topicURL, message, options...) + conf, err := loadConfig(c) + if err != nil { + return err + } + cl := client.New(conf) + return cl.Publish(topic, message, options...) } diff --git a/cmd/subscribe.go b/cmd/subscribe.go index 10846e1a..de4b65af 100644 --- a/cmd/subscribe.go +++ b/cmd/subscribe.go @@ -4,11 +4,13 @@ import ( "errors" "fmt" "github.com/urfave/cli/v2" + "gopkg.in/yaml.v2" "heckel.io/ntfy/client" "heckel.io/ntfy/util" "log" "os" "os/exec" + "os/user" "strings" ) @@ -16,53 +18,102 @@ var cmdSubscribe = &cli.Command{ Name: "subscribe", Aliases: []string{"sub"}, Usage: "Subscribe to one or more topics on a ntfy server", - UsageText: "ntfy subscribe [OPTIONS..] TOPIC", + UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]", Action: execSubscribe, Flags: []cli.Flag{ + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "config file"}, &cli.StringFlag{Name: "exec", Aliases: []string{"e"}, Usage: "execute command for each message event"}, &cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since (Unix timestamp, or all)"}, + &cli.BoolFlag{Name: "from-config", Aliases: []string{"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"}, }, - Description: `(THIS COMMAND IS INCUBATING. IT MAY CHANGE WITHOUT NOTICE.) + Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for +every arriving message. There are 3 modes in which the command can be run: -Subscribe to one or more topics on a ntfy server, and either print -or execute commands for every arriving message. +ntfy subscribe TOPIC + This prints the JSON representation of every incoming message. It is useful when you + have a command that wants to stream-read incoming JSON messages. Unless --poll is passed, + this command stays open forever. -By default, the subscribe command just prints the JSON representation of a message. -When --exec is passed, each incoming message will execute a command. The message fields -are passed to the command as environment variables: + Examples: + ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic + ntfy sub home.lan/backups # Subscribe to topic on different server + ntfy sub --poll home.lan/backups # Just query for latest messages and exit + +ntfy subscribe TOPIC COMMAND + This executes COMMAND for every incoming messages. The message fields are passed to the + command as environment variables: Variable Aliases Description --------------- --------------- ----------------------------------- + $NTFY_ID $id Unique message ID + $NTFY_TIME $time Unix timestamp of the message delivery + $NTFY_TOPIC $topic Topic name $NTFY_MESSAGE $message, $m Message body $NTFY_TITLE $title, $t Message title $NTFY_PRIORITY $priority, $p Message priority (1=min, 5=max) $NTFY_TAGS $tags, $ta Message tags (comma separated list) - $NTFY_ID $id Unique message ID - $NTFY_TIME $time Unix timestamp of the message delivery - $NTFY_TOPIC $topic Topic name - $NTFY_EVENT $event, $ev Event identifier (always "message") -Examples: - ntfy subscribe mytopic # Prints JSON for incoming messages to stdout - ntfy sub home.lan/backups alerts # Subscribe to two different topics - ntfy sub --exec='notify-send "$m"' mytopic # Execute command for incoming messages - ntfy sub --exec=/my/script topic1 topic2 # Subscribe to two topics and execute command for each message + Examples: + ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages + ntfy sub topic1 /my/script.sh # Execute script for incoming messages + +ntfy subscribe --from-config + Service mode (used in ntfy-client.service). This reads the config file (/etc/ntfy/client.yml + or ~/.config/ntfy/client.yml) and sets up subscriptions for every topic in the "subscribe:" + block (see config file). + + Examples: + ntfy sub --from-config # Read topics from config file + ntfy sub --config=/my/client.yml --from-config # Read topics from alternate config file `, } func execSubscribe(c *cli.Context) error { + fromConfig := c.Bool("from-config") + if fromConfig { + return execSubscribeFromConfig(c) + } + return execSubscribeWithoutConfig(c) +} + +func execSubscribeFromConfig(c *cli.Context) error { + conf, err := loadConfig(c) + if err != nil { + return err + } + cl := client.New(conf) + commands := make(map[string]string) + for _, s := range conf.Subscribe { + topicURL := cl.Subscribe(s.Topic) + commands[topicURL] = s.Exec + } + for m := range cl.Messages { + command, ok := commands[m.TopicURL] + if !ok { + continue + } + _ = dispatchMessage(c, command, m) + } + return nil +} + +func execSubscribeWithoutConfig(c *cli.Context) error { if c.NArg() < 1 { return errors.New("topic missing") } fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mThis command is incubating. The interface may change without notice.\x1b[0m") - cl := client.DefaultClient - command := c.String("exec") + conf, err := loadConfig(c) + if err != nil { + return err + } + cl := client.New(conf) since := c.String("since") poll := c.Bool("poll") scheduled := c.Bool("scheduled") - topics := c.Args().Slice() + topic := c.Args().Get(0) + command := c.Args().Get(1) var options []client.SubscribeOption if since != "" { options = append(options, client.WithSince(since)) @@ -74,19 +125,15 @@ func execSubscribe(c *cli.Context) error { options = append(options, client.WithScheduled()) } if poll { - for _, topic := range topics { - messages, err := cl.Poll(expandTopicURL(topic), options...) - if err != nil { - return err - } - for _, m := range messages { - _ = dispatchMessage(c, command, m) - } + messages, err := cl.Poll(topic, options...) + if err != nil { + return err + } + for _, m := range messages { + _ = dispatchMessage(c, command, m) } } else { - for _, topic := range topics { - cl.Subscribe(expandTopicURL(topic), options...) - } + cl.Subscribe(topic, options...) for m := range cl.Messages { _ = dispatchMessage(c, command, m) } @@ -140,7 +187,6 @@ func createTmpScript(command string) (string, error) { func envVars(m *client.Message) []string { env := os.Environ() env = append(env, envVar(m.ID, "NTFY_ID", "id")...) - env = append(env, envVar(m.Event, "NTFY_EVENT", "event", "ev")...) env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...) env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...) env = append(env, envVar(m.Message, "NTFY_MESSAGE", "message", "m")...) @@ -157,3 +203,31 @@ func envVar(value string, vars ...string) []string { } return env } + +func loadConfig(c *cli.Context) (*client.Config, error) { + filename := c.String("config") + if filename != "" { + return loadConfigFromFile(filename) + } + u, _ := user.Current() + configFile := defaultClientRootConfigFile + if u.Uid != "0" { + configFile = util.ExpandHome(defaultClientUserConfigFile) + } + if s, _ := os.Stat(configFile); s != nil { + return loadConfigFromFile(configFile) + } + return client.NewConfig(), nil +} + +func loadConfigFromFile(filename string) (*client.Config, error) { + b, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + c := client.NewConfig() + if err := yaml.Unmarshal(b, c); err != nil { + return nil, err + } + return c, nil +} diff --git a/config/config.go b/config/config.go index 90dbe7cb..a78ec5a9 100644 --- a/config/config.go +++ b/config/config.go @@ -20,8 +20,8 @@ const ( // Defines all the limits // - global topic limit: max number of topics overall -// - per visistor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds) -// - per visistor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP +// - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds) +// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP const ( DefaultGlobalTopicLimit = 5000 DefaultVisitorRequestLimitBurst = 60 diff --git a/config/config.yml b/config/config.yml index 972b4f94..0829778c 100644 --- a/config/config.yml +++ b/config/config.yml @@ -1,23 +1,15 @@ # ntfy config file -# Listen address for the HTTP web server +# Listen address for the HTTP & HTTPS web server. If "listen-https" is set, you must also +# set "key-file" and "cert-file". # Format: : # # listen-http: ":80" - -# Listen address for the HTTPS web server. If set, you must also set "key-file" and "cert-file". -# Format: : -# # listen-https: -# Path to the private key file for the HTTPS web server. Not used if "listen-https" is not set. -# Format: +# Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set. # # key-file: - -# Path to the cert file for the HTTPS web server. Not used if "listen-https" is not set. -# Format: -# # cert-file: # If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. diff --git a/config/ntfy-client.service b/config/ntfy-client.service new file mode 100644 index 00000000..27925d77 --- /dev/null +++ b/config/ntfy-client.service @@ -0,0 +1,12 @@ +[Unit] +Description=ntfy client +After=network.target + +[Service] +User=ntfy +Group=ntfy +ExecStart=/usr/bin/ntfy subscribe --config /etc/ntfy/client.yml --from-config +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/go.mod b/go.mod index c61d0f99..918e9fc1 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 google.golang.org/api v0.63.0 - gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v2 v2.4.0 ) require ( diff --git a/util/util.go b/util/util.go index 665166df..8acd0032 100644 --- a/util/util.go +++ b/util/util.go @@ -98,3 +98,8 @@ func ParsePriority(priority string) (int, error) { return 0, errInvalidPriority } } + +// ExpandHome replaces "~" with the user's home directory +func ExpandHome(path string) string { + return os.ExpandEnv(strings.ReplaceAll(path, "~", "$HOME")) +} diff --git a/util/util_test.go b/util/util_test.go index 2d901521..d5ebcad3 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -3,6 +3,7 @@ package util import ( "github.com/stretchr/testify/require" "io/ioutil" + "os" "path/filepath" "testing" "time" @@ -54,3 +55,11 @@ func TestInStringList(t *testing.T) { require.True(t, InStringList(s, "two")) require.False(t, InStringList(s, "three")) } + +func TestExpandHome_WithTilde(t *testing.T) { + require.Equal(t, os.Getenv("HOME")+"/this/is/a/path", ExpandHome("~/this/is/a/path")) +} + +func TestExpandHome_NoTilde(t *testing.T) { + require.Equal(t, "/this/is/an/absolute/path", ExpandHome("/this/is/an/absolute/path")) +}