diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ebf0db7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2021 Oliver Lowe + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ec9128 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +This repository contains the Go pushover package, +the pover command-line utility, +and example Icinga2 configuration and scripts to send notifications from Icinga2 using pover. diff --git a/cmd/pover/README.md b/cmd/pover/README.md new file mode 100644 index 0000000..a4f6a78 --- /dev/null +++ b/cmd/pover/README.md @@ -0,0 +1,10 @@ +## Tests +Tests are run by building and running pover + +First, build pover: + + go build + +Create a valid credentials file, then run test.sh + + ./test.sh diff --git a/cmd/pover/config.go b/cmd/pover/config.go new file mode 100644 index 0000000..c5667b1 --- /dev/null +++ b/cmd/pover/config.go @@ -0,0 +1,51 @@ +package main + +import ( + "os" + "bufio" + "strings" + "fmt" +) + +type config struct { + user string + token string +} + +func configFromFile(name string) (config, error) { + f, err := os.Open(name) + if err != nil { + f.Close() + return config{}, err + } + defer f.Close() + sc := bufio.NewScanner(f) + i := 0 + c := config{} + for sc.Scan() { + i++ + s := sc.Text() + // skip comments + if strings.HasPrefix(s, "#") { + continue + } + slice := strings.Split(s, " ") + if len(slice) > 2 { + return config{}, fmt.Errorf("%s:%d: too many values", f.Name(), i) + } + switch slice[0] { + case "user": + c.user = slice[1] + case "token": + c.token = slice[1] + default: + return config{}, fmt.Errorf("%s:%d: unknown key %s", f.Name(), i, slice[0]) + } + } + if c.user == "" { + return config{}, fmt.Errorf("no user") + } else if c.token == "" { + return config{}, fmt.Errorf("no token") + } + return c, nil +} diff --git a/cmd/pover/icinga2.conf b/cmd/pover/icinga2.conf new file mode 100644 index 0000000..1770264 --- /dev/null +++ b/cmd/pover/icinga2.conf @@ -0,0 +1,33 @@ +object NotificationCommand "pushover" { + command = [ ConfigDir + "/scripts/pushover-icinga2.sh" ] + arguments = { + "-f" = "$pushover_config$" + } + + env = { + HOSTADDRESS = "$address$" + HOSTDISPLAYNAME = "$host.display_name$" + LONGDATETIME = "$icinga.long_date_time$" + NOTIFICATIONAUTHORNAME = "$notification.author$" + NOTIFICATIONCOMMENT = "$notification.comment$" + NOTIFICATIONTYPE = "$notification.type$" + SERVICEDESC = "$service.name$" + SERVICEDISPLAYNAME = "$service.display_name$" + SERVICEOUTPUT = "$service.output$" + SERVICESTATE = "$service.state$" + } +} + +object User "otl" { + display_name = "Oliver Lowe" + groups = [ "icingaadmins" ] + email = "otl@example.com" + vars.pushover_config = "/path/to/credentials/file" +} + +apply Notification "olly-notification" to Service { + users = [ "otl" ] + command = "pushover" + /* Notify for every Service except for rdiff-backup services. */ + assign where !match("rdiff-backup*", service.name) +} diff --git a/cmd/pover/pover.1 b/cmd/pover/pover.1 new file mode 100644 index 0000000..ce39ebe --- /dev/null +++ b/cmd/pover/pover.1 @@ -0,0 +1,52 @@ +.TH POVER 1 +.SH NAME +pover \- push a notification to Pushover +.SH SYNOPSIS +.B pover +[ +.B -d +] +[ +.B -f +.I file +] +.SH DESCRIPTION +.I Pover +pushes a notification to Pushover using text read from standard input as the message body. +The +.B -d +flag enables debugging output. +The +.B -f +flag sets credentials to be read from +.IR file . +.PP +Credentials must be present in a credentials file. +A credentials file is a newline-delimited text file. +Lines beginning with "#" are treated as comments and ignored. +The recognised keys in the credentials file are: +.TP +.B user +Pushover account User key. +.TP +.B token +API token. +.SH EXAMPLES +An example credentials file +.EX + # for pushover application "shell" + user abcd12345 + token zxcvbnm98765 +.EE +.PP +Send a message "Hello world", +reading credentials from a non-default path +.EX + echo "Hello world" | pover -f /tmp/creds +.EE +.SH FILES +.B $HOME/.config/pover +.TP +default credentials file +.SH SOURCE +.B github.com/ollytom/pover diff --git a/cmd/pover/pover.go b/cmd/pover/pover.go new file mode 100644 index 0000000..d040cd8 --- /dev/null +++ b/cmd/pover/pover.go @@ -0,0 +1,64 @@ +package main + +import ( + "flag" + "fmt" + "os" + "io" + "io/ioutil" + "path/filepath" + + "git.sr.ht/~otl/pushover" +) + +const usage string = "usage: pover [-d] [-f file]" + +var debug *bool +var configflag *string + +func init() { + debug = flag.Bool("d", false, "debug") + configflag = flag.String("f", "", "path to configuration file") + flag.Parse() +} + +func main() { + var configpath string + configpath = *configflag + if *configflag == "" { + s, err := os.UserConfigDir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + configpath = filepath.Join(s, "pover") + } + config, err := configFromFile(configpath) + if err != nil { + fmt.Fprintf(os.Stderr, "load configuration: %v\n", err) + os.Exit(1) + } + + lr := io.LimitReader(os.Stdin, pushover.MaxMsgLength) + b, err := ioutil.ReadAll(lr) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + if len(b) == pushover.MaxMsgLength { + fmt.Fprintf(os.Stderr, "max message length (%d) reached\n", pushover.MaxMsgLength) + } + if *debug { + fmt.Fprint(os.Stderr, string(b)) + } + + msg := pushover.Message{ + User: config.user, + Token: config.token, + Message: string(b), + } + if err := pushover.Push(msg); err != nil { + fmt.Fprintf(os.Stderr, "push message: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/pover/pover.mdoc b/cmd/pover/pover.mdoc new file mode 100644 index 0000000..1b4a447 --- /dev/null +++ b/cmd/pover/pover.mdoc @@ -0,0 +1,75 @@ +.Dd $Mdocdate$ +.Dt pover 1 +.Os +.Sh NAME +.Nm pover +.Nd send a notification to Pushover +.Sh SYNOPSIS +.Nm +.Op Fl d +.Op Fl f Ar file +.Op Fl t Ar title +.Sh DESCRIPTION +.Nm +sends a notification to Pushover using text read from standard input as the message body. +.Pp +The options are: +.Bl -tag -width Ds +.It Fl d +Write debugging output to standard error. +.It Fl f Ar file +Sets configuration to be read from +.Ar file . +.It Fl t Ar title +Sets the message title to +.Ar title . +By default there is no title. +.El +.Pp +Credentials must be present in a configuration file. +A configuration file is a newline-delimited text file. +Lines beginning with +.Dq # +are treated as comments and ignored. +Configuration is a series of key-value pairs separated by whitespace, +one per line. +The recognised keys in the credentials file are: +.Bl -tag -width Ds +.It user +Pushover account user key. +.It token +API token. +.El +.Sh EXIT STATUS +.Ex +.Sh EXAMPLES +An example configuration file: +.Pp +.Bd -literal -offset indent -compact +# for pushover application "shell" +user abcd12345 +token zxcvbnm98765 +.Ed +.Pp +Send the current date as a notification: +.Pp +.Dl date | pover +.Pp +Send a hello world notification, reading configuration from +.Pa /etc/pover : +.Pp +.Dl echo 'hello world' | pover -f /etc/pover +.Sh FILES +The default configuration file location is as returned from Go's os.UserConfigDir(). +.Bl -tag -width Ds +.It Pa $HOME/.config/pover +On Unix. +.It Pa $HOME/Library/Application\ Support/pover +On Darwin. +.It Pa %AppData%\\\pover +On Windows. +.It Pa $home/lib/pover +On Plan 9. +.El +.Sh SEE ALSO +.Lk "Pushover Message API documentation" https://pushover.net/api diff --git a/cmd/pover/pushover-icinga2.sh b/cmd/pover/pushover-icinga2.sh new file mode 100644 index 0000000..368000d --- /dev/null +++ b/cmd/pover/pushover-icinga2.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +while getopt 'f:' flag +do + case $flag in + f) config=$OPTARG ;; + *) echo "unknown flag" $flag ;; + esac +done + +pover -f $config < $badconfig +if ! cmd/pover/pover -f $badconfig +then + echo "bad config ok" +else + echo "bad config failed" +fi +rm $badconfig diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..42431be --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.sr.ht/~otl/pushover + +go 1.16 diff --git a/pushover.go b/pushover.go new file mode 100644 index 0000000..41119f9 --- /dev/null +++ b/pushover.go @@ -0,0 +1,72 @@ +package pushover + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" +) + +const apiurl = "https://api.pushover.net/1/messages.json" +const MaxMsgLength = 1024 +const MaxTitleLength = 250 + +// Message represents a message in the Pushover Message API. +type Message struct { + User string + Token string + Title string + Message string + Priority int +} + +type response struct { + Status int + Request string + Errors errors +} + +type errors []string + +func (e errors) Error() string { + return strings.Join(e, ", ") +} + +func (m *Message) validate() error { + nchar := strings.Count(m.Message, "") + if nchar > MaxMsgLength { + return fmt.Errorf("%d character message too long, allowed %d characters", nchar, MaxMsgLength) + } + nchar = strings.Count(m.Title, "") + if nchar > MaxTitleLength { + return fmt.Errorf("%d-character title too long, allowed %d characters", nchar, MaxTitleLength) + } + return nil +} + +// Push sends the Message m to Pushover. +func Push(m Message) error { + if err := m.validate(); err != nil { + return err + } + req := url.Values{} + req.Add("token", m.Token) + req.Add("user", m.User) + req.Add("title", m.Title) + req.Add("message", m.Message) + resp, err := http.PostForm(apiurl, req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + + var presp response + if err := json.NewDecoder(resp.Body).Decode(&presp); err != nil { + return fmt.Errorf("decode error response: %v", err) + } + return presp.Errors +}