changeset 975:5ffc8350e84b

irccdctl: add support for rule editing
author David Demelier <markand@malikania.fr>
date Tue, 09 Feb 2021 13:00:32 +0100
parents 342fb90f2512
children f5a07c0768c8
files CMakeLists.txt MIGRATING.md extern/libcompat/CMakeLists.txt extern/libcompat/src/compat.h.in extern/libcompat/src/strtonum.c extern/libketopt/CMakeLists.txt extern/libketopt/ketopt.h irccd/peer.c irccdctl/CMakeLists.txt irccdctl/main.c lib/irccd/irccd.c lib/irccd/irccd.h
diffstat 12 files changed, 755 insertions(+), 25 deletions(-) [+]
line wrap: on
line diff
--- a/CMakeLists.txt	Sun Feb 07 14:36:28 2021 +0100
+++ b/CMakeLists.txt	Tue Feb 09 13:00:32 2021 +0100
@@ -45,6 +45,7 @@
 endif ()
 
 add_subdirectory(extern/libcompat)
+add_subdirectory(extern/libketopt)
 
 if (IRCCD_WITH_JS)
 	add_subdirectory(extern/libduktape)
--- a/MIGRATING.md	Sun Feb 07 14:36:28 2021 +0100
+++ b/MIGRATING.md	Tue Feb 09 13:00:32 2021 +0100
@@ -23,6 +23,8 @@
 - The `watch` command no longer produce JSON output but only the original
   "human" format but may be used for scripts as it is honored through the
   semantic versioning.
+- The command `rule-info` has been removed because it is mostly the same as
+  `rule-list`.
 
 Platform support
 ----------------
--- a/extern/libcompat/CMakeLists.txt	Sun Feb 07 14:36:28 2021 +0100
+++ b/extern/libcompat/CMakeLists.txt	Tue Feb 09 13:00:32 2021 +0100
@@ -45,6 +45,7 @@
 	strlcat
 	strlcpy
 	strsep
+	strtonum
 	verr
 	verrc
 	verrx
--- a/extern/libcompat/src/compat.h.in	Sun Feb 07 14:36:28 2021 +0100
+++ b/extern/libcompat/src/compat.h.in	Tue Feb 09 13:00:32 2021 +0100
@@ -19,6 +19,7 @@
 #cmakedefine COMPAT_HAVE_STRNLEN
 #cmakedefine COMPAT_HAVE_STRSEP
 #cmakedefine COMPAT_HAVE_STRTOK_R
+#cmakedefine COMPAT_HAVE_STRTONUM
 #cmakedefine COMPAT_HAVE_VERR
 #cmakedefine COMPAT_HAVE_VERRC
 #cmakedefine COMPAT_HAVE_VERRX
@@ -187,4 +188,9 @@
 strtok_r(char *, const char *, char **);
 #endif
 
+#ifndef COMPAT_HAVE_STRTONUM
+long long
+strtonum(const char *, long long, long long, const char **);
+#endif
+
 #endif /* !LIBCOMPAT_COMPAT_H */
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/extern/libcompat/src/strtonum.c	Tue Feb 09 13:00:32 2021 +0100
@@ -0,0 +1,65 @@
+/*	$OpenBSD: strtonum.c,v 1.8 2015/09/13 08:31:48 guenther Exp $	*/
+
+/*
+ * Copyright (c) 2004 Ted Unangst and Todd Miller
+ * All rights reserved.
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <errno.h>
+#include <limits.h>
+#include <stdlib.h>
+
+#define	INVALID		1
+#define	TOOSMALL	2
+#define	TOOLARGE	3
+
+long long
+strtonum(const char *numstr, long long minval, long long maxval,
+    const char **errstrp)
+{
+	long long ll = 0;
+	int error = 0;
+	char *ep;
+	struct errval {
+		const char *errstr;
+		int err;
+	} ev[4] = {
+		{ NULL,		0 },
+		{ "invalid",	EINVAL },
+		{ "too small",	ERANGE },
+		{ "too large",	ERANGE },
+	};
+
+	ev[0].err = errno;
+	errno = 0;
+	if (minval > maxval) {
+		error = INVALID;
+	} else {
+		ll = strtoll(numstr, &ep, 10);
+		if (numstr == ep || *ep != '\0')
+			error = INVALID;
+		else if ((ll == LLONG_MIN && errno == ERANGE) || ll < minval)
+			error = TOOSMALL;
+		else if ((ll == LLONG_MAX && errno == ERANGE) || ll > maxval)
+			error = TOOLARGE;
+	}
+	if (errstrp != NULL)
+		*errstrp = ev[error].errstr;
+	errno = ev[error].err;
+	if (error)
+		ll = 0;
+
+	return (ll);
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/extern/libketopt/CMakeLists.txt	Tue Feb 09 13:00:32 2021 +0100
@@ -0,0 +1,26 @@
+#
+# CMakeLists.txt -- CMake build system for ketopt
+#
+# Copyright (c) 2016-2021 David Demelier <markand@malikania.fr>
+#
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+cmake_minimum_required(VERSION 3.0)
+project(libirccd-ketopt)
+add_library(libirccd-ketopt INTERFACE ketopt.h)
+target_include_directories(
+	libirccd-ketopt
+	INTERFACE
+		$<BUILD_INTERFACE:${libirccd-ketopt_SOURCE_DIR}>
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/extern/libketopt/ketopt.h	Tue Feb 09 13:00:32 2021 +0100
@@ -0,0 +1,120 @@
+#ifndef KETOPT_H
+#define KETOPT_H
+
+#include <string.h> /* for strchr() and strncmp() */
+
+#define ko_no_argument       0
+#define ko_required_argument 1
+#define ko_optional_argument 2
+
+typedef struct {
+	int ind;   /* equivalent to optind */
+	int opt;   /* equivalent to optopt */
+	char *arg; /* equivalent to optarg */
+	int longidx; /* index of a long option; or -1 if short */
+	/* private variables not intended for external uses */
+	int i, pos, n_args;
+} ketopt_t;
+
+typedef struct {
+	char *name;
+	int has_arg;
+	int val;
+} ko_longopt_t;
+
+static ketopt_t KETOPT_INIT = { 0, 0, 0, -1, 0, 0, 0 };
+
+static void ketopt_permute(char *argv[], int j, int n) /* move argv[j] over n elements to the left */
+{
+	int k;
+	char *p = argv[j];
+	for (k = 0; k < n; ++k)
+		argv[j - k] = argv[j - k - 1];
+	argv[j - k] = p;
+}
+
+/**
+ * Parse command-line options and arguments
+ *
+ * This fuction has a similar interface to GNU's getopt_long(). Each call
+ * parses one option and returns the option name.  s->arg points to the option
+ * argument if present. The function returns -1 when all command-line arguments
+ * are parsed. In this case, s->ind is the index of the first non-option
+ * argument.
+ *
+ * @param s         status; shall be initialized to KETOPT_INIT on the first call
+ * @param argc      length of argv[]
+ * @param argv      list of command-line arguments; argv[0] is ignored
+ * @param permute   non-zero to move options ahead of non-option arguments
+ * @param ostr      option string
+ * @param longopts  long options
+ *
+ * @return ASCII for a short option; ko_longopt_t::val for a long option; -1 if
+ *         argv[] is fully processed; '?' for an unknown option or an ambiguous
+ *         long option; ':' if an option argument is missing
+ */
+static int ketopt(ketopt_t *s, int argc, char *argv[], int permute, const char *ostr, const ko_longopt_t *longopts)
+{
+	int opt = -1, i0, j;
+	if (permute) {
+		while (s->i < argc && (argv[s->i][0] != '-' || argv[s->i][1] == '\0'))
+			++s->i, ++s->n_args;
+	}
+	s->arg = 0, s->longidx = -1, i0 = s->i;
+	if (s->i >= argc || argv[s->i][0] != '-' || argv[s->i][1] == '\0') {
+		s->ind = s->i - s->n_args;
+		return -1;
+	}
+	if (argv[s->i][0] == '-' && argv[s->i][1] == '-') { /* "--" or a long option */
+		if (argv[s->i][2] == '\0') { /* a bare "--" */
+			ketopt_permute(argv, s->i, s->n_args);
+			++s->i, s->ind = s->i - s->n_args;
+			return -1;
+		}
+		s->opt = 0, opt = '?', s->pos = -1;
+		if (longopts) { /* parse long options */
+			int k, n_exact = 0, n_partial = 0;
+			const ko_longopt_t *o = 0, *o_exact = 0, *o_partial = 0;
+			for (j = 2; argv[s->i][j] != '\0' && argv[s->i][j] != '='; ++j) {} /* find the end of the option name */
+			for (k = 0; longopts[k].name != 0; ++k)
+				if (strncmp(&argv[s->i][2], longopts[k].name, j - 2) == 0) {
+					if (longopts[k].name[j - 2] == 0) ++n_exact, o_exact = &longopts[k];
+					else ++n_partial, o_partial = &longopts[k];
+				}
+			if (n_exact > 1 || (n_exact == 0 && n_partial > 1)) return '?';
+			o = n_exact == 1? o_exact : n_partial == 1? o_partial : 0;
+			if (o) {
+				s->opt = opt = o->val, s->longidx = o - longopts;
+				if (argv[s->i][j] == '=') s->arg = &argv[s->i][j + 1];
+				if (o->has_arg == 1 && argv[s->i][j] == '\0') {
+					if (s->i < argc - 1) s->arg = argv[++s->i];
+					else opt = ':'; /* missing option argument */
+				}
+			}
+		}
+	} else { /* a short option */
+		const char *p;
+		if (s->pos == 0) s->pos = 1;
+		opt = s->opt = argv[s->i][s->pos++];
+		p = strchr((char*)ostr, opt);
+		if (p == 0) {
+			opt = '?'; /* unknown option */
+		} else if (p[1] == ':') {
+			if (argv[s->i][s->pos] == 0) {
+				if (s->i < argc - 1) s->arg = argv[++s->i];
+				else opt = ':'; /* missing option argument */
+			} else s->arg = &argv[s->i][s->pos];
+			s->pos = -1;
+		}
+	}
+	if (s->pos < 0 || argv[s->i][s->pos] == 0) {
+		++s->i, s->pos = 0;
+		if (s->n_args > 0) /* permute */
+			for (j = i0; j < s->i; ++j)
+				ketopt_permute(argv, j, s->n_args);
+	}
+	s->ind = s->i - s->n_args;
+	return opt;
+}
+
+#endif
--- a/irccd/peer.c	Sun Feb 07 14:36:28 2021 +0100
+++ b/irccd/peer.c	Tue Feb 09 13:00:32 2021 +0100
@@ -93,7 +93,7 @@
 	return plg;
 }
 
-static int
+static inline int
 ok(struct peer *p)
 {
 	peer_send(p, "OK");
@@ -101,6 +101,22 @@
 	return 0;
 }
 
+static inline int
+error(struct peer *p, const char *fmt, ...)
+{
+	char buf[IRC_BUF_LEN] = {0};
+	va_list ap;
+
+	va_start(ap, fmt);
+	vsnprintf(buf, sizeof (buf), fmt, ap);
+	va_end(ap);
+
+	if (buf[0])
+		peer_send(p, "ERROR %s", buf);
+
+	return 0;
+}
+
 static int
 plugin_list_set(struct peer *p,
                 char *line,
@@ -150,6 +166,20 @@
 	return 0;
 }
 
+static const char *
+rule_list_to_spaces(const char *value)
+{
+	static char buf[IRC_RULE_LEN];
+
+	strlcpy(buf, value, sizeof (buf));
+
+	for (char *p = buf; *p; ++p)
+		if (*p == ':')
+			*p = ' ';
+
+	return buf;
+}
+
 /*
  * PLUGIN-CONFIG plugin [var [value]]
  */
@@ -280,6 +310,254 @@
 }
 
 /*
+ * RULE-ADD accept|drop [(ceiops)=value ...]
+ */
+static int
+cmd_rule_add(struct peer *p, char *line)
+{
+	const char *errstr;
+	char *token, *ptr, *dst, key;
+	enum irc_rule_action act;
+	struct irc_rule *rule;
+	size_t index = -1;
+
+	if (sscanf(line, "RULE-ADD %*s") == EOF)
+		return EINVAL;
+		
+	line += strlen("RULE-ADD ");
+
+	if (strncmp(line, "accept", 6) == 0)
+		act = IRC_RULE_ACCEPT;
+	else if (strncmp(line, "drop", 4) == 0)
+		act = IRC_RULE_DROP;
+	else
+		return error(p, "invalid action");
+
+	rule = irc_rule_new(act);
+
+	/* Skip action value. */
+	while (*line && !isspace(*line))
+		++line;
+	while (*line && isspace(*line))
+		++line;
+
+	for (ptr = line; (token = strtok_r(ptr, " ", &ptr)); ) {
+		dst = NULL;
+
+		if (sscanf(token, "%c=%*s", &key) != 1) {
+			errno = EINVAL;
+			goto fail;
+		}
+
+		switch (*token) {
+		case 'c':
+			dst = rule->channels;
+			break;
+		case 'e':
+			dst = rule->events;
+			break;
+		case 'i':
+			if ((index = strtonum(token + 2, 0, LLONG_MAX, &errstr)) == 0 && errstr)
+				goto fail;
+			break;
+		case 'o':
+			dst = rule->origins;
+			break;
+		case 'p':
+			dst = rule->plugins;
+			break;
+		case 's':
+			dst = rule->servers;
+			break;
+		default:
+			/* TODO: error here. */
+			break;
+		}
+
+		if (dst && irc_rule_add(dst, token + 2) < 0)
+			goto fail;
+	}
+
+	irc_bot_rule_insert(rule, index);
+
+	return ok(p);
+
+fail:
+	irc_rule_finish(rule);
+
+	return error(p, strerror(errno));
+}
+
+/*
+ * RULE-EDIT index [((ceops)(+-)value)|(a=accept|drop) ...]
+ */
+static int
+cmd_rule_edit(struct peer *p, char *line)
+{
+	char *token, *ptr, *dst, key, attr;
+	struct irc_rule *rule;
+	size_t index = -1;
+
+	/*
+	 * Looks like strtonum does not accept when there is text after the
+	 * number.
+	 */
+	if (sscanf(line, "RULE-EDIT %zu", &index) != 1)
+		return EINVAL;
+
+	if (index >= irc_bot_rule_size())
+		return ERANGE;
+
+	/* Skip command and index value. */
+	line += strlen("RULE-EDIT ");
+
+	while (*line && !isspace(*line))
+		++line;
+	while (*line && isspace(*line))
+		++line;
+
+	rule = irc_bot_rule_get(index);
+
+	for (ptr = line; (token = strtok_r(ptr, " ", &ptr)); ) {
+		key = attr = 0;
+
+		if (sscanf(token, "%c%c%*s", &key, &attr) != 2)
+			return EINVAL;
+	
+		if (key == 'a') {
+			if (attr != '=')
+				return EINVAL;
+
+			if (strncmp(token + 2, "accept", 6) == 0)
+				rule->action = IRC_RULE_ACCEPT;
+			else if (strncmp(token + 2, "drop", 4) == 0)
+				rule->action = IRC_RULE_DROP;
+			else
+				return error(p, "invalid action");
+		} else {
+			dst = NULL;
+
+			switch (key) {
+			case 'c':
+				dst = rule->channels;
+				break;
+			case 'e':
+				dst = rule->events;
+				break;
+			case 'o':
+				dst = rule->origins;
+				break;
+			case 'p':
+				dst = rule->plugins;
+				break;
+			case 's':
+				dst = rule->servers;
+				break;
+			default:
+				return EINVAL;
+			}
+
+			if (attr == '+') {
+				if (irc_rule_add(dst, token + 2) < 0)
+					return errno;
+			} else if (attr == '-')
+				irc_rule_remove(dst, token + 2);
+			else
+				return EINVAL;
+		}
+	}
+
+	return ok(p);
+}
+
+/*
+ * RULE-LIST
+ */
+static int
+cmd_rule_list(struct peer *p, char *line)
+{
+	(void)line;
+
+	struct irc_rule *rule;
+	char out[IRC_BUF_LEN];
+	FILE *fp;
+	size_t rulesz = 0;
+
+	if (!(fp = fmemopen(out, sizeof (out), "w")))
+		return error(p, "%s", strerror(errno));
+
+	TAILQ_FOREACH(rule, &irc.rules, link)
+		rulesz++;
+
+	fprintf(fp, "OK %zu\n", rulesz);
+
+	TAILQ_FOREACH(rule, &irc.rules, link) {
+		/* Convert : to spaces. */
+		fprintf(fp, "%s\n", rule->action == IRC_RULE_ACCEPT ? "accept" : "drop");
+		fprintf(fp, "%s\n", rule_list_to_spaces(rule->servers));
+		fprintf(fp, "%s\n", rule_list_to_spaces(rule->channels));
+		fprintf(fp, "%s\n", rule_list_to_spaces(rule->origins));
+		fprintf(fp, "%s\n", rule_list_to_spaces(rule->plugins));
+		fprintf(fp, "%s\n", rule_list_to_spaces(rule->events));
+	}
+
+	if (feof(fp) || ferror(fp)) {
+		fclose(fp);
+		return EMSGSIZE;
+	}
+
+	fclose(fp);
+	peer_send(p, "%s", out);
+
+	return 0;
+}
+
+/*
+ * RULE-MOVE from to
+ */
+static int
+cmd_rule_move(struct peer *p, char *line)
+{
+	const char *args[2], *errstr;
+	unsigned long long from, to;
+
+	if (parse(line, args, 2) != 2)
+		return EINVAL;
+	if ((from = strtonum(args[0], 0, LLONG_MAX, &errstr)) == 0 && errstr)
+		return ERANGE;
+	if ((to = strtonum(args[1], 0, LLONG_MAX, &errstr)) == 0 && errstr)
+		return ERANGE;
+	if (from >= irc_bot_rule_size())
+		return ERANGE;
+
+	irc_bot_rule_move(from, to);
+
+	return ok(p);
+}
+
+/*
+ * RULE-REMOVE index
+ */
+static int
+cmd_rule_remove(struct peer *p, char *line)
+{
+	const char *args[1] = {0};
+	size_t index;
+
+	if (parse(line, args, 1) != 1)
+		return EINVAL;
+
+	index = strtoull(args[0], NULL, 10);
+
+	if (index >= irc_bot_rule_size())
+		return ERANGE;
+
+	irc_bot_rule_remove(index);
+
+	return ok(p);
+}
+
+/*
  * SERVER-DISCONNECT [server]
  */
 static int
@@ -564,6 +842,11 @@
 	{ "PLUGIN-RELOAD",      cmd_plugin_reload       },
 	{ "PLUGIN-TEMPLATE",    cmd_plugin_template     },
 	{ "PLUGIN-UNLOAD",      cmd_plugin_unload       },
+	{ "RULE-ADD",           cmd_rule_add            },
+	{ "RULE-EDIT",          cmd_rule_edit           },
+	{ "RULE-LIST",          cmd_rule_list           },
+	{ "RULE-MOVE",          cmd_rule_move           },
+	{ "RULE-REMOVE",        cmd_rule_remove         },
 	{ "SERVER-DISCONNECT",  cmd_server_disconnect   },
 	{ "SERVER-INFO",        cmd_server_info         },
 	{ "SERVER-INVITE",      cmd_server_invite       },
@@ -601,7 +884,7 @@
 	if (!c)
 		peer_send(p, "command not found");
 	else if ((er = c->call(p, line)) != 0)
-		peer_send(p, "%s", strerror(errno));
+		peer_send(p, "%s", strerror(er));
 }
 
 static void
--- a/irccdctl/CMakeLists.txt	Sun Feb 07 14:36:28 2021 +0100
+++ b/irccdctl/CMakeLists.txt	Tue Feb 09 13:00:32 2021 +0100
@@ -18,4 +18,4 @@
 
 project(irccdctl)
 add_executable(irccdctl main.c)
-target_link_libraries(irccdctl libirccd)
+target_link_libraries(irccdctl libirccd libirccd-ketopt)
--- a/irccdctl/main.c	Sun Feb 07 14:36:28 2021 +0100
+++ b/irccdctl/main.c	Tue Feb 09 13:00:32 2021 +0100
@@ -27,12 +27,13 @@
 #include <errno.h>
 #include <limits.h>
 #include <stdarg.h>
+#include <stdio.h>
 #include <stdlib.h>
 #include <stdnoreturn.h>
 #include <string.h>
 #include <unistd.h>
 
-#include <stdio.h>
+#include <ketopt.h>
 
 #include <irccd/limits.h>
 #include <irccd/util.h>
@@ -488,6 +489,131 @@
 }
 
 static void
+cmd_rule_add(int argc, char **argv)
+{
+	ketopt_t ko = KETOPT_INIT;
+	FILE *fp;
+	char out[IRC_BUF_LEN];
+
+	if (!(fp = fmemopen(out, sizeof (out) - 1, "w")))
+		err(1, "fmemopen");
+
+	/* TODO: invalid option. */
+	for (int ch; (ch = ketopt(&ko, argc, argv, 0, "c:e:i:o:p:s:", NULL)) != -1; )
+		fprintf(fp, "%c=%s ", ch, ko.arg);
+
+	argc -= ko.ind;
+	argv += ko.ind;
+
+	if (argc < 1)
+		errx(1, "missing accept or drop rule action");
+
+	fprintf(fp, "%s", argv[0]);
+
+	if (ferror(fp) || feof(fp))
+		err(1, "fprintf");
+
+	fclose(fp);
+	req("RULE-ADD %s %s", argv[0], out);
+	ok();
+}
+
+static void
+cmd_rule_edit(int argc, char **argv)
+{
+	ketopt_t ko = KETOPT_INIT;
+	FILE *fp;
+	char out[IRC_BUF_LEN];
+
+	if (!(fp = fmemopen(out, sizeof (out) - 1, "w")))
+		err(1, "fmemopen");
+
+	/* TODO: invalid option. */
+	for (int ch; (ch = ketopt(&ko, argc, argv, 0, "a:C:c:E:e:O:o:P:p:S:s:", NULL)) != -1; ) {
+		if (ch == 'a')
+			fprintf(fp, "a=%s ", ko.arg);
+		else
+			fprintf(fp, "%c%c%s ", tolower(ch), isupper(ch) ? '-' : '+', ko.arg);
+	}
+
+	argc -= ko.ind;
+	argv += ko.ind;
+
+	if (argc < 1)
+		errx(1, "missing rule index");
+
+	if (ferror(fp) || feof(fp))
+		err(1, "fprintf");
+
+	fclose(fp);
+	req("RULE-EDIT %s %s", argv[0], out);
+	ok();
+}
+
+/*
+ * Response:
+ *
+ *     OK <n>
+ *     accept
+ *     server1 server2 server3 ...
+ *     channel1 channel2 channel3 ...
+ *     origin1 origin2 origin3 ...
+ *     plugin1 plugin2 plugin3 ...
+ *     event1 event2 plugin3 ...
+ *     (repeat for every rule in <n>)
+ */
+static void
+cmd_rule_list(int argc, char **argv)
+{
+	(void)argc;
+	(void)argv;
+
+	size_t num = 0;
+
+	req("RULE-LIST");
+
+	if (sscanf(ok(), "%zu", &num) != 1)
+		errx(1, "could not retrieve rule list");
+
+	for (size_t i = 0; i < num; ++i) {
+		printf("%-16s: %zu\n", "index", i);
+		printf("%-16s: %s\n", "action", poll());
+		printf("%-16s: %s\n", "servers", poll());
+		printf("%-16s: %s\n", "channels", poll());
+		printf("%-16s: %s\n", "origins", poll());
+		printf("%-16s: %s\n", "plugins", poll());
+		printf("%-16s: %s\n", "events", poll());
+		printf("\n");
+	}
+}
+
+static void
+cmd_rule_move(int argc, char **argv)
+{
+	(void)argc;
+
+	long long from, to;
+	const char *errstr;
+
+	if ((from = strtonum(argv[0], 0, LLONG_MAX, &errstr)) == 0 && errstr)
+		err(1, "%s", argv[0]);
+	if ((to = strtonum(argv[1], 0, LLONG_MAX, &errstr)) == 0 && errstr)
+		err(1, "%s", argv[1]);
+
+	req("RULE-MOVE %lld %lld", from, to);
+	ok();
+}
+
+static void
+cmd_rule_remove(int argc, char **argv)
+{
+	(void)argc;
+
+	req("RULE-REMOVE %s", argv[0]);
+	ok();
+}
+
+static void
 cmd_server_disconnect(int argc, char **argv)
 {
 	if (argc == 1)
@@ -679,6 +805,11 @@
 	{ "plugin-reload",      0,      1,      cmd_plugin_reload       },
 	{ "plugin-template",    1,      3,      cmd_plugin_template     },
 	{ "plugin-unload",      0,      1,      cmd_plugin_unload       },
+	{ "rule-add",          -1,     -1,      cmd_rule_add            },
+	{ "rule-edit",         -1,     -1,      cmd_rule_edit           },
+	{ "rule-list",          0,      0,      cmd_rule_list           },
+	{ "rule-move",          2,      2,      cmd_rule_move           },
+	{ "rule-remove",        1,      1,      cmd_rule_remove         },
 	{ "server-disconnect",  0,      1,      cmd_server_disconnect   },
 	{ "server-info",        1,      1,      cmd_server_info         },
 	{ "server-join",        2,      3,      cmd_server_join         },
@@ -716,7 +847,7 @@
 	--argc;
 	++argv;
 
-	if (argc < c->minargs || argc > c->maxargs)
+	if ((c->minargs != -1 && argc < c->minargs) || (c->minargs != -1 && argc > c->maxargs))
 		errx(1, "abort: invalid number of arguments");
 
 	c->exec(argc, argv);
@@ -725,16 +856,59 @@
 noreturn static void
 usage(void)
 {
-	fprintf(stderr, "usage: %s [-v] [-s sock] command [arguments...]\n", getprogname());
+	fprintf(stderr, "usage: %s [-v] [-s sock] command [options...] [arguments...]\n", getprogname());
+	exit(1);
+}
+
+noreturn static void
+help(void)
+{
+	fprintf(stderr, "usage: %s hook-add name path\n", getprogname());
+	fprintf(stderr, "       %s hook-list\n", getprogname());
+	fprintf(stderr, "       %s hook-remove id\n", getprogname());
+	fprintf(stderr, "       %s plugin-config id [variable [value]]\n", getprogname());
+	fprintf(stderr, "       %s plugin-info id\n", getprogname());
+	fprintf(stderr, "       %s plugin-list\n", getprogname());
+	fprintf(stderr, "       %s plugin-load name\n", getprogname());
+	fprintf(stderr, "       %s plugin-path [variable [value]]\n", getprogname());
+	fprintf(stderr, "       %s plugin-template [variable [value]]\n", getprogname());
+	fprintf(stderr, "       %s plugin-reload [plugin]\n", getprogname());
+	fprintf(stderr, "       %s plugin-unload [plugin]\n", getprogname());
+	fprintf(stderr, "       %s rule-add [-c channel] [-e event] [-i index] [-o origin] [-p plugin] [-s server] accept|drop\n", getprogname());
+	fprintf(stderr, "       %s rule-edit [-a accept|drop] [-c|C channel] [-e|E event] [-o|O origin] [-s|S server] index\n", getprogname());
+	fprintf(stderr, "       %s rule-list\n", getprogname());
+	fprintf(stderr, "       %s rule-move from to\n", getprogname());
+	fprintf(stderr, "       %s rule-remove index\n", getprogname());
+	fprintf(stderr, "       %s server-connect [-n nickname] [-r realname] [-u username] [-p port] id hostname\n", getprogname());
+	fprintf(stderr, "       %s server-disconnect [server]\n", getprogname());
+	fprintf(stderr, "       %s server-info server\n", getprogname());
+	fprintf(stderr, "       %s server-invite server target channel\n", getprogname());
+	fprintf(stderr, "       %s server-join server channel [password]\n", getprogname());
+	fprintf(stderr, "       %s server-kick server target channel [reason]\n", getprogname());
+	fprintf(stderr, "       %s server-list\n", getprogname());
+	fprintf(stderr, "       %s server-me server target message\n", getprogname());
+	fprintf(stderr, "       %s server-message server target message\n", getprogname());
+	fprintf(stderr, "       %s server-mode server target mode [limit] [user] [mask]\n", getprogname());
+	fprintf(stderr, "       %s server-nick server nickname\n", getprogname());
+	fprintf(stderr, "       %s server-notice server target message\n", getprogname());
+	fprintf(stderr, "       %s server-part server channel [reason]\n", getprogname());
+	fprintf(stderr, "       %s server-reconnect [server]\n", getprogname());
+	fprintf(stderr, "       %s server-topic server channel topic\n", getprogname());
+	fprintf(stderr, "       %s watch\n", getprogname());
 	exit(1);
 }
 
 int
 main(int argc, char **argv)
 {
+	ketopt_t ko = KETOPT_INIT;
+
 	setprogname("irccdctl");
 
-	for (int ch; (ch = getopt(argc, argv, "s:v")) != -1; ) {
+	--argc;
+	++argv;
+
+	for (int ch; (ch = ketopt(&ko, argc, argv, 0, "s:v", NULL)) != -1; ) {
 		switch (ch) {
 		case 's':
 			strlcpy(sockaddr.sun_path, optarg, sizeof (sockaddr.sun_path));
@@ -743,15 +917,18 @@
 			verbose = 1;
 			break;
 		default:
+			usage();
 			break;
 		}
 	}
 
-	argc -= optind;
-	argv += optind;
+	argc -= ko.ind;
+	argv += ko.ind;
 
 	if (argc < 1)
 		usage();
+	else if (strcmp(argv[0], "help") == 0)
+		help();
 
 	dial();
 	check();
--- a/lib/irccd/irccd.c	Sun Feb 07 14:36:28 2021 +0100
+++ b/lib/irccd/irccd.c	Tue Feb 09 13:00:32 2021 +0100
@@ -209,18 +209,6 @@
 	return irc_plugin_loader_open(ldr, path);
 }
 
-static inline size_t
-rulescount(void)
-{
-	const struct irc_rule *r;
-	size_t total = 0;
-
-	TAILQ_FOREACH(r, &irc.rules, link)
-		total++;
-
-	return total;
-}
-
 void
 irc_bot_init(void)
 {
@@ -399,22 +387,62 @@
 
 	if (index == 0)
 		TAILQ_INSERT_HEAD(&irc.rules, rule, link);
-	else if (index >= rulescount())
+	else if (index >= irc_bot_rule_size())
 		TAILQ_INSERT_TAIL(&irc.rules, rule, link);
 	else {
-		struct irc_rule *pos = TAILQ_FIRST(&irc.rules);
+		struct irc_rule *pos;
 
-		for (size_t i = 0; i < index; ++i)
+		for (pos = TAILQ_FIRST(&irc.rules); --index; )
 			pos = TAILQ_NEXT(pos, link);
 
 		TAILQ_INSERT_AFTER(&irc.rules, pos, rule, link);
 	}
 }
 
+struct irc_rule *
+irc_bot_rule_get(size_t index)
+{
+	assert(index < irc_bot_rule_size());
+
+	struct irc_rule *rule;
+
+	for (rule = TAILQ_FIRST(&irc.rules); index-- != 0; )
+		rule = TAILQ_NEXT(rule, link);
+
+	return rule;
+}
+
+void
+irc_bot_rule_move(size_t from, size_t to)
+{
+	assert(from < irc_bot_rule_size());
+
+	struct irc_rule *f, *t;
+
+	if (from == to)
+		return;
+
+	f = t = TAILQ_FIRST(&irc.rules);
+
+	while (from--)
+		f = TAILQ_NEXT(f, link);
+
+	TAILQ_REMOVE(&irc.rules, f, link);
+
+	if (to == 0)
+		TAILQ_INSERT_HEAD(&irc.rules, f, link);
+	else {
+		while (TAILQ_NEXT(t, link) && to--)
+			t = TAILQ_NEXT(t, link);
+
+		TAILQ_INSERT_AFTER(&irc.rules, t, f, link);
+	}
+}
+
 void
 irc_bot_rule_remove(size_t index)
 {
-	assert(index < rulescount());
+	assert(index < irc_bot_rule_size());
 
 	struct irc_rule *pos = TAILQ_FIRST(&irc.rules);
 
@@ -424,6 +452,18 @@
 	TAILQ_REMOVE(&irc.rules, pos, link);
 }
 
+size_t
+irc_bot_rule_size(void)
+{
+	const struct irc_rule *r;
+	size_t total = 0;
+
+	TAILQ_FOREACH(r, &irc.rules, link)
+		total++;
+
+	return total;
+}
+
 void
 irc_bot_rule_clear(void)
 {
--- a/lib/irccd/irccd.h	Sun Feb 07 14:36:28 2021 +0100
+++ b/lib/irccd/irccd.h	Tue Feb 09 13:00:32 2021 +0100
@@ -68,9 +68,18 @@
 void
 irc_bot_rule_insert(struct irc_rule *, size_t);
 
+struct irc_rule *
+irc_bot_rule_get(size_t);
+
+void
+irc_bot_rule_move(size_t, size_t);
+
 void
 irc_bot_rule_remove(size_t);
 
+size_t
+irc_bot_rule_size(void);
+
 void
 irc_bot_rule_clear(void);