From 32f0677dbca9e3347b931c1d0105eb37aa57e90d Mon Sep 17 00:00:00 2001 From: Simon Rettberg Date: Fri, 25 Jun 2021 16:21:17 +0200 Subject: [eventlog] Add event filtering and notification system --- inc/database.inc.php | 35 ++++++++++ inc/eventlog.inc.php | 25 ++++++-- inc/mailer.inc.php | 178 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 inc/mailer.inc.php (limited to 'inc') diff --git a/inc/database.inc.php b/inc/database.inc.php index a55555b4..09006f3e 100644 --- a/inc/database.inc.php +++ b/inc/database.inc.php @@ -99,6 +99,41 @@ class Database return $res->fetchAll(PDO::FETCH_KEY_PAIR); } + /** + * Fetch and group by first column. First column is key, value is a list of rows with remaining columns. + * [ + * col1 => [ + * [col2, col3], + * [col2, col3], + * ], + * ..., + * ] + * + * @return array|bool Associative array, first column is key, remaining columns are array values + */ + public static function queryGroupList($query, $args = array(), $ignoreError = null) + { + $res = self::simpleQuery($query, $args, $ignoreError); + if ($res === false) + return false; + return $res->fetchAll(PDO::FETCH_GROUP); + } + + /** + * Fetch and use first column as key of returned array. + * This is like queryGroup list, but it is assumed that the first column is unique, so + * the remaining columns won't be wrapped in another array. + * + * @return array|bool Associative array, first column is key, remaining columns are array values + */ + public static function queryIndexedList($query, $args = array(), $ignoreError = null) + { + $res = self::simpleQuery($query, $args, $ignoreError); + if ($res === false) + return false; + return $res->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_UNIQUE); + } + /** * Execute the given query and return the number of rows affected. * Mostly useful for UPDATEs or INSERTs diff --git a/inc/eventlog.inc.php b/inc/eventlog.inc.php index 3ebb82a4..a29261b8 100644 --- a/inc/eventlog.inc.php +++ b/inc/eventlog.inc.php @@ -17,12 +17,14 @@ class EventLog error_log($message); return; } + $data = [ + 'type' => $type, + 'message' => $message, + 'details' => $details + ]; Database::exec("INSERT INTO eventlog (dateline, logtypeid, description, extra)" - . " VALUES (UNIX_TIMESTAMP(), :type, :message, :details)", array( - 'type' => $type, - 'message' => $message, - 'details' => $details - ), true); + . " VALUES (UNIX_TIMESTAMP(), :type, :message, :details)", $data, true); + self::applyFilterRules('#serverlog', $data); } public static function failure($message, $details = '') @@ -51,5 +53,16 @@ class EventLog return; Database::exec("TRUNCATE eventlog"); } - + + /** + * @param string $type the event. Will either be client state like ~poweron, ~runstate etc. or a client log type + * @param array $data A structured array containing event specific data that can be matched. + */ + public static function applyFilterRules(string $type, array $data) + { + if (!Module::isAvailable('eventlog')) + return; + FilterRuleProcessor::applyFilterRules($type, $data); + } + } diff --git a/inc/mailer.inc.php b/inc/mailer.inc.php new file mode 100644 index 00000000..942bfc75 --- /dev/null +++ b/inc/mailer.inc.php @@ -0,0 +1,178 @@ +curlOptions = [ + CURLOPT_URL => $hosturi, + CURLOPT_USE_SSL => $startTls ? CURLUSESSL_ALL : CURLUSESSL_TRY, + CURLOPT_MAIL_FROM => $from, + CURLOPT_UPLOAD => 1, + CURLOPT_VERBOSE => 1, // XXX + ]; + if (!empty($user)) { + $this->curlOptions += [ + CURLOPT_LOGIN_OPTIONS => 'AUTH=NTLM;AUTH=DIGEST-MD5;AUTH=CRAM-MD5;AUTH=PLAIN;AUTH=LOGIN', + CURLOPT_USERNAME => $user, + CURLOPT_PASSWORD => $pass, + ]; + } + $this->replyTo = $replyTo; + } + + /** + * Send a mail to given list of recipients. + * @param string[] $rcpts Recipients + * @param string $subject Mail subject + * @param string $text Mail body + * @return int curl error code, CURLE_OK on success + */ + public function send(array $rcpts, string $subject, string $text): int + { + // Convert all line breaks to CRLF while trying to avoid introducing additional ones + $text = str_replace(["\r\n", "\r"], "\n", $text); + $text = str_replace("\n", "\r\n", $text); + $mail = ["Date: " . date('r')]; + foreach ($rcpts as $r) { + $mail[] = self::mimeEncode('To', $r); + } + $mail[] = self::mimeEncode('Content-Type', 'text/plain; charset="utf-8"'); + $mail[] = self::mimeEncode('Subject', $subject); + $mail[] = self::mimeEncode('From', $this->curlOptions[CURLOPT_MAIL_FROM]); + $mail[] = self::mimeEncode('Message-ID', '<' . Util::randomUuid() . '@rfcpedant.example.org>'); + if (!empty($this->replyTo)) { + $mail[] = self::mimeEncode('Reply-To', $this->replyTo); + } + + $mail = implode("\r\n", $mail) . "\r\n\r\n" . $text; + $c = curl_init(); + $pos = 0; + $this->curlOptions[CURLOPT_MAIL_RCPT] = array_map(function ($mail) { + return preg_replace('/\s.*$/', '', $mail); + }, $rcpts); + $this->curlOptions[CURLOPT_READFUNCTION] = function($ch, $fp, $len) use (&$pos, $mail) { + $txt = substr($mail, $pos, $len); + $pos += strlen($txt); + return $txt; + }; + $err = fopen('php://temp', 'w+'); + $this->curlOptions[CURLOPT_STDERR] = $err; + curl_setopt_array($c, $this->curlOptions); + curl_exec($c); + rewind($err); + $this->errlog = stream_get_contents($err); + fclose($err); + return curl_errno($c); + } + + public function getLog(): string + { + return $this->errlog; + } + + public static function queue(int $configid, array $rcpts, string $subject, string $text) + { + foreach ($rcpts as $rcpt) { + Database::exec("INSERT INTO mail_queue (rcpt, subject, body, dateline, configid) + VALUES (:rcpt, :subject, :body, UNIX_TIMESTAMP(), :config)", + ['rcpt' => $rcpt, 'subject' => $subject, 'body' => $text, 'config' => $configid]); + } + } + + public static function flushQueue() + { + $list = Database::queryGroupList("SELECT Concat(configid, rcpt, subject) AS keyval, + mailid, configid, rcpt, subject, body, dateline FROM mail_queue + WHERE nexttry <= UNIX_TIMESTAMP() LIMIT 20"); + $cutoff = time() - 43200; // 12h + $mailers = []; + foreach ($list as $mails) { + $delete = []; + $body = []; + foreach ($mails as $mail) { + $delete[] = $mail['mailid']; + if ($mail['dateline'] < $cutoff) { + EventLog::info("Dropping queued mail '{$mail['subject']}' for {$mail['rcpt']} as it's too old."); + continue; // Ignore + } + $body[] = ' *** ' . date('d.m.Y H:i:s', $mail['dateline']) . "\r\n" + . $mail['body']; + } + if (!empty($body) && isset($mail)) { + $body = implode("\r\n\r\n - - - - -\r\n\r\n", $body); + if (!isset($mailers[$mail['configid']])) { + $mailers[$mail['configid']] = self::instanceFromConfig($mail['configid']); + if ($mailers[$mail['configid']] === null) { + EventLog::failure("Invalid mailer config id: " . $mail['configid']); + } + } + if (($mailer = $mailers[$mail['configid']]) !== null) { + $ret = $mailer->send([$mail['rcpt']], $mail['subject'], $body); + if ($ret !== CURLE_OK) { + Database::exec("UPDATE mail_queue SET nexttry = UNIX_TIMESTAMP() + 7200 + WHERE mailid IN (:ids)", ['ids' => $delete]); + $delete = []; + EventLog::info("Error sending mail '{$mail['subject']}' for {$mail['rcpt']}.", + $mailer->getLog()); + } + } + } + // Now delete these, either sending succeeded or it's too old + if (!empty($delete)) { + Database::exec("DELETE FROM mail_queue WHERE mailid IN (:ids)", ['ids' => $delete]); + } + } + } + + private static function mimeEncode(string $field, string $string): string + { + if (preg_match('/[\r\n\x7f-\xff]/', $string)) { + return iconv_mime_encode($field, $string); + } + return "$field: $string"; + } + + private static function getConfig(int $configId): array + { + if (!is_array(self::$configs)) { + self::$configs = Database::queryIndexedList("SELECT configid, host, port, `ssl`, senderaddress, replyto, + username, password + FROM mail_config"); + } + return self::$configs[$configId] ?? []; + } + + /** + * @param int $configId + * @return Mailer|null + */ + public static function instanceFromConfig(int $configId) + { + $config = self::getConfig($configId); + if (empty($config)) + return null; + if ($config['ssl'] === 'IMPLICIT') { + $uri = "smtps://{$config['host']}:${config['port']}"; + } else { + $uri = "smtp://{$config['host']}:${config['port']}"; + } + return new Mailer($uri, $config['ssl'] === 'EXPLICIT', $config['senderaddress'], $config['username'], + $config['password'], $config['replyto']); + } + +} \ No newline at end of file -- cgit v1.2.3-55-g7522