This commit is contained in:
Philipp Heckel 2021-12-18 14:43:27 -05:00
parent 5639cf7a0f
commit f266afa1de
12 changed files with 209 additions and 74 deletions

View file

@ -12,10 +12,6 @@ import (
"time" "time"
) )
const (
DefaultBaseURL = "https://ntfy.sh"
)
const ( const (
MessageEvent = "message" MessageEvent = "message"
KeepaliveEvent = "keepalive" KeepaliveEvent = "keepalive"
@ -23,8 +19,8 @@ const (
) )
type Client struct { type Client struct {
BaseURL string
Messages chan *Message Messages chan *Message
config *Config
subscriptions map[string]*subscription subscriptions map[string]*subscription
mu sync.Mutex mu sync.Mutex
} }
@ -34,7 +30,6 @@ type Message struct {
Event string Event string
Time int64 Time int64
Topic string Topic string
BaseURL string
TopicURL string TopicURL string
Message string Message string
Title string Title string
@ -47,11 +42,10 @@ type subscription struct {
cancel context.CancelFunc cancel context.CancelFunc
} }
var DefaultClient = New() func New(config *Config) *Client {
func New() *Client {
return &Client{ return &Client{
Messages: make(chan *Message), Messages: make(chan *Message),
config: config,
subscriptions: make(map[string]*subscription), subscriptions: make(map[string]*subscription),
} }
} }
@ -73,11 +67,12 @@ func (c *Client) Publish(topicURL, message string, options ...PublishOption) err
return 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() ctx := context.Background()
messages := make([]*Message, 0) messages := make([]*Message, 0)
msgChan := make(chan *Message) msgChan := make(chan *Message)
errChan := make(chan error) errChan := make(chan error)
topicURL := c.expandTopicURL(topic)
go func() { go func() {
err := performSubscribeRequest(ctx, msgChan, topicURL, options...) err := performSubscribeRequest(ctx, msgChan, topicURL, options...)
close(msgChan) close(msgChan)
@ -89,20 +84,23 @@ func (c *Client) Poll(topicURL string, options ...SubscribeOption) ([]*Message,
return messages, <-errChan return messages, <-errChan
} }
func (c *Client) Subscribe(topicURL string, options ...SubscribeOption) { func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
topicURL := c.expandTopicURL(topic)
if _, ok := c.subscriptions[topicURL]; ok { if _, ok := c.subscriptions[topicURL]; ok {
return return topicURL
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
c.subscriptions[topicURL] = &subscription{cancel} c.subscriptions[topicURL] = &subscription{cancel}
go handleSubscribeConnLoop(ctx, c.Messages, topicURL, options...) go handleSubscribeConnLoop(ctx, c.Messages, topicURL, options...)
return topicURL
} }
func (c *Client) Unsubscribe(topicURL string) { func (c *Client) Unsubscribe(topic string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
topicURL := c.expandTopicURL(topic)
sub, ok := c.subscriptions[topicURL] sub, ok := c.subscriptions[topicURL]
if !ok { if !ok {
return return
@ -111,6 +109,15 @@ func (c *Client) Unsubscribe(topicURL string) {
return 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) { func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL string, options ...SubscribeOption) {
for { for {
if err := performSubscribeRequest(ctx, msgChan, topicURL, options...); err != nil { 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 { if err := json.NewDecoder(strings.NewReader(line)).Decode(&m); err != nil {
return err return err
} }
m.BaseURL = strings.TrimSuffix(topicURL, "/"+m.Topic) // FIXME hack!
m.TopicURL = topicURL m.TopicURL = topicURL
m.Raw = line m.Raw = line
msgChan <- m msgChan <- m

18
client/client.yml Normal file
View file

@ -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
client/config.go Normal file
View file

@ -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,
}
}

View file

@ -5,13 +5,16 @@ import (
"fmt" "fmt"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc" "github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/client"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"log"
"os" "os"
"strings" "strings"
) )
var (
defaultClientRootConfigFile = "/etc/ntfy/client.yml"
defaultClientUserConfigFile = "~/.config/ntfy/client.yml"
)
// New creates a new CLI application // New creates a new CLI application
func New() *cli.App { func New() *cli.App {
return &cli.App{ return &cli.App{
@ -35,8 +38,8 @@ func New() *cli.App {
} }
func execMainApp(c *cli.Context) error { 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") fmt.Fprintln(c.App.ErrWriter, "\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;33mThis way of running the server will be removed March 2022. See https://ntfy.sh/docs/deprecations/ for details.\x1b[0m")
return execServe(c) 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 { func collapseTopicURL(s string) string {
return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://")
} }

View file

@ -46,7 +46,7 @@ func execPublish(c *cli.Context) error {
delay := c.String("delay") delay := c.String("delay")
noCache := c.Bool("no-cache") noCache := c.Bool("no-cache")
noFirebase := c.Bool("no-firebase") noFirebase := c.Bool("no-firebase")
topicURL := expandTopicURL(c.Args().Get(0)) topic := c.Args().Get(0)
message := "" message := ""
if c.NArg() > 1 { if c.NArg() > 1 {
message = strings.Join(c.Args().Slice()[1:], " ") message = strings.Join(c.Args().Slice()[1:], " ")
@ -70,5 +70,10 @@ func execPublish(c *cli.Context) error {
if noFirebase { if noFirebase {
options = append(options, client.WithNoFirebase()) 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...)
} }

View file

@ -4,11 +4,13 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gopkg.in/yaml.v2"
"heckel.io/ntfy/client" "heckel.io/ntfy/client"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"log" "log"
"os" "os"
"os/exec" "os/exec"
"os/user"
"strings" "strings"
) )
@ -16,53 +18,102 @@ var cmdSubscribe = &cli.Command{
Name: "subscribe", Name: "subscribe",
Aliases: []string{"sub"}, Aliases: []string{"sub"},
Usage: "Subscribe to one or more topics on a ntfy server", Usage: "Subscribe to one or more topics on a ntfy server",
UsageText: "ntfy subscribe [OPTIONS..] TOPIC", UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
Action: execSubscribe, Action: execSubscribe,
Flags: []cli.Flag{ 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: "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.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: "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"}, &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 ntfy subscribe TOPIC
or execute commands for every arriving message. 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. Examples:
When --exec is passed, each incoming message will execute a command. The message fields ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic
are passed to the command as environment variables: 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 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_MESSAGE $message, $m Message body
$NTFY_TITLE $title, $t Message title $NTFY_TITLE $title, $t Message title
$NTFY_PRIORITY $priority, $p Message priority (1=min, 5=max) $NTFY_PRIORITY $priority, $p Message priority (1=min, 5=max)
$NTFY_TAGS $tags, $ta Message tags (comma separated list) $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: Examples:
ntfy subscribe mytopic # Prints JSON for incoming messages to stdout ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
ntfy sub home.lan/backups alerts # Subscribe to two different topics ntfy sub topic1 /my/script.sh # Execute script for incoming messages
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 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 { 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 { if c.NArg() < 1 {
return errors.New("topic missing") return errors.New("topic missing")
} }
fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mThis command is incubating. The interface may change without notice.\x1b[0m") fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mThis command is incubating. The interface may change without notice.\x1b[0m")
cl := client.DefaultClient conf, err := loadConfig(c)
command := c.String("exec") if err != nil {
return err
}
cl := client.New(conf)
since := c.String("since") since := c.String("since")
poll := c.Bool("poll") poll := c.Bool("poll")
scheduled := c.Bool("scheduled") scheduled := c.Bool("scheduled")
topics := c.Args().Slice() topic := c.Args().Get(0)
command := c.Args().Get(1)
var options []client.SubscribeOption var options []client.SubscribeOption
if since != "" { if since != "" {
options = append(options, client.WithSince(since)) options = append(options, client.WithSince(since))
@ -74,19 +125,15 @@ func execSubscribe(c *cli.Context) error {
options = append(options, client.WithScheduled()) options = append(options, client.WithScheduled())
} }
if poll { if poll {
for _, topic := range topics { messages, err := cl.Poll(topic, options...)
messages, err := cl.Poll(expandTopicURL(topic), options...) if err != nil {
if err != nil { return err
return err }
} for _, m := range messages {
for _, m := range messages { _ = dispatchMessage(c, command, m)
_ = dispatchMessage(c, command, m)
}
} }
} else { } else {
for _, topic := range topics { cl.Subscribe(topic, options...)
cl.Subscribe(expandTopicURL(topic), options...)
}
for m := range cl.Messages { for m := range cl.Messages {
_ = dispatchMessage(c, command, m) _ = dispatchMessage(c, command, m)
} }
@ -140,7 +187,6 @@ func createTmpScript(command string) (string, error) {
func envVars(m *client.Message) []string { func envVars(m *client.Message) []string {
env := os.Environ() env := os.Environ()
env = append(env, envVar(m.ID, "NTFY_ID", "id")...) 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(m.Topic, "NTFY_TOPIC", "topic")...)
env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...) env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...)
env = append(env, envVar(m.Message, "NTFY_MESSAGE", "message", "m")...) env = append(env, envVar(m.Message, "NTFY_MESSAGE", "message", "m")...)
@ -157,3 +203,31 @@ func envVar(value string, vars ...string) []string {
} }
return env 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
}

View file

@ -20,8 +20,8 @@ const (
// Defines all the limits // Defines all the limits
// - global topic limit: max number of topics overall // - 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 visitor 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 subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
const ( const (
DefaultGlobalTopicLimit = 5000 DefaultGlobalTopicLimit = 5000
DefaultVisitorRequestLimitBurst = 60 DefaultVisitorRequestLimitBurst = 60

View file

@ -1,23 +1,15 @@
# ntfy config file # 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> # Format: <hostname>:<port>
# #
# listen-http: ":80" # 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: # listen-https:
# Path to the private key file for the HTTPS web server. Not used if "listen-https" is not set. # Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set.
# Format: <filename>
# #
# key-file: # key-file:
# Path to the cert file for the HTTPS web server. Not used if "listen-https" is not set.
# Format: <filename>
#
# cert-file: # cert-file:
# If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. # If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app.

View file

@ -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

2
go.mod
View file

@ -15,7 +15,7 @@ require (
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
google.golang.org/api v0.63.0 google.golang.org/api v0.63.0
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0
) )
require ( require (

View file

@ -98,3 +98,8 @@ func ParsePriority(priority string) (int, error) {
return 0, errInvalidPriority return 0, errInvalidPriority
} }
} }
// ExpandHome replaces "~" with the user's home directory
func ExpandHome(path string) string {
return os.ExpandEnv(strings.ReplaceAll(path, "~", "$HOME"))
}

View file

@ -3,6 +3,7 @@ package util
import ( import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"io/ioutil" "io/ioutil"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
@ -54,3 +55,11 @@ func TestInStringList(t *testing.T) {
require.True(t, InStringList(s, "two")) require.True(t, InStringList(s, "two"))
require.False(t, InStringList(s, "three")) 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"))
}