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

View file

@ -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...)
}

View file

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

View file

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

View file

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

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/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 (

View file

@ -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"))
}

View file

@ -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"))
}