subscribe.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. package cmd
  2. import (
  3. "errors"
  4. "fmt"
  5. "github.com/urfave/cli/v2"
  6. "heckel.io/ntfy/client"
  7. "heckel.io/ntfy/log"
  8. "heckel.io/ntfy/util"
  9. "os"
  10. "os/exec"
  11. "os/user"
  12. "path/filepath"
  13. "sort"
  14. "strings"
  15. )
  16. func init() {
  17. commands = append(commands, cmdSubscribe)
  18. }
  19. const (
  20. clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml"
  21. clientUserConfigFileUnixRelative = "ntfy/client.yml"
  22. clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
  23. )
  24. var flagsSubscribe = append(
  25. append([]cli.Flag{}, flagsDefault...),
  26. &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
  27. &cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
  28. &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
  29. &cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"},
  30. &cli.BoolFlag{Name: "from-config", Aliases: []string{"from_config", "C"}, Usage: "read subscriptions from config file (service mode)"},
  31. &cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
  32. &cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
  33. )
  34. var cmdSubscribe = &cli.Command{
  35. Name: "subscribe",
  36. Aliases: []string{"sub"},
  37. Usage: "Subscribe to one or more topics on a ntfy server",
  38. UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
  39. Action: execSubscribe,
  40. Category: categoryClient,
  41. Flags: flagsSubscribe,
  42. Before: initLogFunc,
  43. Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for
  44. every arriving message. There are 3 modes in which the command can be run:
  45. ntfy subscribe TOPIC
  46. This prints the JSON representation of every incoming message. It is useful when you
  47. have a command that wants to stream-read incoming JSON messages. Unless --poll is passed,
  48. this command stays open forever.
  49. Examples:
  50. ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic
  51. ntfy sub home.lan/backups # Subscribe to topic on different server
  52. ntfy sub --poll home.lan/backups # Just query for latest messages and exit
  53. ntfy sub -u phil:mypass secret # Subscribe with username/password
  54. ntfy subscribe TOPIC COMMAND
  55. This executes COMMAND for every incoming messages. The message fields are passed to the
  56. command as environment variables:
  57. Variable Aliases Description
  58. --------------- --------------------- -----------------------------------
  59. $NTFY_ID $id Unique message ID
  60. $NTFY_TIME $time Unix timestamp of the message delivery
  61. $NTFY_TOPIC $topic Topic name
  62. $NTFY_MESSAGE $message, $m Message body
  63. $NTFY_TITLE $title, $t Message title
  64. $NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
  65. $NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
  66. $NTFY_RAW $raw Raw JSON message
  67. Examples:
  68. ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
  69. ntfy sub topic1 myscript.sh # Execute script for incoming messages
  70. ntfy subscribe --from-config
  71. Service mode (used in ntfy-client.service). This reads the config file and sets up
  72. subscriptions for every topic in the "subscribe:" block (see config file).
  73. Examples:
  74. ntfy sub --from-config # Read topics from config file
  75. ntfy sub --config=myclient.yml --from-config # Read topics from alternate config file
  76. ` + clientCommandDescriptionSuffix,
  77. }
  78. func execSubscribe(c *cli.Context) error {
  79. // Read config and options
  80. conf, err := loadConfig(c)
  81. if err != nil {
  82. return err
  83. }
  84. cl := client.New(conf)
  85. since := c.String("since")
  86. user := c.String("user")
  87. token := c.String("token")
  88. poll := c.Bool("poll")
  89. scheduled := c.Bool("scheduled")
  90. fromConfig := c.Bool("from-config")
  91. topic := c.Args().Get(0)
  92. command := c.Args().Get(1)
  93. // Checks
  94. if user != "" && token != "" {
  95. return errors.New("cannot set both --user and --token")
  96. } else if !topicRegex.MatchString(topic) {
  97. return fmt.Errorf("topic %s contains invalid characters", topic)
  98. }
  99. if !fromConfig {
  100. conf.Subscribe = nil // wipe if --from-config not passed
  101. }
  102. var options []client.SubscribeOption
  103. if since != "" {
  104. options = append(options, client.WithSince(since))
  105. }
  106. if token != "" {
  107. options = append(options, client.WithBearerAuth(token))
  108. } else if user != "" {
  109. var pass string
  110. parts := strings.SplitN(user, ":", 2)
  111. if len(parts) == 2 {
  112. user = parts[0]
  113. pass = parts[1]
  114. } else {
  115. fmt.Fprint(c.App.ErrWriter, "Enter Password: ")
  116. p, err := util.ReadPassword(c.App.Reader)
  117. if err != nil {
  118. return err
  119. }
  120. pass = string(p)
  121. fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
  122. }
  123. options = append(options, client.WithBasicAuth(user, pass))
  124. } else if conf.DefaultToken != "" {
  125. options = append(options, client.WithBearerAuth(conf.DefaultToken))
  126. } else if conf.DefaultUser != "" && conf.DefaultPassword != nil {
  127. options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
  128. }
  129. if scheduled {
  130. options = append(options, client.WithScheduled())
  131. }
  132. if topic == "" && len(conf.Subscribe) == 0 {
  133. return errors.New("must specify topic, type 'ntfy subscribe --help' for help")
  134. }
  135. // Execute poll or subscribe
  136. if poll {
  137. return doPoll(c, cl, conf, topic, command, options...)
  138. }
  139. return doSubscribe(c, cl, conf, topic, command, options...)
  140. }
  141. func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
  142. for _, s := range conf.Subscribe { // may be nil
  143. if auth := maybeAddAuthHeader(s, conf); auth != nil {
  144. options = append(options, auth)
  145. }
  146. if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil {
  147. return err
  148. }
  149. }
  150. if topic != "" {
  151. if err := doPollSingle(c, cl, topic, command, options...); err != nil {
  152. return err
  153. }
  154. }
  155. return nil
  156. }
  157. func doPollSingle(c *cli.Context, cl *client.Client, topic, command string, options ...client.SubscribeOption) error {
  158. messages, err := cl.Poll(topic, options...)
  159. if err != nil {
  160. return err
  161. }
  162. for _, m := range messages {
  163. printMessageOrRunCommand(c, m, command)
  164. }
  165. return nil
  166. }
  167. func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
  168. cmds := make(map[string]string) // Subscription ID -> command
  169. for _, s := range conf.Subscribe { // May be nil
  170. topicOptions := append(make([]client.SubscribeOption, 0), options...)
  171. for filter, value := range s.If {
  172. topicOptions = append(topicOptions, client.WithFilter(filter, value))
  173. }
  174. if auth := maybeAddAuthHeader(s, conf); auth != nil {
  175. topicOptions = append(topicOptions, auth)
  176. }
  177. subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
  178. if s.Command != "" {
  179. cmds[subscriptionID] = s.Command
  180. } else if conf.DefaultCommand != "" {
  181. cmds[subscriptionID] = conf.DefaultCommand
  182. } else {
  183. cmds[subscriptionID] = ""
  184. }
  185. }
  186. if topic != "" {
  187. subscriptionID := cl.Subscribe(topic, options...)
  188. cmds[subscriptionID] = command
  189. }
  190. for m := range cl.Messages {
  191. cmd, ok := cmds[m.SubscriptionID]
  192. if !ok {
  193. continue
  194. }
  195. log.Debug("%s Dispatching received message: %s", logMessagePrefix(m), m.Raw)
  196. printMessageOrRunCommand(c, m, cmd)
  197. }
  198. return nil
  199. }
  200. func maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption {
  201. // check for subscription token then subscription user:pass
  202. if s.Token != "" {
  203. return client.WithBearerAuth(s.Token)
  204. }
  205. if s.User != "" && s.Password != nil {
  206. return client.WithBasicAuth(s.User, *s.Password)
  207. }
  208. // if no subscription token nor subscription user:pass, check for default token then default user:pass
  209. if conf.DefaultToken != "" {
  210. return client.WithBearerAuth(conf.DefaultToken)
  211. }
  212. if conf.DefaultUser != "" && conf.DefaultPassword != nil {
  213. return client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)
  214. }
  215. return nil
  216. }
  217. func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) {
  218. if command != "" {
  219. runCommand(c, command, m)
  220. } else {
  221. log.Debug("%s Printing raw message", logMessagePrefix(m))
  222. fmt.Fprintln(c.App.Writer, m.Raw)
  223. }
  224. }
  225. func runCommand(c *cli.Context, command string, m *client.Message) {
  226. if err := runCommandInternal(c, command, m); err != nil {
  227. log.Warn("%s Command failed: %s", logMessagePrefix(m), err.Error())
  228. }
  229. }
  230. func runCommandInternal(c *cli.Context, script string, m *client.Message) error {
  231. scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt)
  232. log.Debug("%s Running command '%s' via temporary script %s", logMessagePrefix(m), script, scriptFile)
  233. script = scriptHeader + script
  234. if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
  235. return err
  236. }
  237. defer os.Remove(scriptFile)
  238. log.Debug("%s Executing script %s", logMessagePrefix(m), scriptFile)
  239. cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...)
  240. cmd.Stdin = c.App.Reader
  241. cmd.Stdout = c.App.Writer
  242. cmd.Stderr = c.App.ErrWriter
  243. cmd.Env = envVars(m)
  244. return cmd.Run()
  245. }
  246. func envVars(m *client.Message) []string {
  247. env := make([]string, 0)
  248. env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
  249. env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...)
  250. env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...)
  251. env = append(env, envVar(m.Message, "NTFY_MESSAGE", "message", "m")...)
  252. env = append(env, envVar(m.Title, "NTFY_TITLE", "title", "t")...)
  253. env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...)
  254. env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...)
  255. env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...)
  256. sort.Strings(env)
  257. if log.IsTrace() {
  258. log.Trace("%s With environment:\n%s", logMessagePrefix(m), strings.Join(env, "\n"))
  259. }
  260. return append(os.Environ(), env...)
  261. }
  262. func envVar(value string, vars ...string) []string {
  263. env := make([]string, 0)
  264. for _, v := range vars {
  265. env = append(env, fmt.Sprintf("%s=%s", v, value))
  266. }
  267. return env
  268. }
  269. func loadConfig(c *cli.Context) (*client.Config, error) {
  270. filename := c.String("config")
  271. if filename != "" {
  272. return client.LoadConfig(filename)
  273. }
  274. configFile := defaultClientConfigFile()
  275. if s, _ := os.Stat(configFile); s != nil {
  276. return client.LoadConfig(configFile)
  277. }
  278. return client.NewConfig(), nil
  279. }
  280. //lint:ignore U1000 Conditionally used in different builds
  281. func defaultClientConfigFileUnix() string {
  282. u, _ := user.Current()
  283. configFile := clientRootConfigFileUnixAbsolute
  284. if u.Uid != "0" {
  285. homeDir, _ := os.UserConfigDir()
  286. return filepath.Join(homeDir, clientUserConfigFileUnixRelative)
  287. }
  288. return configFile
  289. }
  290. //lint:ignore U1000 Conditionally used in different builds
  291. func defaultClientConfigFileWindows() string {
  292. homeDir, _ := os.UserConfigDir()
  293. return filepath.Join(homeDir, clientUserConfigFileWindowsRelative)
  294. }
  295. func logMessagePrefix(m *client.Message) string {
  296. return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID)
  297. }