Philipp Heckel 4 ani în urmă
părinte
comite
f266afa1de
12 a modificat fișierele cu 209 adăugiri și 74 ștergeri
  1. 20 14
      client/client.go
  2. 18 0
      client/client.yml
  3. 20 0
      client/config.go
  4. 7 13
      cmd/app.go
  5. 7 2
      cmd/publish.go
  6. 105 31
      cmd/subscribe.go
  7. 2 2
      config/config.go
  8. 3 11
      config/config.yml
  9. 12 0
      config/ntfy-client.service
  10. 1 1
      go.mod
  11. 5 0
      util/util.go
  12. 9 0
      util/util_test.go

+ 20 - 14
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

+ 18 - 0
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:

+ 20 - 0
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,
+	}
+}

+ 7 - 13
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://")
 }

+ 7 - 2
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...)
 }

+ 105 - 31
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
 		}
-	} else {
-		for _, topic := range topics {
-			cl.Subscribe(expandTopicURL(topic), options...)
+		for _, m := range messages {
+			_ = dispatchMessage(c, command, m)
 		}
+	} else {
+		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
+}

+ 2 - 2
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

+ 3 - 11
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: <hostname>:<port>
 #
 # listen-http: ":80"
-
-# Listen address for the HTTPS web server. If set, you must also set "key-file" and "cert-file".
-# Format: <hostname>:<port>
-#
 # listen-https:
 
-# Path to the private key file for the HTTPS web server. Not used if "listen-https" is not set.
-# Format: <filename>
+# 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: <filename>
-#
 # cert-file:
 
 # If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app.

+ 12 - 0
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

+ 1 - 1
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 (

+ 5 - 0
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"))
+}

+ 9 - 0
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"))
+}