From 53ba996ec9c5c8fc64f66934d8c98509bd7ed06d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=99emysl=20Eric=20Janouch?= Date: Tue, 2 Apr 2024 16:44:01 +0200 Subject: [PATCH] Add a simple IRC notifier utility --- LICENSE | 2 +- NEWS | 2 + README.adoc | 4 + xN/.gitignore | 3 + xN/Makefile | 19 ++++ xN/go.mod | 3 + xN/xN.adoc | 86 ++++++++++++++++ xN/xN.go | 279 ++++++++++++++++++++++++++++++++++++++++++++++++++ xN/xN_test.go | 98 ++++++++++++++++++ xS/irc.go | 132 ++++++++++++++++++++++++ xS/xS.go | 126 ----------------------- 11 files changed, 627 insertions(+), 127 deletions(-) create mode 100644 xN/.gitignore create mode 100644 xN/Makefile create mode 100644 xN/go.mod create mode 100644 xN/xN.adoc create mode 100644 xN/xN.go create mode 100644 xN/xN_test.go create mode 100644 xS/irc.go diff --git a/LICENSE b/LICENSE index 0610bbd..d58be36 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2014 - 2023, Přemysl Eric Janouch +Copyright (c) 2014 - 2024, Přemysl Eric Janouch Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. diff --git a/NEWS b/NEWS index 70f3849..7ee97ec 100644 --- a/NEWS +++ b/NEWS @@ -37,6 +37,8 @@ * Added a Go port of xD called xS + * Added a simple notifier called xN + 1.5.0 (2021-12-21) "The Show Must Go On" diff --git a/README.adoc b/README.adoc index 6784e84..ed915dc 100644 --- a/README.adoc +++ b/README.adoc @@ -56,6 +56,10 @@ https://github.com/kiwiirc/webircgateway[]. Any further development, such as P10 or TS6 linking for IRC services, or plugin support for arbitrary bridges, will happen here. +xN +-- +The IRC notifier, should you ever need to send automated messages from a script. + xB -- The IRC bot. While originally intended to be a simple rewrite of my old GNU AWK diff --git a/xN/.gitignore b/xN/.gitignore new file mode 100644 index 0000000..86ad66d --- /dev/null +++ b/xN/.gitignore @@ -0,0 +1,3 @@ +/xN +/xN.1 +/irc.go diff --git a/xN/Makefile b/xN/Makefile new file mode 100644 index 0000000..5340b55 --- /dev/null +++ b/xN/Makefile @@ -0,0 +1,19 @@ +.POSIX: +.SUFFIXES: +AWK = env LC_ALL=C awk + +outputs = irc.go xN xN.1 +all: $(outputs) + +# If we want to keep module dependencies separate, we don't have many options. +# Symlinking seems to work good enough. +irc.go: ../xS/irc.go + ln -sf ../xS/irc.go $@ + +xN: xN.go ../xK-version irc.go + go build -ldflags "-X 'main.projectVersion=$$(cat ../xK-version)'" -o $@ +xN.1: ../xK-version ../liberty/tools/asciiman.awk xN.adoc + env "asciidoc-release-version=$$(cat ../xK-version)" \ + $(AWK) -f ../liberty/tools/asciiman.awk xN.adoc > $@ +clean: + rm -f $(outputs) diff --git a/xN/go.mod b/xN/go.mod new file mode 100644 index 0000000..50ff293 --- /dev/null +++ b/xN/go.mod @@ -0,0 +1,3 @@ +module janouch.name/xK/xN + +go 1.19 diff --git a/xN/xN.adoc b/xN/xN.adoc new file mode 100644 index 0000000..1f5404f --- /dev/null +++ b/xN/xN.adoc @@ -0,0 +1,86 @@ +xN(1) +===== +:doctype: manpage +:manmanual: xK Manual +:mansource: xK {release-version} + +Name +---- +xN - IRC notifier + +Synopsis +-------- +*xN* [_OPTION_]... IRC-URL... + +Description +----------- +*xN* is a simple IRC notifier, sending the text it receives on its standard +input to all IRC targets specified by its command line arguments. + +The input text is forced to validate as UTF-8, and it is _not_ split +automatically to comply with the maximum IRC message length. +Thus, make sure to make the lines short, or they will be trimmed by the servers. + +*xN* does not attempt to appease flood detectors. + +Options +------- +*-debug*:: + Print incoming IRC traffic, which may help in debugging any issues. + +*-version*:: + Output version information and exit. + +URL format +---------- +*xN* accepts URLs describing IRC users and channels, roughly as specified by +the Internet Draft _draft-butcher-irc-url-04.txt_. Note, however, that close +to no validation is done on these, and you should not pass URLs from untrusted +sources, so as to avoid command or parameter injection. + +Any provided username will be propagated to the nickname, username, +and realname. The default value for these is the name of your system user. + +As an extension, you may use the following options: + +*skipjoin*:: + Do not JOIN channels before sending messages to them. + This requires channels to allow external messages + (which are disabled by channel mode *+n*). +*usenotice*:: + Send a NOTICE rather than a PRIVMSG, which distinguishes automated messages, + and is more appropriate for bots. + +Examples +-------- + $ uptime | xN 'irc://uptime@localhost/%23watch?skipjoin&usenotice' + +Send *uptime*(1) information as an external notice to channel *#watch* +on the local server, using the standard port *6667*. + + $ fortune -s | xN ircs://ohayou@irc.libera.chat/john,isuser + +Greet user *john* with a fortune for this day. In compliance with _RFC 7194_, +the default TLS port is assumed to be *6697*. + + $ xN 'ircs://agent:Password123@irc.cia.gov:1337/#hq?key=123456' < +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted. +// +// 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. +// + +// xN is a simple IRC notifier. +package main + +import ( + "bufio" + "bytes" + "crypto/tls" + "errors" + "flag" + "fmt" + "io" + "log" + "net" + "net/url" + "os" + "os/user" + "strconv" + "strings" +) + +const projectName = "xN" + +var projectVersion = "?" + +var debugMode = false + +type parameters struct { + conn net.Conn // underlying network connection + username string // nickname + username + realname + password string // server password + target string // where to send text + isuser bool // the target is a user rather than a channel + chankey string // channel key + skipjoin bool // whether to send external messages to channels + usenotice bool // whether to use NOTICE rather than PRIVMSG + message []string // lines of the message to send +} + +func (p *parameters) send(command string, args ...string) error { + var buf bytes.Buffer + buf.WriteString(command) + for i, arg := range args { + buf.WriteRune(' ') + if i+1 == len(args) { + buf.WriteRune(':') + } + buf.WriteString(arg) + } + buf.WriteString("\r\n") + _, err := p.conn.Write(buf.Bytes()) + return err +} + +const ( + rplWELCOME = "001" + errNICKNAMEINUSE = "433" +) + +func notify(p *parameters) error { + // The intro should comfortably fit in the TCP send buffer whole. + if p.password != "" { + p.send("PASS", p.password) + } + p.send("USER", p.username, "0", "*", p.username) + p.send("NICK", p.username) + + scanner, nickCounter, issue := bufio.NewScanner(p.conn), 1, "" + for scanner.Scan() { + if debugMode { + log.Println(scanner.Text()) + } + + m := ircParseMessage(scanner.Text()) + switch m.command { + case "PING": + p.send("PONG", m.params...) + case rplWELCOME: + if !p.isuser && !p.skipjoin { + if p.chankey != "" { + p.send("JOIN", p.target, p.chankey) + } else { + p.send("JOIN", p.target) + } + } + for _, line := range p.message { + if p.usenotice { + p.send("NOTICE", p.target, line) + } else { + p.send("PRIVMSG", p.target, line) + } + } + p.send("QUIT") + case errNICKNAMEINUSE: + p.send("NICK", fmt.Sprintf("%s%d", p.username, nickCounter)) + nickCounter++ + default: + // Prevent hanging on unsuccessful registrations. + numeric, _ := strconv.Atoi(m.command) + if numeric >= 400 && numeric <= 599 { + if len(m.params) > 1 { + issue = strings.Join(m.params[1:], " ") + } else { + issue = strings.Join(m.params, " ") + } + p.send("QUIT") + } + } + } + if err := scanner.Err(); err != nil { + return err + } + if issue != "" { + return errors.New(issue) + } + return nil +} + +func parse(rawURL string, text []byte) ( + p parameters, connect func() (net.Conn, error), err error) { + u, err := url.Parse(rawURL) + if err != nil { + return p, nil, err + } else if !u.IsAbs() || u.Opaque != "" { + return p, nil, errors.New("need an absolute URL") + } else if u.Path == "/" && u.Fragment != "" { + // Try to handle the common but degenerate case. + fragment := "%23" + u.Fragment + u.Fragment, u.RawFragment = "", "" + if u, err = url.Parse(u.String() + fragment); err != nil { + return p, nil, err + } + } + + // Figure out registration details. + p.username = projectName + if u, _ := user.Current(); u != nil { + p.username = u.Username + } + if u.User.Username() != "" { + p.username = u.User.Username() + } + + p.password, _ = u.User.Password() + + // Figure out the target, which for our intents must accept messages. + path, _ := strings.CutPrefix(u.Path, "/") + elements := strings.Split(path, ",") + if path == "" || elements[0] == "" { + return p, nil, errors.New("unspecified entity") + } + + // The last entity type wins. + p.target, p.isuser = elements[0], false + for _, typ := range elements[1:] { + switch typ { + case "isuser": + p.isuser = true + case "ischannel": + p.isuser = false + case "isserver": + // We do not support network names, and this is the default. + default: + return p, nil, errors.New("unsupported type: " + typ) + } + } + if p.isuser { + if i := strings.IndexAny(p.target, "!@"); i != -1 { + p.target = p.target[:i] + } + } else if !strings.HasPrefix(p.target, "#") { + // TODO(p): We should consult RPL_ISUPPORT rather than guess, + // though other prefixes are rare. + p.target = "#" + p.target + } + + // Note that the draft RFC wants these to be case-insensitive. + p.chankey = u.Query().Get("key") + // Being able to skip channel join is our own requirement and invention, + // as are notices (names taken from Travis CI configuration). + p.skipjoin = u.Query().Has("skipjoin") + p.usenotice = u.Query().Has("usenotice") + + // Ensure valid LF-separated UTF-8, and split it at lines. + sanitized := strings.ReplaceAll(string([]rune(string(text))), "\r", "\n") + for _, line := range strings.Split(sanitized, "\n") { + if line != "" { + p.message = append(p.message, line) + } + } + + hostname, port := u.Hostname(), u.Port() + switch u.Scheme { + case "irc": + if port == "" { + port = "6667" + } + connect = func() (net.Conn, error) { + return net.Dial("tcp", net.JoinHostPort(hostname, port)) + } + case "ircs": + if port == "" { + port = "6697" + } + connect = func() (net.Conn, error) { + return tls.Dial("tcp", net.JoinHostPort(hostname, port), nil) + } + default: + err = errors.New("unsupported scheme: " + u.Scheme) + } + return +} + +// notifyByURL sends the given text to the IRC server specified by a URL. +// See draft-butcher-irc-url-04.txt for the URL scheme specification +// this function loosely follows. +func notifyByURL(rawURL string, text []byte) error { + p, connect, err := parse(rawURL, text) + if p.conn, err = connect(); err != nil { + return err + } + defer p.conn.Close() + return notify(&p) +} + +func main() { + flag.BoolVar(&debugMode, "debug", false, "run in verbose debug mode") + version := flag.Bool("version", false, "show version and exit") + + flag.Usage = func() { + f := flag.CommandLine.Output() + fmt.Fprintf(f, "Usage: %s URL...\n", os.Args[0]) + flag.PrintDefaults() + } + flag.Parse() + if flag.NArg() < 1 { + flag.Usage() + os.Exit(2) + } + + if *version { + fmt.Printf("%s %s\n", projectName, projectVersion) + return + } + + text, err := io.ReadAll(os.Stdin) + if err != nil { + log.Fatalln(err) + } + + status := 0 + for _, rawURL := range flag.Args() { + if err := notifyByURL(rawURL, text); err != nil { + status = 1 + + var ue *url.Error + if errors.As(err, &ue) { + log.Println(err) + } else { + log.Printf("notify %q: %s\n", rawURL, err) + } + } + } + os.Exit(status) +} diff --git a/xN/xN_test.go b/xN/xN_test.go new file mode 100644 index 0000000..4755b39 --- /dev/null +++ b/xN/xN_test.go @@ -0,0 +1,98 @@ +// +// Copyright (c) 2024, Přemysl Eric Janouch +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted. +// +// 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. +// + +package main + +import "testing" + +func TestParseURL(t *testing.T) { + for _, rawURL := range []string{ + `irc:server/channel`, + `irc://server/channel,isnetwork`, + `ircs://ssl.ircnet.io`, + `ircs://ssl.ircnet.io/`, + `http://server/path`, + } { + if _, _, err := parse(rawURL, nil); err == nil { + t.Errorf("%q should not parse\n", rawURL) + } + } + + for _, test := range []struct { + rawURL string + p parameters + }{ + { + rawURL: `irc://uptime@localhost/%23watch?skipjoin&usenotice`, + p: parameters{ + username: "uptime", + target: "#watch", + skipjoin: true, + usenotice: true, + }, + }, + { + rawURL: `ircs://ohayou@irc.libera.chat/john,isuser,isserver`, + p: parameters{ + username: "ohayou", + target: "john", + isuser: true, + }, + }, + { + rawURL: `ircs://agent:Password123@irc.cia.gov:1337/#hq?key=123456`, + p: parameters{ + username: "agent", + password: "Password123", + target: "#hq", + chankey: "123456", + }, + }, + } { + p, _, err := parse(test.rawURL, nil) + if err != nil { + t.Errorf("%q should parse, got: %s\n", test.rawURL, err) + continue + } + if p.username != test.p.username { + t.Errorf("%q: username: %v ≠ %v\n", + test.rawURL, p.username, test.p.username) + } + if p.password != test.p.password { + t.Errorf("%q: password: %v ≠ %v\n", + test.rawURL, p.password, test.p.password) + } + if p.target != test.p.target { + t.Errorf("%q: target: %v ≠ %v\n", + test.rawURL, p.target, test.p.target) + } + if p.isuser != test.p.isuser { + t.Errorf("%q: isuser: %v ≠ %v\n", + test.rawURL, p.isuser, test.p.isuser) + } + if p.chankey != test.p.chankey { + t.Errorf("%q: chankey: %v ≠ %v\n", + test.rawURL, p.chankey, test.p.chankey) + } + if p.skipjoin != test.p.skipjoin { + t.Errorf("%q: skipjoin: %v ≠ %v\n", + test.rawURL, p.skipjoin, test.p.skipjoin) + } + if p.usenotice != test.p.usenotice { + t.Errorf("%q: usenotice: %v ≠ %v\n", + test.rawURL, p.usenotice, test.p.usenotice) + } + } +} diff --git a/xS/irc.go b/xS/irc.go new file mode 100644 index 0000000..71bf961 --- /dev/null +++ b/xS/irc.go @@ -0,0 +1,132 @@ +package main + +import ( + "path/filepath" + "regexp" + "strings" +) + +func ircToLower(c byte) byte { + switch c { + case '[': + return '{' + case ']': + return '}' + case '\\': + return '|' + case '~': + return '^' + } + if c >= 'A' && c <= 'Z' { + return c + ('a' - 'A') + } + return c +} + +func ircToUpper(c byte) byte { + switch c { + case '{': + return '[' + case '}': + return ']' + case '|': + return '\\' + case '^': + return '~' + } + if c >= 'a' && c <= 'z' { + return c - ('a' - 'A') + } + return c +} + +// Convert identifier to a canonical form for case-insensitive comparisons. +// ircToUpper is used so that statically initialized maps can be in uppercase. +func ircToCanon(ident string) string { + var canon []byte + for _, c := range []byte(ident) { + canon = append(canon, ircToUpper(c)) + } + return string(canon) +} + +func ircEqual(s1, s2 string) bool { + return ircToCanon(s1) == ircToCanon(s2) +} + +func ircFnmatch(pattern string, s string) bool { + pattern, s = ircToCanon(pattern), ircToCanon(s) + // FIXME: This should not support [] ranges and handle '/' specially. + // We could translate the pattern to a regular expression. + matched, _ := filepath.Match(pattern, s) + return matched +} + +var reMsg = regexp.MustCompile( + `^(?:@([^ ]*) +)?(?::([^! ]*)(?:!([^@]*)@([^ ]*))? +)?([^ ]+)(.*)?$`) +var reArgs = regexp.MustCompile(`:.*| [^: ][^ ]*`) + +type message struct { + tags map[string]string // IRC 3.2 message tags + nick string // optional nickname + user string // optional username + host string // optional hostname or IP address + command string // command name + params []string // arguments +} + +func ircUnescapeMessageTag(value string) string { + var buf []byte + escape := false + for i := 0; i < len(value); i++ { + if escape { + switch value[i] { + case ':': + buf = append(buf, ';') + case 's': + buf = append(buf, ' ') + case 'r': + buf = append(buf, '\r') + case 'n': + buf = append(buf, '\n') + default: + buf = append(buf, value[i]) + } + escape = false + } else if value[i] == '\\' { + escape = true + } else { + buf = append(buf, value[i]) + } + } + return string(buf) +} + +func ircParseMessageTags(tags string, out map[string]string) { + for _, tag := range strings.Split(tags, ";") { + if tag == "" { + // Ignore empty. + } else if equal := strings.IndexByte(tag, '='); equal < 0 { + out[tag] = "" + } else { + out[tag[:equal]] = ircUnescapeMessageTag(tag[equal+1:]) + } + } +} + +func ircParseMessage(line string) *message { + m := reMsg.FindStringSubmatch(line) + if m == nil { + return nil + } + + msg := message{nil, m[2], m[3], m[4], m[5], nil} + if m[1] != "" { + msg.tags = make(map[string]string) + ircParseMessageTags(m[1], msg.tags) + } + for _, x := range reArgs.FindAllString(m[6], -1) { + msg.params = append(msg.params, x[1:]) + } + return &msg +} diff --git a/xS/xS.go b/xS/xS.go index 25db005..69a9b36 100644 --- a/xS/xS.go +++ b/xS/xS.go @@ -456,132 +456,6 @@ func (fd *floodDetector) check() bool { return count <= fd.limit } -// --- IRC protocol ------------------------------------------------------------ - -func ircToLower(c byte) byte { - switch c { - case '[': - return '{' - case ']': - return '}' - case '\\': - return '|' - case '~': - return '^' - } - if c >= 'A' && c <= 'Z' { - return c + ('a' - 'A') - } - return c -} - -func ircToUpper(c byte) byte { - switch c { - case '{': - return '[' - case '}': - return ']' - case '|': - return '\\' - case '^': - return '~' - } - if c >= 'a' && c <= 'z' { - return c - ('a' - 'A') - } - return c -} - -// Convert identifier to a canonical form for case-insensitive comparisons. -// ircToUpper is used so that statically initialized maps can be in uppercase. -func ircToCanon(ident string) string { - var canon []byte - for _, c := range []byte(ident) { - canon = append(canon, ircToUpper(c)) - } - return string(canon) -} - -func ircEqual(s1, s2 string) bool { - return ircToCanon(s1) == ircToCanon(s2) -} - -func ircFnmatch(pattern string, s string) bool { - pattern, s = ircToCanon(pattern), ircToCanon(s) - // FIXME: This should not support [] ranges and handle '/' specially. - // We could translate the pattern to a regular expression. - matched, _ := filepath.Match(pattern, s) - return matched -} - -var reMsg = regexp.MustCompile( - `^(?:@([^ ]*) +)?(?::([^! ]*)(?:!([^@]*)@([^ ]*))? +)?([^ ]+)(.*)?$`) -var reArgs = regexp.MustCompile(`:.*| [^: ][^ ]*`) - -type message struct { - tags map[string]string // IRC 3.2 message tags - nick string // optional nickname - user string // optional username - host string // optional hostname or IP address - command string // command name - params []string // arguments -} - -func ircUnescapeMessageTag(value string) string { - var buf []byte - escape := false - for i := 0; i < len(value); i++ { - if escape { - switch value[i] { - case ':': - buf = append(buf, ';') - case 's': - buf = append(buf, ' ') - case 'r': - buf = append(buf, '\r') - case 'n': - buf = append(buf, '\n') - default: - buf = append(buf, value[i]) - } - escape = false - } else if value[i] == '\\' { - escape = true - } else { - buf = append(buf, value[i]) - } - } - return string(buf) -} - -func ircParseMessageTags(tags string, out map[string]string) { - for _, tag := range splitString(tags, ";", true /* ignoreEmpty */) { - if equal := strings.IndexByte(tag, '='); equal < 0 { - out[tag] = "" - } else { - out[tag[:equal]] = ircUnescapeMessageTag(tag[equal+1:]) - } - } -} - -func ircParseMessage(line string) *message { - m := reMsg.FindStringSubmatch(line) - if m == nil { - return nil - } - - msg := message{nil, m[2], m[3], m[4], m[5], nil} - if m[1] != "" { - msg.tags = make(map[string]string) - ircParseMessageTags(m[1], msg.tags) - } - for _, x := range reArgs.FindAllString(m[6], -1) { - msg.params = append(msg.params, x[1:]) - } - return &msg - -} - // --- IRC token validation ---------------------------------------------------- // Everything as per RFC 2812