From 803fc57e267fc55e557f22545cf6b4fcb3fbbdee Mon Sep 17 00:00:00 2001 From: "C. McEnroe" Date: Tue, 14 Sep 2021 23:30:49 -0400 Subject: Add downgrade IRC bot --- bin/.gitignore | 1 + bin/Makefile | 2 + bin/downgrade.c | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++ bin/man1/downgrade.1 | 116 +++++++++++++++++ 4 files changed, 479 insertions(+) create mode 100644 bin/downgrade.c create mode 100644 bin/man1/downgrade.1 diff --git a/bin/.gitignore b/bin/.gitignore index 58203f7e..b49c2b39 100644 --- a/bin/.gitignore +++ b/bin/.gitignore @@ -7,6 +7,7 @@ bri c config.mk dehtml +downgrade dtch ever fbatt diff --git a/bin/Makefile b/bin/Makefile index 9e8d5281..c998d417 100644 --- a/bin/Makefile +++ b/bin/Makefile @@ -42,6 +42,7 @@ LINUX += fbatt LINUX += fbclock LINUX += psfed +TLS += downgrade TLS += relay TLS += typer @@ -51,6 +52,7 @@ MANS.GAMES = ${GAMES:%=man6/%.6} MANS.LINUX = ${LINUX:%=man1/%.1} MANS.TLS = ${TLS:%=man1/%.1} +LDLIBS.downgrade = -ltls LDLIBS.dtch = -lutil LDLIBS.fbclock = -lz LDLIBS.freecell = -lcurses diff --git a/bin/downgrade.c b/bin/downgrade.c new file mode 100644 index 00000000..1b41df13 --- /dev/null +++ b/bin/downgrade.c @@ -0,0 +1,360 @@ +/* Copyright (C) 2021 C. McEnroe + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { BufferCap = 8192 + 512 }; + +static bool verbose; +static struct tls *client; + +static void clientWrite(const char *ptr, size_t len) { + if (verbose) printf("%.*s", (int)len, ptr); + while (len) { + ssize_t ret = tls_write(client, ptr, len); + if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) continue; + if (ret < 0) errx(EX_IOERR, "tls_write: %s", tls_error(client)); + ptr += ret; + len -= ret; + } +} + +static void format(const char *format, ...) { + char buf[BufferCap]; + va_list ap; + va_start(ap, format); + int len = vsnprintf(buf, sizeof(buf), format, ap); + va_end(ap); + assert((size_t)len < sizeof(buf)); + clientWrite(buf, len); +} + +static bool invite; +static const char *join; + +enum { Cap = 256 }; +static struct Message { + char *id; + char *nick; + char *chan; + char *mesg; +} msgs[Cap]; +static size_t m; + +static void push(struct Message msg) { + struct Message *dst = &msgs[m++ % Cap]; + free(dst->id); + free(dst->nick); + free(dst->chan); + free(dst->mesg); + dst->id = strdup(msg.id); + dst->nick = strdup(msg.nick); + dst->chan = strdup(msg.chan); + if (!dst->id || !dst->nick || !dst->chan) err(EX_OSERR, "strdup"); + dst->mesg = NULL; + if (msg.mesg) { + dst->mesg = strdup(msg.mesg); + if (!dst->mesg) err(EX_OSERR, "strdup"); + } +} + +static struct Message *find(const char *id) { + for (size_t i = 0; i < Cap; ++i) { + if (!msgs[i].id) return NULL; + if (!strcmp(msgs[i].id, id)) return &msgs[i]; + } + return NULL; +} + +static void handle(char *ptr) { + char *tags = NULL; + char *origin = NULL; + if (ptr && *ptr == '@') tags = 1 + strsep(&ptr, " "); + if (ptr && *ptr == ':') origin = 1 + strsep(&ptr, " "); + char *cmd = strsep(&ptr, " "); + if (!cmd) return; + if (!strcmp(cmd, "CAP")) { + strsep(&ptr, " "); + char *sub = strsep(&ptr, " "); + if (!sub) errx(EX_PROTOCOL, "CAP without subcommand"); + if (!strcmp(sub, "NAK")) { + errx(EX_CONFIG, "server does not support %s", ptr); + } else if (!strcmp(sub, "ACK")) { + if (!ptr) errx(EX_PROTOCOL, "CAP ACK without caps"); + if (*ptr == ':') ptr++; + if (!strcmp(ptr, "sasl")) format("AUTHENTICATE EXTERNAL\r\n"); + } + } else if (!strcmp(cmd, "AUTHENTICATE")) { + format("AUTHENTICATE +\r\nCAP END\r\n"); + } else if (!strcmp(cmd, "433")) { + strsep(&ptr, " "); + char *nick = strsep(&ptr, " "); + if (!nick) errx(EX_PROTOCOL, "ERR_NICKNAMEINUSE missing nick"); + format("NICK %s_\r\n", nick); + } else if (!strcmp(cmd, "001")) { + if (join) format("JOIN %s\r\n", join); + } else if (!strcmp(cmd, "INVITE") && invite) { + strsep(&ptr, " "); + if (!ptr) errx(EX_PROTOCOL, "INVITE missing channel"); + if (*ptr == ':') ptr++; + format("JOIN %s\r\n", ptr); + } else if (!strcmp(cmd, "PING")) { + if (!ptr) errx(EX_PROTOCOL, "PING missing parameter"); + format("PONG %s\r\n", ptr); + } else if (!strcmp(cmd, "ERROR")) { + if (!ptr) errx(EX_PROTOCOL, "ERROR missing parameter"); + if (*ptr == ':') ptr++; + errx(EX_UNAVAILABLE, "%s", ptr); + } + + if ( + strcmp(cmd, "PRIVMSG") && + strcmp(cmd, "NOTICE") && + strcmp(cmd, "TAGMSG") + ) return; + if (!origin) errx(EX_PROTOCOL, "%s missing origin", cmd); + + struct Message msg = { + .nick = strsep(&origin, "!"), + .chan = strsep(&ptr, " "), + }; + if (!msg.chan) errx(EX_PROTOCOL, "%s missing target", cmd); + if (msg.chan[0] == ':') msg.chan++; + if (msg.chan[0] != '#') return; + if (strcmp(cmd, "TAGMSG")) msg.mesg = (*ptr == ':' ? &ptr[1] : ptr); + + if (msg.mesg) { + if (!strncmp(msg.mesg, "\1ACTION ", 8)) msg.mesg += 8; + size_t len = strlen(msg.mesg); + if (msg.mesg[len-1] == '\1') msg.mesg[len-1] = '\0'; + } + + char *reply = NULL; + char *react = NULL; + char *typing = NULL; + if (!tags) return; + while (tags) { + char *tag = strsep(&tags, ";"); + char *key = strsep(&tag, "="); + if (!strcmp(key, "msgid")) { + if (tag) msg.id = tag; + } else if (!strcmp(key, "+draft/reply")) { + if (tag) reply = tag; + } else if (!strcmp(key, "+draft/react")) { + if (!tag) continue; + for (char *ptr = tag; (ptr = strchr(ptr, '\\')); ptr += !!*ptr) { + switch (ptr[1]) { + break; case ':': ptr[1] = ';'; + break; case 's': ptr[1] = ' '; + //break; case 'r': ptr[1] = '\r'; + //break; case 'n': ptr[1] = '\n'; + } + memmove(ptr, &ptr[1], strlen(&ptr[1]) + 1); + } + react = tag; + } else if (!strcmp(key, "+typing") || !strcmp(key, "+draft/typing")) { + if (tag) typing = tag; + } + } + if (msg.id) push(msg); + + if (typing) { + if (!strcmp(typing, "active")) { + format("NOTICE %s :* %s is typing...\r\n", msg.chan, msg.nick); + } else if (!strcmp(typing, "paused")) { + format( + "NOTICE %s :* %s is thinking hard...\r\n", msg.chan, msg.nick + ); + } else if (!strcmp(typing, "done")) { + format("NOTICE %s :* %s has given up :(\r\n", msg.chan, msg.nick); + } else { + format( + "NOTICE %s :* %s is doing some wacky %s typing!\r\n", + msg.chan, msg.nick, typing + ); + } + } else if (react && reply) { + struct Message *to = find(reply); + if (to && strcmp(to->chan, msg.chan)) { + format( + "NOTICE %s :" + "* %s reacted to a message in another channel with \"%s\"\r\n", + msg.chan, msg.nick, react + ); + } else if (to && to->mesg) { + size_t len = 0; + for (size_t n; to->mesg[len]; len += n) { + n = 1 + strcspn(&to->mesg[len+1], " "); + if (len + n > 50) break; + } + format( + "NOTICE %s :" + "* %s reacted to %s's message (\"%.*s\"%s) with \"%s\"\r\n", + msg.chan, msg.nick, + to->nick, (int)len, to->mesg, (to->mesg[len] ? "..." : ""), + react + ); + } else if (to) { + format( + "NOTICE %s :* %s reacted to %s's reaction with \"%s\"\r\n", + msg.chan, msg.nick, to->nick, react + ); + } else { + format( + "NOTICE %s :* %s reacted to an unknown message with \"%s\"\r\n", + msg.chan, msg.nick, react + ); + } + } else if (react) { + format( + "NOTICE %s :* %s reacted to nothing with \"%s\"\r\n", + msg.chan, msg.nick, react + ); + } else if (reply) { + struct Message *to = find(reply); + if (to && strcmp(to->chan, msg.chan)) { + format( + "NOTICE %s :" + "* %s was replying to a message in another channel!\r\n", + msg.chan, msg.nick + ); + } else if (to && to->mesg) { + size_t len = 0; + for (size_t n; to->mesg[len]; len += n) { + n = 1 + strcspn(&to->mesg[len+1], " "); + if (len + n > 50) break; + } + format( + "NOTICE %s :" + "* %s was replying to %s's message (\"%.*s\"%s)\r\n", + msg.chan, msg.nick, + to->nick, (int)len, to->mesg, (to->mesg[len] ? "..." : "") + ); + } else if (to) { + format( + "NOTICE %s :* %s was replying to %s's reaction\r\n", + msg.chan, msg.nick, to->nick + ); + } else { + format( + "NOTICE %s :* %s was replying to an unknown message!\r\n", + msg.chan, msg.nick + ); + } + } +} + +static void quit(int sig) { + (void)sig; + format("QUIT\r\n"); + tls_close(client); + exit(EX_OK); +} + +int main(int argc, char *argv[]) { + const char *host = NULL; + const char *port = "6697"; + const char *nick = "downgrade"; + const char *cert = NULL; + const char *priv = NULL; + + for (int opt; 0 < (opt = getopt(argc, argv, "c:ij:k:n:p:v"));) { + switch (opt) { + break; case 'c': cert = optarg; + break; case 'i': invite = true; + break; case 'j': join = optarg; + break; case 'k': priv = optarg; + break; case 'n': nick = optarg; + break; case 'p': port = optarg; + break; case 'v': verbose = true; + break; default: return EX_USAGE; + } + } + if (optind == argc) errx(EX_USAGE, "host required"); + host = argv[optind]; + + client = tls_client(); + if (!client) errx(EX_SOFTWARE, "tls_client"); + + struct tls_config *config = tls_config_new(); + if (!config) errx(EX_SOFTWARE, "tls_config_new"); + + if (cert) { + if (!priv) priv = cert; + int error = tls_config_set_keypair_file(config, cert, priv); + if (error) errx(EX_NOINPUT, "%s: %s", cert, tls_config_error(config)); + } + + int error = tls_configure(client, config); + if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(client)); + + error = tls_connect(client, host, port); + if (error) errx(EX_UNAVAILABLE, "tls_connect: %s", tls_error(client)); + + do { + error = tls_handshake(client); + } while (error == TLS_WANT_POLLIN || error == TLS_WANT_POLLOUT); + if (error) errx(EX_PROTOCOL, "tls_handshake: %s", tls_error(client)); + tls_config_clear_keys(config); + + signal(SIGHUP, quit); + signal(SIGINT, quit); + signal(SIGTERM, quit); + format( + "CAP REQ :echo-message message-tags\r\n" + "NICK %s\r\n" + "USER %s 0 * :https://causal.agency/bin/downgrade.html\r\n", + nick, nick + ); + if (cert) { + format("CAP REQ sasl\r\n"); + } else { + format("CAP END\r\n"); + } + + size_t len = 0; + char buf[BufferCap]; + for (;;) { + ssize_t n = tls_read(client, &buf[len], sizeof(buf) - len); + if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT) continue; + if (n < 0) errx(EX_IOERR, "tls_read: %s", tls_error(client)); + if (!n) errx(EX_UNAVAILABLE, "disconnected"); + len += n; + + char *ptr = buf; + for ( + char *crlf; + (crlf = memmem(ptr, &buf[len] - ptr, "\r\n", 2)); + ptr = crlf + 2 + ) { + *crlf = '\0'; + if (verbose) printf("%s\n", ptr); + handle(ptr); + } + len -= ptr - buf; + memmove(buf, ptr, len); + } +} diff --git a/bin/man1/downgrade.1 b/bin/man1/downgrade.1 new file mode 100644 index 00000000..910b42c3 --- /dev/null +++ b/bin/man1/downgrade.1 @@ -0,0 +1,116 @@ +.Dd September 14, 2021 +.Dt DOWNGRADE 1 +.Os +. +.Sh NAME +.Nm downgrade +.Nd IRC features for all +. +.Sh SYNOPSIS +.Nm +.Op Fl iv +.Op Fl c Ar cert +.Op Fl j Ar join +.Op Fl k Ar priv +.Op Fl n Ar nick +.Op Fl p Ar port +.Ar host +. +.Sh DESCRIPTION +The +.Nm +IRC bot downgrades new IRC +.Dq features +so +.Em everyone +can see them. +It supports typing notifications, +message reactions +and message replies. +. +.Pp +The arguments are as follows: +.Bl -tag -width Ds +.It Fl c Ar cert +Load the TLS client certificate from +.Ar cert +and authenticate using SASL EXTERNAL. +.It Fl i +Accept invites to channels. +.It Fl j Ar join +Join the channel list +.Ar join . +.It Fl k Ar priv +Load the TLS client private key from +.Ar priv . +The default is the same path as +.Ar cert . +.It Fl n Ar nick +Set the nickname and username to +.Ar nick . +The default is +.Nm . +.It Fl p Ar port +Connect to +.Ar port . +The default is 6697. +.It Fl v +Log IRC protocol. +.It Ar host +Connect to +.Ar host . +.El +. +.Sh EXAMPLES +.Bd -literal +-downgrade- * guest-n4 is typing... + wtf +-downgrade- * june reacted to guest-n4's message ("wtf") with "\[u1F44D]" +-downgrade- * guest-n4 is typing... +-downgrade- * guest-n4 has given up :( +.Ed +.Bd -literal + ,bef +-downgrade- * tildebot is typing... + [Ducks] june: There was no duck! +-downgrade- * tildebot was replying to june's message (",bef") +.Ed +. +.Sh STANDARDS +.Bl -item +.It +.Rs +.%A Kiyoshi Aman +.%A Kyle Fuller +.%A St\('ephan Kochen +.%A Alexey Sokolov +.%A James Wheare +.%T Message Tags +.%U https://ircv3.net/specs/extensions/message-tags +.Re +.It +.Rs +.%A MuffinMedic +.%A James Wheare +.%T typing client tag +.%U https://ircv3.net/specs/client-tags/typing +.Re +.It +.Rs +.%A James Wheare +.%T Message IDs +.%U https://ircv3.net/specs/extensions/message-ids +.Re +.It +.Rs +.%A James Wheare +.%T react client tag +.%U https://ircv3.net/specs/client-tags/react +.Re +.It +.Rs +.%A James Wheare +.%T reply client tag +.%U https://ircv3.net/specs/client-tags/reply +.Re +.El -- cgit 1.4.1