diff options
Diffstat (limited to 'xB.c')
-rw-r--r-- | xB.c | 2063 |
1 files changed, 2063 insertions, 0 deletions
@@ -0,0 +1,2063 @@ +/* + * xB.c: a modular IRC bot + * + * Copyright (c) 2014 - 2020, Přemysl Eric Janouch <p@janouch.name> + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION + * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + */ + +#include "config.h" +#define PROGRAM_NAME "xB" + +#include "common.c" + +// --- Configuration (application-specific) ------------------------------------ + +static struct simple_config_item g_config_table[] = +{ + { "nickname", "xB", "IRC nickname" }, + { "username", "bot", "IRC user name" }, + { "realname", "xB IRC bot", "IRC real name/e-mail" }, + + { "irc_host", NULL, "Address of the IRC server" }, + { "irc_port", "6667", "Port of the IRC server" }, + { "tls", "off", "Whether to use TLS" }, + { "tls_cert", NULL, "Client TLS certificate (PEM)" }, + { "tls_verify", "on", "Whether to verify certificates" }, + { "tls_ca_file", NULL, "OpenSSL CA bundle file" }, + { "tls_ca_path", NULL, "OpenSSL CA bundle path" }, + { "autojoin", NULL, "Channels to join on start" }, + { "reconnect", "on", "Whether to reconnect on error" }, + { "reconnect_delay", "5", "Time between reconnecting" }, + + { "socks_host", NULL, "Address of a SOCKS 4a/5 proxy" }, + { "socks_port", "1080", "SOCKS port number" }, + { "socks_username", NULL, "SOCKS auth. username" }, + { "socks_password", NULL, "SOCKS auth. password" }, + + { "prefix", ":", "The prefix for bot commands" }, + { "admin", NULL, "Host mask for administrators" }, + { "plugins", NULL, "The plugins to load on startup" }, + { "plugin_dir", NULL, "Plugin search path override" }, + { "recover", "on", "Whether to re-launch on crash" }, + + { NULL, NULL, NULL } +}; + +// --- Application data -------------------------------------------------------- + +struct plugin +{ + LIST_HEADER (struct plugin) + struct bot_context *ctx; ///< Parent context + + char *name; ///< Plugin identifier + pid_t pid; ///< PID of the plugin process + + bool is_zombie; ///< Whether the child is a zombie + bool initialized; ///< Ready to exchange IRC messages + struct str queued_output; ///< Output queued up until initialized + + // Since we're doing non-blocking I/O, we need to queue up data so that + // we don't stall on plugins unnecessarily. + + int read_fd; ///< The read end of the comm. pipe + int write_fd; ///< The write end of the comm. pipe + + struct poller_fd read_event; ///< Read FD event + struct poller_fd write_event; ///< Write FD event + + struct str read_buffer; ///< Unprocessed input + struct str write_buffer; ///< Output yet to be sent out +}; + +static struct plugin * +plugin_new (void) +{ + struct plugin *self = xcalloc (1, sizeof *self); + self->pid = -1; + self->queued_output = str_make (); + + self->read_fd = -1; + self->read_buffer = str_make (); + self->write_fd = -1; + self->write_buffer = str_make (); + return self; +} + +static void +plugin_destroy (struct plugin *self) +{ + soft_assert (self->pid == -1); + free (self->name); + + str_free (&self->read_buffer); + if (!soft_assert (self->read_fd == -1)) + xclose (self->read_fd); + + str_free (&self->write_buffer); + if (!soft_assert (self->write_fd == -1)) + xclose (self->write_fd); + + if (!self->initialized) + str_free (&self->queued_output); + + free (self); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct bot_context +{ + struct str_map config; ///< User configuration + regex_t *admin_re; ///< Regex to match our administrator + bool reconnect; ///< Whether to reconnect on conn. fail. + unsigned long reconnect_delay; ///< Reconnect delay in seconds + + int irc_fd; ///< Socket FD of the server + struct str read_buffer; ///< Input yet to be processed + struct poller_fd irc_event; ///< IRC FD event + bool irc_registered; ///< Whether we may send messages now + + struct poller_fd signal_event; ///< Signal FD event + struct poller_timer ping_tmr; ///< We should send a ping + struct poller_timer timeout_tmr; ///< Connection seems to be dead + struct poller_timer reconnect_tmr; ///< We should reconnect now + + SSL_CTX *ssl_ctx; ///< SSL context + SSL *ssl; ///< SSL connection + + struct plugin *plugins; ///< Linked list of plugins + struct str_map plugins_by_name; ///< Indexes @em plugins by their name + + struct poller poller; ///< Manages polled descriptors + bool quitting; ///< User requested quitting + bool polling; ///< The event loop is running +}; + +static void on_irc_ping_timeout (void *user_data); +static void on_irc_timeout (void *user_data); +static void on_irc_reconnect_timeout (void *user_data); + +static void +bot_context_init (struct bot_context *self) +{ + self->config = str_map_make (free); + simple_config_load_defaults (&self->config, g_config_table); + self->admin_re = NULL; + + self->irc_fd = -1; + self->read_buffer = str_make (); + self->irc_registered = false; + + self->ssl = NULL; + self->ssl_ctx = NULL; + + self->plugins = NULL; + self->plugins_by_name = str_map_make (NULL); + + poller_init (&self->poller); + self->quitting = false; + self->polling = false; + + self->timeout_tmr = poller_timer_make (&self->poller); + self->timeout_tmr.dispatcher = on_irc_timeout; + self->timeout_tmr.user_data = self; + + self->ping_tmr = poller_timer_make (&self->poller); + self->ping_tmr.dispatcher = on_irc_ping_timeout; + self->ping_tmr.user_data = self; + + self->reconnect_tmr = poller_timer_make (&self->poller); + self->reconnect_tmr.dispatcher = on_irc_reconnect_timeout; + self->reconnect_tmr.user_data = self; +} + +static void +bot_context_free (struct bot_context *self) +{ + str_map_free (&self->config); + if (self->admin_re) + regex_free (self->admin_re); + str_free (&self->read_buffer); + + // TODO: terminate the plugins properly before this is called + LIST_FOR_EACH (struct plugin, link, self->plugins) + plugin_destroy (link); + + if (self->irc_fd != -1) + { + poller_fd_reset (&self->irc_event); + xclose (self->irc_fd); + } + if (self->ssl) + SSL_free (self->ssl); + if (self->ssl_ctx) + SSL_CTX_free (self->ssl_ctx); + + str_map_free (&self->plugins_by_name); + poller_free (&self->poller); +} + +static void +irc_shutdown (struct bot_context *ctx) +{ + // TODO: set a timer after which we cut the connection? + // Generally non-critical + if (ctx->ssl) + soft_assert (SSL_shutdown (ctx->ssl) != -1); + else + soft_assert (shutdown (ctx->irc_fd, SHUT_WR) == 0); +} + +static void +try_finish_quit (struct bot_context *ctx) +{ + if (ctx->quitting && ctx->irc_fd == -1 && !ctx->plugins) + ctx->polling = false; +} + +static bool plugin_zombify (struct plugin *); + +static void +initiate_quit (struct bot_context *ctx) +{ + // Initiate bringing down of the two things that block our shutdown: + // a/ the IRC socket, b/ our child processes: + + for (struct plugin *plugin = ctx->plugins; + plugin; plugin = plugin->next) + plugin_zombify (plugin); + if (ctx->irc_fd != -1) + irc_shutdown (ctx); + + ctx->quitting = true; + try_finish_quit (ctx); +} + +static bool irc_send (struct bot_context *ctx, + const char *format, ...) ATTRIBUTE_PRINTF (2, 3); + +static bool +irc_send (struct bot_context *ctx, const char *format, ...) +{ + va_list ap; + + if (g_debug_mode) + { + fputs ("[IRC] <== \"", stderr); + va_start (ap, format); + vfprintf (stderr, format, ap); + va_end (ap); + fputs ("\"\n", stderr); + } + + if (!soft_assert (ctx->irc_fd != -1)) + return false; + + va_start (ap, format); + struct str str = str_make (); + str_append_vprintf (&str, format, ap); + str_append (&str, "\r\n"); + va_end (ap); + + bool result = true; + if (ctx->ssl) + { + // TODO: call SSL_get_error() to detect if a clean shutdown has occured + ERR_clear_error (); + if (SSL_write (ctx->ssl, str.str, str.len) != (int) str.len) + { + print_debug ("%s: %s: %s", __func__, "SSL_write", + xerr_describe_error ()); + result = false; + } + } + else if (write (ctx->irc_fd, str.str, str.len) != (ssize_t) str.len) + { + print_debug ("%s: %s: %s", __func__, "write", strerror (errno)); + result = false; + } + + str_free (&str); + return result; +} + +static bool +irc_get_boolean_from_config + (struct bot_context *ctx, const char *name, bool *value, struct error **e) +{ + const char *str = str_map_find (&ctx->config, name); + hard_assert (str != NULL); + + if (set_boolean_if_valid (value, str)) + return true; + + return error_set (e, "invalid configuration value for `%s'", name); +} + +static bool +irc_initialize_ca_set (SSL_CTX *ssl_ctx, const char *file, const char *path, + struct error **e) +{ + ERR_clear_error (); + + if (file || path) + { + if (SSL_CTX_load_verify_locations (ssl_ctx, file, path)) + return true; + + return error_set (e, "%s: %s", + "failed to set locations for the CA certificate bundle", + xerr_describe_error ()); + } + + if (!SSL_CTX_set_default_verify_paths (ssl_ctx)) + return error_set (e, "%s: %s", + "couldn't load the default CA certificate bundle", + xerr_describe_error ()); + return true; +} + +static bool +irc_initialize_ca (struct bot_context *ctx, struct error **e) +{ + const char *ca_file = str_map_find (&ctx->config, "tls_ca_file"); + const char *ca_path = str_map_find (&ctx->config, "tls_ca_path"); + + char *full_file = ca_file + ? resolve_filename (ca_file, resolve_relative_config_filename) : NULL; + char *full_path = ca_path + ? resolve_filename (ca_path, resolve_relative_config_filename) : NULL; + + bool ok = false; + if (ca_file && !full_file) + error_set (e, "couldn't find the CA bundle file"); + else if (ca_path && !full_path) + error_set (e, "couldn't find the CA bundle path"); + else + ok = irc_initialize_ca_set (ctx->ssl_ctx, full_file, full_path, e); + + free (full_file); + free (full_path); + return ok; +} + +static bool +irc_initialize_ssl_ctx (struct bot_context *ctx, struct error **e) +{ + // Disable deprecated protocols (see RFC 7568) + SSL_CTX_set_options (ctx->ssl_ctx, SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3); + + bool verify; + if (!irc_get_boolean_from_config (ctx, "tls_verify", &verify, e)) + return false; + SSL_CTX_set_verify (ctx->ssl_ctx, + verify ? SSL_VERIFY_PEER : SSL_VERIFY_NONE, NULL); + + struct error *error = NULL; + if (!irc_initialize_ca (ctx, &error)) + { + if (verify) + { + error_propagate (e, error); + return false; + } + + // Only inform the user if we're not actually verifying + print_warning ("%s", error->message); + error_free (error); + } + return true; +} + +static bool +irc_initialize_tls (struct bot_context *ctx, struct error **e) +{ + const char *error_info = NULL; + ctx->ssl_ctx = SSL_CTX_new (SSLv23_client_method ()); + if (!ctx->ssl_ctx) + goto error_ssl_1; + if (!irc_initialize_ssl_ctx (ctx, e)) + goto error_ssl_2; + + ctx->ssl = SSL_new (ctx->ssl_ctx); + if (!ctx->ssl) + goto error_ssl_2; + + const char *tls_cert = str_map_find (&ctx->config, "tls_cert"); + if (tls_cert) + { + char *path = resolve_filename + (tls_cert, resolve_relative_config_filename); + if (!path) + print_error ("%s: %s", "cannot open file", tls_cert); + // XXX: perhaps we should read the file ourselves for better messages + else if (!SSL_use_certificate_file (ctx->ssl, path, SSL_FILETYPE_PEM) + || !SSL_use_PrivateKey_file (ctx->ssl, path, SSL_FILETYPE_PEM)) + print_error ("%s: %s", "setting the TLS client certificate failed", + xerr_describe_error ()); + free (path); + } + + SSL_set_connect_state (ctx->ssl); + if (!SSL_set_fd (ctx->ssl, ctx->irc_fd)) + goto error_ssl_3; + // Avoid SSL_write() returning SSL_ERROR_WANT_READ + SSL_set_mode (ctx->ssl, SSL_MODE_AUTO_RETRY); + + switch (xssl_get_error (ctx->ssl, SSL_connect (ctx->ssl), &error_info)) + { + case SSL_ERROR_NONE: + return true; + case SSL_ERROR_ZERO_RETURN: + error_info = "server closed the connection"; + default: + break; + } + +error_ssl_3: + SSL_free (ctx->ssl); + ctx->ssl = NULL; +error_ssl_2: + SSL_CTX_free (ctx->ssl_ctx); + ctx->ssl_ctx = NULL; +error_ssl_1: + if (!error_info) + error_info = xerr_describe_error (); + return error_set (e, "%s: %s", "could not initialize TLS", error_info); +} + +static bool +irc_establish_connection (struct bot_context *ctx, + const char *host, const char *port, struct error **e) +{ + struct addrinfo gai_hints, *gai_result, *gai_iter; + memset (&gai_hints, 0, sizeof gai_hints); + gai_hints.ai_socktype = SOCK_STREAM; + + int err = getaddrinfo (host, port, &gai_hints, &gai_result); + if (err) + return error_set (e, "%s: %s: %s", "connection failed", + "getaddrinfo", gai_strerror (err)); + + int sockfd; + for (gai_iter = gai_result; gai_iter; gai_iter = gai_iter->ai_next) + { + sockfd = socket (gai_iter->ai_family, + gai_iter->ai_socktype, gai_iter->ai_protocol); + if (sockfd == -1) + continue; + set_cloexec (sockfd); + + int yes = 1; + soft_assert (setsockopt (sockfd, SOL_SOCKET, SO_KEEPALIVE, + &yes, sizeof yes) != -1); + + const char *real_host = host; + + // Let's try to resolve the address back into a real hostname; + // we don't really need this, so we can let it quietly fail + char buf[NI_MAXHOST]; + err = getnameinfo (gai_iter->ai_addr, gai_iter->ai_addrlen, + buf, sizeof buf, NULL, 0, 0); + if (err) + print_debug ("%s: %s", "getnameinfo", gai_strerror (err)); + else + real_host = buf; + + // XXX: we shouldn't mix these statuses with `struct error'; choose 1! + char *address = format_host_port_pair (real_host, port); + print_status ("connecting to %s...", address); + free (address); + + if (!connect (sockfd, gai_iter->ai_addr, gai_iter->ai_addrlen)) + break; + + xclose (sockfd); + } + + freeaddrinfo (gai_result); + + if (!gai_iter) + return error_set (e, "connection failed"); + + ctx->irc_fd = sockfd; + return true; +} + +// --- Signals ----------------------------------------------------------------- + +static int g_signal_pipe[2]; ///< A pipe used to signal... signals + +static struct strv + g_original_argv, ///< Original program arguments + g_recovery_env; ///< Environment for re-exec recovery + +/// Program termination has been requested by a signal +static volatile sig_atomic_t g_termination_requested; + +/// Points to startup reason location within `g_recovery_environment' +static char **g_startup_reason_location; +/// The environment variable used to pass the startup reason when re-executing +static const char g_startup_reason_str[] = "STARTUP_REASON"; + +static void +sigchld_handler (int signum) +{ + (void) signum; + + int original_errno = errno; + // Just so that the read end of the pipe wakes up the poller. + // NOTE: Linux has signalfd() and eventfd(), and the BSD's have kqueue. + // All of them are better than this approach, although platform-specific. + if (write (g_signal_pipe[1], "c", 1) == -1) + soft_assert (errno == EAGAIN); + errno = original_errno; +} + +static void +sigterm_handler (int signum) +{ + (void) signum; + + g_termination_requested = true; + + int original_errno = errno; + if (write (g_signal_pipe[1], "t", 1) == -1) + soft_assert (errno == EAGAIN); + errno = original_errno; +} + +static void +setup_signal_handlers (void) +{ + if (pipe (g_signal_pipe) == -1) + exit_fatal ("%s: %s", "pipe", strerror (errno)); + + set_cloexec (g_signal_pipe[0]); + set_cloexec (g_signal_pipe[1]); + + // So that the pipe cannot overflow; it would make write() block within + // the signal handler, which is something we really don't want to happen. + // The same holds true for read(). + set_blocking (g_signal_pipe[0], false); + set_blocking (g_signal_pipe[1], false); + + struct sigaction sa; + sa.sa_flags = SA_RESTART; + sa.sa_handler = sigchld_handler; + sigemptyset (&sa.sa_mask); + + if (sigaction (SIGCHLD, &sa, NULL) == -1) + exit_fatal ("sigaction: %s", strerror (errno)); + + signal (SIGPIPE, SIG_IGN); + + sa.sa_handler = sigterm_handler; + if (sigaction (SIGINT, &sa, NULL) == -1 + || sigaction (SIGTERM, &sa, NULL) == -1) + exit_fatal ("sigaction: %s", strerror (errno)); +} + +static void +translate_signal_info (int no, const char **name, int code, const char **reason) +{ + if (code == SI_USER) *reason = "signal sent by kill()"; + if (code == SI_QUEUE) *reason = "signal sent by sigqueue()"; + + switch (no) + { + case SIGILL: + *name = "SIGILL"; + if (code == ILL_ILLOPC) *reason = "illegal opcode"; + if (code == ILL_ILLOPN) *reason = "illegal operand"; + if (code == ILL_ILLADR) *reason = "illegal addressing mode"; + if (code == ILL_ILLTRP) *reason = "illegal trap"; + if (code == ILL_PRVOPC) *reason = "privileged opcode"; + if (code == ILL_PRVREG) *reason = "privileged register"; + if (code == ILL_COPROC) *reason = "coprocessor error"; + if (code == ILL_BADSTK) *reason = "internal stack error"; + break; + case SIGFPE: + *name = "SIGFPE"; + if (code == FPE_INTDIV) *reason = "integer divide by zero"; + if (code == FPE_INTOVF) *reason = "integer overflow"; + if (code == FPE_FLTDIV) *reason = "floating-point divide by zero"; + if (code == FPE_FLTOVF) *reason = "floating-point overflow"; + if (code == FPE_FLTUND) *reason = "floating-point underflow"; + if (code == FPE_FLTRES) *reason = "floating-point inexact result"; + if (code == FPE_FLTINV) *reason = "invalid floating-point operation"; + if (code == FPE_FLTSUB) *reason = "subscript out of range"; + break; + case SIGSEGV: + *name = "SIGSEGV"; + if (code == SEGV_MAPERR) + *reason = "address not mapped to object"; + if (code == SEGV_ACCERR) + *reason = "invalid permissions for mapped object"; + break; + case SIGBUS: + *name = "SIGBUS"; + if (code == BUS_ADRALN) *reason = "invalid address alignment"; + if (code == BUS_ADRERR) *reason = "nonexistent physical address"; + if (code == BUS_OBJERR) *reason = "object-specific hardware error"; + break; + default: + *name = NULL; + } +} + +static void +recovery_handler (int signum, siginfo_t *info, void *context) +{ + (void) context; + + // TODO: maybe try to force a core dump like this: if (fork() == 0) return; + // TODO: maybe we could even send "\r\nQUIT :reason\r\n" to the server. >_> + // As long as we're not connected via TLS, that is. + + const char *signal_name = NULL, *reason = NULL; + translate_signal_info (signum, &signal_name, info->si_code, &reason); + + char buf[128], numbuf[8]; + if (!signal_name) + { + snprintf (numbuf, sizeof numbuf, "%d", signum); + signal_name = numbuf; + } + + if (reason) + snprintf (buf, sizeof buf, "%s=%s: %s: %s", g_startup_reason_str, + "signal received", signal_name, reason); + else + snprintf (buf, sizeof buf, "%s=%s: %s", g_startup_reason_str, + "signal received", signal_name); + *g_startup_reason_location = buf; + + // Avoid annoying resource intensive infinite loops by sleeping for a bit + (void) sleep (1); + + // TODO: maybe pregenerate the path, see the following for some other ways + // that would be illegal to do from within a signal handler: + // http://stackoverflow.com/a/1024937 + // http://stackoverflow.com/q/799679 + // Especially if we change the current working directory in the program. + // + // Note that I can just overwrite g_orig_argv[0]. + + // NOTE: our children will read EOF on the read ends of their pipes as a + // a result of O_CLOEXEC. That should be enough to make them terminate. + + char **argv = g_original_argv.vector, **argp = g_recovery_env.vector; + execve ("/proc/self/exe", argv, argp); // Linux + execve ("/proc/curproc/file", argv, argp); // BSD + execve ("/proc/curproc/exe", argv, argp); // BSD + execve ("/proc/self/path/a.out", argv, argp); // Solaris + execve (argv[0], argv, argp); // unreliable fallback + + // Let's just crash + perror ("execve"); + signal (signum, SIG_DFL); + raise (signum); +} + +static void +prepare_recovery_environment (void) +{ + g_recovery_env = strv_make (); + strv_append_vector (&g_recovery_env, environ); + + // Prepare a location within the environment where we will put the startup + // (or maybe rather restart) reason in case of an irrecoverable error. + char **iter; + for (iter = g_recovery_env.vector; *iter; iter++) + { + const size_t len = sizeof g_startup_reason_str - 1; + if (!strncmp (*iter, g_startup_reason_str, len) && (*iter)[len] == '=') + break; + } + + if (*iter) + g_startup_reason_location = iter; + else + { + g_startup_reason_location = g_recovery_env.vector + g_recovery_env.len; + strv_append (&g_recovery_env, ""); + } +} + +static bool +setup_recovery_handler (struct bot_context *ctx, struct error **e) +{ + bool recover; + if (!irc_get_boolean_from_config (ctx, "recover", &recover, e)) + return false; + if (!recover) + return true; + + // Make sure these signals aren't blocked, otherwise we would be unable + // to handle them, making the critical conditions fatal. + sigset_t mask; + sigemptyset (&mask); + sigaddset (&mask, SIGSEGV); + sigaddset (&mask, SIGBUS); + sigaddset (&mask, SIGFPE); + sigaddset (&mask, SIGILL); + sigprocmask (SIG_UNBLOCK, &mask, NULL); + + struct sigaction sa; + sa.sa_flags = SA_SIGINFO; + sa.sa_sigaction = recovery_handler; + sigemptyset (&sa.sa_mask); + + prepare_recovery_environment (); + + // TODO: also handle SIGABRT... or avoid doing abort() in the first place? + if (sigaction (SIGSEGV, &sa, NULL) == -1 + || sigaction (SIGBUS, &sa, NULL) == -1 + || sigaction (SIGFPE, &sa, NULL) == -1 + || sigaction (SIGILL, &sa, NULL) == -1) + print_error ("sigaction: %s", strerror (errno)); + return true; +} + +// --- Plugins ----------------------------------------------------------------- + +/// The name of the special IRC command for interprocess communication +static const char *plugin_ipc_command = "ZYKLONB"; + +static struct plugin * +plugin_find_by_pid (struct bot_context *ctx, pid_t pid) +{ + struct plugin *iter; + for (iter = ctx->plugins; iter; iter = iter->next) + if (iter->pid == pid) + return iter; + return NULL; +} + +static bool +plugin_zombify (struct plugin *plugin) +{ + if (plugin->is_zombie) + return false; + + // FIXME: make sure that we don't remove entries from the poller while we + // still may have stuff to read; maybe just check that the read pipe is + // empty before closing it... and then on EOF check if `pid == -1' and + // only then dispose of it (it'd be best to simulate that both of these + // cases may happen). + poller_fd_reset (&plugin->write_event); + + // TODO: try to flush the write buffer (non-blocking)? + + // The plugin should terminate itself after it receives EOF. + xclose (plugin->write_fd); + plugin->write_fd = -1; + + // Make it a pseudo-anonymous zombie. In this state we process any + // remaining commands it attempts to send to us before it finally dies. + str_map_set (&plugin->ctx->plugins_by_name, plugin->name, NULL); + plugin->is_zombie = true; + + // TODO: wait a few seconds and then send SIGKILL to the plugin + return true; +} + +static void +on_plugin_writable (const struct pollfd *fd, struct plugin *plugin) +{ + struct str *buf = &plugin->write_buffer; + size_t written_total = 0; + + if (fd->revents & ~(POLLOUT | POLLHUP | POLLERR)) + print_debug ("fd %d: unexpected revents: %d", fd->fd, fd->revents); + + while (written_total != buf->len) + { + ssize_t n_written = write (fd->fd, buf->str + written_total, + buf->len - written_total); + + if (n_written < 0) + { + if (errno == EAGAIN) + break; + if (errno == EINTR) + continue; + + soft_assert (errno == EPIPE); + // Zombies shouldn't get dispatched for writability + hard_assert (!plugin->is_zombie); + + print_debug ("%s: %s", "write", strerror (errno)); + print_error ("failure on writing to plugin `%s'," + " therefore I'm unloading it", plugin->name); + plugin_zombify (plugin); + break; + } + + // This may be equivalent to EAGAIN on some implementations + if (n_written == 0) + break; + + written_total += n_written; + } + + if (written_total != 0) + str_remove_slice (buf, 0, written_total); + + if (buf->len == 0) + // Everything has been written, there's no need to end up in here again + poller_fd_reset (&plugin->write_event); +} + +static void +plugin_queue_write (struct plugin *plugin) +{ + if (plugin->is_zombie) + return; + + // Don't let the write buffer grow indefinitely. If there's a ton of data + // waiting to be processed by the plugin, it usually means there's something + // wrong with it (such as someone stopping the process). + if (plugin->write_buffer.len >= (1 << 20)) + { + print_warning ("plugin `%s' does not seem to process messages fast" + " enough, I'm unloading it", plugin->name); + plugin_zombify (plugin); + return; + } + poller_fd_set (&plugin->write_event, POLLOUT); +} + +static void +plugin_send (struct plugin *plugin, const char *format, ...) + ATTRIBUTE_PRINTF (2, 3); + +static void +plugin_send (struct plugin *plugin, const char *format, ...) +{ + va_list ap; + + if (g_debug_mode) + { + fprintf (stderr, "[%s] <-- \"", plugin->name); + va_start (ap, format); + vfprintf (stderr, format, ap); + va_end (ap); + fputs ("\"\n", stderr); + } + + va_start (ap, format); + str_append_vprintf (&plugin->write_buffer, format, ap); + va_end (ap); + str_append (&plugin->write_buffer, "\r\n"); + + plugin_queue_write (plugin); +} + +static void +plugin_process_ipc (struct plugin *plugin, const struct irc_message *msg) +{ + // Replies are sent in the order in which they came in, so there's + // no need to attach a special identifier to them. It might be + // desirable in some cases, though. + + if (msg->params.len < 1) + return; + + const char *command = msg->params.vector[0]; + if (!plugin->initialized && !strcasecmp (command, "register")) + { + // Register for relaying of IRC traffic + plugin->initialized = true; + + // Flush any queued up traffic here. The point of queuing it in + // the first place is so that we don't have to wait for plugin + // initialization during startup. + // + // Note that if we start filtering data coming to the plugins e.g. + // based on what it tells us upon registration, we might need to + // filter `queued_output' as well. + str_append_str (&plugin->write_buffer, &plugin->queued_output); + str_free (&plugin->queued_output); + + // NOTE: this may trigger the buffer length check + plugin_queue_write (plugin); + } + else if (!strcasecmp (command, "get_config")) + { + if (msg->params.len < 2) + return; + + const char *value = + str_map_find (&plugin->ctx->config, msg->params.vector[1]); + // TODO: escape the value (although there's no need to ATM) + plugin_send (plugin, "%s :%s", + plugin_ipc_command, value ? value : ""); + } + else if (!strcasecmp (command, "print")) + { + if (msg->params.len < 2) + return; + + printf ("%s\n", msg->params.vector[1]); + } +} + +static void +plugin_process_message (const struct irc_message *msg, + const char *raw, void *user_data) +{ + struct plugin *plugin = user_data; + struct bot_context *ctx = plugin->ctx; + + if (g_debug_mode) + fprintf (stderr, "[%s] --> \"%s\"\n", plugin->name, raw); + + if (!strcasecmp (msg->command, plugin_ipc_command)) + plugin_process_ipc (plugin, msg); + else if (plugin->initialized && ctx->irc_registered) + { + // Pass everything else through to the IRC server + // XXX: when the server isn't ready yet, these messages get silently + // discarded, which shouldn't pose a problem most of the time. + // Perhaps we could send a "connected" notification on `register' + // if `irc_ready' is true, or after it becomes true later, so that + // plugins know when to start sending unprovoked IRC messages. + // XXX: another case is when the connection gets interrupted and the + // plugin tries to send something back while we're reconnecting. + // For that we might set up a global buffer that gets flushed out + // after `irc_ready' becomes true. Note that there is always some + // chance of messages getting lost without us even noticing it. + irc_send (ctx, "%s", raw); + } +} + +static void +on_plugin_readable (const struct pollfd *fd, struct plugin *plugin) +{ + if (fd->revents & ~(POLLIN | POLLHUP | POLLERR)) + print_debug ("fd %d: unexpected revents: %d", fd->fd, fd->revents); + + // TODO: see if I can reuse irc_fill_read_buffer() + struct str *buf = &plugin->read_buffer; + while (true) + { + str_reserve (buf, 512 + 1); + ssize_t n_read = read (fd->fd, buf->str + buf->len, + buf->alloc - buf->len - 1); + + if (n_read < 0) + { + if (errno == EAGAIN) + break; + if (soft_assert (errno == EINTR)) + continue; + + if (!plugin->is_zombie) + { + print_error ("failure on reading from plugin `%s'," + " therefore I'm unloading it", plugin->name); + plugin_zombify (plugin); + } + return; + } + + // EOF; hopefully it will die soon (maybe it already has) + if (n_read == 0) + break; + + buf->str[buf->len += n_read] = '\0'; + if (buf->len >= (1 << 20)) + { + // XXX: this isn't really the best flood prevention mechanism, + // but it wasn't even supposed to be one. + if (plugin->is_zombie) + { + print_error ("a zombie of plugin `%s' is trying to flood us," + " therefore I'm killing it", plugin->name); + kill (plugin->pid, SIGKILL); + } + else + { + print_error ("plugin `%s' seems to spew out data frantically," + " therefore I'm unloading it", plugin->name); + plugin_zombify (plugin); + } + return; + } + } + + irc_process_buffer (buf, plugin_process_message, plugin); +} + +static bool +is_valid_plugin_name (const char *name) +{ + if (!*name) + return false; + for (const char *p = name; *p; p++) + if (!isgraph (*p) || *p == '/') + return false; + return true; +} + +static char * +plugin_resolve_relative_filename (const char *filename) +{ + struct strv paths = strv_make (); + get_xdg_data_dirs (&paths); + char *result = resolve_relative_filename_generic + (&paths, PROGRAM_NAME "/plugins/", filename); + strv_free (&paths); + return result; +} + +static struct plugin * +plugin_launch (struct bot_context *ctx, const char *name, struct error **e) +{ + char *path = NULL; + const char *plugin_dir = str_map_find (&ctx->config, "plugin_dir"); + if (plugin_dir) + { + // resolve_relative_filename_generic() won't accept relative paths, + // so just keep the old behaviour and expect the file to exist. + // We could use resolve_filename() on "plugin_dir" with paths=getcwd(). + path = xstrdup_printf ("%s/%s", plugin_dir, name); + } + else if (!(path = plugin_resolve_relative_filename (name))) + { + error_set (e, "plugin not found"); + goto fail_0; + } + + int stdin_pipe[2]; + if (pipe (stdin_pipe) == -1) + { + error_set (e, "%s: %s", "pipe", strerror (errno)); + goto fail_0; + } + + int stdout_pipe[2]; + if (pipe (stdout_pipe) == -1) + { + error_set (e, "%s: %s", "pipe", strerror (errno)); + goto fail_1; + } + + struct str work_dir = str_make (); + get_xdg_home_dir (&work_dir, "XDG_DATA_HOME", ".local/share"); + str_append_printf (&work_dir, "/%s", PROGRAM_NAME); + + if (!mkdir_with_parents (work_dir.str, e)) + goto fail_2; + + set_cloexec (stdin_pipe[1]); + set_cloexec (stdout_pipe[0]); + + pid_t pid = fork (); + if (pid == -1) + { + error_set (e, "%s: %s", "fork", strerror (errno)); + goto fail_2; + } + + if (pid == 0) + { + // Redirect the child's stdin and stdout to the pipes + if (dup2 (stdin_pipe[0], STDIN_FILENO) == -1 + || dup2 (stdout_pipe[1], STDOUT_FILENO) == -1) + { + print_error ("%s: %s: %s", "failed to load the plugin", + "dup2", strerror (errno)); + _exit (EXIT_FAILURE); + } + if (chdir (work_dir.str)) + { + print_error ("%s: %s: %s", "failed to load the plugin", + "chdir", strerror (errno)); + _exit (EXIT_FAILURE); + } + + xclose (stdin_pipe[0]); + xclose (stdout_pipe[1]); + + // Restore some of the signal handling + signal (SIGPIPE, SIG_DFL); + + char *argv[] = { path, NULL }; + execve (argv[0], argv, environ); + + // We will collect the failure later via SIGCHLD + print_error ("%s: %s: %s", "failed to load the plugin", + "exec", strerror (errno)); + _exit (EXIT_FAILURE); + } + + str_free (&work_dir); + free (path); + + xclose (stdin_pipe[0]); + xclose (stdout_pipe[1]); + + struct plugin *plugin = plugin_new (); + plugin->ctx = ctx; + plugin->pid = pid; + plugin->name = xstrdup (name); + plugin->read_fd = stdout_pipe[0]; + plugin->write_fd = stdin_pipe[1]; + return plugin; + +fail_2: + str_free (&work_dir); + xclose (stdout_pipe[0]); + xclose (stdout_pipe[1]); +fail_1: + xclose (stdin_pipe[0]); + xclose (stdin_pipe[1]); +fail_0: + free (path); + return NULL; +} + +static bool +plugin_load (struct bot_context *ctx, const char *name, struct error **e) +{ + if (!is_valid_plugin_name (name)) + return error_set (e, "invalid plugin name"); + if (str_map_find (&ctx->plugins_by_name, name)) + return error_set (e, "the plugin has already been loaded"); + + struct plugin *plugin; + if (!(plugin = plugin_launch (ctx, name, e))) + return false; + + set_blocking (plugin->read_fd, false); + set_blocking (plugin->write_fd, false); + + plugin->read_event = poller_fd_make (&ctx->poller, plugin->read_fd); + plugin->read_event.dispatcher = (poller_fd_fn) on_plugin_readable; + plugin->read_event.user_data = plugin; + + plugin->write_event = poller_fd_make (&ctx->poller, plugin->write_fd); + plugin->write_event.dispatcher = (poller_fd_fn) on_plugin_writable; + plugin->write_event.user_data = plugin; + + LIST_PREPEND (ctx->plugins, plugin); + str_map_set (&ctx->plugins_by_name, name, plugin); + + poller_fd_set (&plugin->read_event, POLLIN); + return true; +} + +static bool +plugin_unload (struct bot_context *ctx, const char *name, struct error **e) +{ + struct plugin *plugin = str_map_find (&ctx->plugins_by_name, name); + + if (!plugin) + return error_set (e, "no such plugin is loaded"); + + plugin_zombify (plugin); + + // TODO: add a `kill zombies' command to forcefully get rid of processes + // that do not understand the request. + return true; +} + +static void +plugin_load_all_from_config (struct bot_context *ctx) +{ + const char *plugin_list = str_map_find (&ctx->config, "plugins"); + if (!plugin_list) + return; + + struct strv plugins = strv_make (); + cstr_split (plugin_list, ",", true, &plugins); + for (size_t i = 0; i < plugins.len; i++) + { + char *name = cstr_strip_in_place (plugins.vector[i], " "); + + struct error *e = NULL; + if (!plugin_load (ctx, name, &e)) + { + print_error ("plugin `%s' failed to load: %s", name, e->message); + error_free (e); + } + } + + strv_free (&plugins); +} + +// --- Main program ------------------------------------------------------------ + +static bool +parse_bot_command (const char *s, const char *command, const char **following) +{ + size_t command_len = strlen (command); + if (strncasecmp (s, command, command_len)) + return false; + s += command_len; + + // Expect a word boundary, so that we don't respond to invalid things + if (isalnum (*s)) + return false; + + // Ignore any initial spaces; the rest is the command's argument + while (isblank (*s)) + s++; + *following = s; + return true; +} + +static void +split_bot_command_argument_list (const char *arguments, struct strv *out) +{ + cstr_split (arguments, ",", true, out); + for (size_t i = 0; i < out->len; ) + { + if (!*cstr_strip_in_place (out->vector[i], " \t")) + strv_remove (out, i); + else + i++; + } +} + +static bool +is_private_message (const struct irc_message *msg) +{ + hard_assert (msg->params.len); + return !strchr ("#&+!", *msg->params.vector[0]); +} + +static bool +is_sent_by_admin (struct bot_context *ctx, const struct irc_message *msg) +{ + // No administrator set -> everyone is an administrator + if (!ctx->admin_re) + return true; + return regexec (ctx->admin_re, msg->prefix, 0, NULL, 0) != REG_NOMATCH; +} + +static void respond_to_user (struct bot_context *ctx, const struct + irc_message *msg, const char *format, ...) ATTRIBUTE_PRINTF (3, 4); + +static void +respond_to_user (struct bot_context *ctx, const struct irc_message *msg, + const char *format, ...) +{ + if (!soft_assert (msg->prefix && msg->params.len)) + return; + + char nick[strcspn (msg->prefix, "!") + 1]; + strncpy (nick, msg->prefix, sizeof nick - 1); + nick[sizeof nick - 1] = '\0'; + + va_list ap; + struct str text = str_make (); + va_start (ap, format); + str_append_vprintf (&text, format, ap); + va_end (ap); + + if (is_private_message (msg)) + irc_send (ctx, "PRIVMSG %s :%s", nick, text.str); + else + irc_send (ctx, "PRIVMSG %s :%s: %s", + msg->params.vector[0], nick, text.str); + + str_free (&text); +} + +static void +process_plugin_load (struct bot_context *ctx, + const struct irc_message *msg, const char *name) +{ + struct error *e = NULL; + if (plugin_load (ctx, name, &e)) + respond_to_user (ctx, msg, "plugin `%s' queued for loading", name); + else + { + respond_to_user (ctx, msg, "plugin `%s' could not be loaded: %s", + name, e->message); + error_free (e); + } +} + +static void +process_plugin_unload (struct bot_context *ctx, + const struct irc_message *msg, const char *name) +{ + struct error *e = NULL; + if (plugin_unload (ctx, name, &e)) + respond_to_user (ctx, msg, "plugin `%s' unloaded", name); + else + { + respond_to_user (ctx, msg, "plugin `%s' could not be unloaded: %s", + name, e->message); + error_free (e); + } +} + +static void +process_plugin_reload (struct bot_context *ctx, + const struct irc_message *msg, const char *name) +{ + // XXX: we might want to wait until the plugin terminates before we try + // to reload it (so that it can save its configuration or whatever) + + // So far the only error that can occur is that the plugin hasn't been + // loaded, which in this case doesn't really matter. + plugin_unload (ctx, name, NULL); + + process_plugin_load (ctx, msg, name); +} + +static char * +make_status_report (struct bot_context *ctx) +{ + struct str report = str_make (); + const char *reason = getenv (g_startup_reason_str); + if (!reason) + reason = "launched normally"; + str_append_printf (&report, "\x02startup reason:\x0f %s", reason); + + size_t zombies = 0; + const char *prepend = "; \x02plugins:\x0f "; + for (struct plugin *plugin = ctx->plugins; plugin; plugin = plugin->next) + { + if (plugin->is_zombie) + zombies++; + else + { + str_append_printf (&report, "%s%s", prepend, plugin->name); + prepend = ", "; + } + } + if (!ctx->plugins) + str_append_printf (&report, "%s\x02none\x0f", prepend); + + str_append_printf (&report, "; \x02zombies:\x0f %zu", zombies); + return str_steal (&report); +} + +static void +process_privmsg (struct bot_context *ctx, const struct irc_message *msg) +{ + if (!is_sent_by_admin (ctx, msg)) + return; + if (msg->params.len < 2) + return; + + const char *prefix = str_map_find (&ctx->config, "prefix"); + hard_assert (prefix != NULL); // We have a default value for this + + // For us to recognize the command, it has to start with the prefix, + // with the exception of PM's sent directly to us. + const char *text = msg->params.vector[1]; + if (!strncmp (text, prefix, strlen (prefix))) + text += strlen (prefix); + else if (!is_private_message (msg)) + return; + + const char *following; + struct strv list = strv_make (); + + if (parse_bot_command (text, "quote", &following)) + // This seems to replace tons of random stupid commands + irc_send (ctx, "%s", following); + else if (parse_bot_command (text, "quit", &following)) + { + // We actually need this command (instead of just `quote') because we + // could try to reconnect to the server automatically otherwise. + if (*following) + irc_send (ctx, "QUIT :%s", following); + else + irc_send (ctx, "QUIT"); + initiate_quit (ctx); + } + else if (parse_bot_command (text, "status", &following)) + { + char *report = make_status_report (ctx); + respond_to_user (ctx, msg, "%s", report); + free (report); + } + else if (parse_bot_command (text, "load", &following)) + { + split_bot_command_argument_list (following, &list); + for (size_t i = 0; i < list.len; i++) + process_plugin_load (ctx, msg, list.vector[i]); + } + else if (parse_bot_command (text, "reload", &following)) + { + split_bot_command_argument_list (following, &list); + for (size_t i = 0; i < list.len; i++) + process_plugin_reload (ctx, msg, list.vector[i]); + } + else if (parse_bot_command (text, "unload", &following)) + { + split_bot_command_argument_list (following, &list); + for (size_t i = 0; i < list.len; i++) + process_plugin_unload (ctx, msg, list.vector[i]); + } + + strv_free (&list); +} + +static void +irc_forward_message_to_plugins (struct bot_context *ctx, const char *raw) +{ + // For consistency with plugin_process_message() + if (!ctx->irc_registered) + return; + + for (struct plugin *plugin = ctx->plugins; + plugin; plugin = plugin->next) + { + if (plugin->is_zombie) + continue; + + if (plugin->initialized) + plugin_send (plugin, "%s", raw); + else + // TODO: make sure that this buffer doesn't get too large either + str_append_printf (&plugin->queued_output, "%s\r\n", raw); + } +} + +static void +irc_process_message (const struct irc_message *msg, + const char *raw, void *user_data) +{ + struct bot_context *ctx = user_data; + if (g_debug_mode) + fprintf (stderr, "[%s] ==> \"%s\"\n", "IRC", raw); + + // This should be as minimal as possible, I don't want to have the whole bot + // written in C, especially when I have this overengineered plugin system. + // Therefore the very basic functionality only. + // + // I should probably even rip out the autojoin... + + irc_forward_message_to_plugins (ctx, raw); + + if (!strcasecmp (msg->command, "PING")) + { + if (msg->params.len) + irc_send (ctx, "PONG :%s", msg->params.vector[0]); + else + irc_send (ctx, "PONG"); + } + else if (!ctx->irc_registered && !strcasecmp (msg->command, "001")) + { + print_status ("successfully connected"); + ctx->irc_registered = true; + + const char *autojoin = str_map_find (&ctx->config, "autojoin"); + if (autojoin) + irc_send (ctx, "JOIN :%s", autojoin); + } + else if (!strcasecmp (msg->command, "PRIVMSG")) + process_privmsg (ctx, msg); +} + +enum irc_read_result +{ + IRC_READ_OK, ///< Some data were read successfully + IRC_READ_EOF, ///< The server has closed connection + IRC_READ_AGAIN, ///< No more data at the moment + IRC_READ_ERROR ///< General connection failure +}; + +static enum irc_read_result +irc_fill_read_buffer_tls (struct bot_context *ctx, struct str *buf) +{ + int n_read; +start: + ERR_clear_error (); + n_read = SSL_read (ctx->ssl, buf->str + buf->len, + buf->alloc - buf->len - 1 /* null byte */); + + const char *error_info = NULL; + switch (xssl_get_error (ctx->ssl, n_read, &error_info)) + { + case SSL_ERROR_NONE: + buf->str[buf->len += n_read] = '\0'; + return IRC_READ_OK; + case SSL_ERROR_ZERO_RETURN: + return IRC_READ_EOF; + case SSL_ERROR_WANT_READ: + return IRC_READ_AGAIN; + case SSL_ERROR_WANT_WRITE: + { + // Let it finish the handshake as we don't poll for writability; + // any errors are to be collected by SSL_read() in the next iteration + struct pollfd pfd = { .fd = ctx->irc_fd, .events = POLLOUT }; + soft_assert (poll (&pfd, 1, 0) > 0); + goto start; + } + case XSSL_ERROR_TRY_AGAIN: + goto start; + default: + print_debug ("%s: %s: %s", __func__, "SSL_read", error_info); + return IRC_READ_ERROR; + } +} + +static enum irc_read_result +irc_fill_read_buffer (struct bot_context *ctx, struct str *buf) +{ + ssize_t n_read; +start: + n_read = recv (ctx->irc_fd, buf->str + buf->len, + buf->alloc - buf->len - 1 /* null byte */, 0); + + if (n_read > 0) + { + buf->str[buf->len += n_read] = '\0'; + return IRC_READ_OK; + } + if (n_read == 0) + return IRC_READ_EOF; + + if (errno == EAGAIN) + return IRC_READ_AGAIN; + if (errno == EINTR) + goto start; + + print_debug ("%s: %s: %s", __func__, "recv", strerror (errno)); + return IRC_READ_ERROR; +} + +static bool irc_connect (struct bot_context *, struct error **); +static void irc_queue_reconnect (struct bot_context *); + +static void +irc_cancel_timers (struct bot_context *ctx) +{ + poller_timer_reset (&ctx->timeout_tmr); + poller_timer_reset (&ctx->ping_tmr); + poller_timer_reset (&ctx->reconnect_tmr); +} + +static void +on_irc_reconnect_timeout (void *user_data) +{ + struct bot_context *ctx = user_data; + + struct error *e = NULL; + if (irc_connect (ctx, &e)) + { + // TODO: inform plugins about the new connection + return; + } + + print_error ("%s", e->message); + error_free (e); + irc_queue_reconnect (ctx); +} + +static void +irc_queue_reconnect (struct bot_context *ctx) +{ + hard_assert (ctx->irc_fd == -1); + print_status ("trying to reconnect in %ld seconds...", + ctx->reconnect_delay); + poller_timer_set (&ctx->reconnect_tmr, ctx->reconnect_delay * 1000); +} + +static void +on_irc_disconnected (struct bot_context *ctx) +{ + // Get rid of the dead socket and related things + if (ctx->ssl) + { + SSL_free (ctx->ssl); + ctx->ssl = NULL; + SSL_CTX_free (ctx->ssl_ctx); + ctx->ssl_ctx = NULL; + } + + poller_fd_reset (&ctx->irc_event); + xclose (ctx->irc_fd); + ctx->irc_fd = -1; + ctx->irc_registered = false; + + // TODO: inform plugins about the disconnect event + + // All of our timers have lost their meaning now + irc_cancel_timers (ctx); + + if (ctx->quitting) + try_finish_quit (ctx); + else if (!ctx->reconnect) + initiate_quit (ctx); + else + irc_queue_reconnect (ctx); +} + +static void +on_irc_ping_timeout (void *user_data) +{ + struct bot_context *ctx = user_data; + print_error ("connection timeout"); + on_irc_disconnected (ctx); +} + +static void +on_irc_timeout (void *user_data) +{ + // Provoke a response from the server + struct bot_context *ctx = user_data; + irc_send (ctx, "PING :%s", + (char *) str_map_find (&ctx->config, "nickname")); +} + +static void +irc_reset_connection_timeouts (struct bot_context *ctx) +{ + irc_cancel_timers (ctx); + poller_timer_set (&ctx->timeout_tmr, 3 * 60 * 1000); + poller_timer_set (&ctx->ping_tmr, (3 * 60 + 30) * 1000); +} + +static void +on_irc_readable (const struct pollfd *fd, struct bot_context *ctx) +{ + if (fd->revents & ~(POLLIN | POLLHUP | POLLERR)) + print_debug ("fd %d: unexpected revents: %d", fd->fd, fd->revents); + + (void) set_blocking (ctx->irc_fd, false); + + struct str *buf = &ctx->read_buffer; + enum irc_read_result (*fill_buffer)(struct bot_context *, struct str *) + = ctx->ssl + ? irc_fill_read_buffer_tls + : irc_fill_read_buffer; + bool disconnected = false; + while (true) + { + str_reserve (buf, 512); + switch (fill_buffer (ctx, buf)) + { + case IRC_READ_AGAIN: + goto end; + case IRC_READ_ERROR: + print_error ("reading from the IRC server failed"); + disconnected = true; + goto end; + case IRC_READ_EOF: + print_status ("the IRC server closed the connection"); + disconnected = true; + goto end; + case IRC_READ_OK: + break; + } + + if (buf->len >= (1 << 20)) + { + print_error ("the IRC server seems to spew out data frantically"); + irc_shutdown (ctx); + goto end; + } + } +end: + (void) set_blocking (ctx->irc_fd, true); + irc_process_buffer (buf, irc_process_message, ctx); + + if (disconnected) + on_irc_disconnected (ctx); + else + irc_reset_connection_timeouts (ctx); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// The bot is currently mostly synchronous (which also makes it shorter), +// however our current SOCKS code is not, hence we must wrap it. + +struct irc_socks_data +{ + struct bot_context *ctx; ///< Bot context + struct poller inner_poller; ///< Special inner poller + bool polling; ///< Inner poller is no longer needed + struct socks_connector connector; ///< SOCKS connector + bool succeeded; ///< Were we successful in connecting? +}; + +static void +irc_on_socks_connected (void *user_data, int socket, const char *hostname) +{ + (void) hostname; + + struct irc_socks_data *data = user_data; + data->ctx->irc_fd = socket; + data->succeeded = true; + data->polling = false; +} + +static void +irc_on_socks_failure (void *user_data) +{ + struct irc_socks_data *data = user_data; + data->succeeded = false; + data->polling = false; +} + +static void +irc_on_socks_connecting (void *user_data, + const char *address, const char *via, const char *version) +{ + (void) user_data; + print_status ("connecting to %s via %s (%s)...", address, via, version); +} + +static void +irc_on_socks_error (void *user_data, const char *error) +{ + (void) user_data; + print_error ("%s: %s", "SOCKS connection failed", error); +} + +static bool +irc_establish_connection_socks (struct bot_context *ctx, + const char *socks_host, const char *socks_port, + const char *host, const char *service, struct error **e) +{ + struct irc_socks_data data; + struct poller *poller = &data.inner_poller; + struct socks_connector *connector = &data.connector; + + data.ctx = ctx; + poller_init (poller); + data.polling = true; + socks_connector_init (connector, poller); + data.succeeded = false; + + connector->on_connected = irc_on_socks_connected; + connector->on_connecting = irc_on_socks_connecting; + connector->on_error = irc_on_socks_error; + connector->on_failure = irc_on_socks_failure; + connector->user_data = &data; + + if (socks_connector_add_target (connector, host, service, e)) + { + socks_connector_run (connector, socks_host, socks_port, + str_map_find (&ctx->config, "socks_username"), + str_map_find (&ctx->config, "socks_password")); + while (data.polling) + poller_run (poller); + if (!data.succeeded) + error_set (e, "connection failed"); + } + + socks_connector_free (connector); + poller_free (poller); + return data.succeeded; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +irc_connect (struct bot_context *ctx, struct error **e) +{ + const char *irc_host = str_map_find (&ctx->config, "irc_host"); + const char *irc_port = str_map_find (&ctx->config, "irc_port"); + const char *socks_host = str_map_find (&ctx->config, "socks_host"); + const char *socks_port = str_map_find (&ctx->config, "socks_port"); + + const char *nickname = str_map_find (&ctx->config, "nickname"); + const char *username = str_map_find (&ctx->config, "username"); + const char *realname = str_map_find (&ctx->config, "realname"); + + // We have a default value for these + hard_assert (irc_port && socks_port); + hard_assert (nickname && username && realname); + + // TODO: again, get rid of `struct error' in here. The question is: how + // do we tell our caller that he should not try to reconnect? + if (!irc_host) + return error_set (e, "no hostname specified in configuration"); + + bool use_tls; + if (!irc_get_boolean_from_config (ctx, "tls", &use_tls, e)) + return false; + + bool connected = socks_host + ? irc_establish_connection_socks (ctx, + socks_host, socks_port, irc_host, irc_port, e) + : irc_establish_connection (ctx, irc_host, irc_port, e); + if (!connected) + return false; + + if (use_tls && !irc_initialize_tls (ctx, e)) + { + xclose (ctx->irc_fd); + ctx->irc_fd = -1; + return false; + } + print_status ("connection established"); + + ctx->irc_event = poller_fd_make (&ctx->poller, ctx->irc_fd); + ctx->irc_event.dispatcher = (poller_fd_fn) on_irc_readable; + ctx->irc_event.user_data = ctx; + + // TODO: in exec try: 1/ set blocking, 2/ setsockopt() SO_LINGER, + // (struct linger) { .l_onoff = true; .l_linger = 1 /* 1s should do */; } + // 3/ /* O_CLOEXEC */ But only if the QUIT message proves unreliable. + poller_fd_set (&ctx->irc_event, POLLIN); + irc_reset_connection_timeouts (ctx); + + irc_send (ctx, "NICK %s", nickname); + irc_send (ctx, "USER %s 8 * :%s", username, realname); + return true; +} + +static bool +parse_config (struct bot_context *ctx, struct error **e) +{ + if (!irc_get_boolean_from_config (ctx, "reconnect", &ctx->reconnect, e)) + return false; + + const char *delay_str = str_map_find (&ctx->config, "reconnect_delay"); + hard_assert (delay_str != NULL); // We have a default value for this + if (!xstrtoul (&ctx->reconnect_delay, delay_str, 10)) + { + return error_set (e, + "invalid configuration value for `%s'", "reconnect_delay"); + } + + hard_assert (!ctx->admin_re); + const char *admin = str_map_find (&ctx->config, "admin"); + if (!admin) + return true; + + struct error *error = NULL; + ctx->admin_re = regex_compile (admin, REG_EXTENDED | REG_NOSUB, &error); + if (!error) + return true; + + error_set (e, "invalid configuration value for `%s': %s", + "admin", error->message); + error_free (error); + return false; +} + +static void +on_plugin_death (struct plugin *plugin, int status) +{ + struct bot_context *ctx = plugin->ctx; + + // TODO: callbacks on children death, so that we may tell the user + // "plugin `name' died"; use `status' + if (!plugin->is_zombie && WIFSIGNALED (status)) + { + const char *notes = ""; +#ifdef WCOREDUMP + if (WCOREDUMP (status)) + notes = " (core dumped)"; +#endif + print_warning ("Plugin `%s' died from signal %d%s", + plugin->name, WTERMSIG (status), notes); + } + + // Let's go through the zombie state to simplify things a bit + // TODO: might not be a completely bad idea to restart the plugin + plugin_zombify (plugin); + + plugin->pid = -1; + + // In theory we could close `read_fd', set `read_event->closed' to true + // and expect epoll to no longer return events for the descriptor, as + // all the pipe ends should be closed by then (the child is dead, so its + // pipe FDs have been closed [assuming it hasn't forked without closing + // the descriptors, which would be evil], and we would have closed all + // of our FDs for this pipe as well). In practice that doesn't work. + poller_fd_reset (&plugin->read_event); + + xclose (plugin->read_fd); + plugin->read_fd = -1; + + LIST_UNLINK (ctx->plugins, plugin); + plugin_destroy (plugin); + + // Living child processes block us from quitting + try_finish_quit (ctx); +} + +static bool +try_reap_plugin (struct bot_context *ctx) +{ + int status; + pid_t zombie = waitpid (-1, &status, WNOHANG); + + if (zombie == -1) + { + // No children to wait on + if (errno == ECHILD) + return false; + + hard_assert (errno == EINTR); + return true; + } + + if (zombie == 0) + return false; + + struct plugin *plugin = plugin_find_by_pid (ctx, zombie); + // XXX: re-exec if something has died that we don't recognize? + if (soft_assert (plugin != NULL)) + on_plugin_death (plugin, status); + return true; +} + +static void +kill_all_zombies (struct bot_context *ctx) +{ + for (struct plugin *plugin = ctx->plugins; plugin; plugin = plugin->next) + { + if (!plugin->is_zombie) + continue; + + print_status ("forcefully killing a zombie of `%s' (PID %d)", + plugin->name, (int) plugin->pid); + kill (plugin->pid, SIGKILL); + } +} + +static void +on_signal_pipe_readable (const struct pollfd *fd, struct bot_context *ctx) +{ + char dummy; + (void) read (fd->fd, &dummy, 1); + + if (g_termination_requested) + { + g_termination_requested = false; + if (!ctx->quitting) + { + // There may be a timer set to reconnect to the server + irc_cancel_timers (ctx); + + if (ctx->irc_fd != -1) + irc_send (ctx, "QUIT :Terminated by signal"); + initiate_quit (ctx); + } + else + // Disregard proper termination, just kill all the children + kill_all_zombies (ctx); + } + + // Reap all dead children (since the signal pipe may overflow etc. we run + // waitpid() in a loop to return all the zombies it knows about). + while (try_reap_plugin (ctx)) + ; +} + +int +main (int argc, char *argv[]) +{ + g_original_argv = strv_make (); + strv_append_vector (&g_original_argv, argv); + + static const struct opt opts[] = + { + { 'd', "debug", NULL, 0, "run in debug mode" }, + { 'h', "help", NULL, 0, "display this help and exit" }, + { 'V', "version", NULL, 0, "output version information and exit" }, + { 'w', "write-default-cfg", "FILENAME", + OPT_OPTIONAL_ARG | OPT_LONG_ONLY, + "write a default configuration file and exit" }, + { 0, NULL, NULL, 0, NULL } + }; + + struct opt_handler oh = + opt_handler_make (argc, argv, opts, NULL, "Modular IRC bot."); + + int c; + while ((c = opt_handler_get (&oh)) != -1) + switch (c) + { + case 'd': + g_debug_mode = true; + break; + case 'h': + opt_handler_usage (&oh, stdout); + exit (EXIT_SUCCESS); + case 'V': + printf (PROGRAM_NAME " " PROGRAM_VERSION "\n"); + exit (EXIT_SUCCESS); + case 'w': + call_simple_config_write_default (optarg, g_config_table); + exit (EXIT_SUCCESS); + default: + print_error ("wrong options"); + opt_handler_usage (&oh, stderr); + exit (EXIT_FAILURE); + } + + opt_handler_free (&oh); + + print_status (PROGRAM_NAME " " PROGRAM_VERSION " starting"); + setup_signal_handlers (); + init_openssl (); + + struct bot_context ctx; + bot_context_init (&ctx); + + struct error *e = NULL; + if (!simple_config_update_from_file (&ctx.config, &e) + || !setup_recovery_handler (&ctx, &e)) + { + print_error ("%s", e->message); + error_free (e); + exit (EXIT_FAILURE); + } + + ctx.signal_event = poller_fd_make (&ctx.poller, g_signal_pipe[0]); + ctx.signal_event.dispatcher = (poller_fd_fn) on_signal_pipe_readable; + ctx.signal_event.user_data = &ctx; + poller_fd_set (&ctx.signal_event, POLLIN); + +#if OpenBSD >= 201605 + // cpath is for creating the plugin home directory + if (pledge ("stdio rpath cpath inet proc exec", NULL)) + exit_fatal ("%s: %s", "pledge", strerror (errno)); +#endif + + plugin_load_all_from_config (&ctx); + if (!parse_config (&ctx, &e) + || !irc_connect (&ctx, &e)) + { + print_error ("%s", e->message); + error_free (e); + exit (EXIT_FAILURE); + } + + // TODO: clean re-exec support; to save the state I can either use argv, + // argp, or I can create a temporary file, unlink it and use the FD + // (mkstemp() on a `struct str' constructed from XDG_RUNTIME_DIR, TMPDIR + // or /tmp as a last resort + PROGRAM_NAME + ".XXXXXX" -> unlink(); + // remember to use O_CREAT | O_EXCL). The state needs to be versioned. + // Unfortunately I cannot de/serialize SSL state. + + ctx.polling = true; + while (ctx.polling) + poller_run (&ctx.poller); + + bot_context_free (&ctx); + strv_free (&g_original_argv); + return EXIT_SUCCESS; +} + |