about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore10
-rw-r--r--Makefile89
-rw-r--r--QUIRKS.766
-rw-r--r--README.7156
-rw-r--r--bounce.c592
-rw-r--r--bounce.h123
-rw-r--r--calico.197
-rw-r--r--client.c531
-rw-r--r--compat.h37
-rw-r--r--config.c36
-rwxr-xr-xconfigure85
-rw-r--r--dispatch.c207
-rw-r--r--local.c160
-rw-r--r--notify.c353
-rw-r--r--palaver.c796
-rw-r--r--pounce-notify.1115
-rw-r--r--pounce-palaver.1112
-rw-r--r--pounce.1785
-rw-r--r--rc.d/calico30
-rw-r--r--rc.d/pounce63
-rw-r--r--ring.c39
-rw-r--r--server.c124
-rw-r--r--state.c149
-rw-r--r--xdg.c131
24 files changed, 3537 insertions, 1349 deletions
diff --git a/.gitignore b/.gitignore
index c7ce8fe..5249f84 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,10 @@
 *.conf
 *.o
-/calico
-/pounce
+calico
 config.mk
-localhost.crt
-localhost.key
+localhost.pem
+pounce
+pounce-edit
+pounce-notify
+pounce-palaver
 tags
diff --git a/Makefile b/Makefile
index 597534e..c7c1ef7 100644
--- a/Makefile
+++ b/Makefile
@@ -1,61 +1,70 @@
-PREFIX = /usr/local
-MANDIR = ${PREFIX}/share/man
-ETCDIR = ${PREFIX}/etc
+PREFIX ?= /usr/local
+BINDIR ?= ${PREFIX}/bin
+MANDIR ?= ${PREFIX}/man
 
 CFLAGS += -std=c11 -Wall -Wextra -Wpedantic
-LDLIBS = -lcrypt -lcrypto -ltls
+LDADD.crypt = -lcrypt
+LDADD.libcurl = -lcurl
+LDADD.libtls = -ltls
+LDADD.sqlite3 = -lsqlite3
 
 BINS = calico pounce
 MANS = ${BINS:=.1}
-RCS  = ${BINS:%=rc.d/%}
-DIRS = ${ETCDIR}/pounce /var/run/calico
 
 -include config.mk
 
-OBJS += bounce.o
-OBJS += client.o
-OBJS += config.o
-OBJS += local.o
-OBJS += ring.o
-OBJS += server.o
-OBJS += state.o
+LDLIBS.calico =
+LDLIBS.pounce = ${LDADD.crypt} ${LDADD.libtls}
+LDLIBS.pounce-notify = ${LDADD.libtls}
+LDLIBS.pounce-palaver = ${LDADD.libcurl} ${LDADD.libtls} ${LDADD.sqlite3}
+
+OBJS.calico += dispatch.o
+
+OBJS.pounce += bounce.o
+OBJS.pounce += client.o
+OBJS.pounce += config.o
+OBJS.pounce += local.o
+OBJS.pounce += ring.o
+OBJS.pounce += server.o
+OBJS.pounce += state.o
+OBJS.pounce += xdg.o
+
+OBJS.pounce-notify = notify.o
+OBJS.pounce-palaver = palaver.o xdg.o
+
+OBJS += ${OBJS.calico}
+OBJS += ${OBJS.pounce}
+OBJS += ${OBJS.pounce-notify}
+OBJS += ${OBJS.pounce-palaver}
 
 dev: tags all
 
 all: ${BINS}
 
-calico: dispatch.o
-	${CC} ${LDFLAGS} dispatch.o ${LDLIBS_calico} -o $@
-
-pounce: ${OBJS}
-	${CC} ${LDFLAGS} ${OBJS} ${LDLIBS} -o $@
+calico: ${OBJS.calico}
+pounce: ${OBJS.pounce}
+pounce-notify: ${OBJS.pounce-notify}
+pounce-palaver: ${OBJS.pounce-palaver}
 
-${OBJS}: bounce.h compat.h
+${BINS}:
+	${CC} ${LDFLAGS} ${OBJS.$@} ${LDLIBS.$@} -o $@
 
-dispatch.o: compat.h
+${OBJS.pounce}: bounce.h
 
-tags: *.c *.h
-	ctags -w *.c *.h
+tags: *.[ch]
+	ctags -w *.[ch]
 
 clean:
-	rm -f tags ${BINS} ${OBJS} dispatch.o
+	rm -f ${BINS} ${OBJS} tags
 
-install: ${BINS} ${MANS} ${RCS}
-	install -d ${PREFIX}/bin ${MANDIR}/man1
-	install ${BINS} ${PREFIX}/bin
-	install -m 644 ${MANS} ${MANDIR}/man1
-	if [ -n '${RCS}' ]; then install -d ${ETCDIR}/rc.d; fi
-	if [ -n '${RCS}' ]; then install ${RCS} ${ETCDIR}/rc.d; fi
-	if [ -n '${DIRS}' ]; then install -d ${DIRS}; fi
+install: ${BINS} ${MANS}
+	install -d ${DESTDIR}${BINDIR} ${DESTDIR}${MANDIR}/man1
+	install ${BINS} ${DESTDIR}${BINDIR}
+	install -m 644 ${MANS} ${DESTDIR}${MANDIR}/man1
 
 uninstall:
-	rm -f ${BINS:%=${PREFIX}/bin/%}
-	rm -f ${MANS:%=${MANDIR}/man1/%}
-	if [ -n '${RCS}' ]; then rm -f ${RCS:%=${ETCDIR}/%}; fi
-	if [ -n '${DIRS}' ]; then rmdir ${DIRS}; fi
-
-localhost.crt:
-	printf "[dn]\nCN=localhost\n[req]\ndistinguished_name=dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth" \
-		| openssl req -x509 -out localhost.crt -keyout localhost.key \
-		-newkey rsa:2048 -nodes -sha256 \
-		-subj '/CN=localhost' -extensions EXT -config /dev/fd/0
+	rm -f ${BINS:%=${DESTDIR}${BINDIR}/%}
+	rm -f ${MANS:%=${DESTDIR}${MANDIR}/man1/%}
+
+localhost.pem: pounce
+	./pounce -g $@
diff --git a/QUIRKS.7 b/QUIRKS.7
new file mode 100644
index 0000000..f6e3aca
--- /dev/null
+++ b/QUIRKS.7
@@ -0,0 +1,66 @@
+.Dd October 15, 2021
+.Dt QUIRKS 7
+.Os "Causal Agency"
+.
+.Sh NAME
+.Nm Quirks
+.Nd IRC networks and clients
+.
+.Sh DESCRIPTION
+This file documents the quirks
+of connecting
+.Xr pounce 1
+with particular IRC networks and clients.
+.
+.Ss Networks
+.Bl -tag -width Ds
+.It EFnet
+EFnet uses port 9999 for TLS
+and uses self-signed certificates.
+.
+.It EsperNet
+EsperNet supports SASL,
+but not SASL EXTERNAL.
+Either the
+.Cm sasl-plain
+option can be used,
+or the
+.Cm client-cert
+option without the
+.Cm sasl-external
+option.
+.
+.It Libera.Chat
+Libera.Chat requires SASL authentication
+for some IP address ranges.
+They recommend
+.Lk https://libera.chat/guides/sasl#sasl-access-only-ip-ranges "using their webchat to register" .
+It is possible to enable
+.Sy userhost-in-names
+with
+.Cm blind-req .
+.
+.It OFTC
+OFTC does not support SASL,
+but does support CertFP.
+The
+.Cm client-cert
+option can be used without the
+.Cm sasl-external
+option.
+.El
+.
+.Ss Clients
+.Bl -tag -width Ds
+.It Palaver (iOS)
+Palaver sets its username
+to the same as its nickname.
+The default nick of
+.Dq Palaver
+therefore works well.
+.
+.It Revolution (Android)
+Revolution won't connect properly
+if the nick it is configured with
+is not the one pounce is currently using.
+.El
diff --git a/README.7 b/README.7
index 225a7a7..b15a8e2 100644
--- a/README.7
+++ b/README.7
@@ -1,4 +1,4 @@
-.Dd March  1, 2020
+.Dd October 21, 2023
 .Dt README 7
 .Os "Causal Agency"
 .
@@ -9,11 +9,38 @@
 .Sh DESCRIPTION
 .Xr pounce 1
 is a multi-client, TLS-only IRC bouncer.
-It takes a simple approach,
-using a multiple-consumer ring buffer
-and the IRCv3.2
+It maintains a persistent connection
+to an IRC server,
+acting as a proxy and buffer
+for a number of clients.
+When a client connects,
+any messages received
+since it last disconnected
+will be relayed to it.
+Unlike some other bouncers,
+.Nm
+uses a single buffer
+for all IRC messages,
+which acts as a queue
+from which each client
+reads messages independently.
+.
+.Pp
+.Nm
+speaks regular modern IRC
+to both servers and clients,
+using the
 .Sy server-time
-extension to communicate with clients.
+extension to indicate
+when messages originally occurred.
+Clients identify themselves to
+.Nm
+by their IRC usernames.
+See
+.Xr QUIRKS 7
+for notes on connecting
+.Nm
+with particular networks and clients.
 .
 .Sh RATIONALE
 As a former
@@ -42,35 +69,95 @@ rather than being limited to messages.
 .
 .Sh INSTALLING
 .Nm
-requires LibreSSL
-.Pq Fl ltls
-and primarily targets
-.Fx ,
-as well as macOS and Linux.
-On
-.Fx ,
-processes are sandboxed with
-.Xr capsicum 4 .
-On other systems,
-who knows what might happen?
+requires
+.Sy libtls ,
+provided by either
+.Lk https://git.causal.agency/libretls/about LibreTLS
+(for OpenSSL)
+or by LibreSSL.
+.Nm
+and
+.Sy libtls
+may be packaged for your system.
+Check the Repology pages for
+.Lk https://repology.org/project/pounce/versions pounce
+and
+.Lk https://repology.org/project/libretls/versions libretls .
 .
+.Pp
+.Nm
+primarily targets
+.Ox ,
+where it is sandboxed with
+.Xr pledge 2
+and
+.Xr unveil 2 .
+.Fx ,
+Linux and macOS
+are also supported.
 .Bd -literal -offset indent
 \&./configure
 make all
-sudo make install PREFIX=/usr/local
+sudo make install
 .Ed
 .
 .Pp
-If your system installs LibreSSL
-in a non-standard path, set
+If installing
+.Sy libtls
+manually to
+.Pa /usr/local ,
+for example,
+make sure
+.Pa /usr/local/lib
+appears in
+.Pa /etc/ld.so.conf
+or
+.Pa /etc/ld.so.conf.d/*
+and be sure to run
+.Xr ldconfig 8
+once the library is installed.
+Set
 .Ev PKG_CONFIG_PATH
 for
-.Nm ./configure .
-For example,
+.Nm ./configure
+to find it.
 .Bd -literal -offset indent
-PKG_CONFIG_PATH=/opt/libressl/lib/pkgconfig ./configure
+PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure
 .Ed
 .
+.Pp
+On
+.Ox
+the recommended way to run
+.Nm
+is with the process supervisor
+.Lk https://git.causal.agency/kitd kitd .
+.
+.Ss Additional Components
+Additional functionality can be provided
+by special-purpose clients connected to
+.Nm .
+.Bl -inset
+.It Lk https://git.causal.agency/litterbox litterbox
+provides logging and search.
+.It Xr pounce-notify 1
+provides notifications
+by running an external command.
+Configure with
+.Fl \-enable-notify
+to build.
+.It Xr pounce-palaver 1
+provides push notifications
+for the Palaver IRC app.
+Configure with
+.Fl \-enable-palaver
+to build.
+Requires
+.Sy libcurl
+and
+.Sy libsqlite3 .
+.El
+.
 .Sh FILES
 .Bl -tag -width "dispatch.c" -compact
 .It Pa bounce.h
@@ -90,23 +177,25 @@ buffer between server and clients
 .It Pa config.c
 .Xr getopt_long 3 Ns -integrated
 configuration parsing
+.It Pa xdg.c
+XDG base directories
 .It Pa dispatch.c
 SNI socket dispatch
-.It Pa compat.h
-compatibility with lesser operating systems
-.It Pa rc.d/
-.Fx
-.Xr rc 8
-scripts
 .El
 .
 .Sh CONTRIBUTING
 The upstream URL of this project is
 .Aq Lk https://git.causal.agency/pounce .
-I'm happy to receive contributions in any form at
-.Aq Mt june@causal.agency .
+Contributions in any form can be sent to
+.Aq Mt list+pounce@causal.agency .
 For sending patches by email, see
 .Aq Lk https://git-send-email.io .
+Mailing list archives are available at
+.Aq Lk https://causal.agency/list/pounce.html .
+.
+.Pp
+Monetary contributions can be
+.Lk https://liberapay.com/june/donate "donated via Liberapay" .
 .
 .Sh SEE ALSO
 .Xr calico 1 ,
@@ -115,3 +204,10 @@ For sending patches by email, see
 .Pp
 Central logging with full-text search:
 .Lk https://git.causal.agency/litterbox "litterbox"
+.
+.Rs
+.%A June McEnroe
+.%T IRC Suite
+.%U https://text.causal.agency/010-irc-suite.txt
+.%D June 19, 2020
+.Re
diff --git a/bounce.c b/bounce.c
index 1aae7a3..9ab0f1d 100644
--- a/bounce.c
+++ b/bounce.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -12,9 +12,19 @@
  *
  * You should have received a copy of the GNU General Public License
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
  */
 
-#include <assert.h>
 #include <err.h>
 #include <errno.h>
 #include <fcntl.h>
@@ -31,84 +41,54 @@
 #include <sys/file.h>
 #include <sys/socket.h>
 #include <sys/stat.h>
+#include <sys/time.h>
 #include <sysexits.h>
+#include <time.h>
 #include <tls.h>
 #include <unistd.h>
 
-#ifdef __FreeBSD__
-#include <sys/capsicum.h>
+#ifndef SIGINFO
+#define SIGINFO SIGUSR2
 #endif
 
 #include "bounce.h"
 
 bool verbose;
 
-static void hashPass(void) {
-	char *pass = getpass("Password: ");
-	byte rand[12];
-	arc4random_buf(rand, sizeof(rand));
-	char salt[3 + BASE64_SIZE(sizeof(rand))] = "$6$";
-	base64(&salt[3], rand, sizeof(rand));
-	printf("%s\n", crypt(pass, salt));
-}
-
-static void genKey(const char *path) {
-	const char *name = strrchr(path, '/');
-	name = (name ? &name[1] : path);
-	char subj[256];
-	snprintf(subj, sizeof(subj), "/CN=%.*s", (int)strcspn(name, "."), name);
-	execlp(
-		OPENSSL_BIN, "openssl", "req",
-		"-x509", "-new", "-newkey", "rsa:4096", "-sha256", "-days", "3650",
-		"-nodes", "-subj", subj, "-keyout", path,
-		NULL
-	);
-	err(EX_UNAVAILABLE, "openssl");
+static volatile sig_atomic_t signals[NSIG];
+static void signalHandler(int signal) {
+	signals[signal] = 1;
 }
-
-static void redir(int dst, int src) {
-	int fd = dup2(src, dst);
-	if (fd < 0) err(EX_OSERR, "dup2");
-	close(src);
+static void justExit(int signal) {
+	exit(128 + signal);
 }
 
-static void genCert(const char *path, const char *ca) {
-	int out = open(path, O_WRONLY | O_APPEND | O_CREAT, 0600);
-	if (out < 0) err(EX_CANTCREAT, "%s", path);
-
-	redir(STDOUT_FILENO, out);
-	if (!ca) {
-		genKey(path);
-		return;
-	}
-
-	int rw[2];
-	int error = pipe(rw);
-	if (error) err(EX_OSERR, "pipe");
+static struct {
+	struct pollfd *fds;
+	struct Client **clients;
+	size_t cap, len;
+} event;
 
-	pid_t pid = fork();
-	if (pid < 0) err(EX_OSERR, "fork");
-	if (!pid) {
-		close(rw[0]);
-		redir(STDOUT_FILENO, rw[1]);
-		genKey(path);
+static void eventAdd(int fd, struct Client *client) {
+	if (event.len == event.cap) {
+		event.cap = (event.cap ? event.cap * 2 : 8);
+		event.fds = realloc(event.fds, sizeof(*event.fds) * event.cap);
+		if (!event.fds) err(EX_OSERR, "realloc");
+		event.clients = realloc(
+			event.clients, sizeof(*event.clients) * event.cap
+		);
+		if (!event.clients) err(EX_OSERR, "realloc");
 	}
-
-	close(rw[1]);
-	redir(STDIN_FILENO, rw[0]);
-	execlp(
-		OPENSSL_BIN, "openssl", "x509",
-		"-CA", ca, "-CAcreateserial", "-days", "3650",
-		NULL
-	);
-	err(EX_UNAVAILABLE, "openssl");
+	event.fds[event.len] = (struct pollfd) { .fd = fd, .events = POLLIN };
+	event.clients[event.len] = client;
+	event.len++;
 }
 
-static size_t parseSize(const char *str) {
-	char *rest;
-	size_t size = strtoull(str, &rest, 0);
-	if (*rest) errx(EX_USAGE, "invalid size: %s", str);
-	return size;
+static void eventRemove(size_t i) {
+	close(event.fds[i].fd);
+	event.len--;
+	event.fds[i] = event.fds[event.len];
+	event.clients[i] = event.clients[event.len];
 }
 
 static FILE *saveFile;
@@ -122,8 +102,8 @@ static void saveSave(void) {
 
 static void saveLoad(const char *path) {
 	umask(0066);
-	saveFile = fopen(path, "a+");
-	if (!saveFile) err(EX_CANTCREAT, "%s", path);
+	saveFile = dataOpen(path, "a+");
+	if (!saveFile) exit(EX_CANTCREAT);
 
 	int error = flock(fileno(saveFile), LOCK_EX | LOCK_NB);
 	if (error && errno != EWOULDBLOCK) err(EX_OSERR, "flock");
@@ -137,113 +117,29 @@ static void saveLoad(const char *path) {
 	atexit(saveSave);
 }
 
-struct SplitPath {
-	int dir;
-	char *file;
-	int targetDir;
-};
-
-static bool linkTarget(char *target, size_t cap, int dir, const char *file) {
-	ssize_t len = readlinkat(dir, file, target, cap - 1);
-	if (len < 0 && errno == EINVAL) return false;
-	if (len < 0) err(EX_NOINPUT, "%s", file);
-	target[len] = '\0';
-	return true;
-}
-
-static struct SplitPath splitPath(char *path) {
-	struct SplitPath split = { .targetDir = -1 };
-	split.file = strrchr(path, '/');
-	if (split.file) {
-		*split.file++ = '\0';
-		split.dir = open(path, O_DIRECTORY);
-	} else {
-		split.file = path;
-		split.dir = open(".", O_DIRECTORY);
-	}
-	if (split.dir < 0) err(EX_NOINPUT, "%s", path);
-
-	// Capsicum workaround for certbot "live" symlinks to "../../archive".
-	char target[PATH_MAX];
-	if (!linkTarget(target, sizeof(target), split.dir, split.file)) {
-		return split;
-	}
-	char *file = strrchr(target, '/');
-	if (file) {
-		*file = '\0';
-		split.targetDir = openat(split.dir, target, O_DIRECTORY);
-		if (split.targetDir < 0) err(EX_NOINPUT, "%s", target);
-	}
-
-	return split;
-}
-
-static FILE *splitOpen(struct SplitPath split) {
-	if (split.targetDir >= 0) {
-		char target[PATH_MAX];
-		if (!linkTarget(target, sizeof(target), split.dir, split.file)) {
-			errx(EX_CONFIG, "file is no longer a symlink");
-		}
-		split.dir = split.targetDir;
-		split.file = strrchr(target, '/');
-		if (!split.file) {
-			errx(EX_CONFIG, "symlink no longer targets directory");
-		}
-		split.file++;
-	}
-
-	int fd = openat(split.dir, split.file, O_RDONLY);
-	if (fd < 0) err(EX_NOINPUT, "%s", split.file);
-	FILE *file = fdopen(fd, "r");
-	if (!file) err(EX_IOERR, "fdopen");
-	return file;
-}
-
-#ifdef __FreeBSD__
-static void capLimit(int fd, const cap_rights_t *rights) {
-	int error = cap_rights_limit(fd, rights);
-	if (error) err(EX_OSERR, "cap_rights_limit");
-}
-static void capLimitSplit(struct SplitPath split, const cap_rights_t *rights) {
-	capLimit(split.dir, rights);
-	if (split.targetDir >= 0) capLimit(split.targetDir, rights);
-}
-#endif
-
-static volatile sig_atomic_t signals[NSIG];
-static void signalHandler(int signal) {
-	signals[signal] = 1;
+static size_t parseSize(const char *str) {
+	char *rest;
+	size_t size = strtoull(str, &rest, 0);
+	if (*rest) errx(EX_USAGE, "invalid size: %s", str);
+	return size;
 }
 
-static struct {
-	struct pollfd *fds;
-	struct Client **clients;
-	size_t cap, len;
-} event;
-
-static void eventAdd(int fd, struct Client *client) {
-	if (event.len == event.cap) {
-		event.cap = (event.cap ? event.cap * 2 : 8);
-		event.fds = realloc(event.fds, sizeof(*event.fds) * event.cap);
-		if (!event.fds) err(EX_OSERR, "realloc");
-		event.clients = realloc(
-			event.clients, sizeof(*event.clients) * event.cap
-		);
-		if (!event.clients) err(EX_OSERR, "realloc");
-	}
-	event.fds[event.len] = (struct pollfd) { .fd = fd, .events = POLLIN };
-	event.clients[event.len] = client;
-	event.len++;
+static struct timeval parseInterval(const char *str) {
+	char *rest;
+	long ms = strtol(str, &rest, 0);
+	if (*rest) errx(EX_USAGE, "invalid interval: %s", str);
+	return (struct timeval) {
+		.tv_sec = ms / 1000,
+		.tv_usec = 1000 * (ms % 1000),
+	};
 }
 
-static void eventRemove(size_t i) {
-	close(event.fds[i].fd);
-	event.len--;
-	event.fds[i] = event.fds[event.len];
-	event.clients[i] = event.clients[event.len];
-}
+static void hashPass(void);
+static void genCert(const char *path, const char *ca);
 
 int main(int argc, char *argv[]) {
+	int error;
+
 	size_t ringSize = 4096;
 	const char *savePath = NULL;
 
@@ -256,6 +152,8 @@ int main(int argc, char *argv[]) {
 	const char *genPath = NULL;
 
 	bool insecure = false;
+	bool printCert = false;
+	const char *trust = NULL;
 	const char *clientCert = NULL;
 	const char *clientPriv = NULL;
 	const char *serverBindHost = NULL;
@@ -263,12 +161,13 @@ int main(int argc, char *argv[]) {
 	const char *host = NULL;
 	const char *port = "6697";
 	char *pass = NULL;
-	bool sasl = false;
 	char *plain = NULL;
+	enum Cap blindReq = 0;
 	const char *nick = NULL;
 	const char *user = NULL;
 	const char *real = NULL;
 
+	const char *mode = NULL;
 	const char *join = NULL;
 	const char *quit = "connection reset by purr";
 
@@ -278,8 +177,11 @@ int main(int argc, char *argv[]) {
 		{ .val = 'C', .name = "local-cert", required_argument },
 		{ .val = 'H', .name = "local-host", required_argument },
 		{ .val = 'K', .name = "local-priv", required_argument },
+		{ .val = 'L', .name = "palaver", no_argument },
 		{ .val = 'N', .name = "no-names", no_argument },
 		{ .val = 'P', .name = "local-port", required_argument },
+		{ .val = 'Q', .name = "queue-interval", required_argument },
+		{ .val = 'R', .name = "blind-req", required_argument },
 		{ .val = 'S', .name = "bind", required_argument },
 		{ .val = 'T', .name = "no-sts", no_argument },
 		{ .val = 'U', .name = "local-path", required_argument },
@@ -292,26 +194,19 @@ int main(int argc, char *argv[]) {
 		{ .val = 'h', .name = "host", required_argument },
 		{ .val = 'j', .name = "join", required_argument },
 		{ .val = 'k', .name = "client-priv", required_argument },
+		{ .val = 'm', .name = "mode", required_argument },
 		{ .val = 'n', .name = "nick", required_argument },
+		{ .val = 'o', .name = "print-cert", no_argument },
 		{ .val = 'p', .name = "port", required_argument },
 		{ .val = 'q', .name = "quit", required_argument },
 		{ .val = 'r', .name = "real", required_argument },
 		{ .val = 's', .name = "size", required_argument },
+		{ .val = 't', .name = "trust", required_argument },
 		{ .val = 'u', .name = "user", required_argument },
 		{ .val = 'v', .name = "verbose", no_argument },
 		{ .val = 'w', .name = "pass", required_argument },
 		{ .val = 'x', .name = "hash", no_argument },
 		{ .val = 'y', .name = "away", required_argument },
-
-		// Deprecated names:
-		{ .val = 'A', .name = "client-ca", required_argument },
-		{ .val = 'C', .name = "cert", required_argument },
-		{ .val = 'H', .name = "bind-host", required_argument },
-		{ .val = 'K', .name = "priv", required_argument },
-		{ .val = 'P', .name = "bind-port", required_argument },
-		{ .val = 'U', .name = "bind-path", required_argument },
-		{ .val = 'W', .name = "client-pass", required_argument },
-
 		{0},
 	};
 	char opts[2 * ARRAY_LEN(options)];
@@ -323,59 +218,61 @@ int main(int argc, char *argv[]) {
 	for (int opt; 0 < (opt = getopt_config(argc, argv, opts, options, NULL));) {
 		switch (opt) {
 			break; case '!': insecure = true;
-			break; case 'A': clientCA = true; caPath = optarg;
-			break; case 'C': strlcpy(certPath, optarg, sizeof(certPath));
+			break; case 'A': caPath = optarg; clientCaps |= CapSASL;
+			break; case 'C': snprintf(certPath, sizeof(certPath), "%s", optarg);
 			break; case 'H': bindHost = optarg;
-			break; case 'K': strlcpy(privPath, optarg, sizeof(privPath));
+			break; case 'K': snprintf(privPath, sizeof(privPath), "%s", optarg);
 			break; case 'N': stateNoNames = true;
+			break; case 'L': clientCaps |= CapPalaverApp;
 			break; case 'P': bindPort = optarg;
+			break; case 'Q': serverQueueInterval = parseInterval(optarg);
+			break; case 'R': blindReq |= capParse(optarg, NULL);
 			break; case 'S': serverBindHost = optarg;
-			break; case 'T': clientSTS = false;
-			break; case 'U': strlcpy(bindPath, optarg, sizeof(bindPath));
+			break; case 'T': clientCaps &= ~CapSTS;
+			break; case 'U': snprintf(bindPath, sizeof(bindPath), "%s", optarg);
 			break; case 'W': clientPass = optarg;
-			break; case 'a': sasl = true; plain = optarg;
+			break; case 'a': blindReq |= CapSASL; plain = optarg;
 			break; case 'c': clientCert = optarg;
-			break; case 'e': sasl = true;
+			break; case 'e': blindReq |= CapSASL;
 			break; case 'f': savePath = optarg;
 			break; case 'g': genPath = optarg;
 			break; case 'h': host = optarg;
 			break; case 'j': join = optarg;
 			break; case 'k': clientPriv = optarg;
+			break; case 'm': mode = optarg;
 			break; case 'n': nick = optarg;
+			break; case 'o': printCert = true;
 			break; case 'p': port = optarg;
 			break; case 'q': quit = optarg;
 			break; case 'r': real = optarg;
 			break; case 's': ringSize = parseSize(optarg);
+			break; case 't': trust = optarg;
 			break; case 'u': user = optarg;
-			break; case 'v': verbose = true;
+			break; case 'v': verbose = true; setlinebuf(stdout);
 			break; case 'w': pass = optarg;
 			break; case 'x': hashPass(); return EX_OK;
 			break; case 'y': clientAway = optarg;
 			break; default:  return EX_USAGE;
 		}
 	}
+	if (blindReq & CapUnsupported) errx(EX_USAGE, "unsupported capability");
 	if (genPath) genCert(genPath, caPath);
 
 	if (bindPath[0]) {
 		struct stat st;
 		int error = stat(bindPath, &st);
-		if (error && errno != ENOENT) err(EX_CANTCREAT, "%s", bindPath);
-		if (S_ISDIR(st.st_mode)) {
-			strlcat(bindPath, "/", sizeof(bindPath));
-			strlcat(bindPath, bindHost, sizeof(bindPath));
+		if (error) {
+			if (errno != ENOENT) err(EX_CANTCREAT, "%s", bindPath);
+		} else if (S_ISDIR(st.st_mode)) {
+			size_t len = strlen(bindPath);
+			snprintf(&bindPath[len], sizeof(bindPath) - len, "/%s", bindHost);
 		}
 	}
 	if (!certPath[0]) {
-		snprintf(
-			certPath, sizeof(certPath), CERTBOT_PATH "/live/%s/fullchain.pem",
-			bindHost
-		);
+		snprintf(certPath, sizeof(certPath), "%s.pem", bindHost);
 	}
 	if (!privPath[0]) {
-		snprintf(
-			privPath, sizeof(privPath), CERTBOT_PATH "/live/%s/privkey.pem",
-			bindHost
-		);
+		snprintf(privPath, sizeof(privPath), "%s.key", bindHost);
 	}
 
 	if (!host) errx(EX_USAGE, "host required");
@@ -387,149 +284,270 @@ int main(int argc, char *argv[]) {
 	if (!real) real = nick;
 	if (!clientAway) clientAway = "pounced :3";
 	if (clientPass && clientPass[0] != '$') {
-		errx(EX_CONFIG, "password must be hashed with -x");
+		errx(EX_USAGE, "password must be hashed with -x");
+	}
+	if (strchr(bindHost, '.')) {
+		clientOrigin = strdup(bindHost);
+		if (!clientOrigin) err(EX_OSERR, "strdup");
+	} else {
+		int n = asprintf(&clientOrigin, "%s.", bindHost);
+		if (n < 0) err(EX_OSERR, "asprintf");
 	}
 
+	if (printCert) {
+#ifdef __OpenBSD__
+		error = pledge("stdio inet dns", NULL);
+		if (error) err(EX_OSERR, "pledge");
+#endif
+		serverConfig(true, NULL, NULL, NULL);
+		serverConnect(serverBindHost, host, port);
+		serverPrintCert();
+		serverClose();
+		return EX_OK;
+	}
+
+	// Either exit with cleanup or ignore signals until entering the main loop.
+	signal(SIGINT, justExit);
+	signal(SIGTERM, justExit);
+	signal(SIGINFO, SIG_IGN);
+	signal(SIGUSR1, SIG_IGN);
+
 	ringAlloc(ringSize);
 	if (savePath) saveLoad(savePath);
+	serverConfig(insecure, trust, clientCert, clientPriv);
+
+#ifdef __OpenBSD__
+	char buf[PATH_MAX];
+	const char *paths[] = { certPath, privPath, caPath };
+	for (size_t i = 0; i < ARRAY_LEN(paths); ++i) {
+		if (!paths[i]) continue;
+		for (int j = 0; configPath(buf, sizeof(buf), paths[i], j); ++j) {
+			error = unveil(buf, "r");
+			if (error && errno != ENOENT) err(EX_NOINPUT, "%s", buf);
+		}
+	}
+	error = unveil(tls_default_ca_cert_file(), "r");
+	if (error) err(EX_OSFILE, "%s", tls_default_ca_cert_file());
 
-	FILE *localCA = NULL;
-	if (caPath) {
-		localCA = fopen(caPath, "r");
-		if (!localCA) err(EX_NOINPUT, "%s", caPath);
+	if (bindPath[0]) {
+		error = unveil(bindPath, "c");
+		if (error) err(EX_NOINPUT, "%s", bindPath);
+		error = pledge("stdio rpath inet dns cpath unix recvfd", NULL);
+	} else {
+		error = pledge("stdio rpath inet dns", NULL);
 	}
+	if (error) err(EX_OSERR, "pledge");
+#endif
 
-	struct SplitPath certSplit = splitPath(certPath);
-	struct SplitPath privSplit = splitPath(privPath);
-	FILE *cert = splitOpen(certSplit);
-	FILE *priv = splitOpen(privSplit);
-	localConfig(cert, priv, localCA, !clientPass);
-	fclose(cert);
-	fclose(priv);
+	error = localConfig(certPath, privPath, caPath, !clientPass);
+	if (error) return EX_NOINPUT;
 
 	int bind[8];
 	size_t binds = bindPath[0]
 		? localUnix(bind, ARRAY_LEN(bind), bindPath)
 		: localBind(bind, ARRAY_LEN(bind), bindHost, bindPort);
-
-	serverConfig(insecure, clientCert, clientPriv);
 	int server = serverConnect(serverBindHost, host, port);
 
-#ifdef __FreeBSD__
-	int error = cap_enter();
-	if (error) err(EX_OSERR, "cap_enter");
-
-	cap_rights_t saveRights, fileRights, sockRights, bindRights;
-	cap_rights_init(&saveRights, CAP_WRITE);
-	cap_rights_init(&fileRights, CAP_FCNTL, CAP_FSTAT, CAP_LOOKUP, CAP_PREAD);
-	cap_rights_init(&sockRights, CAP_EVENT, CAP_RECV, CAP_SEND, CAP_SETSOCKOPT);
-	cap_rights_init(&bindRights, CAP_LISTEN, CAP_ACCEPT);
-	cap_rights_merge(&bindRights, &sockRights);
-
-	if (saveFile) capLimit(fileno(saveFile), &saveRights);
-	if (localCA) capLimit(fileno(localCA), &fileRights);
-	capLimitSplit(certSplit, &fileRights);
-	capLimitSplit(privSplit, &fileRights);
-	for (size_t i = 0; i < binds; ++i) {
-		capLimit(bind[i], &bindRights);
+#ifdef __OpenBSD__
+	if (bindPath[0]) {
+		error = pledge("stdio rpath cpath unix recvfd", NULL);
+	} else {
+		error = pledge("stdio rpath inet", NULL);
 	}
-	capLimit(server, &sockRights);
+	if (error) err(EX_OSERR, "pledge");
 #endif
 
-	stateLogin(pass, sasl, plain, nick, user, real);
+	stateLogin(pass, blindReq, plain, nick, user, real);
 	if (pass) explicit_bzero(pass, strlen(pass));
 	if (plain) explicit_bzero(plain, strlen(plain));
 
 	while (!stateReady()) serverRecv();
 	serverFormat("AWAY :%s\r\n", clientAway);
-	if (join) serverFormat("JOIN :%s\r\n", join);
+	if (mode) serverFormat("MODE %s %s\r\n", stateNick(), mode);
+	if (join) serverFormat("JOIN %s\r\n", join);
 
 	signal(SIGINT, signalHandler);
 	signal(SIGTERM, signalHandler);
 	signal(SIGPIPE, SIG_IGN);
+	signal(SIGALRM, signalHandler);
 	signal(SIGINFO, signalHandler);
 	signal(SIGUSR1, signalHandler);
 
 	for (size_t i = 0; i < binds; ++i) {
-		int error = listen(bind[i], 1);
+		error = listen(bind[i], -1);
 		if (error) err(EX_IOERR, "listen");
 		eventAdd(bind[i], NULL);
 	}
 	eventAdd(server, NULL);
+	size_t clientIndex = event.len;
 
+	enum {
+		NeedTime = 10,
+		IdleTime = 15 * 60,
+	};
 	for (;;) {
-		int nfds = poll(event.fds, event.len, -1);
-		if (nfds < 0 && errno != EINTR) err(EX_IOERR, "poll");
-		if (signals[SIGINT] || signals[SIGTERM]) break;
-
-		if (signals[SIGINFO]) {
-			ringInfo();
-			signals[SIGINFO] = 0;
+		enum Need needs = 0;
+		time_t now = time(NULL);
+		for (size_t i = clientIndex; i < event.len; ++i) {
+			struct Client *client = event.clients[i];
+			event.fds[i].events = POLLIN;
+			needs |= client->need;
+			if (client->need) continue;
+			if (ringDiff(client->consumer) || now - client->idle >= IdleTime) {
+				event.fds[i].events |= POLLOUT;
+			}
 		}
 
-		if (signals[SIGUSR1]) {
-			cert = splitOpen(certSplit);
-			priv = splitOpen(privSplit);
-			localConfig(cert, priv, localCA, !clientPass);
-			fclose(cert);
-			fclose(priv);
-			signals[SIGUSR1] = 0;
+		int ready = poll(event.fds, event.len, (needs ? NeedTime * 1000 : -1));
+		if (ready < 0 && errno != EINTR) err(EX_IOERR, "poll");
+
+		if (needs) {
+			time_t now = time(NULL);
+			for (size_t i = event.len - 1; i >= clientIndex; --i) {
+				struct Client *client = event.clients[i];
+				if (!client->need) continue;
+				if (now - client->time < NeedTime) continue;
+				clientFree(client);
+				eventRemove(i);
+			}
 		}
 
-		if (nfds < 0) continue;
-		for (size_t i = event.len - 1; i < event.len; --i) {
+		for (size_t i = event.len - 1; ready > 0 && i < event.len; --i) {
 			short revents = event.fds[i].revents;
 			if (!revents) continue;
 
-			if (event.fds[i].fd == server) {
+			struct Client *client = event.clients[i];
+			if (client) {
+				if (revents & POLLOUT) {
+					clientConsume(client);
+					if (now - client->idle >= IdleTime) {
+						clientFormat(client, "PING :%s\r\n", clientOrigin);
+					}
+				}
+				if (revents & POLLIN) clientRecv(client);
+				if (client->remove || revents & (POLLHUP | POLLERR)) {
+					clientFree(client);
+					eventRemove(i);
+				}
+			} else if (event.fds[i].fd == server) {
 				serverRecv();
-				continue;
-			}
-
-			if (!event.clients[i]) {
-				int fd;
-				struct tls *tls = localAccept(&fd, event.fds[i].fd);
-				if (!tls) {
+			} else {
+				struct tls *tls = NULL;
+				int sock = localAccept(&tls, event.fds[i].fd);
+				if (sock < 0) {
 					warn("accept");
 					continue;
 				}
-
-				int error = tls_handshake(tls);
-				if (error) {
-					warnx("tls_handshake: %s", tls_error(tls));
-					tls_free(tls);
-					close(fd);
-				} else {
-					eventAdd(fd, clientAlloc(tls));
-				}
-				continue;
-			}
-
-			struct Client *client = event.clients[i];
-			if (revents & POLLOUT) clientConsume(client);
-			if (revents & POLLIN) clientRecv(client);
-			if (clientError(client) || revents & (POLLHUP | POLLERR)) {
-				clientFree(client);
-				eventRemove(i);
+				eventAdd(sock, clientAlloc(sock, tls));
 			}
 		}
 
-		for (size_t i = binds + 1; i < event.len; ++i) {
-			assert(event.clients[i]);
-			if (clientDiff(event.clients[i])) {
-				event.fds[i].events |= POLLOUT;
-			} else {
-				event.fds[i].events &= ~POLLOUT;
-			}
+		if (clientQuit || signals[SIGINT] || signals[SIGTERM]) {
+			break;
+		}
+		if (signals[SIGALRM]) {
+			signals[SIGALRM] = 0;
+			serverDequeue();
+		}
+		if (signals[SIGINFO]) {
+			signals[SIGINFO] = 0;
+			ringInfo();
+		}
+		if (signals[SIGUSR1]) {
+			signals[SIGUSR1] = 0;
+			localConfig(certPath, privPath, caPath, !clientPass);
 		}
 	}
 
+	if (clientQuit && clientQuit[0]) quit = clientQuit;
 	serverFormat("QUIT :%s\r\n", quit);
-	for (size_t i = binds + 1; i < event.len; ++i) {
-		assert(event.clients[i]);
-		clientFormat(event.clients[i], ":%s QUIT :%s\r\n", stateEcho(), quit);
-		clientFormat(event.clients[i], "ERROR :Disconnecting\r\n");
-		clientFree(event.clients[i]);
-		close(event.fds[i].fd);
+	serverClose();
+	for (size_t i = clientIndex; i < event.len; ++i) {
+		struct Client *client = event.clients[i];
+		if (!client->need) {
+			clientFormat(client, ":%s QUIT :%s\r\n", stateEcho(), quit);
+			clientFormat(client, "ERROR :Disconnecting\r\n");
+		}
+		clientFree(client);
 	}
+	if (bindPath[0]) unlink(bindPath);
+}
+
+#ifdef __OpenBSD__
+static void hashPass(void) {
+	int error = pledge("stdio tty", NULL);
+	if (error) err(EX_OSERR, "pledge");
+	char hash[_PASSWORD_LEN];
+	char *pass = getpass("Password: ");
+	error = crypt_newhash(pass, "bcrypt,a", hash, sizeof(hash));
+	if (error) err(EX_OSERR, "crypt_newhash");
+	printf("%s\n", hash);
+}
+#else
+static void hashPass(void) {
+	byte rand[12];
+	FILE *file = fopen("/dev/urandom", "r");
+	if (!file) err(EX_OSFILE, "/dev/urandom");
+	size_t n = fread(rand, sizeof(rand), 1, file);
+	if (!n) err(EX_IOERR, "/dev/urandom");
+	fclose(file);
+	char salt[3 + BASE64_SIZE(sizeof(rand))] = "$6$";
+	base64(&salt[3], rand, sizeof(rand));
+	char *pass = getpass("Password: ");
+	printf("%s\n", crypt(pass, salt));
+}
+#endif
+
+static void genReq(const char *path) {
+	const char *name = strrchr(path, '/');
+	name = (name ? &name[1] : path);
+	char subj[256];
+	snprintf(subj, sizeof(subj), "/CN=%.*s", (int)strcspn(name, "."), name);
+	execlp(
+		OPENSSL_BIN, "openssl", "req",
+		"-new", "-newkey", "rsa:4096", "-sha256", "-nodes",
+		"-subj", subj, "-keyout", path,
+		NULL
+	);
+	err(EX_UNAVAILABLE, "openssl");
+}
+
+static void redir(int dst, int src) {
+	int fd = dup2(src, dst);
+	if (fd < 0) err(EX_OSERR, "dup2");
+	close(src);
+}
+
+static void genCert(const char *path, const char *ca) {
+	int out = open(path, O_WRONLY | O_APPEND | O_CREAT, 0600);
+	if (out < 0) err(EX_CANTCREAT, "%s", path);
+
+	int error;
+#ifdef __OpenBSD__
+	error = pledge("stdio proc exec", NULL);
+	if (error) err(EX_OSERR, "pledge");
+#endif
+
+	int rw[2];
+	error = pipe(rw);
+	if (error) err(EX_OSERR, "pipe");
+
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+	if (!pid) {
+		close(rw[0]);
+		redir(STDOUT_FILENO, rw[1]);
+		genReq(path);
+	}
+
+	close(rw[1]);
+	redir(STDIN_FILENO, rw[0]);
+	redir(STDOUT_FILENO, out);
+	execlp(
+		OPENSSL_BIN, "openssl", "x509",
+		"-req", "-days", "3650", "-CAcreateserial",
+		(ca ? "-CA" : "-signkey"), (ca ? ca : path),
+		NULL
+	);
+	err(EX_UNAVAILABLE, "openssl");
 }
diff --git a/bounce.h b/bounce.h
index a5dc836..a7bad16 100644
--- a/bounce.h
+++ b/bounce.h
@@ -1,4 +1,4 @@
-/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -12,33 +12,59 @@
  *
  * You should have received a copy of the GNU General Public License
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
  */
 
+#include <err.h>
+#include <limits.h>
+#include <stdarg.h>
 #include <stdbool.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <sys/time.h>
+#include <sysexits.h>
 #include <tls.h>
 
-#include "compat.h"
-
-#ifndef CERTBOT_PATH
-#define CERTBOT_PATH "/usr/local/etc/letsencrypt"
-#endif
-
 #ifndef OPENSSL_BIN
 #define OPENSSL_BIN "openssl"
 #endif
 
 #define SOURCE_URL "https://git.causal.agency/pounce"
-#define ORIGIN "irc.invalid"
 
 #define BIT(x) x##Bit, x = 1 << x##Bit, x##Bit_ = x##Bit
 #define ARRAY_LEN(a) (sizeof(a) / sizeof(a[0]))
 
 typedef unsigned char byte;
 
+static inline char *seprintf(char *ptr, char *end, const char *fmt, ...)
+	__attribute__((format(printf, 3, 4)));
+static inline char *seprintf(char *ptr, char *end, const char *fmt, ...) {
+	va_list ap;
+	va_start(ap, fmt);
+	int n = vsnprintf(ptr, end - ptr, fmt, ap);
+	va_end(ap);
+	if (n < 0) return NULL;
+	if (n > end - ptr) return end;
+	return ptr + n;
+}
+
+static inline void set(char **field, const char *value) {
+	if (*field) free(*field);
+	*field = strdup(value);
+	if (!*field) err(EX_OSERR, "strdup");
+}
+
 enum { MessageCap = 8191 + 512 };
 
 enum { ParamCap = 15 };
@@ -51,9 +77,10 @@ struct Message {
 
 static inline struct Message parse(char *line) {
 	struct Message msg = {0};
-	if (line[0] == '@') msg.tags = 1 + strsep(&line, " ");
-	if (line[0] == ':') msg.origin = 1 + strsep(&line, " ");
+	if (line && line[0] == '@') msg.tags = 1 + strsep(&line, " ");
+	if (line && line[0] == ':') msg.origin = 1 + strsep(&line, " ");
 	msg.cmd = strsep(&line, " ");
+	if (msg.cmd && !msg.cmd[0]) msg.cmd = NULL;
 	for (size_t i = 0; line && i < ParamCap; ++i) {
 		if (line[0] == ':') {
 			msg.params[i] = &line[1];
@@ -73,16 +100,21 @@ static inline struct Message parse(char *line) {
 	X("causal.agency/consumer", CapConsumer) \
 	X("causal.agency/passive", CapPassive) \
 	X("chghost", CapChghost) \
+	X("draft/read-marker", CapReadMarker) \
+	X("echo-message", CapEchoMessage) \
 	X("extended-join", CapExtendedJoin) \
+	X("extended-monitor", CapExtendedMonitor) \
 	X("invite-notify", CapInviteNotify) \
 	X("labeled-response", CapLabeledResponse) \
 	X("message-tags", CapMessageTags) \
 	X("multi-prefix", CapMultiPrefix) \
+	X("palaverapp.com", CapPalaverApp) \
 	X("sasl", CapSASL) \
 	X("server-time", CapServerTime) \
 	X("setname", CapSetname) \
 	X("sts", CapSTS) \
 	X("userhost-in-names", CapUserhostInNames) \
+	X("znc.in/self-message", CapSelfMessage) \
 	X("", CapUnsupported)
 
 enum Cap {
@@ -127,13 +159,14 @@ static inline enum Cap capParse(const char *list, const char *values[CapBits]) {
 static inline const char *capList(enum Cap caps, const char *values[CapBits]) {
 	static char buf[1024];
 	buf[0] = '\0';
+	char *ptr = buf, *end = &buf[sizeof(buf)];
 	for (size_t i = 0; i < ARRAY_LEN(CapNames); ++i) {
 		if (caps & (1 << i)) {
-			if (buf[0]) strlcat(buf, " ", sizeof(buf));
-			strlcat(buf, CapNames[i], sizeof(buf));
+			ptr = seprintf(
+				ptr, end, "%s%s", (ptr > buf ? " " : ""), CapNames[i]
+			);
 			if (values && values[i]) {
-				strlcat(buf, "=", sizeof(buf));
-				strlcat(buf, values[i], sizeof(buf));
+				ptr = seprintf(ptr, end, "=%s", values[i]);
 			}
 		}
 	}
@@ -141,6 +174,13 @@ static inline const char *capList(enum Cap caps, const char *values[CapBits]) {
 }
 
 extern bool verbose;
+static inline void
+verboseLog(const char *prefix, const char *line, size_t len) {
+	if (!verbose) return;
+	if (len && line[len - 1] == '\n') len--;
+	if (len && line[len - 1] == '\r') len--;
+	printf("%s %.*s\n", prefix, (int)len, line);
+}
 
 void ringAlloc(size_t len);
 void ringProduce(const char *line);
@@ -154,36 +194,68 @@ void ringInfo(void);
 int ringSave(FILE *file);
 void ringLoad(FILE *file);
 
-void localConfig(FILE *cert, FILE *priv, FILE *ca, bool require);
+int localConfig(
+	const char *cert, const char *priv, const char *ca, bool require
+);
 size_t localBind(int fds[], size_t cap, const char *host, const char *port);
 size_t localUnix(int fds[], size_t cap, const char *path);
-struct tls *localAccept(int *fd, int bind);
+int localAccept(struct tls **tls, int bind);
 
-void serverConfig(bool insecure, const char *cert, const char *priv);
+extern struct timeval serverQueueInterval;
+void serverConfig(
+	bool insecure, const char *trust, const char *cert, const char *priv
+);
 int serverConnect(const char *bindHost, const char *host, const char *port);
+void serverPrintCert(void);
 void serverRecv(void);
 void serverSend(const char *ptr, size_t len);
 void serverFormat(const char *format, ...)
 	__attribute__((format(printf, 1, 2)));
+void serverEnqueue(const char *format, ...)
+	__attribute__((format(printf, 1, 2)));
+void serverDequeue(void);
+void serverClose(void);
 
-extern bool clientCA;
-extern bool clientSTS;
+enum Need {
+	BIT(NeedHandshake),
+	BIT(NeedNick),
+	BIT(NeedUser),
+	BIT(NeedPass),
+	BIT(NeedCapEnd),
+};
+struct Client {
+	bool remove;
+	int sock;
+	struct tls *tls;
+	time_t time;
+	time_t idle;
+	enum Need need;
+	enum Cap caps;
+	size_t consumer;
+	size_t setPos;
+	char buf[MessageCap];
+	size_t len;
+};
+extern enum Cap clientCaps;
+extern char *clientOrigin;
 extern char *clientPass;
 extern char *clientAway;
-struct Client *clientAlloc(struct tls *tls);
+extern char *clientQuit;
+struct Client *clientAlloc(int sock, struct tls *tls);
 void clientFree(struct Client *client);
-bool clientError(const struct Client *client);
 void clientRecv(struct Client *client);
 void clientSend(struct Client *client, const char *ptr, size_t len);
 void clientFormat(struct Client *client, const char *format, ...)
 	__attribute__((format(printf, 2, 3)));
-size_t clientDiff(const struct Client *client);
 void clientConsume(struct Client *client);
+void clientGetMarker(struct Client *client, const char *target);
 
 extern bool stateNoNames;
 extern enum Cap stateCaps;
+extern char *stateAccount;
+extern bool stateAway;
 void stateLogin(
-	const char *pass, bool sasl, const char *plain,
+	const char *pass, enum Cap blind, const char *plain,
 	const char *nick, const char *user, const char *real
 );
 bool stateReady(void);
@@ -192,6 +264,11 @@ void stateSync(struct Client *client);
 const char *stateNick(void);
 const char *stateEcho(void);
 
+char *configPath(char *buf, size_t cap, const char *path, int i);
+char *dataPath(char *buf, size_t cap, const char *path, int i);
+FILE *configOpen(const char *path, const char *mode);
+FILE *dataOpen(const char *path, const char *mode);
+
 struct option;
 int getopt_config(
 	int argc, char *const *argv,
diff --git a/calico.1 b/calico.1
index 4d52a10..37e61a9 100644
--- a/calico.1
+++ b/calico.1
@@ -1,4 +1,4 @@
-.Dd November 6, 2019
+.Dd August 27, 2020
 .Dt CALICO 1
 .Os
 .
@@ -16,7 +16,7 @@
 .Sh DESCRIPTION
 The
 .Nm
-daemon
+program
 dispatches incoming TLS connections
 to instances of
 .Xr pounce 1
@@ -26,11 +26,25 @@ Instances of
 should be configured with
 .Fl U
 to bind to UNIX-domain sockets
-named by the host they wish to accept connections for
 in the directory passed to
 .Nm .
 .
 .Pp
+Note that
+.Nm
+is not a proxy.
+Incoming connections are passed directly
+to instances of
+.Xr pounce 1 ,
+which handle TLS negotiation.
+Instances of
+.Xr pounce 1
+and
+.Nm
+can be restarted
+independently of each other.
+.
+.Pp
 The arguments are as follows:
 .Bl -tag -width Ds
 .It Fl H Ar host
@@ -52,84 +66,53 @@ The path to the directory containing
 UNIX-domain sockets.
 .El
 .
-.Ss Service Configuration
-Add the following to
-.Pa /etc/rc.conf
-to enable the
-.Nm
-daemon:
-.Bd -literal -offset indent
-calico_enable="YES"
-.Ed
-.
-.Pp
-The default socket directory is
-.Pa /var/run/calico .
-It can be changed by setting
-.Va calico_path .
-The
-.Xr pounce 1
-service can be configured
-to listen in this directory
-with the following:
+.Sh EXAMPLES
+Start and dispatch to two instances of
+.Xr pounce 1 :
 .Bd -literal -offset indent
-pounce_flags="-U /var/run/calico"
+$ pounce -U /var/run/calico -H oftc.example.org oftc.conf
+$ pounce -U /var/run/calico -H libera.example.org libera.conf
+$ calico -H example.org /var/run/calico
 .Ed
-.
 .Pp
-The
-.Nm
+The two instances can be connected to via
+.Li oftc.example.org:6697
 and
-.Xr pounce 1
-services can be started and stopped
-completely independently of each other.
-.
-.Sh EXAMPLES
-.Bd -literal -offset indent
-pounce -U sockets/foo.example.org foo.conf
-pounce -U sockets/bar.example.org bar.conf
-calico -H example.org sockets/
-.Ed
+.Li libera.example.org:6697 ,
+respectively.
 .
 .Sh SEE ALSO
 .Xr pounce 1
 .
 .Sh STANDARDS
-The
-.Nm
-daemon implements the following:
-.
 .Bl -item
 .It
 .Rs
-.%A E. Rescorla
-.%Q Mozilla
-.%T The Transport Layer Security (TLS) Protocol Version 1.3
+.%A D. Eastlake 3rd
+.%T Transport Layer Security (TLS) Extensions: Extension Definitions
 .%I IETF
-.%N RFC 8446
-.%D August 2018
-.%U https://tools.ietf.org/html/rfc8446
+.%R RFC 6066
+.%U https://tools.ietf.org/html/rfc6066
+.%D January 2011
 .Re
-.
 .It
 .Rs
-.%A D. Eastlake 3rd
-.%Q Huawei
-.%T Transport Layer Security (TLS) Extensions: Extension Definitions
+.%A E. Rescorla
+.%T The Transport Layer Security (TLS) Protocol Version 1.3
 .%I IETF
-.%N RFC 6066
-.%D January 2011
-.%U https://tools.ietf.org/html/rfc6066
+.%R RFC 8446
+.%U https://tools.ietf.org/html/rfc8446
+.%D August 2018
 .Re
 .El
 .
 .Sh AUTHORS
-.An June Bug Aq Mt june@causal.agency
+.An June McEnroe Aq Mt june@causal.agency
 .
 .Sh BUGS
 Send mail to
-.Aq Mt june@causal.agency
+.Aq Mt list+pounce@causal.agency
 or join
 .Li #ascii.town
 on
-.Li chat.freenode.net .
+.Li irc.tilde.chat .
diff --git a/client.c b/client.c
index 25707a8..23cde36 100644
--- a/client.c
+++ b/client.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -12,10 +12,22 @@
  *
  * You should have received a copy of the GNU General Public License
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
  */
 
 #include <assert.h>
 #include <err.h>
+#include <fcntl.h>
 #include <regex.h>
 #include <stdarg.h>
 #include <stdbool.h>
@@ -30,70 +42,87 @@
 
 #include "bounce.h"
 
-bool clientCA;
-bool clientSTS = true;
+enum Cap clientCaps = 0
+	| CapConsumer
+	| CapPassive
+	| CapReadMarker
+	| CapSTS
+	| CapSelfMessage
+	| CapServerTime;
+
+char *clientOrigin;
 char *clientPass;
 char *clientAway;
+char *clientQuit;
 
 static size_t active;
 
-enum Need {
-	BIT(NeedNick),
-	BIT(NeedUser),
-	BIT(NeedPass),
-	BIT(NeedCapEnd),
-};
+static void activeIncr(const struct Client *client) {
+	if (client->need) return;
+	if (client->caps & CapPassive) return;
+	if (!active++) {
+		serverEnqueue("AWAY\r\n");
+	}
+}
 
-struct Client {
-	struct tls *tls;
-	enum Need need;
-	size_t consumer;
-	size_t setPos;
-	enum Cap caps;
-	char buf[MessageCap];
-	size_t len;
-	bool error;
-};
+static void activeDecr(const struct Client *client) {
+	if (client->need) return;
+	if (client->caps & CapPassive) return;
+	if (!--active && !stateAway) {
+		serverEnqueue("AWAY :%s\r\n", clientAway);
+	}
+}
 
-struct Client *clientAlloc(struct tls *tls) {
+struct Client *clientAlloc(int sock, struct tls *tls) {
 	struct Client *client = calloc(1, sizeof(*client));
 	if (!client) err(EX_OSERR, "calloc");
+	fcntl(sock, F_SETFL, O_NONBLOCK);
+	client->sock = sock;
 	client->tls = tls;
-	client->need = NeedNick | NeedUser | (clientPass ? NeedPass : 0);
-	if (clientCA && tls_peer_cert_provided(tls)) {
+	client->time = time(NULL);
+	client->idle = client->time;
+	client->need = NeedHandshake | NeedNick | NeedUser;
+	if (clientPass) client->need |= NeedPass;
+	return client;
+}
+
+static void clientHandshake(struct Client *client) {
+	int error = tls_handshake(client->tls);
+	if (error == TLS_WANT_POLLIN || error == TLS_WANT_POLLOUT) return;
+	if (error) {
+		warnx("client tls_handshake: %s", tls_error(client->tls));
+		client->remove = true;
+		return;
+	}
+	client->need &= ~NeedHandshake;
+	if ((clientCaps & CapSASL) && tls_peer_cert_provided(client->tls)) {
 		client->need &= ~NeedPass;
 	}
-	return client;
 }
 
 void clientFree(struct Client *client) {
-	if (!client->need) {
-		if (!(client->caps & CapPassive) && !--active) {
-			serverFormat("AWAY :%s\r\n", clientAway);
-		}
-	}
+	activeDecr(client);
 	tls_close(client->tls);
 	tls_free(client->tls);
 	free(client);
 }
 
-bool clientError(const struct Client *client) {
-	return client->error;
-}
-
 void clientSend(struct Client *client, const char *ptr, size_t len) {
-	if (verbose) fprintf(stderr, "\x1B[34m%.*s\x1B[m", (int)len, ptr);
+	verboseLog("<-", ptr, len);
+	fcntl(client->sock, F_SETFL, 0);
 	while (len) {
 		ssize_t ret = tls_write(client->tls, ptr, len);
 		if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) continue;
 		if (ret < 0) {
 			warnx("client tls_write: %s", tls_error(client->tls));
-			client->error = true;
-			return;
+			client->remove = true;
+			break;
 		}
 		ptr += ret;
 		len -= ret;
 	}
+	fcntl(client->sock, F_SETFL, O_NONBLOCK);
+	client->idle = time(NULL);
 }
 
 void clientFormat(struct Client *client, const char *format, ...) {
@@ -111,9 +140,9 @@ static void passRequired(struct Client *client) {
 		client,
 		":%s 464 * :Password incorrect\r\n"
 		"ERROR :Password incorrect\r\n",
-		ORIGIN
+		clientOrigin
 	);
-	client->error = true;
+	client->remove = true;
 }
 
 static void maybeSync(struct Client *client) {
@@ -121,9 +150,7 @@ static void maybeSync(struct Client *client) {
 	if (!client->need) {
 		stateSync(client);
 		if (client->setPos) ringSet(client->consumer, client->setPos);
-		if (!(client->caps & CapPassive) && !active++) {
-			serverFormat("AWAY\r\n");
-		}
+		activeIncr(client);
 	}
 }
 
@@ -137,7 +164,7 @@ static void handleNick(struct Client *client, struct Message *msg) {
 
 static void handleUser(struct Client *client, struct Message *msg) {
 	if (!msg->params[0]) {
-		client->error = true;
+		client->remove = true;
 		return;
 	}
 	if (client->need & NeedPass) {
@@ -153,25 +180,27 @@ static void handleUser(struct Client *client, struct Message *msg) {
 static void handlePass(struct Client *client, struct Message *msg) {
 	if (!clientPass) return;
 	if (!msg->params[0]) {
-		client->error = true;
+		client->remove = true;
 		return;
 	}
-	if (!strcmp(crypt(msg->params[0], clientPass), clientPass)) {
+#ifdef __OpenBSD__
+	int error = crypt_checkpass(msg->params[0], clientPass);
+#else
+	int error = strcmp(crypt(msg->params[0], clientPass), clientPass);
+#endif
+	explicit_bzero(msg->params[0], strlen(msg->params[0]));
+	if (error) {
+		passRequired(client);
+	} else {
 		client->need &= ~NeedPass;
 		maybeSync(client);
-	} else {
-		passRequired(client);
 	}
-	explicit_bzero(msg->params[0], strlen(msg->params[0]));
 }
 
 static void handleCap(struct Client *client, struct Message *msg) {
 	if (!msg->params[0]) msg->params[0] = "";
 
-	enum Cap avail = (stateCaps & ~CapSASL)
-		| CapServerTime | CapConsumer | CapPassive
-		| (clientCA ? CapSASL : 0)
-		| (clientSTS ? CapSTS : 0);
+	enum Cap avail = clientCaps | (stateCaps & ~CapSASL);
 	const char *values[CapBits] = {
 		[CapSASLBit] = "EXTERNAL",
 		[CapSTSBit] = "duration=2147483647",
@@ -190,12 +219,12 @@ static void handleCap(struct Client *client, struct Message *msg) {
 			if (avail & CapCapNotify) client->caps |= CapCapNotify;
 			clientFormat(
 				client, ":%s CAP * LS :%s\r\n",
-				ORIGIN, capList(avail, values)
+				clientOrigin, capList(avail, values)
 			);
 		} else {
 			clientFormat(
 				client, ":%s CAP * LS :%s\r\n",
-				ORIGIN, capList(avail, NULL)
+				clientOrigin, capList(avail, NULL)
 			);
 		}
 
@@ -203,106 +232,277 @@ static void handleCap(struct Client *client, struct Message *msg) {
 		if (client->need) client->need |= NeedCapEnd;
 		enum Cap caps = capParse(msg->params[1], values);
 		if (caps == (avail & caps)) {
-			client->caps |= caps;
 			if (caps & CapConsumer && values[CapConsumerBit]) {
 				client->setPos = strtoull(values[CapConsumerBit], NULL, 10);
 			}
-			clientFormat(client, ":%s CAP * ACK :%s\r\n", ORIGIN, msg->params[1]);
+			if (caps & CapPassive && !(client->caps & CapPassive)) {
+				activeDecr(client);
+			}
+			client->caps |= caps;
+			clientFormat(
+				client, ":%s CAP * ACK :%s\r\n",
+				clientOrigin, msg->params[1]
+			);
 		} else {
-			clientFormat(client, ":%s CAP * NAK :%s\r\n", ORIGIN, msg->params[1]);
+			clientFormat(
+				client, ":%s CAP * NAK :%s\r\n",
+				clientOrigin, msg->params[1]
+			);
 		}
 
 	} else if (!strcmp(msg->params[0], "LIST")) {
 		clientFormat(
 			client, ":%s CAP * LIST :%s\r\n",
-			ORIGIN, capList(client->caps, NULL)
+			clientOrigin, capList(client->caps, NULL)
 		);
 
 	} else {
-		clientFormat(client, ":%s 410 * :Invalid CAP subcommand\r\n", ORIGIN);
+		clientFormat(
+			client, ":%s 410 * :Invalid CAP subcommand\r\n", clientOrigin
+		);
 	}
 }
 
 static void handleAuthenticate(struct Client *client, struct Message *msg) {
 	if (!msg->params[0]) msg->params[0] = "";
-	if (!strcmp(msg->params[0], "EXTERNAL")) {
+	bool cert = (clientCaps & CapSASL) && tls_peer_cert_provided(client->tls);
+	if (cert && !strcmp(msg->params[0], "EXTERNAL")) {
 		clientFormat(client, "AUTHENTICATE +\r\n");
-	} else if (!strcmp(msg->params[0], "+")) {
+	} else if (cert && !strcmp(msg->params[0], "+")) {
+		const char *account = (stateAccount ? stateAccount : "*");
 		clientFormat(
-			client, ":%s 900 * %s * :You are now logged in as *\r\n",
-			ORIGIN, stateEcho()
+			client, ":%s 900 * %s %s :You are now logged in as %s\r\n",
+			clientOrigin, stateEcho(), account, account
 		);
 		clientFormat(
 			client, ":%s 903 * :SASL authentication successful\r\n",
-			ORIGIN
+			clientOrigin
 		);
 	} else {
 		clientFormat(
 			client, ":%s 904 * :SASL authentication failed\r\n",
-			ORIGIN
+			clientOrigin
 		);
 	}
 }
 
-static void handleQuit(struct Client *client, struct Message *msg) {
+static void handleJoin(struct Client *client, struct Message *msg) {
+	(void)client;
 	(void)msg;
-	clientFormat(client, "ERROR :Detaching\r\n");
-	client->error = true;
+	// irssi intentionally sends an invalid JOIN command, at
+	// an invalid time (during client registration), on every
+	// connection. Utterly mind-boggling. Ignore it so the
+	// connection doesn't just get dropped like it deserves to be.
 }
 
-static void handlePrivmsg(struct Client *client, struct Message *msg) {
-	if (!msg->params[0] || !msg->params[1]) return;
+static void handleQuit(struct Client *client, struct Message *msg) {
+	const char *mesg = msg->params[0];
+	if (mesg && !strncmp(mesg, "$pounce", 7) && (!mesg[7] || mesg[7] == ' ')) {
+		mesg += 7;
+		mesg += strspn(mesg, " ");
+		clientQuit = strdup(mesg);
+		if (!clientQuit) err(EX_OSERR, "strdup");
+	} else {
+		clientFormat(client, "ERROR :Detaching\r\n");
+		client->remove = true;
+	}
+}
 
-	int origin;
-	char line[MessageCap];
-	snprintf(
-		line, sizeof(line), "@%s %n:%s %s %s :%s",
-		(msg->tags ? msg->tags : ""), &origin,
-		stateEcho(), msg->cmd, msg->params[0], msg->params[1]
-	);
-	size_t diff = ringDiff(client->consumer);
-	ringProduce((msg->tags ? line : &line[origin]));
-	if (!diff) ringConsume(NULL, client->consumer);
-	if (!strcmp(msg->params[0], stateNick())) return;
+static bool hasTag(const char *tags, const char *tag) {
+	if (!tags) return false;
+	size_t len = strlen(tag);
+	bool val = strchr(tag, '=');
+	while (*tags && *tags != ' ') {
+		if (
+			!strncmp(tags, tag, len) &&
+			(!tags[len] || strchr((val ? "; " : "=; "), tags[len]))
+		) return true;
+		tags += strcspn(tags, "; ");
+		tags += (*tags == ';');
+	}
+	return false;
+}
+
+static const char *synthLabel(struct Client *client) {
+	enum { LabelCap = 64 };
+	static char buf[sizeof("label=") + LabelCap];
+	snprintf(buf, sizeof(buf), "label=pounce~%zu", client->consumer);
+	return buf;
+}
 
+static void reserialize(
+	char *buf, size_t cap, const char *origin, const struct Message *msg
+) {
+	char *ptr = buf, *end = &buf[cap];
 	if (msg->tags) {
+		ptr = seprintf(ptr, end, "@%s ", msg->tags);
+	}
+	if (origin || msg->origin) {
+		ptr = seprintf(ptr, end, ":%s ", (origin ? origin : msg->origin));
+	}
+	ptr = seprintf(ptr, end, "%s", msg->cmd);
+	for (size_t i = 0; i < ParamCap && msg->params[i]; ++i) {
+		if (i + 1 == ParamCap || !msg->params[i + 1]) {
+			ptr = seprintf(ptr, end, " :%s", msg->params[i]);
+		} else {
+			ptr = seprintf(ptr, end, " %s", msg->params[i]);
+		}
+	}
+}
+
+static void clientProduce(struct Client *client, const char *line) {
+	size_t diff = ringDiff(client->consumer);
+	ringProduce(line);
+	if (!diff && !(client->caps & CapEchoMessage)) {
+		ringConsume(NULL, client->consumer);
+	}
+}
+
+static void handlePrivmsg(struct Client *client, struct Message *msg) {
+	if (!msg->params[0]) return;
+	char buf[MessageCap];
+	bool self = !strcmp(msg->params[0], stateNick());
+	if (!(stateCaps & CapEchoMessage) || self) {
+		reserialize(buf, sizeof(buf), stateEcho(), msg);
+		clientProduce(client, buf);
+	}
+	if (self) return;
+	reserialize(buf, sizeof(buf), NULL, msg);
+	if (stateCaps & CapEchoMessage && !hasTag(msg->tags, "label")) {
 		serverFormat(
-			"@%s %s %s :%s\r\n",
-			msg->tags, msg->cmd, msg->params[0], msg->params[1]
+			"@%s%c%s\r\n",
+			synthLabel(client),
+			(buf[0] == '@' ? ';' : ' '),
+			(buf[0] == '@' ? &buf[1] : buf)
 		);
 	} else {
-		serverFormat("%s %s :%s\r\n", msg->cmd, msg->params[0], msg->params[1]);
+		serverFormat("%s\r\n", buf);
 	}
 }
 
-static void handleTagmsg(struct Client *client, struct Message *msg) {
-	if (!msg->tags || !msg->params[0]) return;
-	char line[MessageCap];
+static void handlePalaver(struct Client *client, struct Message *msg) {
+	if (client->need & NeedPass) return;
+	char buf[MessageCap];
+	reserialize(buf, sizeof(buf), NULL, msg);
+	clientProduce(client, buf);
+}
+
+struct Marker {
+	char *target;
+	char *timestamp;
+};
+
+static struct {
+	struct Marker *ptr;
+	size_t cap, len;
+} markers;
+
+void clientGetMarker(struct Client *client, const char *target) {
+	for (size_t i = 0; i < markers.len; ++i) {
+		struct Marker marker = markers.ptr[i];
+		if (strcasecmp(marker.target, target)) continue;
+		clientFormat(
+			client, ":%s MARKREAD %s timestamp=%s\r\n",
+			clientOrigin, target, marker.timestamp
+		);
+		return;
+	}
+	clientFormat(client, ":%s MARKREAD %s *\r\n", clientOrigin, target);
+}
+
+static void clientSetMarker(
+	struct Client *client, const char *target, const char *timestamp
+) {
+	struct Marker *marker = NULL;
+	for (size_t i = 0; i < markers.len; ++i) {
+		marker = &markers.ptr[i];
+		if (strcasecmp(marker->target, target)) continue;
+		if (strcmp(timestamp, marker->timestamp) < 0) {
+			clientFormat(
+				client, ":%s MARKREAD %s timestamp=%s\r\n",
+				clientOrigin, target, marker->timestamp
+			);
+			return;
+		}
+		set(&marker->timestamp, timestamp);
+		goto notify;
+	}
+	if (markers.len == markers.cap) {
+		markers.cap = (markers.cap ? markers.cap * 2 : 8);
+		markers.ptr = realloc(markers.ptr, sizeof(*markers.ptr) * markers.cap);
+		if (!markers.ptr) err(EX_OSERR, "realloc");
+	}
+	marker = &markers.ptr[markers.len++];
+	*marker = (struct Marker) {0};
+	set(&marker->target, target);
+	set(&marker->timestamp, timestamp);
+notify:;
+	char buf[512];
 	snprintf(
-		line, sizeof(line), "@%s :%s TAGMSG %s",
-		msg->tags, stateEcho(), msg->params[0]
+		buf, sizeof(buf), ":%s MARKREAD %s timestamp=%s",
+		clientOrigin, marker->target, marker->timestamp
 	);
-	size_t diff = ringDiff(client->consumer);
-	ringProduce(line);
-	if (!diff) ringConsume(NULL, client->consumer);
-	if (!strcmp(msg->params[0], stateNick())) return;
-	serverFormat("@%s TAGMSG %s\r\n", msg->tags, msg->params[0]);
+	ringProduce(buf);
+}
+
+static regex_t *TimestampRegex(void) {
+	static const char *Pattern = {
+#define R2D "[0-9]{2}"
+		"^timestamp=[0-9]{4,}-" R2D "-" R2D
+		"T" R2D ":" R2D ":" R2D "[.][0-9]{3}Z$"
+#undef R2D
+	};
+	static bool compiled;
+	static regex_t regex;
+	if (!compiled) {
+		int error = regcomp(&regex, Pattern, REG_EXTENDED | REG_NOSUB);
+		assert(!error);
+	}
+	compiled = true;
+	return &regex;
+}
+
+static void handleMarkRead(struct Client *client, struct Message *msg) {
+	if (!msg->params[0]) {
+		clientFormat(
+			client, "FAIL MARKREAD NEED_MORE_PARAMS :Missing parameters\r\n"
+		);
+	} else if (!msg->params[1]) {
+		clientGetMarker(client, msg->params[0]);
+	} else if (regexec(TimestampRegex(), msg->params[1], 0, NULL, 0)) {
+		clientFormat(
+			client, "FAIL MARKREAD INVALID_PARAMS %s :Invalid parameters\r\n",
+			msg->params[1]
+		);
+	} else {
+		clientSetMarker(client, msg->params[0], &msg->params[1][10]);
+	}
+}
+
+static void handlePong(struct Client *client, struct Message *msg) {
+	(void)client;
+	(void)msg;
 }
 
 static const struct {
+	bool intercept;
+	bool need;
 	const char *cmd;
 	Handler *fn;
-	bool need;
 } Handlers[] = {
-	{ "AUTHENTICATE", handleAuthenticate, false },
-	{ "CAP", handleCap, false },
-	{ "NICK", handleNick, false },
-	{ "NOTICE", handlePrivmsg, true },
-	{ "PASS", handlePass, false },
-	{ "PRIVMSG", handlePrivmsg, true },
-	{ "QUIT", handleQuit, true },
-	{ "TAGMSG", handleTagmsg, true },
-	{ "USER", handleUser, false },
+	{ false, false, "AUTHENTICATE", handleAuthenticate },
+	{ false, false, "JOIN", handleJoin },
+	{ false, false, "NICK", handleNick },
+	{ false, false, "PASS", handlePass },
+	{ false, false, "USER", handleUser },
+	{ true, false, "CAP", handleCap },
+	{ true, false, "PALAVER", handlePalaver },
+	{ true, false, "PONG", handlePong },
+	{ true, true, "MARKREAD", handleMarkRead },
+	{ true, true, "NOTICE", handlePrivmsg },
+	{ true, true, "PRIVMSG", handlePrivmsg },
+	{ true, true, "QUIT", handleQuit },
+	{ true, true, "TAGMSG", handlePrivmsg },
 };
 
 static void clientParse(struct Client *client, char *line) {
@@ -314,7 +514,7 @@ static void clientParse(struct Client *client, char *line) {
 		Handlers[i].fn(client, &msg);
 		return;
 	}
-	client->error = true;
+	client->remove = true;
 }
 
 static bool intercept(const char *line, size_t len) {
@@ -325,16 +525,22 @@ static bool intercept(const char *line, size_t len) {
 		len -= sp - line;
 		line = sp;
 	}
-	if (len >= 4 && !memcmp(line, "CAP ", 4)) return true;
-	if (len == 4 && !memcmp(line, "QUIT", 4)) return true;
-	if (len >= 5 && !memcmp(line, "QUIT ", 5)) return true;
-	if (len >= 7 && !memcmp(line, "NOTICE ", 7)) return true;
-	if (len >= 7 && !memcmp(line, "TAGMSG ", 7)) return true;
-	if (len >= 8 && !memcmp(line, "PRIVMSG ", 8)) return true;
+	for (size_t i = 0; i < ARRAY_LEN(Handlers); ++i) {
+		if (!Handlers[i].intercept) continue;
+		size_t n = strlen(Handlers[i].cmd);
+		if (len < n) continue;
+		if (memcmp(line, Handlers[i].cmd, n)) continue;
+		if (len == n || line[n] == ' ' || line[n] == '\r') return true;
+	}
 	return false;
 }
 
 void clientRecv(struct Client *client) {
+	if (client->need & NeedHandshake) {
+		clientHandshake(client);
+		return;
+	}
+
 	ssize_t read = tls_read(
 		client->tls,
 		&client->buf[client->len], sizeof(client->buf) - client->len
@@ -342,34 +548,29 @@ void clientRecv(struct Client *client) {
 	if (read == TLS_WANT_POLLIN || read == TLS_WANT_POLLOUT) return;
 	if (read <= 0) {
 		if (read < 0) warnx("client tls_read: %s", tls_error(client->tls));
-		client->error = true;
+		client->remove = true;
 		return;
 	}
 	client->len += read;
 
-	char *crlf;
+	char *lf;
 	char *line = client->buf;
 	for (;;) {
-		crlf = memmem(line, &client->buf[client->len] - line, "\r\n", 2);
-		if (!crlf) break;
-		if (verbose) {
-			fprintf(stderr, "\x1B[33m%.*s\x1B[m\n", (int)(crlf - line), line);
-		}
-		if (client->need || intercept(line, crlf - line)) {
-			crlf[0] = '\0';
+		lf = memchr(line, '\n', &client->buf[client->len] - line);
+		if (!lf) break;
+		verboseLog("->", line, lf - line);
+		if (client->need || intercept(line, lf - line)) {
+			lf[0] = '\0';
+			if (lf - line && lf[-1] == '\r') lf[-1] = '\0';
 			clientParse(client, line);
 		} else {
-			serverSend(line, crlf + 2 - line);
+			serverSend(line, lf + 1 - line);
 		}
-		line = crlf + 2;
+		line = lf + 1;
 	}
 	client->len -= line - client->buf;
 	memmove(client->buf, line, client->len);
-}
-
-size_t clientDiff(const struct Client *client) {
-	if (client->need) return 0;
-	return ringDiff(client->consumer);
+	client->idle = time(NULL);
 }
 
 static int wordcmp(const char *line, size_t i, const char *word) {
@@ -391,34 +592,22 @@ static int wordcmp(const char *line, size_t i, const char *word) {
 		: (int)len - (int)strlen(word);
 }
 
-static size_t strlcpyn(char *dst, const char *src, size_t cap, size_t len) {
-	if (len < cap) {
-		memcpy(dst, src, len);
-		dst[len] = '\0';
-	} else {
-		memcpy(dst, src, cap - 1);
-		dst[cap - 1] = '\0';
-	}
-	return len;
-}
-
 // s/..(..)../\1/g
-static char *snip(char *dst, size_t cap, const char *src, const regex_t *regex) {
-	size_t len = 0;
+static char *
+snip(char *dst, size_t cap, const char *src, const regex_t *regex) {
+	char *ptr = dst, *end = &dst[cap];
 	regmatch_t match[2];
 	assert(regex->re_nsub);
 	for (; *src; src += match[0].rm_eo) {
 		if (regexec(regex, src, 2, match, 0)) break;
-		len += strlcpyn(&dst[len], src, cap - len, match[0].rm_so);
-		if (len >= cap) return NULL;
-		len += strlcpyn(
-			&dst[len], &src[match[1].rm_so],
-			cap - len, match[1].rm_eo - match[1].rm_so
+		ptr = seprintf(
+			ptr, end, "%.*s%.*s",
+			(int)match[0].rm_so, src,
+			(int)(match[1].rm_eo - match[1].rm_so), &src[match[1].rm_so]
 		);
-		if (len >= cap) return NULL;
 	}
-	len += strlcpy(&dst[len], src, cap - len);
-	return (len < cap ? dst : NULL);
+	ptr = seprintf(ptr, end, "%s", src);
+	return (ptr == end ? NULL : dst);
 }
 
 static regex_t *compile(regex_t *regex, const char *pattern) {
@@ -496,6 +685,14 @@ static const char *filterMultiPrefix(const char *line) {
 	}
 }
 
+static const char *filterReadMarker(const char *line) {
+	return (wordcmp(line, 0, "MARKREAD") ? line : NULL);
+}
+
+static const char *filterPalaverApp(const char *line) {
+	return (wordcmp(line, 0, "PALAVER") ? line : NULL);
+}
+
 static const char *filterSetname(const char *line) {
 	return (wordcmp(line, 0, "SETNAME") ? line : NULL);
 }
@@ -510,13 +707,7 @@ static const char *filterUserhostInNames(const char *line) {
 	);
 }
 
-static const char *filterTags(const char *line) {
-	if (line[0] != '@') return line;
-	const char *sp = strchr(line, ' ');
-	return (sp ? sp + 1 : NULL);
-}
-
-static Filter *Filters[] = {
+static Filter *Filters[CapBits] = {
 	[CapAccountNotifyBit] = filterAccountNotify,
 	[CapAwayNotifyBit] = filterAwayNotify,
 	[CapBatchBit] = filterBatch,
@@ -527,18 +718,22 @@ static Filter *Filters[] = {
 	[CapLabeledResponseBit] = filterLabeledResponse,
 	[CapMessageTagsBit] = filterMessageTags,
 	[CapMultiPrefixBit] = filterMultiPrefix,
+	[CapPalaverAppBit] = filterPalaverApp,
+	[CapReadMarkerBit] = filterReadMarker,
 	[CapSetnameBit] = filterSetname,
 	[CapUserhostInNamesBit] = filterUserhostInNames,
 };
 
-static bool hasTime(const char *line) {
-	if (!strncmp(line, "@time=", 6)) return true;
-	while (*line && *line != ' ') {
-		line += strcspn(line, "; ");
-		if (!strncmp(line, ";time=", 6)) return true;
-		if (*line == ';') line++;
-	}
-	return false;
+static const char *filterEchoMessage(struct Client *client, const char *line) {
+	if (line[0] != '@') return line;
+	if (!hasTag(&line[1], synthLabel(client))) return line;
+	return NULL;
+}
+
+static const char *filterTags(const char *line) {
+	if (line[0] != '@') return line;
+	const char *sp = strchr(line, ' ');
+	return (sp ? sp + 1 : NULL);
 }
 
 void clientConsume(struct Client *client) {
@@ -546,10 +741,13 @@ void clientConsume(struct Client *client) {
 	const char *line = ringPeek(&time, client->consumer);
 	if (!line) return;
 
-	if (stateCaps & TagCaps && !(client->caps & TagCaps)) {
+	enum Cap diff = client->caps ^ (clientCaps | stateCaps);
+	if (diff & CapEchoMessage) {
+		line = filterEchoMessage(client, line);
+	}
+	if (line && stateCaps & TagCaps && !(client->caps & TagCaps)) {
 		line = filterTags(line);
 	}
-	enum Cap diff = client->caps ^ stateCaps;
 	for (size_t i = 0; line && i < ARRAY_LEN(Filters); ++i) {
 		if (!Filters[i]) continue;
 		if (diff & (1 << i)) line = Filters[i](line);
@@ -559,7 +757,10 @@ void clientConsume(struct Client *client) {
 		return;
 	}
 
-	if (client->caps & CapServerTime && !hasTime(line)) {
+	if (
+		client->caps & CapServerTime &&
+		(line[0] != '@' || !hasTag(&line[1], "time"))
+	) {
 		char ts[sizeof("YYYY-MM-DDThh:mm:ss")];
 		struct tm *tm = gmtime(&time.tv_sec);
 		strftime(ts, sizeof(ts), "%FT%T", tm);
@@ -580,5 +781,5 @@ void clientConsume(struct Client *client) {
 	} else {
 		clientFormat(client, "%s\r\n", line);
 	}
-	if (!client->error) ringConsume(NULL, client->consumer);
+	if (!client->remove) ringConsume(NULL, client->consumer);
 }
diff --git a/compat.h b/compat.h
deleted file mode 100644
index d471a8f..0000000
--- a/compat.h
+++ /dev/null
@@ -1,37 +0,0 @@
-/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU 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 General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-#include <stdint.h>
-#include <stdlib.h>
-
-// libcrypto defines these functions if libc doesn't.
-void explicit_bzero(void *b, size_t len);
-#ifndef strlcpy
-size_t strlcpy(char *restrict dst, const char *restrict src, size_t dstsize);
-size_t strlcat(char *restrict dst, const char *restrict src, size_t dstsize);
-#endif
-uint32_t arc4random(void);
-void arc4random_buf(void *buf, size_t nbytes);
-uint32_t arc4random_uniform(uint32_t upper_bound);
-
-// The default value of SO_RCVLOWAT is 1 anyway...
-#ifndef SO_NOSIGPIPE
-#define SO_NOSIGPIPE SO_RCVLOWAT
-#endif
-
-#ifndef SIGINFO
-#define SIGINFO SIGUSR2
-#endif
diff --git a/config.c b/config.c
index 653ae16..30d7c6c 100644
--- a/config.c
+++ b/config.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -12,6 +12,17 @@
  *
  * You should have received a copy of the GNU General Public License
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
  */
 
 #include <err.h>
@@ -43,24 +54,17 @@ int getopt_config(
 	const char *optstring, const struct option *longopts, int *longindex
 ) {
 	static int opt;
-	if (opt >= 0) {
-		opt = getopt_long(argc, argv, optstring, longopts, longindex);
-	}
-	if (opt >= 0) return opt;
-
 	for (;;) {
 		if (!file) {
-			if (optind < argc) {
-				num = 0;
-				path = argv[optind++];
-				file = fopen(path, "r");
-				if (!file) {
-					warn("%s", path);
-					return clean('?');
-				}
-			} else {
-				return clean(-1);
+			if (optind == argc) return clean(-1);
+			if (opt >= 0 && argv[optind][0] == '-') {
+				opt = getopt_long(argc, argv, optstring, longopts, longindex);
+				if (opt >= 0 || optind == argc) return clean(opt);
 			}
+			num = 0;
+			path = argv[optind++];
+			file = configOpen(path, "r");
+			if (!file) return clean('?');
 		}
 
 		for (;;) {
diff --git a/configure b/configure
index b75a2d3..29587a2 100755
--- a/configure
+++ b/configure
@@ -1,45 +1,64 @@
 #!/bin/sh
 set -eu
 
-base='-lcrypt'
-libs='libcrypto libtls'
+cflags() {
+	echo "CFLAGS += $*"
+}
+defstr() {
+	cflags "-D'$1=\"$2\"'"
+}
+defvar() {
+	defstr "$1" "$(pkg-config --variable=$3 $2)${4:-}"
+}
+ldadd() {
+	lib=$1; shift
+	echo "LDADD.${lib} = $*"
+}
+config() {
+	pkg-config --print-errors "$@"
+	cflags $(pkg-config --cflags "$@")
+	for lib; do ldadd $lib $(pkg-config --libs $lib); done
+}
 
 exec >config.mk
 
+for opt; do
+	case "${opt}" in
+		(--prefix=*) echo "PREFIX = ${opt#*=}" ;;
+		(--bindir=*) echo "BINDIR = ${opt#*=}" ;;
+		(--mandir=*) echo "MANDIR = ${opt#*=}" ;;
+		(--enable-notify) echo 'BINS += pounce-notify' ;;
+		(--enable-palaver)
+			echo 'BINS += pounce-palaver'
+			config libcurl sqlite3
+			;;
+		(*) echo "warning: unsupported option ${opt}" >&2 ;;
+	esac
+done
+
 case "$(uname)" in
-	(Darwin)
-		base=''
-		;;
 	(FreeBSD)
-		if ! pkg info -e libressl; then
-			echo 'LibreSSL required' >&2
-			exit 1
-		fi
-		prefix=$(pkg query '%p' libressl)
-		cat <<-EOF
-		MANDIR = \${PREFIX}/man
-		CFLAGS += -I${prefix}/include
-		CFLAGS += -D'OPENSSL_BIN="${prefix}/bin/openssl"'
-		LDFLAGS += -L${prefix}/lib
-		EOF
-		exit
+		config libtls
+		defstr OPENSSL_BIN /usr/bin/openssl
+		;;
+	(OpenBSD)
+		ldadd crypt ''
+		defstr OPENSSL_BIN /usr/bin/openssl
 		;;
 	(Linux)
-		cat <<-EOF
-		CFLAGS += -D_GNU_SOURCE
-		CFLAGS += -D'CERTBOT_PATH="/etc/letsencrypt"'
-		LDLIBS_calico = \${LDLIBS}
-		RCS =
-		DIRS =
-		EOF
+		cflags -D_GNU_SOURCE
+		config libtls
+		defvar OPENSSL_BIN openssl exec_prefix /bin/openssl
+		;;
+	(Darwin)
+		cflags -D__STDC_WANT_LIB_EXT1__=1
+		cflags "-D'explicit_bzero(b,l)=memset_s((b),(l),0,(l))'"
+		ldadd crypt ''
+		config libtls
+		defvar OPENSSL_BIN openssl exec_prefix /bin/openssl
+		;;
+	(*)
+		config libtls
+		defvar OPENSSL_BIN openssl exec_prefix /bin/openssl
 		;;
 esac
-
-pkg-config --print-errors $libs
-
-cat <<EOF
-CFLAGS += $(pkg-config --cflags $libs)
-CFLAGS += -D'OPENSSL_BIN="$(pkg-config --variable=prefix openssl)/bin/openssl"'
-LDFLAGS += $(pkg-config --libs-only-L $libs)
-LDLIBS = $base $(pkg-config --libs-only-l $libs)
-EOF
diff --git a/dispatch.c b/dispatch.c
index c558729..6353686 100644
--- a/dispatch.c
+++ b/dispatch.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -12,8 +12,20 @@
  *
  * You should have received a copy of the GNU General Public License
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
  */
 
+#include <assert.h>
 #include <err.h>
 #include <fcntl.h>
 #include <netdb.h>
@@ -29,55 +41,6 @@
 #include <sysexits.h>
 #include <unistd.h>
 
-#ifdef __FreeBSD__
-#include <sys/capsicum.h>
-#endif
-
-#include "compat.h"
-
-static struct {
-	struct pollfd *ptr;
-	size_t len, cap;
-} event;
-
-static void eventAdd(int fd) {
-	if (event.len == event.cap) {
-		event.cap = (event.cap ? event.cap * 2 : 8);
-		event.ptr = realloc(event.ptr, sizeof(*event.ptr) * event.cap);
-		if (!event.ptr) err(EX_OSERR, "malloc");
-	}
-	event.ptr[event.len++] = (struct pollfd) {
-		.fd = fd,
-		.events = POLLIN,
-	};
-}
-
-static void eventRemove(size_t i) {
-	close(event.ptr[i].fd);
-	event.ptr[i] = event.ptr[--event.len];
-}
-
-static ssize_t sendfd(int sock, int fd) {
-	char buf[CMSG_SPACE(sizeof(int))];
-
-	char x = 0;
-	struct iovec iov = { .iov_base = &x, .iov_len = 1 };
-	struct msghdr msg = {
-		.msg_iov = &iov,
-		.msg_iovlen = 1,
-		.msg_control = buf,
-		.msg_controllen = sizeof(buf),
-	};
-
-	struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
-	cmsg->cmsg_len = CMSG_LEN(sizeof(int));
-	cmsg->cmsg_level = SOL_SOCKET;
-	cmsg->cmsg_type = SCM_RIGHTS;
-	*(int *)CMSG_DATA(cmsg) = fd;
-
-	return sendmsg(sock, &msg, 0);
-}
-
 static struct {
 	uint8_t buf[4096];
 	uint8_t *ptr;
@@ -112,7 +75,9 @@ static char *serverName(void) {
 	skip(uint8());
 	skip(uint16());
 	skip(uint8());
-	peek.len = uint16();
+	uint16_t len = uint16();
+	if (len > peek.len) return NULL;
+	peek.len = len;
 	while (peek.len) {
 		// Extension
 		uint16_t type = uint16();
@@ -145,14 +110,36 @@ static void alert(int sock) {
 	if (len < 0) warn("send");
 }
 
+static ssize_t sendfd(int sock, int fd) {
+	char buf[CMSG_SPACE(sizeof(int))];
+
+	char x = 0;
+	struct iovec iov = { .iov_base = &x, .iov_len = 1 };
+	struct msghdr msg = {
+		.msg_iov = &iov,
+		.msg_iovlen = 1,
+		.msg_control = buf,
+		.msg_controllen = sizeof(buf),
+	};
+
+	struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
+	cmsg->cmsg_len = CMSG_LEN(sizeof(int));
+	cmsg->cmsg_level = SOL_SOCKET;
+	cmsg->cmsg_type = SCM_RIGHTS;
+	*(int *)CMSG_DATA(cmsg) = fd;
+
+	return sendmsg(sock, &msg, 0);
+}
+
 int main(int argc, char *argv[]) {
+	int error;
+
 	const char *host = "localhost";
 	const char *port = "6697";
 	const char *path = NULL;
 	int timeout = 1000;
 
-	int opt;
-	while (0 < (opt = getopt(argc, argv, "H:P:t:"))) {
+	for (int opt; 0 < (opt = getopt(argc, argv, "H:P:t:"));) {
 		switch (opt) {
 			break; case 'H': host = optarg;
 			break; case 'P': port = optarg;
@@ -170,12 +157,21 @@ int main(int argc, char *argv[]) {
 		errx(EX_USAGE, "directory required");
 	}
 
-	int dir = open(path, O_DIRECTORY);
-	if (dir < 0) err(EX_NOINPUT, "%s", path);
+#ifdef __OpenBSD__
+	error = unveil(path, "rw");
+	if (error) err(EX_OSERR, "unveil");
 
-	int error = fchdir(dir);
+	error = pledge("stdio rpath inet unix dns sendfd", NULL);
+	if (error) err(EX_OSERR, "pledge");
+#endif
+
+	error = chdir(path);
 	if (error) err(EX_NOINPUT, "%s", path);
 
+	enum { Cap = 1024 };
+	struct pollfd fds[Cap];
+	size_t nfds = 0;
+
 	struct addrinfo *head;
 	struct addrinfo hints = {
 		.ai_family = AF_UNSPEC,
@@ -186,7 +182,7 @@ int main(int argc, char *argv[]) {
 	if (error) errx(EX_NOHOST, "%s:%s: %s", host, port, gai_strerror(error));
 
 	size_t binds = 0;
-	for (struct addrinfo *ai = head; ai; ai = ai->ai_next) {
+	for (struct addrinfo *ai = head; ai && binds < Cap - 1; ai = ai->ai_next) {
 		int sock = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
 		if (sock < 0) err(EX_OSERR, "socket");
 
@@ -203,122 +199,87 @@ int main(int argc, char *argv[]) {
 			continue;
 		}
 
-		eventAdd(sock);
+		fds[nfds++] = (struct pollfd) { .fd = sock, .events = POLLIN };
 		binds++;
 	}
 	if (!binds) errx(EX_UNAVAILABLE, "could not bind any sockets");
 	freeaddrinfo(head);
 
-#ifdef __FreeBSD__
-	error = cap_enter();
-	if (error) err(EX_OSERR, "cap_enter");
-
-	cap_rights_t dirRights, sockRights, unixRights, bindRights;
-	cap_rights_init(&dirRights, CAP_CONNECTAT);
-	cap_rights_init(&sockRights, CAP_EVENT, CAP_RECV, CAP_SEND, CAP_SETSOCKOPT);
-	cap_rights_init(&unixRights, CAP_CONNECT, CAP_SEND);
-	cap_rights_init(&bindRights, CAP_LISTEN, CAP_ACCEPT);
-	cap_rights_merge(&bindRights, &sockRights);
-
-	error = cap_rights_limit(dir, &dirRights);
-	if (error) err(EX_OSERR, "cap_rights_limit");
 	for (size_t i = 0; i < binds; ++i) {
-		error = cap_rights_limit(event.ptr[i].fd, &bindRights);
-		if (error) err(EX_OSERR, "cap_rights_limit");
-	}
-#endif
-
-	for (size_t i = 0; i < binds; ++i) {
-		error = listen(event.ptr[i].fd, 1);
+		error = listen(fds[i].fd, -1);
 		if (error) err(EX_IOERR, "listen");
 	}
 
 	signal(SIGPIPE, SIG_IGN);
 	for (;;) {
-		int nfds = poll(
-			event.ptr, event.len, (event.len > binds ? timeout : -1)
-		);
-		if (nfds < 0) err(EX_IOERR, "poll");
+		for (size_t i = 0; i < binds; ++i) {
+			fds[i].events = (nfds < Cap ? POLLIN : 0);
+		}
+
+		int ready = poll(fds, nfds, (nfds > binds ? timeout : -1));
+		if (ready < 0) err(EX_IOERR, "poll");
 
-		if (!nfds) {
-			for (size_t i = event.len - 1; i >= binds; --i) {
-				eventRemove(i);
+		if (!ready) {
+			for (size_t i = binds; i < nfds; ++i) {
+				close(fds[i].fd);
 			}
+			nfds = binds;
 			continue;
 		}
 
-		for (size_t i = event.len - 1; i < event.len; --i) {
-			if (!event.ptr[i].revents) continue;
+		for (size_t i = nfds - 1; i < nfds; --i) {
+			if (!fds[i].revents) continue;
 
 			if (i < binds) {
-				int sock = accept(event.ptr[i].fd, NULL, NULL);
+				int sock = accept(fds[i].fd, NULL, NULL);
 				if (sock < 0) {
 					warn("accept");
 					continue;
 				}
-
-				int yes = 1;
-				error = setsockopt(
-					sock, SOL_SOCKET, SO_NOSIGPIPE, &yes, sizeof(yes)
-				);
-				if (error) err(EX_OSERR, "setsockopt");
-
-				eventAdd(sock);
+				assert(nfds < Cap);
+				fds[nfds++] = (struct pollfd) { .fd = sock, .events = POLLIN };
 				continue;
 			}
 
-			if (event.ptr[i].revents & (POLLHUP | POLLERR)) {
-				eventRemove(i);
-				continue;
-			}
+			if (fds[i].revents & (POLLHUP | POLLERR)) goto remove;
 
 			ssize_t len = recv(
-				event.ptr[i].fd, peek.buf, sizeof(peek.buf) - 1, MSG_PEEK
+				fds[i].fd, peek.buf, sizeof(peek.buf) - 1, MSG_PEEK
 			);
 			if (len < 0) {
 				warn("recv");
-				eventRemove(i);
-				continue;
+				goto remove;
 			}
 			peek.len = len;
 
 			char *name = serverName();
-			if (!name || name[0] == '.' || name[0] == '/') {
-				alert(event.ptr[i].fd);
-				eventRemove(i);
-				continue;
+			if (!name || name[0] == '.' || strchr(name, '/')) {
+				alert(fds[i].fd);
+				goto remove;
 			}
 
 			struct sockaddr_un addr = { .sun_family = AF_UNIX };
-			strlcpy(addr.sun_path, name, sizeof(addr.sun_path));
+			snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", name);
 
 			int sock = socket(PF_UNIX, SOCK_STREAM, 0);
 			if (sock < 0) err(EX_OSERR, "socket");
 
-#ifdef __FreeBSD__
-			error = cap_rights_limit(sock, &unixRights);
-			if (error) err(EX_OSERR, "cap_rights_limit");
-
-			error = connectat(
-				dir, sock, (struct sockaddr *)&addr, SUN_LEN(&addr)
-			);
-#else
 			error = connect(sock, (struct sockaddr *)&addr, SUN_LEN(&addr));
-#endif
-
 			if (error) {
 				warn("%s", name);
-				alert(event.ptr[i].fd);
+				alert(fds[i].fd);
 			} else {
-				len = sendfd(sock, event.ptr[i].fd);
+				len = sendfd(sock, fds[i].fd);
 				if (len < 0) {
 					warn("%s", name);
-					alert(event.ptr[i].fd);
+					alert(fds[i].fd);
 				}
 			}
-
 			close(sock);
-			eventRemove(i);
+
+remove:
+			close(fds[i].fd);
+			fds[i] = fds[--nfds];
 		}
 	}
 }
diff --git a/local.c b/local.c
index 0a140e6..fcd670a 100644
--- a/local.c
+++ b/local.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -12,81 +12,66 @@
  *
  * You should have received a copy of the GNU General Public License
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
  */
 
 #include <err.h>
 #include <errno.h>
-#include <fcntl.h>
 #include <limits.h>
 #include <netdb.h>
 #include <netinet/in.h>
 #include <stdbool.h>
 #include <stdio.h>
 #include <stdlib.h>
-#include <string.h>
 #include <sys/socket.h>
-#include <sys/stat.h>
 #include <sys/un.h>
 #include <sysexits.h>
 #include <tls.h>
 #include <unistd.h>
 
-#ifdef __FreeBSD__
-#include <sys/capsicum.h>
-#endif
-
 #include "bounce.h"
 
 static struct tls *server;
 
-static byte *readFile(size_t *len, FILE *file) {
-	struct stat stat;
-	int error = fstat(fileno(file), &stat);
-	if (error) err(EX_IOERR, "fstat");
-
-	byte *buf = malloc(stat.st_size);
-	if (!buf) err(EX_OSERR, "malloc");
-
-	rewind(file);
-	*len = fread(buf, 1, stat.st_size, file);
-	if (ferror(file)) err(EX_IOERR, "fread");
-
-	return buf;
-}
-
-void localConfig(FILE *cert, FILE *priv, FILE *ca, bool require) {
-	tls_free(server);
-	server = tls_server();
+int localConfig(
+	const char *cert, const char *priv, const char *ca, bool require
+) {
+	if (!server) server = tls_server();
 	if (!server) errx(EX_SOFTWARE, "tls_server");
 
 	struct tls_config *config = tls_config_new();
 	if (!config) errx(EX_SOFTWARE, "tls_config_new");
 
-	size_t len;
-	byte *buf = readFile(&len, cert);
-	int error = tls_config_set_cert_mem(config, buf, len);
-	if (error) {
-		errx(EX_CONFIG, "tls_config_set_cert_mem: %s", tls_config_error(config));
+	int error;
+	char buf[PATH_MAX];
+	for (int i = 0; configPath(buf, sizeof(buf), cert, i); ++i) {
+		error = tls_config_set_cert_file(config, buf);
+		if (!error) break;
 	}
-	free(buf);
+	if (error) goto fail;
 
-	buf = readFile(&len, priv);
-	error = tls_config_set_key_mem(config, buf, len);
-	if (error) {
-		errx(EX_CONFIG, "tls_config_set_key_mem: %s", tls_config_error(config));
+	for (int i = 0; configPath(buf, sizeof(buf), priv, i); ++i) {
+		error = tls_config_set_key_file(config, buf);
+		if (!error) break;
 	}
-	free(buf);
+	if (error) goto fail;
 
 	if (ca) {
-		buf = readFile(&len, ca);
-		error = tls_config_set_ca_mem(config, buf, len);
-		if (error) {
-			errx(
-				EX_CONFIG, "tls_config_set_ca_mem: %s",
-				tls_config_error(config)
-			);
+		for (int i = 0; configPath(buf, sizeof(buf), ca, i); ++i) {
+			error = tls_config_set_ca_file(config, buf);
+			if (!error) break;
 		}
-		free(buf);
+		if (error) goto fail;
 		if (require) {
 			tls_config_verify_client(config);
 		} else {
@@ -97,6 +82,12 @@ void localConfig(FILE *cert, FILE *priv, FILE *ca, bool require) {
 	error = tls_configure(server, config);
 	if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(server));
 	tls_config_free(config);
+	return 0;
+
+fail:
+	warnx("%s", tls_config_error(config));
+	tls_config_free(config);
+	return -1;
 }
 
 size_t localBind(int fds[], size_t cap, const char *host, const char *port) {
@@ -134,48 +125,39 @@ size_t localBind(int fds[], size_t cap, const char *host, const char *port) {
 }
 
 static bool unix;
-static int unixDir = -1;
-static char unixFile[PATH_MAX];
-
-static void unixUnlink(void) {
-	int error = unlinkat(unixDir, unixFile, 0);
-	if (error) warn("unlinkat");
-}
-
-size_t localUnix(int fds[], size_t cap, const char *path) {
-	if (!cap) return 0;
-
-	int sock = socket(PF_UNIX, SOCK_STREAM, 0);
-	if (sock < 0) err(EX_OSERR, "socket");
 
+static int unixBind(int sock, const char *path) {
 	struct sockaddr_un addr = { .sun_family = AF_UNIX };
-	size_t len = strlcpy(addr.sun_path, path, sizeof(addr.sun_path));
-	if (len >= sizeof(addr.sun_path)) {
+	int len = snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", path);
+	if ((size_t)len >= sizeof(addr.sun_path)) {
 		errx(EX_CONFIG, "path too long: %s", path);
 	}
 
 	int error = bind(sock, (struct sockaddr *)&addr, SUN_LEN(&addr));
-	if (error) err(EX_UNAVAILABLE, "%s", path);
+	if (!error || errno != EADDRINUSE) return error;
 
-	char dir[PATH_MAX] = ".";
-	const char *base = strrchr(path, '/');
-	if (base) {
-		snprintf(dir, sizeof(dir), "%.*s", (int)(base - path), path);
-		base++;
-	} else {
-		base = path;
+	int check = socket(PF_UNIX, SOCK_STREAM, 0);
+	if (check < 0) err(EX_OSERR, "socket");
+
+	error = connect(check, (struct sockaddr *)&addr, SUN_LEN(&addr));
+	close(check);
+	if (!error) {
+		errno = EADDRINUSE;
+		return -1;
 	}
-	snprintf(unixFile, sizeof(unixFile), "%s", base);
 
-	unixDir = open(dir, O_DIRECTORY);
-	if (unixDir < 0) err(EX_UNAVAILABLE, "%s", dir);
-	atexit(unixUnlink);
+	unlink(path);
+	return bind(sock, (struct sockaddr *)&addr, SUN_LEN(&addr));
+}
+
+size_t localUnix(int fds[], size_t cap, const char *path) {
+	if (!cap) return 0;
+
+	int sock = socket(PF_UNIX, SOCK_STREAM, 0);
+	if (sock < 0) err(EX_OSERR, "socket");
 
-#ifdef __FreeBSD__
-	cap_rights_t rights;
-	error = cap_rights_limit(unixDir, cap_rights_init(&rights, CAP_UNLINKAT));
-	if (error) err(EX_OSERR, "cap_rights_limit");
-#endif
+	int error = unixBind(sock, path);
+	if (error) err(EX_UNAVAILABLE, "%s", path);
 
 	unix = true;
 	fds[0] = sock;
@@ -203,23 +185,19 @@ static int recvfd(int sock) {
 	return *(int *)CMSG_DATA(cmsg);
 }
 
-struct tls *localAccept(int *fd, int bind) {
-	*fd = accept(bind, NULL, NULL);
-	if (*fd < 0) return NULL;
+int localAccept(struct tls **client, int bind) {
+	int fd = accept(bind, NULL, NULL);
+	if (fd < 0) return fd;
 
 	if (unix) {
-		int sent = recvfd(*fd);
-		if (sent < 0) err(EX_IOERR, "recvfd");
-		close(*fd);
-		*fd = sent;
+		int sent = recvfd(fd);
+		close(fd);
+		if (sent < 0) return sent;
+		fd = sent;
 	}
 
-	int yes = 1;
-	int error = setsockopt(*fd, SOL_SOCKET, SO_NOSIGPIPE, &yes, sizeof(yes));
-	if (error) err(EX_OSERR, "setsockopt");
-
-	struct tls *client;
-	error = tls_accept_socket(server, &client, *fd);
+	int error = tls_accept_socket(server, client, fd);
 	if (error) errx(EX_SOFTWARE, "tls_accept_socket: %s", tls_error(server));
-	return client;
+
+	return fd;
 }
diff --git a/notify.c b/notify.c
new file mode 100644
index 0000000..22295f7
--- /dev/null
+++ b/notify.c
@@ -0,0 +1,353 @@
+/* Copyright (C) 2021  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#include <assert.h>
+#include <ctype.h>
+#include <err.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <sysexits.h>
+#include <time.h>
+#include <tls.h>
+#include <unistd.h>
+
+#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0]))
+
+static bool verbose;
+static struct tls *client;
+
+static void clientWrite(const char *ptr, size_t len) {
+	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[1024];
+	va_list ap;
+	va_start(ap, format);
+	int len = vsnprintf(buf, sizeof(buf), format, ap);
+	va_end(ap);
+	assert((size_t)len < sizeof(buf));
+	if (verbose) fprintf(stderr, "%s", buf);
+	clientWrite(buf, len);
+}
+
+enum { ParamCap = 2 };
+struct Message {
+	char *time;
+	char *nick;
+	char *user;
+	char *host;
+	char *cmd;
+	char *params[ParamCap];
+};
+
+static struct Message parse(char *line) {
+	if (verbose) fprintf(stderr, "%s\n", line);
+	struct Message msg = {0};
+	if (line[0] == '@') {
+		char *tags = 1 + strsep(&line, " ");
+		while (tags) {
+			char *tag = strsep(&tags, ";");
+			char *key = strsep(&tag, "=");
+			if (!strcmp(key, "time")) msg.time = tag;
+		}
+	}
+	if (line[0] == ':') {
+		char *origin = 1 + strsep(&line, " ");
+		msg.nick = strsep(&origin, "!");
+		msg.user = strsep(&origin, "@");
+		msg.host = origin;
+	}
+	msg.cmd = strsep(&line, " ");
+	for (size_t i = 0; line && i < ParamCap; ++i) {
+		if (line[0] == ':') {
+			msg.params[i] = &line[1];
+			break;
+		}
+		msg.params[i] = strsep(&line, " ");
+	}
+	return msg;
+}
+
+static void require(const struct Message *msg, bool nick, size_t len) {
+	if (nick && !msg->nick) errx(EX_PROTOCOL, "%s missing origin", msg->cmd);
+	for (size_t i = 0; i < len; ++i) {
+		if (msg->params[i]) continue;
+		errx(EX_PROTOCOL, "%s missing parameter %zu", msg->cmd, 1 + i);
+	}
+}
+
+typedef void Handler(struct Message *msg);
+
+static void handlePing(struct Message *msg) {
+	require(msg, false, 1);
+	format("PONG :%s\r\n", msg->params[0]);
+}
+
+static void handleError(struct Message *msg) {
+	require(msg, false, 1);
+	errx(EX_UNAVAILABLE, "%s", msg->params[0]);
+}
+
+static char *nick;
+static bool away;
+
+static void handleReplyWelcome(struct Message *msg) {
+	require(msg, false, 1);
+	free(nick);
+	nick = strdup(msg->params[0]);
+	if (!nick) err(EX_OSERR, "strdup");
+	format("USERHOST %s\r\n", nick);
+}
+
+static void handleNick(struct Message *msg) {
+	require(msg, true, 1);
+	if (nick && !strcmp(msg->nick, nick)) {
+		free(nick);
+		nick = strdup(msg->params[0]);
+		if (!nick) err(EX_OSERR, "strdup");
+	}
+}
+
+static void handleReplyUserHost(struct Message *msg) {
+	require(msg, false, 2);
+	while (msg->params[1]) {
+		char *reply = strsep(&msg->params[1], " ");
+		char *replyNick = strsep(&reply, "*=");
+		if (strcmp(replyNick, nick)) continue;
+		if (reply && !reply[0]) strsep(&msg->params[1], "=");
+		if (!reply) errx(EX_PROTOCOL, "invalid USERHOST reply");
+		away = (reply[0] == '-');
+		break;
+	}
+}
+
+static void handleReplyNowAway(struct Message *msg) {
+	(void)msg;
+	away = true;
+}
+
+static void handleReplyUnaway(struct Message *msg) {
+	(void)msg;
+	away = false;
+}
+
+static const char *command;
+
+static void handlePrivmsg(struct Message *msg) {
+	require(msg, true, 2);
+	if (!nick || !away) return;
+
+	if (!msg->time) return;
+	struct tm tm = {0};
+	strptime(msg->time, "%FT%T", &tm);
+	time_t then = timegm(&tm);
+	if (time(NULL) - then > 60) return;
+
+	bool query = (msg->params[0][0] != '#');
+	bool mention = false;
+	size_t len = strlen(nick);
+	for (
+		char *match = msg->params[1];
+		NULL != (match = strstr(match, nick));
+		match = &match[len]
+	) {
+		char a = (match > msg->params[1] ? match[-1] : ' ');
+		char b = (match[len] ? match[len] : ' ');
+		if (b == '\1') b = ' ';
+		if ((isspace(a) || ispunct(a)) && (isspace(b) || ispunct(b))) {
+			mention = true;
+			break;
+		}
+		match = &match[len];
+	}
+	if (!query && !mention) return;
+
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid) return;
+
+	setenv("NOTIFY_TIME", msg->time, 1);
+	setenv("NOTIFY_NICK", msg->nick, 1);
+	if (msg->user) setenv("NOTIFY_USER", msg->user, 1);
+	if (msg->host) setenv("NOTIFY_HOST", msg->host, 1);
+	if (!query) setenv("NOTIFY_CHANNEL", msg->params[0], 1);
+	setenv("NOTIFY_MESSAGE", msg->params[1], 1);
+
+	const char *shell = getenv("SHELL");
+	if (!shell) shell = "/bin/sh";
+	execl(shell, "sh", "-c", command, NULL);
+	err(EX_OSERR, "%s", shell);
+}
+
+static const struct {
+	const char *cmd;
+	Handler *fn;
+} Handlers[] = {
+	{ "001", handleReplyWelcome },
+	{ "302", handleReplyUserHost },
+	{ "305", handleReplyUnaway },
+	{ "306", handleReplyNowAway },
+	{ "ERROR", handleError },
+	{ "NICK", handleNick },
+	{ "PING", handlePing },
+	{ "PRIVMSG", handlePrivmsg },
+};
+
+static void handle(struct Message *msg) {
+	if (!msg->cmd) return;
+	for (size_t i = 0; i < ARRAY_LEN(Handlers); ++i) {
+		if (strcmp(msg->cmd, Handlers[i].cmd)) continue;
+		Handlers[i].fn(msg);
+		break;
+	}
+}
+
+static void reap(int sig) {
+	(void)sig;
+	int status;
+	wait(&status);
+}
+
+static void quit(int sig) {
+	(void)sig;
+	format("QUIT\r\n");
+	tls_close(client);
+	_exit(EX_OK);
+}
+
+int main(int argc, char *argv[]) {
+	bool insecure = false;
+	const char *cert = NULL;
+	const char *priv = NULL;
+	const char *host = NULL;
+	const char *port = "6697";
+	const char *pass = NULL;
+	const char *trust = NULL;
+	const char *user = "pounce-notify";
+
+	for (int opt; 0 < (opt = getopt(argc, argv, "!c:k:p:t:u:vw:"));) {
+		switch (opt) {
+			break; case '!': insecure = true;
+			break; case 'c': cert = optarg;
+			break; case 'k': priv = optarg;
+			break; case 'p': port = optarg;
+			break; case 't': trust = optarg;
+			break; case 'u': user = optarg;
+			break; case 'v': verbose = true;
+			break; case 'w': pass = optarg;
+			break; default:  return EX_USAGE;
+		}
+	}
+	if (argc - optind < 1) errx(EX_USAGE, "host required");
+	if (argc - optind < 2) errx(EX_USAGE, "command required");
+	host = argv[optind++];
+	command = argv[optind];
+
+	setenv("POUNCE_HOST", host, 1);
+	setenv("POUNCE_PORT", port, 1);
+
+	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 (insecure) {
+		tls_config_insecure_noverifycert(config);
+		tls_config_insecure_noverifyname(config);
+	}
+
+	int error;
+	if (trust) {
+		tls_config_insecure_noverifyname(config);
+		error = tls_config_set_ca_file(config, trust);
+		if (error) errx(EX_NOINPUT, "%s: %s", trust, tls_config_error(config));
+	}
+	if (cert) {
+		error = tls_config_set_keypair_file(config, cert, (priv ? priv : cert));
+		if (error) {
+			errx(
+				EX_NOINPUT, "tls_config_set_keypair_file: %s",
+				tls_config_error(config)
+			);
+		}
+	}
+
+	error = tls_configure(client, config);
+	if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(client));
+	tls_config_free(config);
+
+	error = tls_connect(client, host, port);
+	if (error) errx(EX_UNAVAILABLE, "tls_connect: %s", tls_error(client));
+
+	if (pass) format("PASS :%s\r\n", pass);
+	format(
+		"CAP REQ :causal.agency/passive server-time\r\n"
+		"CAP END\r\n"
+		"NICK *\r\n"
+		"USER %s 0 * :pounce-notify\r\n",
+		user
+	);
+
+	signal(SIGINT, quit);
+	signal(SIGTERM, quit);
+	signal(SIGCHLD, reap);
+
+	size_t len = 0;
+	char buf[8191 + 512];
+	for (;;) {
+		ssize_t ret = tls_read(client, &buf[len], sizeof(buf) - len);
+		if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) continue;
+		if (ret < 0) errx(EX_IOERR, "tls_read: %s", tls_error(client));
+		if (!ret) errx(EX_PROTOCOL, "server closed connection");
+		len += ret;
+
+		char *line = buf;
+		for (;;) {
+			char *crlf = memmem(line, &buf[len] - line, "\r\n", 2);
+			if (!crlf) break;
+			*crlf = '\0';
+			struct Message msg = parse(line);
+			handle(&msg);
+			line = crlf + 2;
+		}
+		len -= line - buf;
+		memmove(buf, line, len);
+	}
+}
diff --git a/palaver.c b/palaver.c
new file mode 100644
index 0000000..1453551
--- /dev/null
+++ b/palaver.c
@@ -0,0 +1,796 @@
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#include <assert.h>
+#include <ctype.h>
+#include <curl/curl.h>
+#include <err.h>
+#include <errno.h>
+#include <limits.h>
+#include <signal.h>
+#include <sqlite3.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sysexits.h>
+#include <time.h>
+#include <tls.h>
+#include <unistd.h>
+
+char *dataPath(char *buf, size_t cap, const char *path, int i);
+
+// Why must it return (const unsigned char *)?
+#define sqlite3_column_text(...) (const char *)sqlite3_column_text(__VA_ARGS__)
+
+#define SQL(...) #__VA_ARGS__
+#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0]))
+
+static bool verbose;
+static char curlError[CURL_ERROR_SIZE];
+
+static CURL *curl;
+static sqlite3 *db;
+static struct tls *client;
+
+static void dbOpen(const char *path, int flags) {
+	int error = sqlite3_open_v2(path, &db, flags, NULL);
+	if (error == SQLITE_CANTOPEN) {
+		sqlite3_close(db);
+		db = NULL;
+		return;
+	}
+	if (error) errx(EX_NOINPUT, "%s: %s", path, sqlite3_errmsg(db));
+
+	sqlite3_busy_timeout(db, 10000);
+}
+
+static void dbFind(const char *path) {
+	if (path) {
+		dbOpen(path, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE);
+		if (db) return;
+		errx(EX_NOINPUT, "%s: database not found", path);
+	}
+
+	char buf[PATH_MAX];
+	for (int i = 0; dataPath(buf, sizeof(buf), "palaver.sqlite", i); ++i) {
+		dbOpen(buf, SQLITE_OPEN_READWRITE);
+		if (db) return;
+	}
+
+	int error = mkdir(dataPath(buf, sizeof(buf), "", 0), 0700);
+	if (error && errno != EEXIST) err(EX_CANTCREAT, "%s", buf);
+
+	dbOpen(
+		dataPath(buf, sizeof(buf), "palaver.sqlite", 0),
+		SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE
+	);
+	if (!db) errx(EX_CANTCREAT, "%s: cannot create database", buf);
+}
+
+static int dbParam(sqlite3_stmt *stmt, const char *param) {
+	int index = sqlite3_bind_parameter_index(stmt, param);
+	if (index) return index;
+	errx(EX_SOFTWARE, "no such parameter %s: %s", param, sqlite3_sql(stmt));
+}
+
+static void
+dbBindText(sqlite3_stmt *stmt, const char *param, const char *value) {
+	if (!sqlite3_bind_text(stmt, dbParam(stmt, param), value, -1, NULL)) return;
+	errx(EX_SOFTWARE, "sqlite3_bind_text: %s", sqlite3_errmsg(db));
+}
+
+static void
+dbBindCopy(sqlite3_stmt *stmt, const char *param, const char *value) {
+	int error = sqlite3_bind_text(
+		stmt, dbParam(stmt, param), value, -1, SQLITE_TRANSIENT
+	);
+	if (error) errx(EX_SOFTWARE, "sqlite3_bind_text: %s", sqlite3_errmsg(db));
+}
+
+static void dbVerbose(sqlite3_stmt *stmt) {
+	if (!verbose) return;
+	char *sql = sqlite3_expanded_sql(stmt);
+	if (sql) fprintf(stderr, "%s\n", sql);
+	sqlite3_free(sql);
+}
+
+static void dbInit(void) {
+	const char *sql = SQL(
+		CREATE TABLE IF NOT EXISTS clients (
+			host TEXT NOT NULL,
+			port INTEGER NOT NULL,
+			client TEXT NOT NULL,
+			version TEXT NOT NULL,
+			UNIQUE (host, port, client)
+		);
+		CREATE TABLE IF NOT EXISTS preferences (
+			client TEXT NOT NULL,
+			key TEXT NOT NULL,
+			value TEXT NOT NULL
+		);
+		CREATE INDEX IF NOT EXISTS preferencesIndex
+		ON preferences (client, key);
+		CREATE TABLE IF NOT EXISTS badges (
+			host TEXT NOT NULL,
+			port TEXT NOT NULL,
+			count INTEGER NOT NULL,
+			UNIQUE (host, port)
+		);
+	);
+	int error = sqlite3_exec(db, sql, NULL, NULL, NULL);
+	if (error) errx(EX_SOFTWARE, "%s: %s", sqlite3_errmsg(db), sql);
+}
+
+static void clientWrite(const char *ptr, size_t len) {
+	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[1024];
+	va_list ap;
+	va_start(ap, format);
+	int len = vsnprintf(buf, sizeof(buf), format, ap);
+	va_end(ap);
+	assert((size_t)len < sizeof(buf));
+	if (verbose) fprintf(stderr, "%s", buf);
+	clientWrite(buf, len);
+}
+
+enum { ParamCap = 4 };
+struct Message {
+	char *time;
+	char *nick;
+	char *cmd;
+	char *params[ParamCap];
+};
+
+static struct Message parse(char *line) {
+	if (verbose) fprintf(stderr, "%s\n", line);
+	struct Message msg = {0};
+	if (line[0] == '@') {
+		char *tags = 1 + strsep(&line, " ");
+		while (tags) {
+			char *tag = strsep(&tags, ";");
+			char *key = strsep(&tag, "=");
+			if (!strcmp(key, "time")) msg.time = tag;
+		}
+	}
+	if (line[0] == ':') {
+		char *origin = 1 + strsep(&line, " ");
+		msg.nick = strsep(&origin, "!");
+	}
+	msg.cmd = strsep(&line, " ");
+	for (size_t i = 0; line && i < ParamCap; ++i) {
+		if (line[0] == ':') {
+			msg.params[i] = &line[1];
+			break;
+		}
+		msg.params[i] = strsep(&line, " ");
+	}
+	return msg;
+}
+
+static void require(const struct Message *msg, bool nick, size_t len) {
+	if (nick && !msg->nick) errx(EX_PROTOCOL, "%s missing origin", msg->cmd);
+	for (size_t i = 0; i < len; ++i) {
+		if (msg->params[i]) continue;
+		errx(EX_PROTOCOL, "%s missing parameter %zu", msg->cmd, 1 + i);
+	}
+}
+
+typedef void Handler(struct Message *msg);
+
+static void handleCap(struct Message *msg) {
+	require(msg, false, 3);
+	if (!strcmp(msg->params[1], "NAK")) {
+		errx(EX_CONFIG, "pounce palaver option not enabled");
+	}
+}
+
+static void handlePing(struct Message *msg) {
+	require(msg, false, 1);
+	format("PONG :%s\r\n", msg->params[0]);
+}
+
+static void handleError(struct Message *msg) {
+	require(msg, false, 1);
+	errx(EX_UNAVAILABLE, "%s", msg->params[0]);
+}
+
+static char *nick;
+static bool away;
+
+static void handleReplyWelcome(struct Message *msg) {
+	require(msg, false, 1);
+	free(nick);
+	nick = strdup(msg->params[0]);
+	if (!nick) err(EX_OSERR, "strdup");
+	format("USERHOST %s\r\n", nick);
+}
+
+static void handleNick(struct Message *msg) {
+	require(msg, true, 1);
+	if (nick && !strcmp(msg->nick, nick)) {
+		free(nick);
+		nick = strdup(msg->params[0]);
+		if (!nick) err(EX_OSERR, "strdup");
+	}
+}
+
+static void handleReplyUserHost(struct Message *msg) {
+	require(msg, false, 2);
+	while (msg->params[1]) {
+		char *reply = strsep(&msg->params[1], " ");
+		char *replyNick = strsep(&reply, "*=");
+		if (strcmp(replyNick, nick)) continue;
+		if (reply && !reply[0]) strsep(&msg->params[1], "=");
+		if (!reply) errx(EX_PROTOCOL, "invalid USERHOST reply");
+		away = (reply[0] == '-');
+		break;
+	}
+}
+
+static bool sensitive;
+
+static void keyword(sqlite3_context *ctx, int n, sqlite3_value *args[]) {
+	assert(n == 2);
+	const char *haystack = (const char *)sqlite3_value_text(args[0]);
+	const char *needle = (const char *)sqlite3_value_text(args[1]);
+	if (!nick || !haystack || !needle) {
+		sqlite3_result_null(ctx);
+		return;
+	}
+
+	char *copy = NULL;
+	const char *replace;
+	if (!strcmp(needle, "{nick}")) {
+		needle = nick;
+	} else if (NULL != (replace = strstr(needle, "{nick}"))) {
+		int n = asprintf(
+			&copy, "%.*s%s%s",
+			(int)(replace - needle), needle, nick, &replace[6]
+		);
+		if (n < 0) {
+			sqlite3_result_error_nomem(ctx);
+			return;
+		}
+		needle = copy;
+	}
+
+	size_t len = strlen(needle);
+	const char *match = haystack;
+	sqlite3_result_int(ctx, false);
+	while (NULL != (match = (sensitive ? strstr : strcasestr)(match, needle))) {
+		char a = (match > haystack ? match[-1] : ' ');
+		char b = (match[len] ? match[len] : ' ');
+		if (b == '\1') b = ' ';
+		if ((isspace(a) || ispunct(a)) && (isspace(b) || ispunct(b))) {
+			sqlite3_result_int(ctx, true);
+			break;
+		}
+		match = &match[len];
+	}
+	free(copy);
+}
+
+enum {
+	Identify,
+	Begin,
+	Set,
+	End,
+	Each,
+	Notify,
+	Increment,
+	Reset,
+	Badge,
+	QueriesLen,
+};
+
+static sqlite3_stmt *stmts[QueriesLen];
+static const char *Queries[QueriesLen] = {
+	[Identify] = SQL(
+		SELECT 1 FROM clients
+		WHERE host = :host AND port = :port
+		AND client = :client AND version = :version;
+	),
+
+	[Begin] = SQL(
+		DELETE FROM preferences WHERE client = :client;
+	),
+
+	[Set] = SQL(
+		INSERT INTO preferences (client, key, value)
+		VALUES (:client, :key, :value);
+	),
+
+	[End] = SQL(
+		INSERT INTO clients (host, port, client, version)
+		VALUES (:host, :port, :client, :version)
+		ON CONFLICT (host, port, client) DO
+		UPDATE SET version = :version
+		WHERE host = :host AND port = :port AND client = :client;
+	),
+
+	[Each] = SQL(
+		SELECT pushToken.value, pushEndpoint.value
+		FROM clients
+		JOIN preferences AS pushToken USING (client)
+		JOIN preferences AS pushEndpoint USING (client)
+		WHERE host = :host AND port = :port
+			AND pushToken.key = 'PUSH-TOKEN'
+			AND pushEndpoint.key = 'PUSH-ENDPOINT';
+	),
+
+	[Notify] = SQL(
+		WITH mentions AS (
+			SELECT DISTINCT client
+			FROM clients
+			JOIN preferences USING (client)
+			WHERE host = :host AND port = :port AND (
+				(key = 'MENTION-KEYWORD' AND keyword(:message, value)) OR
+				(key = 'MENTION-CHANNEL' AND value = :channel) OR
+				(key = 'MENTION-NICK' AND value = :nick) OR
+				:direct
+			)
+		),
+		ignores AS (
+			SELECT DISTINCT client
+			FROM clients
+			JOIN preferences USING (client)
+			WHERE host = :host AND port = :port AND (
+				(key = 'IGNORE-KEYWORD' AND keyword(:message, value)) OR
+				(key = 'IGNORE-CHANNEL' AND value = :channel) OR
+				(key = 'IGNORE-NICK' AND value = :nick)
+			)
+		),
+		matches AS (SELECT * FROM mentions EXCEPT SELECT * FROM ignores)
+		SELECT
+			pushToken.value,
+			pushEndpoint.value,
+			coalesce(showMessagePreview.value, 'true')
+		FROM clients
+		JOIN matches USING (client)
+		JOIN preferences AS pushToken USING (client)
+		JOIN preferences AS pushEndpoint USING (client)
+		LEFT JOIN preferences AS showMessagePreview
+			ON showMessagePreview.client = clients.client
+			AND showMessagePreview.key = 'SHOW-MESSAGE-PREVIEW'
+		WHERE pushToken.key = 'PUSH-TOKEN'
+			AND pushEndpoint.key = 'PUSH-ENDPOINT';
+	),
+
+	[Increment] = SQL(
+		INSERT INTO badges (host, port, count)
+		VALUES (:host, :port, 1)
+		ON CONFLICT (host, port) DO
+		UPDATE SET count = count + 1
+		WHERE host = :host AND port = :port;
+	),
+
+	[Reset] = SQL(
+		DELETE FROM badges WHERE host = :host AND port = :port;
+	),
+
+	[Badge] = SQL(
+		SELECT sum(count) FROM badges;
+	),
+};
+
+static int badgeCount(int op) {
+	dbVerbose(stmts[op]);
+	int result = sqlite3_step(stmts[op]);
+	if (result != SQLITE_DONE) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	sqlite3_reset(stmts[op]);
+
+	dbVerbose(stmts[Badge]);
+	result = sqlite3_step(stmts[Badge]);
+	if (result != SQLITE_ROW) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	int badge = sqlite3_column_int(stmts[Badge], 0);
+	sqlite3_reset(stmts[Badge]);
+	return badge;
+}
+
+static void palaverIdentify(struct Message *msg) {
+	require(msg, false, 3);
+	dbBindText(stmts[Identify], ":client", msg->params[1]);
+	dbBindText(stmts[Identify], ":version", msg->params[2]);
+	dbVerbose(stmts[Identify]);
+	int result = sqlite3_step(stmts[Identify]);
+	if (result == SQLITE_DONE) {
+		format("PALAVER REQ\r\n");
+	} else if (result != SQLITE_ROW) {
+		errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	}
+	sqlite3_reset(stmts[Identify]);
+}
+
+static void palaverBegin(struct Message *msg) {
+	require(msg, false, 3);
+	dbBindText(stmts[Begin], ":client", msg->params[1]);
+	dbVerbose(stmts[Begin]);
+	int result = sqlite3_step(stmts[Begin]);
+	if (result != SQLITE_DONE) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	sqlite3_reset(stmts[Begin]);
+	dbBindCopy(stmts[Set], ":client", msg->params[1]);
+	dbBindCopy(stmts[End], ":client", msg->params[1]);
+	dbBindCopy(stmts[End], ":version", msg->params[2]);
+}
+
+static void palaverSet(struct Message *msg) {
+	require(msg, false, 3);
+	dbBindText(stmts[Set], ":key", msg->params[1]);
+	dbBindText(stmts[Set], ":value", msg->params[2]);
+	dbVerbose(stmts[Set]);
+	int result = sqlite3_step(stmts[Set]);
+	if (result != SQLITE_DONE) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	sqlite3_reset(stmts[Set]);
+}
+
+static void palaverEnd(struct Message *msg) {
+	(void)msg;
+	dbVerbose(stmts[End]);
+	int result = sqlite3_step(stmts[End]);
+	if (result != SQLITE_DONE) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	sqlite3_reset(stmts[End]);
+}
+
+static void handlePalaver(struct Message *msg) {
+	require(msg, false, 1);
+	if (!strcmp(msg->params[0], "IDENTIFY")) {
+		palaverIdentify(msg);
+	} else if (!strcmp(msg->params[0], "BEGIN")) {
+		palaverBegin(msg);
+	} else if (!strcmp(msg->params[0], "SET")) {
+		palaverSet(msg);
+	} else if (!strcmp(msg->params[0], "ADD")) {
+		palaverSet(msg);
+	} else if (!strcmp(msg->params[0], "END")) {
+		palaverEnd(msg);
+	}
+}
+
+static void pushNotify(const char *endpoint, const char *token, char *body) {
+	CURLcode code = curl_easy_setopt(curl, CURLOPT_URL, endpoint);
+	if (code) {
+		warnx("%s: %s", endpoint, curlError);
+		return;
+	}
+
+	char auth[256];
+	struct curl_slist *list = NULL;
+	snprintf(auth, sizeof(auth), "Authorization: Bearer %s", token);
+	list = curl_slist_append(list, "Content-Type: application/json");
+	list = curl_slist_append(list, auth);
+	curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list);
+
+	size_t len = strlen(body);
+	FILE *file = fmemopen(body, len, "r");
+	if (!file) err(EX_OSERR, "fmemopen");
+
+	curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)len);
+	curl_easy_setopt(curl, CURLOPT_READDATA, file);
+
+	if (verbose) fprintf(stderr, "%s\n", body);
+	code = curl_easy_perform(curl);
+	if (code) warnx("%s: %s", endpoint, curlError);
+
+	fclose(file);
+	curl_easy_setopt(curl, CURLOPT_HTTPHEADER, NULL);
+	curl_slist_free_all(list);
+}
+
+static void handleReplyNowAway(struct Message *msg) {
+	(void)msg;
+	away = true;
+}
+
+static void handleReplyUnaway(struct Message *msg) {
+	(void)msg;
+	if (!away) return;
+	away = false;
+
+	char json[32];
+	snprintf(json, sizeof(json), "{\"badge\":%d}", badgeCount(Reset));
+
+	int result;
+	dbVerbose(stmts[Each]);
+	while (SQLITE_ROW == (result = sqlite3_step(stmts[Each]))) {
+		int i = 0;
+		const char *token = sqlite3_column_text(stmts[Each], i++);
+		const char *endpoint = sqlite3_column_text(stmts[Each], i++);
+		pushNotify(endpoint, token, json);
+	}
+	if (result != SQLITE_DONE) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	sqlite3_reset(stmts[Each]);
+}
+
+static bool noPreview;
+static bool noPrivatePreview;
+
+static void jsonString(FILE *file, const char *str) {
+	fputc('"', file);
+	for (const char *ch = str; *ch; ++ch) {
+		if (iscntrl(*ch) || *ch == '"' || *ch == '\\') {
+			fprintf(file, "\\u%04x", (unsigned)*ch);
+		} else {
+			fputc(*ch, file);
+		}
+	}
+	fputc('"', file);
+}
+
+static char *jsonBody(int badge, struct Message *msg, bool preview) {
+	bool private = (msg->params[0][0] != '#');
+	if (private && noPrivatePreview) preview = false;
+	if (noPreview) preview = false;
+
+	char *buf;
+	size_t len;
+	FILE *file = open_memstream(&buf, &len);
+	if (!file) err(EX_OSERR, "open_memstream");
+
+	fprintf(file, "{\"badge\":%d", badge);
+	fprintf(file, ",\"sender\":");
+	jsonString(file, msg->nick);
+	if (!private) {
+		fprintf(file, ",\"channel\":");
+		jsonString(file, msg->params[0]);
+	}
+	if (preview) {
+		if (!strncmp(msg->params[1], "\1ACTION ", 8)) {
+			size_t len = strlen(msg->params[1]);
+			if (msg->params[1][len - 1] == '\1') msg->params[1][len - 1] = '\0';
+			fprintf(file, ",\"intent\":\"ACTION\",\"message\":");
+			jsonString(file, &msg->params[1][8]);
+		} else {
+			fprintf(file, ",\"message\":");
+			jsonString(file, msg->params[1]);
+		}
+	} else {
+		fprintf(file, ",\"private\":true");
+	}
+	fprintf(file, "}");
+
+	int error = fclose(file);
+	if (error) err(EX_IOERR, "fclose");
+
+	return buf;
+}
+
+static void handlePrivmsg(struct Message *msg) {
+	require(msg, true, 2);
+	if (!away) return;
+	if (!msg->time) return;
+	struct tm tm = {0};
+	strptime(msg->time, "%FT%T", &tm);
+	time_t then = timegm(&tm);
+	if (time(NULL) - then > 60) return;
+
+	dbBindText(stmts[Notify], ":nick", msg->nick);
+	dbBindText(stmts[Notify], ":channel", msg->params[0]);
+	dbBindText(stmts[Notify], ":message", msg->params[1]);
+	dbBindText(
+		stmts[Notify], ":direct", (!strcmp(msg->params[0], nick) ? "1" : NULL)
+	);
+	dbVerbose(stmts[Notify]);
+	int result;
+	int badge = 0;
+	while (SQLITE_ROW == (result = sqlite3_step(stmts[Notify]))) {
+		int i = 0;
+		const char *token = sqlite3_column_text(stmts[Notify], i++);
+		const char *endpoint = sqlite3_column_text(stmts[Notify], i++);
+		const char *preview = sqlite3_column_text(stmts[Notify], i++);
+
+		if (!badge) badge = badgeCount(Increment);
+		char *body = jsonBody(badge, msg, !strcmp(preview, "true"));
+		pushNotify(endpoint, token, body);
+		free(body);
+	}
+	if (result != SQLITE_DONE) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	sqlite3_reset(stmts[Notify]);
+}
+
+static const struct {
+	const char *cmd;
+	Handler *fn;
+} Handlers[] = {
+	{ "001", handleReplyWelcome },
+	{ "302", handleReplyUserHost },
+	{ "305", handleReplyUnaway },
+	{ "306", handleReplyNowAway },
+	{ "CAP", handleCap },
+	{ "ERROR", handleError },
+	{ "NICK", handleNick },
+	{ "NOTICE", handlePrivmsg },
+	{ "PALAVER", handlePalaver },
+	{ "PING", handlePing },
+	{ "PRIVMSG", handlePrivmsg },
+};
+
+static void handle(struct Message *msg) {
+	if (!msg->cmd) return;
+	for (size_t i = 0; i < ARRAY_LEN(Handlers); ++i) {
+		if (strcmp(msg->cmd, Handlers[i].cmd)) continue;
+		Handlers[i].fn(msg);
+		break;
+	}
+}
+
+static void atExit(void) {
+	if (client) tls_close(client);
+	curl_easy_cleanup(curl);
+	for (size_t i = 0; i < QueriesLen; ++i) {
+		sqlite3_finalize(stmts[i]);
+	}
+	sqlite3_close(db);
+}
+
+static void quit(int sig) {
+	(void)sig;
+	format("QUIT\r\n");
+	atExit();
+	_exit(EX_OK);
+}
+
+int main(int argc, char *argv[]) {
+	bool insecure = false;
+	char *path = NULL;
+	const char *cert = NULL;
+	const char *priv = NULL;
+	const char *host = NULL;
+	const char *port = "6697";
+	const char *pass = NULL;
+	const char *trust = NULL;
+	const char *user = "pounce-palaver";
+
+	for (int opt; 0 < (opt = getopt(argc, argv, "!NPc:d:k:p:st:u:vw:"));) {
+		switch (opt) {
+			break; case '!': insecure = true;
+			break; case 'N': noPreview = true;
+			break; case 'P': noPrivatePreview = true;
+			break; case 'c': cert = optarg;
+			break; case 'd': path = optarg;
+			break; case 'k': priv = optarg;
+			break; case 'p': port = optarg;
+			break; case 's': sensitive = true;
+			break; case 't': trust = optarg;
+			break; case 'u': user = optarg;
+			break; case 'v': verbose = true;
+			break; case 'w': pass = optarg;
+			break; default:  return EX_USAGE;
+		}
+	}
+	if (optind == argc) errx(EX_USAGE, "host required");
+	host = argv[optind];
+
+	CURLcode code = curl_global_init(CURL_GLOBAL_ALL);
+	if (code) errx(EX_OSERR, "curl_global_init: %s", curl_easy_strerror(code));
+
+	curl = curl_easy_init();
+	if (!curl) errx(EX_SOFTWARE, "curl_easy_init");
+
+	curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, curlError);
+	curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L);
+	curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
+	curl_easy_setopt(curl, CURLOPT_VERBOSE, (verbose ? 1L : 0L));
+	curl_easy_setopt(curl, CURLOPT_POST, 1L);
+
+	dbFind(path);
+	atexit(atExit);
+
+	dbInit();
+	sqlite3_create_function(
+		db, "keyword", 2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL,
+		keyword, NULL, NULL
+	);
+	for (size_t i = 0; i < QueriesLen; ++i) {
+		int error = sqlite3_prepare_v3(
+			db, Queries[i], -1, SQLITE_PREPARE_PERSISTENT, &stmts[i], NULL
+		);
+		if (error) errx(EX_SOFTWARE, "%s: %s", sqlite3_errmsg(db), Queries[i]);
+		if (sqlite3_bind_parameter_index(stmts[i], ":host")) {
+			dbBindText(stmts[i], ":host", host);
+			dbBindText(stmts[i], ":port", port);
+		}
+	}
+
+	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 (insecure) {
+		tls_config_insecure_noverifycert(config);
+		tls_config_insecure_noverifyname(config);
+	}
+
+	int error;
+	if (trust) {
+		tls_config_insecure_noverifyname(config);
+		error = tls_config_set_ca_file(config, trust);
+		if (error) errx(EX_NOINPUT, "%s: %s", trust, tls_config_error(config));
+	}
+	if (cert) {
+		error = tls_config_set_keypair_file(config, cert, (priv ? priv : cert));
+		if (error) {
+			errx(
+				EX_SOFTWARE, "tls_config_set_keypair_file: %s",
+				tls_config_error(config)
+			);
+		}
+	}
+
+	error = tls_configure(client, config);
+	if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(client));
+	tls_config_free(config);
+
+	error = tls_connect(client, host, port);
+	if (error) errx(EX_UNAVAILABLE, "tls_connect: %s", tls_error(client));
+
+	if (pass) format("PASS :%s\r\n", pass);
+	format(
+		"CAP REQ :server-time palaverapp.com causal.agency/passive\r\n"
+		"CAP END\r\n"
+		"NICK *\r\n"
+		"USER %s 0 * :pounce-palaver\r\n",
+		user
+	);
+
+	signal(SIGINT, quit);
+	signal(SIGTERM, quit);
+
+	char buf[8191 + 512];
+	size_t len = 0;
+	for (;;) {
+		ssize_t ret = tls_read(client, &buf[len], sizeof(buf) - len);
+		if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) continue;
+		if (ret < 0) errx(EX_IOERR, "tls_read: %s", tls_error(client));
+		if (!ret) errx(EX_PROTOCOL, "server closed connection");
+		len += ret;
+
+		char *line = buf;
+		for (;;) {
+			char *crlf = memmem(line, &buf[len] - line, "\r\n", 2);
+			if (!crlf) break;
+			crlf[0] = '\0';
+			struct Message msg = parse(line);
+			handle(&msg);
+			line = crlf + 2;
+		}
+		len -= line - buf;
+		memmove(buf, line, len);
+	}
+}
diff --git a/pounce-notify.1 b/pounce-notify.1
new file mode 100644
index 0000000..2df59a0
--- /dev/null
+++ b/pounce-notify.1
@@ -0,0 +1,115 @@
+.Dd November 28, 2021
+.Dt POUNCE-NOTIFY 1
+.Os
+.
+.Sh NAME
+.Nm pounce-notify
+.Nd notifications for pounce
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl v
+.Op Fl c Ar cert
+.Op Fl k Ar priv
+.Op Fl p Ar port
+.Op Fl t Ar trust
+.Op Fl u Ar user
+.Op Fl w Ar pass
+.Ar host
+.Ar command
+.
+.Sh DESCRIPTION
+The
+.Nm
+program connects to an instance of
+.Xr pounce 1
+at
+.Ar host
+and runs
+.Ar command
+for each private message
+or message containing the user's nickname
+while the user is marked as away.
+The nickname is matched case-sensitively
+and must be surrounded by whitespace or punctuation.
+Information about the message
+is passed to the command
+through the environment.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl c Ar cert
+Load the TLS client certificate from
+.Ar cert .
+If the private key is in a separate file,
+it is loaded with
+.Fl k .
+.It Fl k Ar priv
+Load the TLS client private key from
+.Ar priv .
+.It Fl p Ar port
+Connect to
+.Ar port .
+The default port is 6697.
+.It Fl t Ar path
+Trust the self-signed certificate loaded
+from
+.Ar path
+and disable server name verification.
+.It Fl u Ar user
+Set the username to
+.Ar user .
+The default username is
+.Dq pounce-notify .
+.It Fl w Ar pass
+Log in with the server password
+.Ar pass .
+.El
+.
+.Sh ENVIRONMENT
+The following variables
+are set in the environment of
+.Ar command :
+.Pp
+.Bl -tag -width "NOTIFY_MESSAGE" -compact
+.It Ev NOTIFY_CHANNEL
+The name of the channel
+in which the message was sent.
+Unset if the message was private.
+.It Ev NOTIFY_HOST
+The hostname of the user who sent the message.
+.It Ev NOTIFY_MESSAGE
+The content of the message.
+.It Ev NOTIFY_NICK
+The nickname of the user who sent the message.
+.It Ev NOTIFY_TIME
+The time the message was sent,
+in
+.Sy server-time
+format.
+.It Ev NOTIFY_USER
+The username of the user who sent the message.
+.It Ev POUNCE_HOST
+The host
+.Nm
+is connected to.
+.It Ev POUNCE_PORT
+The port
+.Nm
+is connected to.
+.El
+.
+.Sh SEE ALSO
+.Xr pounce 1
+.
+.Sh AUTHORS
+.An June McEnroe Aq Mt june@causal.agency
+.
+.Sh BUGS
+Send mail to
+.Aq Mt list+pounce@causal.agency
+or join
+.Li #ascii.town
+on
+.Li irc.tilde.chat .
diff --git a/pounce-palaver.1 b/pounce-palaver.1
new file mode 100644
index 0000000..2d5aa1d
--- /dev/null
+++ b/pounce-palaver.1
@@ -0,0 +1,112 @@
+.Dd November 28, 2021
+.Dt POUNCE-PALAVER 1
+.Os
+.
+.Sh NAME
+.Nm pounce-palaver
+.Nd Palaver push notifications for pounce
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl PNsv
+.Op Fl c Ar cert
+.Op Fl d Ar path
+.Op Fl k Ar priv
+.Op Fl p Ar port
+.Op Fl t Ar trust
+.Op Fl u Ar user
+.Op Fl w Ar pass
+.Ar host
+.
+.Sh DESCRIPTION
+The
+.Nm
+daemon provides push notifications
+for the Palaver IRC app via the
+.Xr pounce 1
+IRC bouncer.
+The
+.Cm palaver
+option must be enabled in
+.Xr pounce 1 .
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl N
+Never send message previews,
+regardless of the app preferences.
+.It Fl P
+Never send message previews
+for private messages.
+.It Fl c Ar cert
+Load the TLS client certificate from
+.Ar path .
+If the private key is in a separate file,
+it is loaded with
+.Fl k .
+.It Fl d Ar path
+Set the path to the database file
+used to store notification preferences.
+The default path is documented in
+.Sx FILES .
+.It Fl k Ar priv
+Load the TLS client private key from
+.Ar path .
+.It Fl p Ar port
+Connect to
+.Ar port .
+The default port is 6697.
+.It Fl s
+Match nick and keywords case-sensitively,
+despite the specification.
+.It Fl t Ar path
+Trust the self-signed certificate loaded from
+.Ar path
+and disable server name verification.
+.It Fl u Ar user
+Set the username to
+.Ar user .
+The default username is
+.Dq pounce-palaver .
+.It Fl v
+Log IRC protocol, SQL and HTTP to standard error.
+.It Fl w Ar pass
+Log in with the server password
+.Ar pass .
+.It Ar host
+Connect to
+.Ar host .
+.El
+.
+.Sh FILES
+.Bl -tag -width Ds
+.It Pa $XDG_DATA_DIRS/pounce/palaver.sqlite
+The database file is searched for first in
+.Ev $XDG_DATA_HOME ,
+usually
+.Pa ~/.local/share ,
+followed by the colon-separated list of paths
+.Ev $XDG_DATA_DIRS ,
+usually
+.Pa /usr/local/share:/usr/share .
+.It Pa ~/.local/share/pounce/palaver.sqlite
+The most likely default path to the database file.
+.El
+.
+.Sh SEE ALSO
+.Xr pounce 1
+.
+.Sh STANDARDS
+.Lk https://github.com/cocodelabs/palaver-irc-capability "Palaver IRC Capability"
+.
+.Sh AUTHORS
+.An June McEnroe Aq Mt june@causal.agency
+.
+.Sh BUGS
+Send mail to
+.Aq Mt list+pounce@causal.agency
+or join
+.Li #ascii.town
+on
+.Li irc.tilde.chat .
diff --git a/pounce.1 b/pounce.1
index b61527a..e4919d2 100644
--- a/pounce.1
+++ b/pounce.1
@@ -1,4 +1,4 @@
-.Dd February 27, 2020
+.Dd July 16, 2023
 .Dt POUNCE 1
 .Os
 .
@@ -8,25 +8,29 @@
 .
 .Sh SYNOPSIS
 .Nm
-.Op Fl NTev
-.Op Fl A Ar cert
-.Op Fl C Ar cert
-.Op Fl H Ar host
-.Op Fl K Ar priv
-.Op Fl P Ar port
+.Op Fl LNTev
+.Op Fl A Ar local-ca
+.Op Fl C Ar local-cert
+.Op Fl H Ar local-host
+.Op Fl K Ar local-priv
+.Op Fl P Ar local-port
+.Op Fl Q Ar queue-interval
+.Op Fl R Ar blind-req
 .Op Fl S Ar bind
-.Op Fl U Ar unix
-.Op Fl W Ar pass
-.Op Fl a Ar auth
-.Op Fl c Ar cert
+.Op Fl U Ar local-path
+.Op Fl W Ar local-pass
+.Op Fl a Ar sasl-plain
+.Op Fl c Ar client-cert
 .Op Fl f Ar save
 .Op Fl h Ar host
 .Op Fl j Ar join
-.Op Fl k Ar priv
+.Op Fl k Ar client-priv
+.Op Fl m Ar mode
 .Op Fl n Ar nick
 .Op Fl p Ar port
 .Op Fl q Ar quit
 .Op Fl r Ar real
+.Op Fl t Ar trust
 .Op Fl s Ar size
 .Op Fl u Ar user
 .Op Fl w Ar pass
@@ -34,6 +38,13 @@
 .Op Ar config ...
 .
 .Nm
+.Fl o
+.Op Fl S Ar bind
+.Op Fl h Ar host
+.Op Fl p Ar port
+.Op Ar config ...
+.
+.Nm
 .Op Fl A Ar ca
 .Fl g Ar cert
 .
@@ -43,23 +54,79 @@
 .Sh DESCRIPTION
 The
 .Nm
-daemon
+program
 is a multi-client, TLS-only IRC bouncer.
 It maintains a persistent connection to an IRC server
 while allowing clients to connect and disconnect,
 receiving messages that were missed upon reconnection.
-Clients should use the IRCv3.2
+Clients must uniquely identify themselves to
+.Nm
+by their IRC username
+(not nickname).
+The IRCv3
 .Sy server-time
-extension
-to know when missed messages were received
-and uniquely identify themselves by username.
+extension is used to indicate
+when messages were originally received.
 See
 .Sx Client Configuration
 for details.
 .
 .Pp
+The local server portion of
+.Nm
+requires a TLS certificate,
+which can be obtained for example
+by an ACME client such as
+.Xr acme-client 8 .
+The private key
+must be made readable by
+the user running
+.Nm .
+.
+.Pp
+One instance of
+.Nm
+must be configured for each IRC network.
+Instances of
+.Nm
+must either use different local ports with
+.Cm local-port
+or different local host names with
+.Cm local-host
+and
+.Cm local-path
+to be dispatched from the same port by
+.Xr calico 1 .
+.
+.Pp
+Client connections are not accepted
+until successful login to the server.
+If the server connection is lost,
+the
+.Nm
+process exits.
+.
+.Pp
 Options can be loaded from
 files listed on the command line.
+Files are searched for in
+.Pa $XDG_CONFIG_DIRS/pounce
+.Po
+usually
+.Pa ~/.config/pounce
+.Pc
+unless the path starts with
+.Ql / ,
+.Ql \&./
+or
+.Ql \&../ .
+Certificate and private key paths
+are searched for in the same manner.
+Files and flags
+listed later on the command line
+take precedence over those listed earlier.
+.
+.Pp
 Each option is placed on a line,
 and lines beginning with
 .Ql #
@@ -67,75 +134,80 @@ are ignored.
 The options are listed below
 following their corresponding flags.
 .
-.Pp
-The arguments are as follows:
-.
+.Ss Local Server Options
 .Bl -tag -width Ds
-.It Fl A Ar path , Cm local-ca = Ar path
+.It Fl A Ar path | Cm local-ca No = Ar path
 Require clients to authenticate
 using a TLS client certificate
-signed by the certificate authority loaded from
+either contained in
+or signed by a certificate in
+the file loaded from
 .Ar path .
+The file is reloaded when the
+.Dv SIGUSR1
+signal is received.
 See
 .Sx Generating Client Certificates .
 If
-.Fl W
+.Cm local-pass
 is also set,
-clients may instead connect
+clients may instead authenticate
 with a server password.
 .
-.It Fl C Ar path , Cm local-cert = Ar path
+.It Fl C Ar path | Cm local-cert No = Ar path
 Load TLS certificate from
 .Ar path .
-The default path is the
-.Xr certbot 8
-path for the
+The file is reloaded when the
+.Dv SIGUSR1
+signal is received.
+The default path is
+.Ar host Ns .pem ,
+where
 .Ar host
-set by
-.Fl H .
+is set by
+.Cm local-host .
 .
-.It Fl H Ar host , Cm local-host = Ar host
+.It Fl H Ar host | Cm local-host No = Ar host
 Bind to
 .Ar host .
 The default host is localhost.
 .
-.It Fl K Ar path , Cm local-priv = Ar path
+.It Fl K Ar path | Cm local-priv No = Ar path
 Load TLS private key from
 .Ar path .
-The default path is the
-.Xr certbot 8
-path for the
+The file is reloaded when the
+.Dv SIGUSR1
+signal is received.
+The default path is
+.Ar host Ns .key ,
+where
 .Ar host
-set by
-.Fl H .
-.
-.It Fl N , Cm no-names
-Do not request
-.Ql NAMES
-for each channel when a client connects.
-This avoids already connected clients
-receiving unsolicited responses
-but prevents new clients from populating user lists.
-.
-.It Fl P Ar port , Cm local-port = Ar port
+is set by
+.Cm local-host .
+.
+.It Fl L | Cm palaver
+Advertise the
+.Sy palaverapp.com
+IRCv3 vendor-specific capability to clients.
+This option only enables the capability;
+push notifications must be provided by the
+.Xr pounce-palaver 1
+special-purpose client.
+.
+.It Fl P Ar port | Cm local-port No = Ar port
 Bind to
 .Ar port .
 The default port is 6697.
 .
-.It Fl S Ar host , Cm bind = Ar host
-Bind to source address
-.Ar host
-when connecting to the server.
-.
-.It Fl T
+.It Fl T | Cm no-sts
 Do not advertise a
 strict transport security (STS) policy
 to clients.
 .
-.It Fl U Ar path , Cm local-path = Ar path
+.It Fl U Ar path | Cm local-path No = Ar path
 Bind to a UNIX-domain socket at
 .Ar path .
-Clients are accepted as sent by
+Clients are only accepted as dispatched by
 .Xr calico 1 .
 If
 .Ar path
@@ -143,14 +215,14 @@ is a directory,
 the
 .Ar host
 set by
-.Fl H
+.Cm local-host
 is appended to it.
 This option takes precedence over
-.Fl H
+.Cm local-host
 and
-.Fl P .
+.Cm local-port .
 .
-.It Fl W Ar pass , Cm local-pass = Ar pass
+.It Fl W Ar pass | Cm local-pass No = Ar pass
 Require the server password
 .Ar pass
 for clients to connect.
@@ -159,12 +231,80 @@ The
 string must be hashed using
 .Fl x .
 If
-.Fl A
+.Cm local-ca
 is also set,
-clients may instead connect
+clients may instead authenticate
 using a TLS client certificate.
 .
-.It Fl a Ar user : Ns Ar pass , Cm sasl-plain = Ar user : Ns Ar pass
+.It Fl f Ar path | Cm save No = Ar path
+Save and load the contents of the buffer from
+.Ar path
+in
+.Pa $XDG_DATA_DIRS/pounce ,
+usually
+.Pa ~/.local/share/pounce ,
+or an absolute or relative path if
+.Ar path
+starts with
+.Ql / ,
+.Ql \&./
+or
+.Ql \&../ .
+The file is truncated after loading.
+.
+.It Fl s Ar size | Cm size No = Ar size
+Set the number of messages contained in the buffer to
+.Ar size .
+This sets the maximum number
+of recent messages
+which can be relayed
+to a reconnecting client.
+The size must be a power of two.
+The default size is 4096.
+.El
+.
+.Ss Remote Server Options
+.Bl -tag -width Ds
+.It Fl N | Cm no-names
+Do not request
+.Ql NAMES
+for each channel when a client connects.
+This avoids already connected clients
+receiving unsolicited responses
+but prevents new clients from populating user lists.
+.
+.It Fl Q Ar ms | Cm queue-interval No = Ar ms
+Set the server send queue interval in milliseconds.
+The queue is used
+to send automated messages from
+.Nm
+to the server.
+Messages from clients
+are sent to the server directly.
+The default interval is 200 milliseconds.
+.
+.It Fl R Ar caps | Cm blind-req No = Ar caps
+Blindly request the IRCv3 capabilities
+.Ar caps ,
+which must be supported by
+.Nm .
+This can be used to enable hidden capabilities,
+such as
+.Sy userhost-in-names
+on some networks.
+.
+.It Fl S Ar host | Cm bind No = Ar host
+Bind to source address
+.Ar host
+when connecting to the server.
+To connect from any address
+over IPv4 only,
+use 0.0.0.0.
+To connect from any address
+over IPv6 only,
+use ::.
+.
+.It Fl a Ar user : Ns Ar pass | Cm sasl-plain No = Ar user : Ns Ar pass
 Authenticate as
 .Ar user
 with
@@ -172,147 +312,148 @@ with
 using SASL PLAIN.
 Since this method requires
 the account password in plaintext,
-it is recommended to use SASL EXTERNAL instead with
-.Fl e .
+it is recommended to use CertFP instead with
+.Cm sasl-external .
 .
-.It Fl c Ar path , Cm client-cert = Ar path
+.It Fl c Ar path | Cm client-cert No = Ar path
 Load the TLS client certificate from
 .Ar path .
 If the private key is in a separate file,
 it is loaded with
-.Fl k .
+.Cm client-priv .
 With
-.Fl e ,
+.Cm sasl-external ,
 authenticate using SASL EXTERNAL.
 Certificates can be generated with
 .Fl g .
 .
-.It Fl e , Cm sasl-external
+.It Fl e | Cm sasl-external
 Authenticate using SASL EXTERNAL,
 also known as CertFP.
 The TLS client certificate is loaded with
-.Fl c .
-For more information, see
+.Cm client-cert .
+See
 .Sx Configuring CertFP .
 .
-.It Fl f Ar path , Cm save = Ar path
-Load the contents of the buffer from
-.Ar path ,
-if it exists,
-and truncate it.
-On shutdown,
-save the contents of the buffer to
-.Ar path .
-.
-.It Fl g Ar path
-Generate a TLS client certificate using
-.Xr openssl 1
-and write it to
-.Ar path .
-The certificate is signed
-by the certificate authority if
-.Fl A
-is set,
-otherwise it is self-signed.
-.
-.It Fl h Ar host , Cm host = Ar host
+.It Fl h Ar host | Cm host No = Ar host
 Connect to
 .Ar host .
 .
-.It Fl j Ar chan , Cm join = Ar chan
+.It Fl j Ar channels Oo Ar keys Oc | Cm join No = Ar channels Op Ar keys
 Join the comma-separated list of
-.Ar chan .
+.Ar channels
+with the optional comma-separated list of channel
+.Ar keys .
 .
-.It Fl k Ar path , Cm client-priv = Ar path
+.It Fl k Ar path | Cm client-priv No = Ar path
 Load the TLS client private key from
 .Ar path .
 .
-.It Fl n Ar nick , Cm nick = Ar nick
+.It Fl m Ar mode | Cm mode No = Ar mode
+Set the user
+.Ar mode .
+.
+.It Fl n Ar nick | Cm nick No = Ar nick
 Set nickname to
 .Ar nick .
 The default nickname is the user's name.
 .
-.It Fl p Ar port , Cm port = Ar port
+.It Fl p Ar port | Cm port No = Ar port
 Connect to
 .Ar port .
 The default port is 6697.
 .
-.It Fl q Ar mesg , Cm quit = Ar mesg
+.It Fl q Ar mesg | Cm quit No = Ar mesg
 Quit with message
 .Ar mesg
 when shutting down.
 .
-.It Fl r Ar real , Cm real = Ar real
+.It Fl r Ar real | Cm real No = Ar real
 Set realname to
 .Ar real .
 The default realname is the same as the nickname.
 .
-.It Fl s Ar size , Cm size = Ar size
-Set the number of messages contained in the buffer to
-.Ar size .
-The size must be a power of two.
-The default size is 4096.
+.It Fl t Ar path | Cm trust No = Ar path
+Trust the certificate loaded from
+.Ar path .
+Server name verification is disabled.
+See
+.Sx Connecting to Servers with Self-signed Certificates .
 .
-.It Fl u Ar user , Cm user = Ar user
+.It Fl u Ar user | Cm user No = Ar user
 Set username to
 .Ar user .
 The default username is the same as the nickname.
 .
-.It Fl v , Cm verbose
-Write IRC messages to standard error
-in red to the server,
-green from the server,
-yellow from clients
-and blue to clients.
-.
-.It Fl w Ar pass , Cm pass = Ar pass
+.It Fl w Ar pass | Cm pass No = Ar pass
 Log in with the server password
 .Ar pass .
 .
-.It Fl x
-Prompt for a password
-and output a hash
-for use with
-.Fl W .
-.
-.It Fl y Ar mesg , Cm away = Ar mesg
+.It Fl y Ar mesg | Cm away No = Ar mesg
 Set away status to
 .Ar mesg
-when no clients are connected.
+when no clients are connected
+and no other away status has been set.
 .El
 .
+.Ss Other Options
+.Bl -tag -width Ds
+.It Fl g Ar path
+Generate a TLS client certificate using
+.Xr openssl 1
+and write it to
+.Ar path .
+The certificate is signed
+by the certificate authority if
+.Fl A
+is set,
+otherwise it is self-signed.
+.
+.It Fl o
+Print the server certificate chain
+to standard output in PEM format
+and exit.
+.
+.It Fl v | Cm verbose
+Log IRC messages to standard output:
 .Pp
-Client connections are not accepted
-until successful login to the server.
-If the server connection is lost,
-the
+.Bl -tag -width "<<" -compact
+.It <<
+from
+.Nm
+to the server
+.It >>
+from the server to
+.Nm
+.It ->
+from clients to
+.Nm
+.It <-
+from
 .Nm
-daemon exits.
+to clients
+.El
 .
-.Pp
-Upon receiving the
-.Dv SIGUSR1
-signal,
-the certificate and private key
-will be reloaded from the paths
-specified by
-.Fl C
-and
-.Fl K .
+.It Fl x
+Prompt for a password
+and output a hash
+for use with
+.Cm local-pass .
+.El
 .
 .Ss Client Configuration
 Clients should be configured to
 connect to the host and port set by
-.Fl H
+.Cm local-host
 and
-.Fl P ,
+.Cm local-port ,
 with TLS or SSL enabled.
 If
-.Fl W
+.Cm local-pass
 is used,
 clients must send a server password.
 If
-.Fl A
+.Cm local-ca
 is used,
 clients must connect with a client certificate
 and may request SASL EXTERNAL.
@@ -320,7 +461,7 @@ If both are used,
 clients may authenticate with either method.
 .
 .Pp
-Clients should register with unique usernames,
+Clients must register with unique usernames (not nicknames),
 for example the name of the client software
 or location from which it is connecting.
 New clients with the same username
@@ -331,13 +472,33 @@ The nickname and real name
 sent by clients are ignored.
 .
 .Pp
+Normally a client sending
+.Ic QUIT
+will simply be disconnected from
+.Nm .
+If, however,
+the quit message
+starts with the keyword
+.Sy $pounce ,
+.Nm
+itself will quit.
+The remainder of the message
+following the keyword
+will be used as
+.Nm Ap s
+quit message,
+or the default set by
+.Cm quit
+if there isn't any.
+.
+.Pp
 Clients which request the
 .Sy causal.agency/passive
 capability
 or with usernames beginning with hyphen
 .Ql -
 are considered passive
-and do not affect away status.
+and do not affect automatic away status.
 .
 .Pp
 Pass-through of the following IRCv3 capabilities
@@ -348,7 +509,9 @@ is supported:
 .Sy batch ,
 .Sy cap-notify ,
 .Sy chghost ,
+.Sy echo-message ,
 .Sy extended-join ,
+.Sy extended-monitor ,
 .Sy invite-notify ,
 .Sy labeled-response ,
 .Sy message-tags ,
@@ -366,23 +529,54 @@ not to the server.
 .Ss Generating Client Certificates
 .Bl -enum
 .It
-Generate a self-signed certificate authority (CA):
+Generate self-signed client certificates and private keys:
+.Bd -literal -offset indent
+$ pounce -g client1.pem
+$ pounce -g client2.pem
+.Ed
+.It
+Concatenate the certificate public keys into a CA file:
+.Bd -literal -offset indent
+$ openssl x509 -subject -in client1.pem \e
+	>> ~/.config/pounce/auth.pem
+$ openssl x509 -subject -in client2.pem \e
+	>> ~/.config/pounce/auth.pem
+.Ed
+.It
+Configure
+.Nm
+to verify client certificates
+against the CA file:
+.Bd -literal -offset indent
+local-ca = auth.pem
+# or: $ pounce -A auth.pem
+.Ed
+.El
+.
+.Pp
+Alternatively,
+client certificates can be signed
+by a generated certificate authority:
+.
+.Bl -enum
+.It
+Generate a self-signed certificate authority:
 .Bd -literal -offset indent
-pounce -g auth.pem
+$ pounce -g auth.pem
 .Ed
 .It
 Generate and sign client certificates
 using the CA:
 .Bd -literal -offset indent
-pounce -A auth.pem -g client1.pem
-pounce -A auth.pem -g client2.pem
+$ pounce -A auth.pem -g client1.pem
+$ pounce -A auth.pem -g client2.pem
 .Ed
 .It
 Since only the public key is needed
 for certificate verification,
 extract it from the CA:
 .Bd -literal -offset indent
-openssl x509 -in auth.pem -out auth.crt
+$ openssl x509 -in auth.pem -out ~/.config/pounce/auth.crt
 .Ed
 .It
 Configure
@@ -391,7 +585,7 @@ to verify client certificates
 against the CA:
 .Bd -literal -offset indent
 local-ca = auth.crt
-# or: pounce -A auth.crt
+# or: $ pounce -A auth.crt
 .Ed
 .El
 .
@@ -400,13 +594,13 @@ local-ca = auth.crt
 .It
 Generate a new TLS client certificate:
 .Bd -literal -offset indent
-pounce -g example.pem
+$ pounce -g ~/.config/pounce/example.pem
 .Ed
 .It
 Connect to the server using the certificate:
 .Bd -literal -offset indent
 client-cert = example.pem
-# or: pounce -c example.pem
+# or: $ pounce -c example.pem
 .Ed
 .It
 Identify with services or use
@@ -421,68 +615,27 @@ to require successful authentication when connecting:
 .Bd -literal -offset indent
 client-cert = example.pem
 sasl-external
-# or: pounce -e -c example.pem
+# or: $ pounce -e -c example.pem
 .Ed
 .El
 .
-.Ss Service Configuration
-Add the following to
-.Pa /etc/rc.conf
-to enable the
-.Nm
-daemon:
-.Bd -literal -offset indent
-pounce_enable="YES"
-.Ed
-.
-.Pp
-By default,
-the
-.Nm
-daemon is started in the
-.Pa /usr/local/etc/pounce
-directory.
-Configuration files in that location
-can be loaded by setting
-.Va pounce_flags :
+.Ss Connecting to Servers with Self-signed Certificates
+.Bl -enum
+.It
+Connect to the server
+and write its certificate to a file:
 .Bd -literal -offset indent
-pounce_flags="example.conf"
+$ pounce -o -h irc.example.org > ~/.config/pounce/example.pem
 .Ed
-.
-.Pp
-The
+.It
+Configure
 .Nm
-service supports profiles
-for running multiple instances.
-Set
-.Va pounce_profiles
-to a space-separated list of names.
-Flags for each profile will be set from
-.Va pounce_${profile}_flags .
-For example:
+to trust the certificate:
 .Bd -literal -offset indent
-pounce_profiles="example1 example2"
-pounce_example1_flags="example1.conf"
-pounce_example2_flags="example2.conf"
+trust = example.pem
+# or: $ pounce -t example.pem
 .Ed
-.
-.Pp
-The commands
-.Cm start , stop ,
-etc.\&
-will operate on the profile given as an additional argument,
-or on all profiles without an additional argument.
-.
-.Pp
-The
-.Cm reload
-command will cause the
-.Nm
-daemon to reload certificate files.
-To reload other configuration,
-use the
-.Cm restart
-command.
+.El
 .
 .Sh ENVIRONMENT
 .Bl -tag -width Ds
@@ -490,94 +643,203 @@ command.
 The default nickname.
 .El
 .
+.Sh FILES
+.Bl -tag -width Ds
+.It Pa $XDG_CONFIG_DIRS/pounce
+Configuration files, certificates and private keys
+are searched for first in
+.Ev $XDG_CONFIG_HOME ,
+usually
+.Pa ~/.config ,
+followed by the colon-separated list of paths
+.Ev $XDG_CONFIG_DIRS ,
+usually
+.Pa /etc/xdg .
+.It Pa ~/.config/pounce
+The most likely location of configuration files.
+.
+.It Pa $XDG_DATA_DIRS/pounce
+Save files are searched for first in
+.Ev $XDG_DATA_HOME ,
+usually
+.Pa ~/.local/share ,
+followed by the colon-separated list of paths
+.Ev $XDG_DATA_DIRS ,
+usually
+.Pa /usr/local/share:/usr/share .
+New save files are created in
+.Ev $XDG_DATA_HOME .
+.It Pa ~/.local/share/pounce
+The most likely location of save files.
+.El
+.
 .Sh EXAMPLES
-Configuration on the command line:
+Start
+.Nm :
 .Bd -literal -offset indent
-pounce -H pounce.example.org -h chat.freenode.net -j '#ascii.town'
+$ pounce -H irc.example.org -h irc.tilde.chat -j '#ascii.town'
 .Ed
-.
 .Pp
-Configuration in a file:
+Write an equivalent configuration file to
+.Pa ~/.config/pounce/tilde.conf :
 .Bd -literal -offset indent
-local-host = pounce.example.org
-host = chat.freenode.net
+local-host = irc.example.org
+host = irc.tilde.chat
 join = #ascii.town
 .Ed
+.Pp
+Load the configuration file:
+.Bd -literal -offset indent
+$ pounce tilde.conf
+.Ed
+.
+.Pp
+Add a certificate to
+.Xr acme-client.conf 5 :
+.Bd -literal -offset indent
+domain irc.example.org {
+	domain key "/home/user/.config/pounce/irc.example.org.key"
+	domain full chain certificate \e
+		"/home/user/.config/pounce/irc.example.org.pem"
+	sign with letsencrypt
+}
+.Ed
+.Pp
+Obtain the certificate
+and make the private key readable by
+.Nm :
+.Bd -literal -offset indent
+# acme-client irc.example.org
+# chown user /home/user/.config/pounce/irc.example.org.key
+.Ed
+.Pp
+Renew and reload the certificate with a
+.Xr cron 8
+job:
+.Bd -literal -offset indent
+~ * * * *	acme-client irc.example.org && pkill -USR1 pounce
+.Ed
+.
+.Sh DIAGNOSTICS
+Upon receiving the
+.Dv SIGINFO
+signal,
+.Nm
+prints the current producer position
+and the positions of each consumer
+identified by username.
+Following each consumer position
+is the number by which it trails the producer.
+On systems lacking
+.Dv SIGINFO ,
+.Dv SIGUSR2
+is used.
+.
+.Pp
+If a client reconnects
+after having missed more messages
+than the size of the buffer,
+.Nm
+will print a warning:
+.Bd -ragged -offset indent
+consumer
+.Em name
+dropped
+.Em n
+messages
+.Ed
+.Pp
+The size of the buffer
+can be adjusted with
+.Fl s .
 .
 .Sh SEE ALSO
 .Xr calico 1
 .
 .Sh STANDARDS
-The
-.Nm
-daemon implements the following:
-.
 .Bl -item
 .It
 .Rs
-.%A Attila Molnar
+.%A Waldo Bastian
+.%A Ryan Lortie
+.%A Lennart Poettering
+.%T XDG Base Directory Specification
+.%U https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
+.%D November 24, 2010
+.Re
+.It
+.Rs
+.%A Kyle Fuller
+.%A St\('ephan Kochen
+.%A Alexey Sokolov
 .%A James Wheare
-.%T IRCv3 Strict Transport Security
+.%T server-time Extension
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/sts
+.%U https://ircv3.net/specs/extensions/server-time
 .Re
 .It
 .Rs
+.%A Lee Hardy
+.%A Perry Lorier
+.%A Kevin L. Mitchell
 .%A Attila Molnar
+.%A Daniel Oakley
 .%A William Pitcock
-.%T IRCv3.2 SASL Authentication
+.%A James Wheare
+.%T IRCv3 Client Capability Negotiation
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/sasl-3.2
+.%U https://ircv3.net/specs/core/capability-negotiation
 .Re
 .It
 .Rs
-.%A C. Kalt
-.%T Internet Relay Chat: Client Protocol
+.%A S. Josefsson
+.%T The Base16, Base32, and Base64 Data Encodings
 .%I IETF
-.%N RFC 2812
-.%D April 2000
-.%U https://tools.ietf.org/html/rfc2812
+.%R RFC 4648
+.%U https://tools.ietf.org/html/rfc4648
+.%D October 2006
 .Re
 .It
 .Rs
-.%A K. Zeilenga, Ed.
-.%T The PLAIN Simple Authentication and Security Layer (SASL) Mechanism
+.%A C. Kalt
+.%T Internet Relay Chat: Client Protocol
 .%I IETF
-.%N RFC 4616
-.%D August 2006
-.%U https://tools.ietf.org/html/rfc4616
+.%R RFC 2812
+.%U https://tools.ietf.org/html/rfc2812
+.%D April 2000
 .Re
 .It
 .Rs
-.%A Kevin L. Mitchell
-.%A Perry Lorier
-.%A Lee Hardy
-.%A William Pitcock
 .%A Attila Molnar
-.%A Daniel Oakley
 .%A James Wheare
-.%T IRCv3 Client Capability Negotiation
+.%T IRCv3 Strict Transport Security
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/core/capability-negotiation
+.%U https://ircv3.net/specs/extensions/sts
 .Re
 .It
 .Rs
-.%A S. Josefsson
-.%T The Base16, Base32, and Base64 Data Encodings
-.%I IETF
-.%N RFC 4648
-.%D October 2006
-.%U https://tools.ietf.org/html/rfc4648
+.%A Attila Molnar
+.%A William Pitcock
+.%T IRCv3.2 SASL Authentication
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/sasl-3.2
 .Re
 .It
 .Rs
-.%A St\('ephan Kochen
-.%A Alexey Sokolov
-.%A Kyle Fuller
-.%A James Wheare
-.%T IRCv3.2 server-time Extension
+.%A Simon Ser
+.%A delthas
+.%T Read marker
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/server-time-3.2
+.%U https://ircv3.net/specs/extensions/read-marker
+.Re
+.It
+.Rs
+.%A K. Zeilenga, Ed.
+.%T The PLAIN Simple Authentication and Security Layer (SASL) Mechanism
+.%I IETF
+.%R RFC 4616
+.%U https://tools.ietf.org/html/rfc4616
+.%D August 2006
 .Re
 .El
 .
@@ -618,7 +880,7 @@ indicate if
 capabilities MUST NOT have values.
 The
 .Nm
-daemon parses
+implementation parses
 .Ql CAP REQ
 values in the same way as
 .Ql CAP LS
@@ -632,36 +894,15 @@ indicates that a client
 should not affect the automatic away status.
 .
 .Sh AUTHORS
-.An June Bug Aq Mt june@causal.agency
-.
-.Sh CAVEATS
-One instance of
-.Nm ,
-and therefore one local port,
-is required for each server connection.
-Alternatively,
-the
-.Xr calico 1
-daemon can be used to dispatch from one local port
-to many instances of
-.Nm
-using Server Name Indication.
-.
-.Pp
-The
-.Nm
-daemon makes no distinction between channels.
-Elevated activity in one channel
-may push messages from a quieter channel
-out of the buffer.
+.An June McEnroe Aq Mt june@causal.agency
 .
 .Sh BUGS
 Send mail to
-.Aq Mt june@causal.agency
+.Aq Mt list+pounce@causal.agency
 or join
 .Li #ascii.town
 on
-.Li chat.freenode.net .
+.Li irc.tilde.chat .
 .
 .Pp
 A client will sometimes receive its own message,
diff --git a/rc.d/calico b/rc.d/calico
deleted file mode 100644
index 32c21d8..0000000
--- a/rc.d/calico
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/bin/sh
-
-# PROVIDE: calico
-# REQUIRE: LOGIN
-# KEYWORD: shutdown
-
-. /etc/rc.subr
-
-name='calico'
-rcvar='calico_enable'
-
-load_rc_config "${name}"
-
-: ${calico_enable:='NO'}
-: ${calico_path:="/var/run/${name}"}
-calico_flags="${calico_flags} ${calico_path}"
-
-command='/usr/sbin/daemon'
-procname='/usr/local/bin/calico'
-pidfile="/var/run/${name}.pid"
-required_dirs=$calico_path
-
-child_flags=$calico_flags
-child_user=$calico_user
-unset calico_flags calico_user
-command_args="\
-	-p ${pidfile} -T ${name} ${child_user:+-u ${child_user}} \
-	-- ${procname} ${child_flags}"
-
-run_rc_command "$1"
diff --git a/rc.d/pounce b/rc.d/pounce
deleted file mode 100644
index 9777fda..0000000
--- a/rc.d/pounce
+++ /dev/null
@@ -1,63 +0,0 @@
-#!/bin/sh
-
-# PROVIDE: pounce
-# REQUIRE: LOGIN
-# KEYWORD: shutdown
-
-. /etc/rc.subr
-
-name='pounce'
-rcvar='pounce_enable'
-extra_commands='reload'
-sig_reload='USR1'
-
-load_rc_config "${name}"
-
-: ${pounce_enable:='NO'}
-: ${pounce_chdir:="/usr/local/etc/${name}"}
-
-command='/usr/sbin/daemon'
-pidprefix="/var/run/${name}"
-pidfile="${pidprefix}.pid"
-required_dirs=$pounce_chdir
-
-child_command='/usr/local/bin/pounce'
-child_pidfile="${pidprefix}.child.pid"
-
-if [ -n "$2" ]; then
-	profile=$2
-	if [ -n "${pounce_profiles}" ]; then
-		pidfile="${pidprefix}.${profile}.pid"
-		child_pidfile="${pidprefix}.${profile}.child.pid"
-		eval pounce_enable="\${pounce_${profile}_enable:-${pounce_enable}}"
-		eval pounce_flags="\${pounce_${profile}_flags:-${pounce_flags}}"
-		eval pounce_chdir="\${pounce_${profile}_chdir:-${pounce_chdir}}"
-		eval pounce_user="\${pounce_${profile}_user:-${pounce_user}}"
-	else
-		echo "$0: extra argument ignored"
-	fi
-else
-	if [ -n "${pounce_profiles}" -a -n "$1" ]; then
-		for profile in ${pounce_profiles}; do
-			echo "===> ${name} profile: ${profile}"
-			/usr/local/etc/rc.d/${name} "$1" "${profile}" || exit "$?"
-		done
-		exit
-	fi
-fi
-
-child_flags=$pounce_flags
-child_user=$pounce_user
-unset pounce_flags pounce_user
-command_args="\
-	-r -P ${pidfile} -p ${child_pidfile} -T ${name}${profile:+/${profile}} \
-	${child_user:+-u ${child_user}} \
-	-- ${child_command} ${child_flags}"
-
-pounce_reload() {
-	rc_pid=$(check_pidfile "$child_pidfile" "$child_command")
-	kill "-$sig_reload" "$rc_pid"
-}
-reload_cmd='pounce_reload'
-
-run_rc_command "$1"
diff --git a/ring.c b/ring.c
index 254c285..07b66d8 100644
--- a/ring.c
+++ b/ring.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -12,10 +12,23 @@
  *
  * You should have received a copy of the GNU General Public License
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
  */
 
 #include <assert.h>
 #include <err.h>
+#include <inttypes.h>
+#include <stdint.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <sys/time.h>
@@ -31,7 +44,7 @@ static struct {
 
 void ringAlloc(size_t len) {
 	if (len & (len - 1)) {
-		errx(EX_CONFIG, "ring length must be power of two: %zu", len);
+		errx(EX_USAGE, "ring length must be power of two: %zu", len);
 	}
 	ring.lines = calloc(len, sizeof(*ring.lines));
 	if (!ring.lines) err(EX_OSERR, "calloc");
@@ -40,7 +53,7 @@ void ringAlloc(size_t len) {
 	ring.len = len;
 }
 
-size_t producer;
+static size_t producer;
 
 void ringProduce(const char *line) {
 	size_t i = producer++ & (ring.len - 1);
@@ -99,10 +112,12 @@ size_t ringDiff(size_t consumer) {
 const char *ringPeek(struct timeval *time, size_t consumer) {
 	if (!ringDiff(consumer)) return NULL;
 	if (ringDiff(consumer) > ring.len) {
-		warnx(
-			"consumer %s dropped %zu messages",
-			consumers.ptr[consumer].name, ringDiff(consumer) - ring.len
-		);
+		if (consumers.ptr[consumer].pos) {
+			warnx(
+				"consumer %s dropped %zu messages",
+				consumers.ptr[consumer].name, ringDiff(consumer) - ring.len
+			);
+		}
 		consumers.ptr[consumer].pos = producer - ring.len;
 	}
 	size_t i = consumers.ptr[consumer].pos & (ring.len - 1);
@@ -128,17 +143,17 @@ void ringInfo(void) {
 	}
 }
 
-static const size_t Signatures[] = {
+static const uint64_t Signatures[] = {
 	0x0165636E756F70, // no ring size
 	0x0265636E756F70, // time_t only
 	0x0365636E756F70,
 };
 
-static size_t signatureVersion(size_t signature) {
+static size_t signatureVersion(uint64_t signature) {
 	for (size_t i = 0; i < ARRAY_LEN(Signatures); ++i) {
 		if (signature == Signatures[i]) return i;
 	}
-	errx(EX_DATAERR, "unknown file signature %zX", signature);
+	errx(EX_DATAERR, "unknown file signature %" PRIX64, signature);
 }
 
 static int writeSize(FILE *file, size_t value) {
@@ -152,7 +167,7 @@ static int writeString(FILE *file, const char *str) {
 }
 
 int ringSave(FILE *file) {
-	if (writeSize(file, Signatures[2])) return -1;
+	if (!fwrite(&Signatures[2], sizeof(*Signatures), 1, file)) return -1;
 	if (writeSize(file, ring.len)) return -1;
 	if (writeSize(file, producer)) return -1;
 	if (writeSize(file, consumers.len)) return -1;
@@ -191,7 +206,7 @@ static void readString(FILE *file, char **buf, size_t *cap) {
 }
 
 void ringLoad(FILE *file) {
-	size_t signature;
+	uint64_t signature;
 	fread(&signature, sizeof(signature), 1, file);
 	if (ferror(file)) err(EX_IOERR, "fread");
 	if (feof(file)) return;
diff --git a/server.c b/server.c
index 20d94e3..9d7be14 100644
--- a/server.c
+++ b/server.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -12,10 +12,22 @@
  *
  * You should have received a copy of the GNU General Public License
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
  */
 
 #include <assert.h>
 #include <err.h>
+#include <limits.h>
 #include <netdb.h>
 #include <netinet/in.h>
 #include <stdarg.h>
@@ -30,29 +42,47 @@
 #include "bounce.h"
 
 static struct tls *client;
+static struct tls_config *config;
 
-void serverConfig(bool insecure, const char *cert, const char *priv) {
-	struct tls_config *config = tls_config_new();
+void serverConfig(
+	bool insecure, const char *trust, const char *cert, const char *priv
+) {
+	int error = 0;
+	char buf[PATH_MAX];
+	config = tls_config_new();
 	if (!config) errx(EX_SOFTWARE, "tls_config_new");
 
-	int error = tls_config_set_ciphers(config, "compat");
-	if (error) {
-		errx(EX_SOFTWARE, "tls_config_set_ciphers: %s", tls_config_error(config));
-	}
-
 	if (insecure) {
 		tls_config_insecure_noverifycert(config);
 		tls_config_insecure_noverifyname(config);
 	}
 
+	if (trust) {
+		tls_config_insecure_noverifyname(config);
+		for (int i = 0; configPath(buf, sizeof(buf), trust, i); ++i) {
+			error = tls_config_set_ca_file(config, buf);
+			if (!error) break;
+		}
+		if (error) errx(EX_NOINPUT, "%s: %s", trust, tls_config_error(config));
+	}
+
 	if (cert) {
-		error = tls_config_set_keypair_file(config, cert, (priv ? priv : cert));
-		if (error) {
-			errx(
-				EX_SOFTWARE, "tls_config_set_keypair_file: %s",
-				tls_config_error(config)
-			);
+		for (int i = 0; configPath(buf, sizeof(buf), cert, i); ++i) {
+			if (priv) {
+				error = tls_config_set_cert_file(config, buf);
+			} else {
+				error = tls_config_set_keypair_file(config, buf, buf);
+			}
+			if (!error) break;
+		}
+		if (error) errx(EX_NOINPUT, "%s: %s", cert, tls_config_error(config));
+	}
+	if (priv) {
+		for (int i = 0; configPath(buf, sizeof(buf), priv, i); ++i) {
+			error = tls_config_set_key_file(config, buf);
+			if (!error) break;
 		}
+		if (error) errx(EX_NOINPUT, "%s: %s", priv, tls_config_error(config));
 	}
 
 	client = tls_client();
@@ -60,7 +90,6 @@ void serverConfig(bool insecure, const char *cert, const char *priv) {
 
 	error = tls_configure(client, config);
 	if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(client));
-	tls_config_free(config);
 }
 
 int serverConnect(const char *bindHost, const char *host, const char *port) {
@@ -114,21 +143,32 @@ int serverConnect(const char *bindHost, const char *host, const char *port) {
 	if (sock < 0) err(EX_UNAVAILABLE, "%s:%s", host, port);
 	freeaddrinfo(head);
 
-	int yes = 1;
-	error = setsockopt(sock, SOL_SOCKET, SO_NOSIGPIPE, &yes, sizeof(yes));
-	if (error) err(EX_OSERR, "setsockopt");
-
 	error = tls_connect_socket(client, sock, host);
 	if (error) errx(EX_PROTOCOL, "tls_connect: %s", tls_error(client));
 
-	error = tls_handshake(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);
 
 	return sock;
 }
 
+void serverClose(void) {
+	tls_close(client);
+	tls_free(client);
+}
+
+void serverPrintCert(void) {
+	size_t len;
+	const byte *pem = tls_peer_cert_chain_pem(client, &len);
+	printf("subject= %s\n", tls_peer_cert_subject(client));
+	fwrite(pem, len, 1, stdout);
+}
+
 void serverSend(const char *ptr, size_t len) {
-	if (verbose) fprintf(stderr, "\x1B[31m%.*s\x1B[m", (int)len, ptr);
+	verboseLog("<<", ptr, len);
 	while (len) {
 		ssize_t ret = tls_write(client, ptr, len);
 		if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) continue;
@@ -148,6 +188,46 @@ void serverFormat(const char *format, ...) {
 	serverSend(buf, len);
 }
 
+enum { QueueCap = 256 };
+static struct {
+	size_t enq;
+	size_t deq;
+	char *msgs[QueueCap];
+} queue;
+
+void serverDequeue(void) {
+	if (queue.enq - queue.deq) {
+		char *msg = queue.msgs[queue.deq++ % QueueCap];
+		serverSend(msg, strlen(msg));
+		free(msg);
+	} else {
+		struct itimerval timer = { .it_value = {0} };
+		int error = setitimer(ITIMER_REAL, &timer, NULL);
+		if (error) err(EX_OSERR, "setitimer");
+	}
+}
+
+struct timeval serverQueueInterval = { .tv_usec = 1000 * 200 };
+
+void serverEnqueue(const char *format, ...) {
+	if (queue.enq - queue.deq == QueueCap) {
+		warnx("server send queue full");
+		serverDequeue();
+	} else if (queue.enq == queue.deq) {
+		struct itimerval timer = {
+			.it_interval = serverQueueInterval,
+			.it_value = { .tv_usec = 1 },
+		};
+		int error = setitimer(ITIMER_REAL, &timer, NULL);
+		if (error) err(EX_OSERR, "setitimer");
+	}
+	va_list ap;
+	va_start(ap, format);
+	int len = vasprintf(&queue.msgs[queue.enq++ % QueueCap], format, ap);
+	va_end(ap);
+	if (len < 0) err(EX_OSERR, "vasprintf");
+}
+
 void serverRecv(void) {
 	static char buf[MessageCap];
 	static size_t len;
@@ -164,7 +244,7 @@ void serverRecv(void) {
 		crlf = memmem(line, &buf[len] - line, "\r\n", 2);
 		if (!crlf) break;
 		crlf[0] = '\0';
-		if (verbose) fprintf(stderr, "\x1B[32m%s\x1B[m\n", line);
+		verboseLog(">>", line, crlf - line);
 		const char *ping = line;
 		if (ping[0] == '@') {
 			ping += strcspn(ping, " ");
diff --git a/state.c b/state.c
index 5da5bcc..a28b3ba 100644
--- a/state.c
+++ b/state.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -12,6 +12,17 @@
  *
  * You should have received a copy of the GNU General Public License
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
  */
 
 #include <assert.h>
@@ -27,6 +38,8 @@
 
 bool stateNoNames;
 enum Cap stateCaps;
+char *stateAccount;
+bool stateAway;
 
 typedef void Handler(struct Message *msg);
 
@@ -45,31 +58,37 @@ enum { AuthLen = 299 };
 static char plainBase64[BASE64_SIZE(AuthLen)];
 
 void stateLogin(
-	const char *pass, bool sasl, const char *plain,
+	const char *pass, enum Cap blind, const char *plain,
 	const char *nick, const char *user, const char *real
 ) {
+	if (plain) {
+		byte buf[AuthLen] = {0};
+		size_t len = 1 + strlen(plain);
+		if (len > sizeof(buf)) errx(EX_USAGE, "SASL PLAIN too long");
+		memcpy(&buf[1], plain, len - 1);
+		byte *sep = memchr(buf, ':', len);
+		if (!sep) errx(EX_USAGE, "SASL PLAIN missing colon");
+		*sep = 0;
+		base64(plainBase64, buf, len);
+		explicit_bzero(buf, len);
+	}
+
 	serverFormat("CAP LS 302\r\n");
 	if (pass) serverFormat("PASS :%s\r\n", pass);
-	if (sasl) {
-		serverFormat("CAP REQ :%s\r\n", capList(CapSASL, NULL));
-		if (plain) {
-			byte buf[AuthLen];
-			size_t len = 1 + strlen(plain);
-			if (sizeof(buf) < len) {
-				errx(EX_SOFTWARE, "SASL PLAIN is too long");
-			}
-			buf[0] = 0;
-			for (size_t i = 0; plain[i]; ++i) {
-				buf[1 + i] = (plain[i] == ':' ? 0 : plain[i]);
-			}
-			base64(plainBase64, buf, len);
-			explicit_bzero(buf, sizeof(buf));
-		}
-	}
+	if (blind) serverFormat("CAP REQ :%s\r\n", capList(blind, NULL));
 	serverFormat("NICK %s\r\n", nick);
 	serverFormat("USER %s 0 * :%s\r\n", user, real);
 }
 
+static const enum Cap DontReq = 0
+	| CapConsumer
+	| CapPalaverApp
+	| CapPassive
+	| CapReadMarker
+	| CapSASL
+	| CapSTS
+	| CapUnsupported;
+
 static void handleCap(struct Message *msg) {
 	require(msg, false, 3);
 	enum Cap caps;
@@ -80,8 +99,15 @@ static void handleCap(struct Message *msg) {
 	}
 
 	if (!strcmp(msg->params[1], "LS") || !strcmp(msg->params[1], "NEW")) {
-		caps &= ~(CapSASL | CapSTS | CapUnsupported);
-		if (caps) serverFormat("CAP REQ :%s\r\n", capList(caps, NULL));
+		caps &= ~DontReq;
+		if (caps & CapEchoMessage && !(caps & CapLabeledResponse)) {
+			caps &= ~CapEchoMessage;
+		}
+		if (caps) {
+			serverFormat("CAP REQ :%s\r\n", capList(caps, NULL));
+		} else {
+			if (!(stateCaps & CapSASL)) serverFormat("CAP END\r\n");
+		}
 
 	} else if (!strcmp(msg->params[1], "ACK")) {
 		stateCaps |= caps;
@@ -111,7 +137,8 @@ static void handleAuthenticate(struct Message *msg) {
 }
 
 static void handleReplyLoggedIn(struct Message *msg) {
-	(void)msg;
+	require(msg, false, 3);
+	set(&stateAccount, msg->params[2]);
 	serverFormat("CAP END\r\n");
 }
 
@@ -150,12 +177,6 @@ bool stateReady(void) {
 		&& intro.myInfo[0];
 }
 
-static void set(char **field, const char *value) {
-	if (*field) free(*field);
-	*field = strdup(value);
-	if (!*field) err(EX_OSERR, "strdup");
-}
-
 static void handleErrorNicknameInUse(struct Message *msg) {
 	if (self.nick) return;
 	require(msg, false, 2);
@@ -189,6 +210,7 @@ static void handleReplyMyInfo(struct Message *msg) {
 }
 
 static struct {
+	bool done;
 	char **tokens;
 	size_t cap, len;
 } support;
@@ -206,12 +228,18 @@ static void supportAdd(const char *token) {
 
 static void handleReplyISupport(struct Message *msg) {
 	require(msg, false, 1);
+	if (support.done) return;
 	for (size_t i = 1; i < ParamCap; ++i) {
 		if (!msg->params[i] || strchr(msg->params[i], ' ')) break;
 		supportAdd(msg->params[i]);
 	}
 }
 
+static void handleReplyMOTDStart(struct Message *msg) {
+	(void)msg;
+	support.done = true;
+}
+
 struct Channel {
 	char *name;
 	char *topic;
@@ -258,9 +286,9 @@ static bool originSelf(const char *origin) {
 	size_t len = strlen(self.nick);
 	if (strlen(origin) < len) return false;
 	if (strncmp(origin, self.nick, len)) return false;
-	if (origin[len] != '!') return false;
+	if (origin[len] && origin[len] != '!') return false;
 
-	if (!self.origin || strcmp(self.origin, origin)) {
+	if (origin[len] && (!self.origin || strcmp(self.origin, origin))) {
 		set(&self.origin, origin);
 	}
 	return true;
@@ -271,6 +299,7 @@ static void handleNick(struct Message *msg) {
 	if (!originSelf(msg->origin)) return;
 	set(&self.nick, msg->params[0]);
 
+	if (!self.origin) return;
 	char *rest = strchr(self.origin, '!');
 	assert(rest);
 	size_t size = strlen(self.nick) + strlen(rest) + 1;
@@ -308,6 +337,16 @@ static void handleReplyTopic(struct Message *msg) {
 	chanTopic(msg->params[1], msg->params[2]);
 }
 
+static void handleReplyUnaway(struct Message *msg) {
+	(void)msg;
+	stateAway = false;
+}
+
+static void handleReplyNowAway(struct Message *msg) {
+	(void)msg;
+	stateAway = true;
+}
+
 static void handleError(struct Message *msg) {
 	require(msg, false, 1);
 	errx(EX_UNAVAILABLE, "%s", msg->params[0]);
@@ -322,8 +361,13 @@ static const struct {
 	{ "003", handleReplyCreated },
 	{ "004", handleReplyMyInfo },
 	{ "005", handleReplyISupport },
+	{ "305", handleReplyUnaway },
+	{ "306", handleReplyNowAway },
 	{ "332", handleReplyTopic },
+	{ "375", handleReplyMOTDStart },
+	{ "422", handleReplyMOTDStart },
 	{ "433", handleErrorNicknameInUse },
+	{ "437", handleErrorNicknameInUse },
 	{ "900", handleReplyLoggedIn },
 	{ "904", handleErrorSASLFail },
 	{ "905", handleErrorSASLFail },
@@ -355,18 +399,30 @@ void stateSync(struct Client *client) {
 		client,
 		":%s NOTICE %s :"
 		"pounce is GPLv3 fwee softwawe ^w^  code is avaiwable fwom %s\r\n",
-		ORIGIN, self.nick, SOURCE_URL
+		clientOrigin, self.nick, SOURCE_URL
 	);
 
+	if (stateAccount) {
+		clientFormat(
+			client, ":%s 900 %s %s %s :You are now logged in as %s\r\n",
+			clientOrigin, self.nick, stateEcho(), stateAccount, stateAccount
+		);
+	}
+
 	clientFormat(
-		client,
-		":%s 001 %s :%s\r\n"
-		":%s 002 %s :%s\r\n"
-		":%s 003 %s :%s\r\n"
-		":%s 004 %s %s %s %s %s%s%s\r\n",
-		intro.origin, self.nick, intro.welcome,
-		intro.origin, self.nick, intro.yourHost,
-		intro.origin, self.nick, intro.created,
+		client, ":%s 001 %s :%s\r\n",
+		intro.origin, self.nick, intro.welcome
+	);
+	clientFormat(
+		client, ":%s 002 %s :%s\r\n",
+		intro.origin, self.nick, intro.yourHost
+	);
+	clientFormat(
+		client, ":%s 003 %s :%s\r\n",
+		intro.origin, self.nick, intro.created
+	);
+	clientFormat(
+		client, ":%s 004 %s %s %s %s %s%s%s\r\n",
 		intro.origin, self.nick,
 		intro.myInfo[0], intro.myInfo[1], intro.myInfo[2], intro.myInfo[3],
 		(intro.myInfo[4] ? " " : ""), (intro.myInfo[4] ? intro.myInfo[4] : "")
@@ -390,16 +446,18 @@ void stateSync(struct Client *client) {
 		);
 	}
 	if (i < support.len) {
-		clientFormat(client, ":%s 005 %s", intro.origin, self.nick);
+		char buf[512], *ptr = buf, *end = &buf[sizeof(buf)];
+		ptr = seprintf(ptr, end, ":%s 005 %s", intro.origin, self.nick);
 		for (; i < support.len; ++i) {
-			clientFormat(client, " %s", support.tokens[i]);
+			ptr = seprintf(ptr, end, " %s", support.tokens[i]);
 		}
-		clientFormat(client, " :are supported by this server\r\n");
+		ptr = seprintf(ptr, end, " :are supported by this server\r\n");
+		clientSend(client, buf, ptr - buf);
 	}
 
 	clientFormat(
 		client, ":%s 422 %s :MOTD File is missing\r\n",
-		ORIGIN, self.nick
+		clientOrigin, self.nick
 	);
 
 	if (chans.len) assert(self.origin);
@@ -409,10 +467,13 @@ void stateSync(struct Client *client) {
 		if (chan->topic) {
 			clientFormat(
 				client, ":%s 332 %s %s :%s\r\n",
-				ORIGIN, self.nick, chan->name, chan->topic
+				clientOrigin, self.nick, chan->name, chan->topic
 			);
 		}
+		if (client->caps & CapReadMarker) {
+			clientGetMarker(client, chan->name);
+		}
 		if (stateNoNames) continue;
-		serverFormat("NAMES %s\r\n", chan->name);
+		serverEnqueue("NAMES %s\r\n", chan->name);
 	}
 }
diff --git a/xdg.c b/xdg.c
new file mode 100644
index 0000000..03f8c8d
--- /dev/null
+++ b/xdg.c
@@ -0,0 +1,131 @@
+/* Copyright (C) 2019, 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#include <err.h>
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sysexits.h>
+
+#include "bounce.h"
+
+#define SUBDIR "pounce"
+
+struct Base {
+	const char *envHome;
+	const char *envDirs;
+	const char *defHome;
+	const char *defDirs;
+};
+
+static const struct Base Config = {
+	.envHome = "XDG_CONFIG_HOME",
+	.envDirs = "XDG_CONFIG_DIRS",
+	.defHome = ".config",
+	.defDirs = "/etc/xdg",
+};
+
+static const struct Base Data = {
+	.envHome = "XDG_DATA_HOME",
+	.envDirs = "XDG_DATA_DIRS",
+	.defHome = ".local/share",
+	.defDirs = "/usr/local/share:/usr/share",
+};
+
+static char *basePath(
+	struct Base base, char *buf, size_t cap, const char *path, int i
+) {
+	if (path[strspn(path, ".")] == '/') {
+		if (i > 0) return NULL;
+		snprintf(buf, cap, "%s", path);
+		return buf;
+	}
+
+	if (i > 0) {
+		const char *dirs = getenv(base.envDirs);
+		if (!dirs) dirs = base.defDirs;
+		for (; i > 1; --i) {
+			dirs += strcspn(dirs, ":");
+			dirs += (*dirs == ':');
+		}
+		if (!*dirs) return NULL;
+		snprintf(
+			buf, cap, "%.*s/" SUBDIR "/%s",
+			(int)strcspn(dirs, ":"), dirs, path
+		);
+		return buf;
+	}
+
+	const char *home = getenv("HOME");
+	const char *baseHome = getenv(base.envHome);
+	if (baseHome) {
+		snprintf(buf, cap, "%s/" SUBDIR "/%s", baseHome, path);
+	} else if (home) {
+		snprintf(buf, cap, "%s/%s/" SUBDIR "/%s", home, base.defHome, path);
+	} else {
+		errx(EX_USAGE, "HOME unset");
+	}
+	return buf;
+}
+
+char *configPath(char *buf, size_t cap, const char *path, int i) {
+	return basePath(Config, buf, cap, path, i);
+}
+
+char *dataPath(char *buf, size_t cap, const char *path, int i) {
+	return basePath(Data, buf, cap, path, i);
+}
+
+FILE *configOpen(const char *path, const char *mode) {
+	char buf[PATH_MAX];
+	for (int i = 0; configPath(buf, sizeof(buf), path, i); ++i) {
+		FILE *file = fopen(buf, mode);
+		if (file) return file;
+		if (errno != ENOENT) warn("%s", buf);
+	}
+	warn("%s", configPath(buf, sizeof(buf), path, 0));
+	return NULL;
+}
+
+FILE *dataOpen(const char *path, const char *mode) {
+	char buf[PATH_MAX];
+	for (int i = 0; dataPath(buf, sizeof(buf), path, i); ++i) {
+		FILE *file = fopen(buf, mode);
+		if (file) return file;
+		if (errno != ENOENT) warn("%s", buf);
+	}
+	if (mode[0] != 'r') {
+		int error = mkdir(dataPath(buf, sizeof(buf), "", 0), S_IRWXU);
+		if (error && errno != EEXIST) warn("%s", buf);
+	}
+	FILE *file = fopen(dataPath(buf, sizeof(buf), path, 0), mode);
+	if (!file) warn("%s", buf);
+	return file;
+}