about summary refs log tree commit diff homepage
diff options
context:
space:
mode:
-rw-r--r--Makefile26
-rw-r--r--README62
-rw-r--r--client.c36
-rw-r--r--explore.html2
-rw-r--r--image.c48
-rw-r--r--index.html4
-rw-r--r--merge.c20
-rw-r--r--meta.c2
-rw-r--r--png.h2
-rw-r--r--rc.torus33
-rw-r--r--server.c361
-rwxr-xr-xsnapshot.sh7
-rw-r--r--sshd_config6
-rw-r--r--torus.114
-rw-r--r--torus.h2
15 files changed, 258 insertions, 367 deletions
diff --git a/Makefile b/Makefile
index dfe30e3..4b73389 100644
--- a/Makefile
+++ b/Makefile
@@ -1,9 +1,10 @@
 CHROOT_USER = torus
 CHROOT_GROUP = ${CHROOT_USER}
+WEBROOT = /var/www/ascii.town
 
 CFLAGS += -std=c11 -Wall -Wextra -Wpedantic
 LDFLAGS = -static
-LDLIBS = -lcursesw -lutil -lz
+LDLIBS = -lncursesw -lz
 
 -include config.mk
 
@@ -29,25 +30,19 @@ chroot.tar: client image server default8x16.psfu
 		root \
 		root/bin \
 		root/home \
-		root/usr/share/misc \
-		root/usr/share/torus \
-		root/var/run
+		root/usr/share/torus
 	install -d -o ${CHROOT_USER} -g ${CHROOT_GROUP} root/home/${CHROOT_USER}
-	install -d -o ${CHROOT_USER} -g ${CHROOT_GROUP} root/var/run/torus
 	cp -af /usr/share/locale root/usr/share
-	cp -fp /usr/share/misc/termcap.db root/usr/share/misc
-	cp -fp /rescue/sh root/bin
+	cp -af /usr/share/terminfo root/usr/share
+	cp -fp /bin/sh root/bin
 	install client image server root/bin
 	install -m 644 default8x16.psfu root/usr/share/torus
-	tar -c -f chroot.tar -C root bin home usr var
+	tar -c -f chroot.tar -C root bin home usr
 
-install: chroot.tar rc.torus explore.html index.html
-	tar -x -f chroot.tar -C /home/${CHROOT_USER}
-	install rc.torus /usr/local/etc/rc.d/torus
+install: chroot.tar explore.html index.html
+	tar -px -f chroot.tar -C /home/${CHROOT_USER}
 	install -o ${CHROOT_USER} -g ${CHROOT_GROUP} -m 644 \
-		explore.html \
-		index.html \
-		/usr/local/www/ascii.town
+		explore.html index.html ${WEBROOT}
 
 clean:
 	rm -fr ${OBJS} ${BINS} tags root chroot.tar
@@ -58,6 +53,3 @@ help.h:
 		> help.h
 	echo 'static const struct Tile *Help = (const struct Tile *)HelpData;' \
 		>> help.h
-
-README: torus.1
-	mandoc torus.1 | col -bx > README
diff --git a/README b/README
deleted file mode 100644
index 83645fa..0000000
--- a/README
+++ /dev/null
@@ -1,62 +0,0 @@
-torus(1)                FreeBSD General Commands Manual               torus(1)
-
-NAME
-     server, client, image, meta, merge – collaborative ASCII art
-
-SYNOPSIS
-     server [-d data] [-p pidfile] [-s sock]
-     client [-h] [-s sock]
-     image [-k] [-d data] [-f font] [-x x] [-y y]
-     meta
-     merge data1 data2 data3
-
-DESCRIPTION
-     server maps a data file and listens on a UNIX-domain socket to
-     synchronize events between clients.
-
-     client connects to a UNIX-domain socket and presents a curses(3)
-     interface.
-
-     image renders a tile from a data file using a PSF2 font to PNG on
-     standard output.  To build with kcgi(3) support, copy kcgi.mk to
-     config.mk.
-
-     meta extracts metadata from a data file on standard input to CSV on
-     standard ouput.  The CSV fields are tileX, tileY, createTime,
-     modifyCount, modifyTime, accessCount, accessTime.
-
-     merge interactively merges two data files data1 and data2 into data3.
-     Differing tiles are presented in a curses(3) interface and are chosen by
-     typing a or b.
-
-     The arguments are as follows:
-
-     -d data
-             Set path to data file.  The default path is torus.dat.
-
-     -f font
-             Set path to PSF2 font.  The default path is default8x16.psfu.
-
-     -h      Write help page data to standard output and exit.
-
-     -k      Run a FastCGI worker for use with kfcgi(8).
-
-     -p pidfile
-             Daemonize and write PID to pidfile.  Only available on FreeBSD.
-
-     -s sock
-             Set path to UNIX-domain socket.  The default path is torus.sock.
-
-     -x x    Set tile X coordinate to render.
-
-     -y y    Set tile Y coordinate to render.
-
-IMPLEMENTATION NOTES
-     This software targets FreeBSD and Darwin.
-
-     help.h contains tile data for the help page and can be generated from the
-     first tile of torus.dat.
-
-     default8x16.psfu is taken from kbd: http://kbd-project.org.
-
-Causal Agency                   January 8, 2019                  Causal Agency
diff --git a/client.c b/client.c
index dc2c166..be43641 100644
--- a/client.c
+++ b/client.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2018  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2018  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License as published by
@@ -33,10 +33,6 @@
 #include <unistd.h>
 #include <wchar.h>
 
-#ifdef __FreeBSD__
-#include <sys/capsicum.h>
-#endif
-
 #include "torus.h"
 #include "help.h"
 
@@ -524,8 +520,12 @@ static void inputDirection(bool keyCode, wchar_t ch) {
 
 static void inputInsert(bool keyCode, wchar_t ch) {
 	if (keyCode) {
-		inputNormal(keyCode, ch);
-		return;
+		if (ch == KEY_BACKSPACE) {
+			ch = '\b';
+		} else {
+			inputNormal(keyCode, ch);
+			return;
+		}
 	}
 	switch (ch) {
 		break; case Esc: {
@@ -681,6 +681,7 @@ static void readInput(void) {
 }
 
 int main(int argc, char *argv[]) {
+	int error;
 	const char *sockPath = DefaultSockPath;
 	int opt;
 	while (0 < (opt = getopt(argc, argv, "hs:"))) {
@@ -695,6 +696,12 @@ int main(int argc, char *argv[]) {
 	}
 
 	curse();
+
+#ifdef __OpenBSD__
+	error = pledge("stdio tty unix", NULL);
+	if (error) err(EX_OSERR, "pledge");
+#endif
+
 	modeHelp();
 	readInput();
 
@@ -702,18 +709,13 @@ int main(int argc, char *argv[]) {
 	if (client < 0) err(EX_OSERR, "socket");
 
 	struct sockaddr_un addr = { .sun_family = AF_LOCAL };
-	strlcpy(addr.sun_path, sockPath, sizeof(addr.sun_path));
-	int error = connect(client, (struct sockaddr *)&addr, SUN_LEN(&addr));
+	snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", sockPath);
+	error = connect(client, (struct sockaddr *)&addr, SUN_LEN(&addr));
 	if (error) err(EX_NOINPUT, "%s", sockPath);
 
-#ifdef __FreeBSD__
-	error = cap_enter();
-	if (error) err(EX_OSERR, "cap_enter");
-
-	cap_rights_t rights;
-	cap_rights_init(&rights, CAP_READ, CAP_WRITE, CAP_EVENT);
-	error = cap_rights_limit(client, &rights);
-	if (error) err(EX_OSERR, "cap_rights_limit");
+#ifdef __OpenBSD__
+	error = pledge("stdio tty", NULL);
+	if (error) err(EX_OSERR, "pledge");
 #endif
 
 	struct pollfd fds[2] = {
diff --git a/explore.html b/explore.html
index 0c930f5..84b4192 100644
--- a/explore.html
+++ b/explore.html
@@ -44,7 +44,7 @@ Code is available from
 <a href="https://git.causal.agency/torus">Causal Agency</a>.
 
 <script>
-	/* 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 Affero General Public License as published by
diff --git a/image.c b/image.c
index 80e2567..584eb4c 100644
--- a/image.c
+++ b/image.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2018, 2019  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2018, 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 Affero General Public License as published by
@@ -27,10 +27,6 @@
 #include <unistd.h>
 #include <zlib.h>
 
-#ifdef __FreeBSD__
-#include <sys/capsicum.h>
-#endif
-
 #ifdef HAVE_KCGI
 #include <sys/types.h>
 #include <stdarg.h>
@@ -204,16 +200,21 @@ static int streamWrite(void *cookie, const char *buf, int len) {
 
 static void worker(void) {
 	struct kfcgi *fcgi;
-	enum kcgi_err error = khttp_fcgi_init(
+	int error = khttp_fcgi_init(
 		&fcgi, Keys, KeysLen, Pages, PagesLen, PageTile
 	);
 	if (error) errkcgi(EX_CONFIG, error, "khttp_fcgi_init");
 
-	for (;;) {
-		struct kreq req;
-		error = khttp_fcgi_parse(fcgi, &req);
-		if (error) errkcgi(EX_DATAERR, error, "khttp_fcgi_parse");
+#ifdef __OpenBSD__
+	error = pledge("stdio recvfd", NULL);
+	if (error) err(EX_OSERR, "pledge");
+#endif
 
+	for (
+		struct kreq req;
+		!(error = khttp_fcgi_parse(fcgi, &req));
+		khttp_free(&req)
+	) {
 		uint32_t tileX = TileInitX;
 		uint32_t tileY = TileInitY;
 		if (req.fieldmap[KeyX]) {
@@ -226,18 +227,18 @@ static void worker(void) {
 		error = khttp_head(
 			&req, kresps[KRESP_STATUS], "%s", khttps[KHTTP_200]
 		);
-		if (error == KCGI_HUP) goto next;
+		if (error == KCGI_HUP) continue;
 		if (error) errkcgi(EX_IOERR, error, "khttp_head");
 
 		error = khttp_head(
 			&req, kresps[KRESP_CONTENT_TYPE], "%s", kmimetypes[KMIME_IMAGE_PNG]
 		);
-		if (error == KCGI_HUP) goto next;
+		if (error == KCGI_HUP) continue;
 		if (error) errkcgi(EX_IOERR, error, "khttp_head");
 
 		// XXX: kcgi never enables compression for FastCGI.
 		error = khttp_body(&req);
-		if (error == KCGI_HUP) goto next;
+		if (error == KCGI_HUP) continue;
 		if (error) errkcgi(EX_IOERR, error, "khttp_body");
 
 		struct Stream cookie = { .req = &req };
@@ -246,10 +247,9 @@ static void worker(void) {
 
 		render(stream, tileX, tileY);
 		fclose(stream);
-
-next:
-		khttp_free(&req);
 	}
+	if (error != KCGI_EXIT) errkcgi(EX_PROTOCOL, error, "khttp_fcgi_parse");
+	khttp_fcgi_free(fcgi);
 }
 
 #endif /* HAVE_KCGI */
@@ -276,13 +276,21 @@ int main(int argc, char *argv[]) {
 	fontLoad(fontPath);
 	tilesMap(dataPath);
 
-#ifdef __FreeBSD__
-	int error = cap_enter();
-	if (error) err(EX_OSERR, "cap_enter");
+#ifdef __OpenBSD__
+	if (kcgi) {
+		int error = pledge("stdio unix sendfd recvfd proc", NULL);
+		if (error) err(EX_OSERR, "pledge");
+	} else {
+		int error = pledge("stdio", NULL);
+		if (error) err(EX_OSERR, "pledge");
+	}
 #endif
 
 #ifdef HAVE_KCGI
-	if (kcgi) worker();
+	if (kcgi) {
+		worker();
+		return EX_OK;
+	}
 #endif
 
 	render(stdout, tileX, tileY);
diff --git a/index.html b/index.html
index 552f45e..a5059ba 100644
--- a/index.html
+++ b/index.html
@@ -17,7 +17,7 @@
 		</td>
 	</tr>
 	<tr>
-		<td>A 2048 clone with scoreboard</td>
+		<td>Some games with scoreboards</td>
 		<td>
 			<code><a href="ssh://play@ascii.town">ssh play@ascii.town</a></code>
 		</td>
@@ -31,4 +31,4 @@
 </table>
 
 <p style="text-align: center;">
-<style>.bmc-button img{width: 27px !important;margin-bottom: 1px !important;box-shadow: none !important;border: none !important;vertical-align: middle !important;}.bmc-button{line-height: 36px !important;height:37px !important;text-decoration: none !important;display:inline-flex !important;color:#ffffff !important;background-color:#000000 !important;border-radius: 3px !important;border: 1px solid transparent !important;padding: 1px 9px !important;font-size: 22px !important;letter-spacing:0.6px !important;box-shadow: 0px 1px 2px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 1px 2px 2px rgba(190, 190, 190, 0.5) !important;margin: 0 auto !important;font-family:'Cookie', cursive !important;-webkit-box-sizing: border-box !important;box-sizing: border-box !important;-o-transition: 0.3s all linear !important;-webkit-transition: 0.3s all linear !important;-moz-transition: 0.3s all linear !important;-ms-transition: 0.3s all linear !important;transition: 0.3s all linear !important;}.bmc-button:hover, .bmc-button:active, .bmc-button:focus {-webkit-box-shadow: 0px 1px 2px 2px rgba(190, 190, 190, 0.5) !important;text-decoration: none !important;box-shadow: 0px 1px 2px 2px rgba(190, 190, 190, 0.5) !important;opacity: 0.85 !important;color:#ffffff !important;}</style><link href="https://fonts.googleapis.com/css?family=Cookie" rel="stylesheet"><a class="bmc-button" target="_blank" href="https://www.buymeacoffee.com/asciitown"><img src="https://bmc-cdn.nyc3.digitaloceanspaces.com/BMC-button-images/BMC-btn-logo.svg" alt="Buy me a coffee"><span style="margin-left:5px">Buy me a coffee</span></a>
+<a href="https://liberapay.com/june/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a>
diff --git a/merge.c b/merge.c
index 5db1a58..42ef2c1 100644
--- a/merge.c
+++ b/merge.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2017  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2017  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License as published by
@@ -23,6 +23,7 @@
 #include <stdio.h>
 #include <sysexits.h>
 #include <wchar.h>
+#include <zlib.h>
 
 #include "torus.h"
 
@@ -83,10 +84,10 @@ static void drawTile(int offsetY, const struct Tile *tile) {
 int main(int argc, char *argv[]) {
 	if (argc != 4) return EX_USAGE;
 
-	FILE *fileA = fopen(argv[1], "r");
+	gzFile fileA = gzopen(argv[1], "r");
 	if (!fileA) err(EX_NOINPUT, "%s", argv[1]);
 
-	FILE *fileB = fopen(argv[2], "r");
+	gzFile fileB = gzopen(argv[2], "r");
 	if (!fileB) err(EX_NOINPUT, "%s", argv[2]);
 
 	FILE *fileC = fopen(argv[3], "w");
@@ -94,13 +95,18 @@ int main(int argc, char *argv[]) {
 
 	curse();
 
+	int error;
 	struct Tile tileA, tileB;
 	for (;;) {
-		size_t countA = fread(&tileA, sizeof(tileA), 1, fileA);
-		if (ferror(fileA)) err(EX_IOERR, "%s", argv[1]);
+		size_t countA = gzfread(&tileA, sizeof(tileA), 1, fileA);
+		if (!countA && gzerror(fileA, &error)) {
+			errx(EX_IOERR, "%s: %s", argv[1], gzerror(fileA, &error));
+		}
 
-		size_t countB = fread(&tileB, sizeof(tileB), 1, fileB);
-		if (ferror(fileB)) err(EX_IOERR, "%s", argv[2]);
+		size_t countB = gzfread(&tileB, sizeof(tileB), 1, fileB);
+		if (!countB && gzerror(fileB, &error)) {
+			errx(EX_IOERR, "%s: %s", argv[2], gzerror(fileB, &error));
+		}
 
 		if (!countA && !countB) break;
 		if (!countA || !countB) errx(EX_DATAERR, "different size inputs");
diff --git a/meta.c b/meta.c
index be2fd56..6e76647 100644
--- a/meta.c
+++ b/meta.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2017  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2017  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License as published by
diff --git a/png.h b/png.h
index 56ddb01..d5fd3f5 100644
--- a/png.h
+++ b/png.h
@@ -1,4 +1,4 @@
-/* Copyright (C) 2018, 2019  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2018, 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 Affero General Public License as published by
diff --git a/rc.torus b/rc.torus
deleted file mode 100644
index 660701f..0000000
--- a/rc.torus
+++ /dev/null
@@ -1,33 +0,0 @@
-#!/bin/sh
-
-# PROVIDE: torus
-# REQUIRE: LOGIN
-# KEYWORD: shutdown
-
-. /etc/rc.subr
-
-name=torus
-rcvar=torus_enable
-load_rc_config ${name}
-
-: ${torus_enable:=NO}
-: ${torus_user=torus}
-: ${torus_group=${torus_user}}
-: ${torus_user:+${torus_chroot=/home/${torus_user}}}
-: ${torus_user:+${torus_data_path=/home/${torus_user}/torus.dat}}
-: ${torus_user:+${torus_sock_path=/home/${torus_user}/torus.sock}}
-torus_flags="\
-	${torus_data_path:+-d ${torus_data_path}} \
-	${torus_sock_path:+-s ${torus_sock_path}} \
-	${torus_flags}"
-
-torus_run=/var/run/${name}
-torus_pid=${torus_run}/${name}.pid
-
-required_dirs=${torus_chroot}${torus_run}
-pidfile=${torus_chroot}${torus_pid}
-
-command=/bin/server
-command_args="-p ${torus_pid}"
-
-run_rc_command "$1"
diff --git a/server.c b/server.c
index bf18fce..3c26f09 100644
--- a/server.c
+++ b/server.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2017  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2017, 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 Affero General Public License as published by
@@ -14,30 +14,24 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-#include <sys/types.h>
-
+#include <assert.h>
 #include <err.h>
 #include <errno.h>
 #include <fcntl.h>
+#include <poll.h>
 #include <signal.h>
-#include <stdbool.h>
 #include <stdint.h>
+#include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
-#include <sys/event.h>
+#include <sys/file.h>
 #include <sys/mman.h>
 #include <sys/socket.h>
-#include <sys/time.h>
 #include <sys/un.h>
 #include <sysexits.h>
 #include <time.h>
 #include <unistd.h>
 
-#ifdef __FreeBSD__
-#include <libutil.h>
-#include <sys/capsicum.h>
-#endif
-
 #include "torus.h"
 
 static struct Tile *tiles;
@@ -53,9 +47,6 @@ static void tilesMap(const char *path) {
 	if (tiles == MAP_FAILED) err(EX_OSERR, "mmap");
 	close(fd);
 
-	error = madvise(tiles, TilesSize, MADV_RANDOM);
-	if (error) err(EX_OSERR, "madvise");
-
 #ifdef MADV_NOCORE
 	error = madvise(tiles, TilesSize, MADV_NOCORE);
 	if (error) err(EX_OSERR, "madvise");
@@ -76,6 +67,16 @@ static struct Tile *tileAccess(uint32_t tileX, uint32_t tileY) {
 	struct Tile *tile = tileGet(tileX, tileY);
 	tile->accessTime = time(NULL);
 	tile->accessCount++;
+#ifndef NO_DECAY
+	uint8_t y = arc4random_uniform(CellRows);
+	uint8_t x = arc4random_uniform(CellCols);
+	uint8_t b = 1 << arc4random_uniform(8);
+	if (arc4random_uniform(2)) {
+		tile->cells[y][x] &= ~b;
+	} else {
+		tile->colors[y][x] &= ~b;
+	}
+#endif
 	return tile;
 }
 
@@ -86,110 +87,100 @@ static struct Tile *tileModify(uint32_t tileX, uint32_t tileY) {
 	return tile;
 }
 
+static struct Tile *tileSync(struct Tile *tile) {
+	int error = msync(tile, sizeof(*tile), MS_ASYNC);
+	if (error) err(EX_IOERR, "msync");
+	return tile;
+}
+
+enum { ClientsCap = 256 };
 static struct Client {
 	int fd;
-
 	uint32_t tileX;
 	uint32_t tileY;
 	uint8_t cellX;
 	uint8_t cellY;
-
-	struct Client *prev;
-	struct Client *next;
-} *clientHead;
+} clients[ClientsCap];
+static size_t clientsLen;
 
 static struct Client *clientAdd(int fd) {
-	struct Client *client = malloc(sizeof(*client));
-	if (!client) err(EX_OSERR, "malloc");
-
+	if (clientsLen == ClientsCap) return NULL;
+	struct Client *client = &clients[clientsLen++];
 	client->fd = fd;
 	client->tileX = TileInitX;
 	client->tileY = TileInitY;
 	client->cellX = CellInitX;
 	client->cellY = CellInitY;
-
-	client->prev = NULL;
-	if (clientHead) {
-		clientHead->prev = client;
-		client->next = clientHead;
-	} else {
-		client->next = NULL;
-	}
-	clientHead = client;
-
 	return client;
 }
 
-static bool clientSend(const struct Client *client, struct ServerMessage msg) {
-	ssize_t size = send(client->fd, &msg, sizeof(msg), 0);
-	if (size < 0) return false;
-
-	if (msg.type == ServerTile) {
-		struct Tile *tile = tileAccess(client->tileX, client->tileY);
-		size = send(client->fd, tile, sizeof(*tile), 0);
-		if (size < 0) return false;
+static int
+clientSend(const struct Client *client, const struct ServerMessage *msg) {
+	ssize_t len = send(client->fd, msg, sizeof(*msg), 0);
+	if (len < 0) return -1;
+	if (msg->type == ServerTile) {
+		const struct Tile *tile = tileSync(
+			tileAccess(client->tileX, client->tileY)
+		);
+		len = send(client->fd, tile, sizeof(*tile), 0);
+		if (len < 0) return -1;
 	}
-
-	return true;
+	return 0;
 }
 
-static void clientCast(const struct Client *origin, struct ServerMessage msg) {
-	for (struct Client *client = clientHead; client; client = client->next) {
-		if (client == origin) continue;
-		if (client->tileX != origin->tileX) continue;
-		if (client->tileY != origin->tileY) continue;
-		clientSend(client, msg);
+static void
+clientCast(const struct Client *source, const struct ServerMessage *msg) {
+	for (size_t i = 0; i < clientsLen; ++i) {
+		if (&clients[i] == source) continue;
+		if (clients[i].tileX != source->tileX) continue;
+		if (clients[i].tileY != source->tileY) continue;
+		clientSend(&clients[i], msg);
 	}
 }
 
-static void clientRemove(struct Client *client) {
-	if (client->prev) client->prev->next = client->next;
-	if (client->next) client->next->prev = client->prev;
-	if (clientHead == client) clientHead = client->next;
-
+static void clientRemove(size_t i) {
+	struct Client client = clients[i];
+	clients[i] = clients[--clientsLen];
+	close(client.fd);
 	struct ServerMessage msg = {
 		.type = ServerCursor,
 		.cursor = {
-			.oldCellX = client->cellX, .oldCellY = client->cellY,
-			.newCellX = CursorNone,    .newCellY = CursorNone,
+			.oldCellX = client.cellX, .oldCellY = client.cellY,
+			.newCellX = CursorNone, .newCellY = CursorNone,
 		},
 	};
-	clientCast(client, msg);
-
-	close(client->fd);
-	free(client);
+	clientCast(&client, &msg);
 }
 
-static bool clientCursors(const struct Client *client) {
+static int clientCursors(const struct Client *client) {
 	struct ServerMessage msg = {
 		.type = ServerCursor,
-		.cursor = { .oldCellX = CursorNone, .oldCellY = CursorNone },
+		.cursor.oldCellX = CursorNone,
+		.cursor.oldCellY = CursorNone,
 	};
-
-	for (struct Client *friend = clientHead; friend; friend = friend->next) {
-		if (friend == client) continue;
-		if (friend->tileX != client->tileX) continue;
-		if (friend->tileY != client->tileY) continue;
-
-		msg.cursor.newCellX = friend->cellX;
-		msg.cursor.newCellY = friend->cellY;
-		if (!clientSend(client, msg)) return false;
+	for (size_t i = 0; i < clientsLen; ++i) {
+		if (&clients[i] == client) continue;
+		if (clients[i].tileX != client->tileX) continue;
+		if (clients[i].tileY != client->tileY) continue;
+		msg.cursor.newCellX = clients[i].cellX;
+		msg.cursor.newCellY = clients[i].cellY;
+		if (clientSend(client, &msg) < 0) return -1;
 	}
-	return true;
+	return 0;
 }
 
-static bool clientUpdate(struct Client *client, const struct Client *old) {
+static int clientUpdate(struct Client *new, const struct Client *old) {
 	struct ServerMessage msg = {
 		.type = ServerMove,
-		.move = { .cellX = client->cellX, .cellY = client->cellY },
+		.move.cellX = new->cellX,
+		.move.cellY = new->cellY,
 	};
-	if (!clientSend(client, msg)) return false;
+	if (clientSend(new, &msg) < 0) return -1;
 
-	if (client->tileX != old->tileX || client->tileY != old->tileY) {
+	if (new->tileX != old->tileX || new->tileY != old->tileY) {
 		msg.type = ServerTile;
-		if (!clientSend(client, msg)) return false;
-
-		if (!clientCursors(client)) return false;
+		if (clientSend(new, &msg) < 0) return -1;
+		if (clientCursors(new) < 0) return -1;
 
 		msg = (struct ServerMessage) {
 			.type = ServerCursor,
@@ -198,32 +189,31 @@ static bool clientUpdate(struct Client *client, const struct Client *old) {
 				.newCellX = CursorNone, .newCellY = CursorNone,
 			},
 		};
-		clientCast(old, msg);
+		clientCast(old, &msg);
 
 		msg = (struct ServerMessage) {
 			.type = ServerCursor,
 			.cursor = {
-				.oldCellX = CursorNone,    .oldCellY = CursorNone,
-				.newCellX = client->cellX, .newCellY = client->cellY,
+				.oldCellX = CursorNone, .oldCellY = CursorNone,
+				.newCellX = new->cellX, .newCellY = new->cellY,
 			},
 		};
-		clientCast(client, msg);
+		clientCast(new, &msg);
 
 	} else {
 		msg = (struct ServerMessage) {
 			.type = ServerCursor,
 			.cursor = {
-				.oldCellX = old->cellX,    .oldCellY = old->cellY,
-				.newCellX = client->cellX, .newCellY = client->cellY,
+				.oldCellX = old->cellX, .oldCellY = old->cellY,
+				.newCellX = new->cellX, .newCellY = new->cellY,
 			},
 		};
-		clientCast(client, msg);
+		clientCast(new, &msg);
 	}
-
-	return true;
+	return 0;
 }
 
-static bool clientMove(struct Client *client, int8_t dx, int8_t dy) {
+static int clientMove(struct Client *client, int8_t dx, int8_t dy) {
 	struct Client old = *client;
 
 	if (dx > CellCols - client->cellX) dx = CellCols - client->cellX;
@@ -251,9 +241,9 @@ static bool clientMove(struct Client *client, int8_t dx, int8_t dy) {
 		client->cellY = CellRows - 1;
 	}
 
-	if (client->tileX == TileCols)  client->tileX = 0;
+	if (client->tileX == TileCols)   client->tileX = 0;
 	if (client->tileX == UINT32_MAX) client->tileX = TileCols - 1;
-	if (client->tileY == TileRows)  client->tileY = 0;
+	if (client->tileY == TileRows)   client->tileY = 0;
 	if (client->tileY == UINT32_MAX) client->tileY = TileRows - 1;
 
 	assert(client->cellX < CellCols);
@@ -264,18 +254,18 @@ static bool clientMove(struct Client *client, int8_t dx, int8_t dy) {
 	return clientUpdate(client, &old);
 }
 
-static bool clientFlip(struct Client *client) {
+static int clientFlip(struct Client *client) {
 	struct Client old = *client;
 	client->tileX = (client->tileX + TileCols / 2) % TileCols;
 	client->tileY = (client->tileY + TileRows / 2) % TileRows;
 	return clientUpdate(client, &old);
 }
 
-static bool clientPut(const struct Client *client, uint8_t color, uint8_t cell) {
+static int clientPut(const struct Client *client, uint8_t color, uint8_t cell) {
 	struct Tile *tile = tileModify(client->tileX, client->tileY);
 	tile->colors[client->cellY][client->cellX] = color;
 	tile->cells[client->cellY][client->cellX] = cell;
-
+	tileSync(tile);
 	struct ServerMessage msg = {
 		.type = ServerPut,
 		.put = {
@@ -285,12 +275,12 @@ static bool clientPut(const struct Client *client, uint8_t color, uint8_t cell)
 			.cell = cell,
 		},
 	};
-	bool success = clientSend(client, msg);
-	clientCast(client, msg);
-	return success;
+	int error = clientSend(client, &msg);
+	clientCast(client, &msg);
+	return error;
 }
 
-static bool clientMap(const struct Client *client) {
+static int clientMap(const struct Client *client) {
 	int32_t mapY = (int32_t)client->tileY - MapRows / 2;
 	int32_t mapX = (int32_t)client->tileX - MapCols / 2;
 
@@ -354,13 +344,13 @@ static bool clientMap(const struct Client *client) {
 	}
 
 	struct ServerMessage msg = { .type = ServerMap };
-	if (!clientSend(client, msg)) return false;
-	if (0 > send(client->fd, &map, sizeof(map), 0)) return false;
-	return true;
+	if (clientSend(client, &msg) < 0) return -1;
+	if (send(client->fd, &map, sizeof(map), 0) < 0) return -1;
+	return 0;
 }
 
-static bool clientTele(struct Client *client, uint8_t port) {
-	if (port >= ARRAY_LEN(Ports)) return false;
+static int clientTele(struct Client *client, uint8_t port) {
+	if (port >= ARRAY_LEN(Ports)) return -1;
 	struct Client old = *client;
 	client->tileX = Ports[port].tileX;
 	client->tileY = Ports[port].tileY;
@@ -375,8 +365,8 @@ int main(int argc, char *argv[]) {
 	const char *dataPath = DefaultDataPath;
 	const char *sockPath = DefaultSockPath;
 	const char *pidPath = NULL;
-	int opt;
-	while (0 < (opt = getopt(argc, argv, "d:p:s:"))) {
+
+	for (int opt; 0 < (opt = getopt(argc, argv, "d:p:s:"));) {
 		switch (opt) {
 			break; case 'd': dataPath = optarg;
 			break; case 'p': pidPath = optarg;
@@ -385,126 +375,115 @@ int main(int argc, char *argv[]) {
 		}
 	}
 
-#ifdef __FreeBSD__
-	struct pidfh *pid = NULL;
+	int pidFile = -1;
 	if (pidPath) {
-		pid = pidfile_open(pidPath, 0600, NULL);
-		if (!pid) err(EX_CANTCREAT, "%s", pidPath);
+		pidFile = open(pidPath, O_WRONLY | O_CREAT | O_CLOEXEC, 0600);
+		if (pidFile < 0) err(EX_CANTCREAT, "%s", pidPath);
+
+		error = flock(pidFile, LOCK_EX | LOCK_NB);
+		if (error && errno != EWOULDBLOCK) err(EX_IOERR, "%s", pidPath);
+		if (error) errx(EX_CANTCREAT, "%s: file is locked", pidPath);
+
+		error = ftruncate(pidFile, 0);
+		if (error) err(EX_IOERR, "%s", pidPath);
 	}
-#endif
 
 	tilesMap(dataPath);
 
-	int server = socket(PF_LOCAL, SOCK_STREAM, 0);
+	int server = socket(PF_UNIX, SOCK_STREAM, 0);
 	if (server < 0) err(EX_OSERR, "socket");
 
 	error = unlink(sockPath);
-	if (error && errno != ENOENT) err(EX_IOERR, "%s", sockPath);
+	if (error && errno != ENOENT) err(EX_CANTCREAT, "%s", sockPath);
 
-	struct sockaddr_un addr = { .sun_family = AF_LOCAL };
-	strlcpy(addr.sun_path, sockPath, sizeof(addr.sun_path));
+	struct sockaddr_un addr = { .sun_family = AF_UNIX };
+	snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", sockPath);
 	error = bind(server, (struct sockaddr *)&addr, SUN_LEN(&addr));
 	if (error) err(EX_CANTCREAT, "%s", sockPath);
 
-#ifdef __FreeBSD__
-	error = cap_enter();
-	if (error) err(EX_OSERR, "cap_enter");
-
-	cap_rights_t rights;
-	cap_rights_init(
-		&rights,
-		CAP_LISTEN, CAP_ACCEPT, CAP_EVENT,
-		CAP_READ, CAP_WRITE, CAP_SETSOCKOPT
-	);
-	error = cap_rights_limit(server, &rights);
-	if (error) err(EX_OSERR, "cap_rights_limit");
-
-	if (pid) {
-		cap_rights_init(&rights, CAP_PWRITE, CAP_FSTAT, CAP_FTRUNCATE);
-		error = cap_rights_limit(pidfile_fileno(pid), &rights);
-		if (error) err(EX_OSERR, "cap_rights_limit");
-
-		// FIXME: daemon(3) can't chdir or open /dev/null in capability mode.
+	if (pidPath) {
 		error = daemon(0, 0);
 		if (error) err(EX_OSERR, "daemon");
-		pidfile_write(pid);
+		dprintf(pidFile, "%ju", (uintmax_t)getpid());
 	}
+
+#ifdef __OpenBSD__
+	error = pledge("stdio unix", NULL);
+	if (error) err(EX_OSERR, "pledge");
 #endif
 
-	error = listen(server, 0);
+	error = listen(server, -1);
 	if (error) err(EX_OSERR, "listen");
 
-	int kq = kqueue();
-	if (kq < 0) err(EX_OSERR, "kqueue");
+	signal(SIGPIPE, SIG_IGN);
+	struct pollfd fds[1 + ClientsCap] = {
+		{ .fd = server, .events = POLLIN },
+	};
+	for (;;) {
+		for (size_t i = 0; i < clientsLen; ++i) {
+			fds[1 + i].fd = clients[i].fd;
+			fds[1 + i].events = POLLIN;
+		}
+		int nfds = poll(fds, 1 + clientsLen, -1);
+		if (nfds < 0 && errno != EINTR) err(EX_IOERR, "poll");
+		if (nfds < 0) continue;
+
+		for (size_t i = clientsLen - 1; i < clientsLen; --i) {
+			if (!fds[1 + i].revents) continue;
+			if (fds[1 + i].revents & (POLLHUP | POLLERR)) {
+				clientRemove(i);
+				continue;
+			}
 
-	struct kevent event;
-	EV_SET(&event, server, EVFILT_READ, EV_ADD, 0, 0, 0);
-	int nevents = kevent(kq, &event, 1, NULL, 0, NULL);
-	if (nevents < 0) err(EX_OSERR, "kevent");
+			struct ClientMessage msg;
+			ssize_t len = recv(clients[i].fd, &msg, sizeof(msg), 0);
+			if (len != sizeof(msg)) {
+				clientRemove(i);
+				continue;
+			}
 
-	for (;;) {
-		nevents = kevent(kq, NULL, 0, &event, 1, NULL);
-		if (nevents < 0) err(EX_IOERR, "kevent");
+			switch (msg.type) {
+				break; case ClientMove: {
+					error = clientMove(&clients[i], msg.move.dx, msg.move.dy);
+				}
+				break; case ClientFlip: {
+					error = clientFlip(&clients[i]);
+				}
+				break; case ClientPut: {
+					error = clientPut(&clients[i], msg.put.color, msg.put.cell);
+				}
+				break; case ClientMap: {
+					error = clientMap(&clients[i]);
+				}
+				break; case ClientTele: {
+					error = clientTele(&clients[i], msg.port);
+				}
+				break; default: error = -1;
+			}
+			if (error) clientRemove(i);
+		}
 
-		if (!event.udata) {
+		if (fds[0].revents) {
 			int fd = accept(server, NULL, NULL);
 			if (fd < 0) err(EX_IOERR, "accept");
 			fcntl(fd, F_SETFL, O_NONBLOCK);
 
-			int on = 1;
-			error = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on));
-			if (error) err(EX_IOERR, "setsockopt");
-
 			int size = 2 * sizeof(struct Tile);
 			error = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size));
 			if (error) err(EX_IOERR, "setsockopt");
 
 			struct Client *client = clientAdd(fd);
-
-			EV_SET(&event, fd, EVFILT_READ, EV_ADD, 0, 0, client);
-			nevents = kevent(kq, &event, 1, NULL, 0, NULL);
-			if (nevents < 0) err(EX_IOERR, "kevent");
+			if (!client) {
+				close(fd);
+				continue;
+			}
 
 			struct ServerMessage msg = { .type = ServerTile };
-			bool success = clientSend(client, msg)
-				&& clientMove(client, 0, 0)
-				&& clientCursors(client);
-			if (!success) clientRemove(client);
-
-			continue;
-		}
-
-		struct Client *client = (struct Client *)event.udata;
-		if (event.flags & EV_EOF) {
-			clientRemove(client);
-			continue;
-		}
-
-		struct ClientMessage msg;
-		ssize_t size = recv(client->fd, &msg, sizeof(msg), 0);
-		if (size != sizeof(msg)) {
-			clientRemove(client);
-			continue;
-		}
-
-		bool success = false;
-		switch (msg.type) {
-			break; case ClientMove: {
-				success = clientMove(client, msg.move.dx, msg.move.dy);
-			}
-			break; case ClientFlip: {
-				success = clientFlip(client);
-			}
-			break; case ClientPut: {
-				success = clientPut(client, msg.put.color, msg.put.cell);
-			}
-			break; case ClientMap: {
-				success = clientMap(client);
-			}
-			break; case ClientTele: {
-				success = clientTele(client, msg.port);
-			}
+			error = 0
+				|| clientSend(client, &msg)
+				|| clientMove(client, 0, 0)
+				|| clientCursors(client);
+			if (error) clientRemove(clientsLen - 1);
 		}
-		if (!success) clientRemove(client);
 	}
 }
diff --git a/snapshot.sh b/snapshot.sh
index 6dc44fb..94526e2 100755
--- a/snapshot.sh
+++ b/snapshot.sh
@@ -1,6 +1,11 @@
 #!/bin/sh
-set -e -u
+set -eu
 
 ts=$(date +'%Y.%m.%d.%H.%M')
 gzip -c -9 < "$1/torus.dat" > "$2/torus.dat.$ts.gz"
 $(dirname "$0")/meta < "$1/torus.dat" | gzip -c -9 > "$2/torus.csv.$ts.gz"
+
+if [ $# -gt 2 ]; then
+	cp "$2/torus.csv.$ts.gz" "$3"
+	echo "torus.csv.$ts.gz" > "$3/LATEST"
+fi
diff --git a/sshd_config b/sshd_config
index 2d4366a..0c56b8c 100644
--- a/sshd_config
+++ b/sshd_config
@@ -5,9 +5,5 @@ Match User torus
 	PermitEmptyPasswords yes
 	ChrootDirectory /home/torus
 	ForceCommand client
-
-	AllowAgentForwarding no
-	AllowTcpForwarding no
-	AllowStreamLocalForwarding no
+	DisableForwarding yes
 	MaxSessions 1
-	X11Forwarding no
diff --git a/torus.1 b/torus.1
index addbda8..ddf79a4 100644
--- a/torus.1
+++ b/torus.1
@@ -1,4 +1,4 @@
-.Dd January 8, 2019
+.Dd December 22, 2021
 .Dt torus 1
 .Os "Causal Agency"
 .
@@ -82,6 +82,11 @@ and
 .Ar data2
 into
 .Ar data3 .
+.Ar data1
+and
+.Ar data2
+may be compressed with
+.Xr gzip 1 .
 Differing tiles are presented in a
 .Xr curses 3
 interface
@@ -113,8 +118,6 @@ Run a FastCGI worker for use with
 .It Fl p Ar pidfile
 Daemonize and write PID to
 .Ar pidfile .
-Only available on
-.Fx .
 .
 .It Fl s Ar sock
 Set path to UNIX-domain socket.
@@ -129,11 +132,6 @@ Set tile Y coordinate to render.
 .El
 .
 .Sh IMPLEMENTATION NOTES
-This software targets
-.Fx
-and Darwin.
-.
-.Pp
 .Pa help.h
 contains tile data for the help page
 and can be generated from the first tile of
diff --git a/torus.h b/torus.h
index 4c62990..d5accf5 100644
--- a/torus.h
+++ b/torus.h
@@ -1,4 +1,4 @@
-/* Copyright (C) 2017  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2017  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License as published by