diff --git a/config/config.sample.php b/config/config.sample.php index b25a4baeadd5d..58d36423215e7 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -743,6 +743,8 @@ * ``systemd``: the logs are sent to the Systemd journal. This requires a system * that runs Systemd and the Systemd journal. The PHP extension ``systemd`` * must be installed and active. + * ``graylog``: the logs are sent to a Graylog server. This requires a running + * Graylog service reachable by your Nextcloud instance. * * Defaults to ``file`` */ @@ -781,6 +783,22 @@ */ 'syslog_tag' => 'Nextcloud', +/** + * Your graylog host server name, for example ``localhost``, ``hostname``, + * ``hostname.example.com``, or the IP address. To specify a port use + * ``hostname:####``. The default port is 5410 + * Only effective when ``log_type`` set to ``graylog`` + */ +'graylog_host' => '', + +/** + * The protocol used for sending logs to the graylog server. + * Can be ``udp`` or ``tcp``. + * + * The default value is ``udp``. + */ +'graylog_proto' => 'udp', + /** * Log condition for log level increase based on conditions. Once one of these * conditions is met, the required log level is set to debug. This allows to diff --git a/core/Command/Log/Manage.php b/core/Command/Log/Manage.php index 5a1dd3d048b8e..d0cd07e618fd8 100644 --- a/core/Command/Log/Manage.php +++ b/core/Command/Log/Manage.php @@ -56,7 +56,8 @@ protected function configure() { 'backend', null, InputOption::VALUE_REQUIRED, - 'set the logging backend [file, syslog, errorlog, systemd]' + 'set the logging backend [file, syslog, errorlog, systemd, + graylog]' ) ->addOption( 'level', @@ -70,6 +71,18 @@ protected function configure() { InputOption::VALUE_REQUIRED, 'set the logging timezone' ) + ->addOption( + 'host', + null, + InputOption::VALUE_REQUIRED, + 'set the log server host:port if backend is graylog' + ) + ->addOption( + 'protocol', + null, + InputOption::VALUE_REQUIRED, + 'set the log server protocol if backend is graylog' + ) ; } @@ -99,6 +112,20 @@ protected function execute(InputInterface $input, OutputInterface $output) { $toBeSet['logtimezone'] = $timezone; } + if ($host = $input->getOption('host')) { + array_key_exists('log_type', $toBeSet) ? + $this->isBackendGraylogSet($toBeSet['log_type']) : + $this->isBackendGraylogSet(); + $toBeSet['graylog_host'] = $host; + } + + if ($protocol = $input->getOption('protocol')) { + array_key_exists('log_type', $toBeSet) ? + $this->isBackendGraylogSet($toBeSet['log_type']) : + $this->isBackendGraylogSet(); + $toBeSet['graylog_proto'] = $protocol; + } + // set config foreach ($toBeSet as $option => $value) { $this->config->setSystemValue($option, $value); @@ -108,6 +135,13 @@ protected function execute(InputInterface $input, OutputInterface $output) { $backend = $this->config->getSystemValue('log_type', self::DEFAULT_BACKEND); $output->writeln('Enabled logging backend: '.$backend); + if ('graylog' === $backend) { + $host = $this->config->getSystemValue('graylog_host'); + $output->writeln('Log server: '.$host); + $protocol = $this->config->getSystemValue('graylog_proto', 'udp'); + $output->writeln('Connection protocol: '.$protocol); + } + $levelNum = $this->config->getSystemValue('loglevel', self::DEFAULT_LOG_LEVEL); $level = $this->convertLevelNumber($levelNum); $output->writeln('Log level: '.$level.' ('.$levelNum.')'); @@ -126,6 +160,17 @@ protected function validateBackend($backend) { } } + /** + * @param string $new + * @throws \InvalidArgumentException + */ + protected function isBackendGraylogSet($new=null) { + $old = $this->config->getSystemValue('log_type', self::DEFAULT_BACKEND); + if ((null === $new && $old != 'graylog') || 'graylog' != $new) { + throw new \InvalidArgumentException('Graylog not set as backend'); + } + } + /** * @param string $timezone * @throws \Exception @@ -187,6 +232,8 @@ public function completeOptionValues($optionName, CompletionContext $context) { return ['debug', 'info', 'warning', 'error']; } else if ($optionName === 'timezone') { return \DateTimeZone::listIdentifiers(); + } else if ($optionName === 'protocol') { + return ['udp', 'tcp']; } return []; } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index a0fe19e011822..de3f5c8d55af6 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -922,6 +922,7 @@ 'OC\\Log\\Errorlog' => $baseDir . '/lib/private/Log/Errorlog.php', 'OC\\Log\\ExceptionSerializer' => $baseDir . '/lib/private/Log/ExceptionSerializer.php', 'OC\\Log\\File' => $baseDir . '/lib/private/Log/File.php', + 'OC\\Log\\Graylog' => $baseDir . '/lib/private/Log/Graylog.php', 'OC\\Log\\LogFactory' => $baseDir . '/lib/private/Log/LogFactory.php', 'OC\\Log\\Rotate' => $baseDir . '/lib/private/Log/Rotate.php', 'OC\\Log\\Syslog' => $baseDir . '/lib/private/Log/Syslog.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 3dcca438cb67d..2a557d94f9313 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -952,6 +952,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OC\\Log\\Errorlog' => __DIR__ . '/../../..' . '/lib/private/Log/Errorlog.php', 'OC\\Log\\ExceptionSerializer' => __DIR__ . '/../../..' . '/lib/private/Log/ExceptionSerializer.php', 'OC\\Log\\File' => __DIR__ . '/../../..' . '/lib/private/Log/File.php', + 'OC\\Log\\Graylog' => __DIR__ . '/../../..' . '/lib/private/Log/Graylog.php', 'OC\\Log\\LogFactory' => __DIR__ . '/../../..' . '/lib/private/Log/LogFactory.php', 'OC\\Log\\Rotate' => __DIR__ . '/../../..' . '/lib/private/Log/Rotate.php', 'OC\\Log\\Syslog' => __DIR__ . '/../../..' . '/lib/private/Log/Syslog.php', diff --git a/lib/private/Log/Graylog.php b/lib/private/Log/Graylog.php new file mode 100644 index 0000000000000..e0f7b509a999d --- /dev/null +++ b/lib/private/Log/Graylog.php @@ -0,0 +1,109 @@ +, (t.pulzer@thesecretgamer.de) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Log; + + +use OCP\IConfig; +use OCP\ILogger; +use OCP\Log\IWriter; + +class Graylog implements IWriter { + + private static $VERSION = '1.1'; + private $host; + private $target; + private $port; + private $protocol; + + protected static $LEVELS = [ + ILogger::FATAL => LOG_CRIT, + ILogger::ERROR => LOG_ERR, + ILogger::WARN => LOG_WARNING, + ILogger::INFO => LOG_INFO, + ILogger::DEBUG => LOG_DEBUG + ]; + + public function __construct(IConfig $config) { + $this->host = gethostname(); + $this->protocol = $config->getSystemValue('graylog_proto', 'udp'); + $address = $config->getSystemValue('graylog_host', ''); + if (false !== strpos($address, ':')) { + $this->target = explode(':', $address)[0]; + $this->port = intval(explode(':', $address)[1]); + } else { + $this->target = $address; + $this->port = 514; + } + } + + /** + * send a message to the Graylog server + * + * @param string $app + * @param string $message + * @param int $level + */ + public function write(string $app, $message, int $level) { + $chunks = []; + $msg = json_encode([ + 'version' => self::$VERSION, + 'host' => $this->host, + 'short_message' => '{'.$app.'} '.$message, + 'level' => self::$LEVELS[$level], + 'timestamp' => time() + ]); + switch ($this->protocol) { + case 'udp': + $chunks = str_split($msg, 1024); + break; + case 'tcp': + $chunks[0] = $msg; + break; + } + $count = count($chunks); + $errNo = 0; + $errStr = ''; + $fp = stream_socket_client( + $this->protocol . '://' . $this->target . ':' . $this->port, + $errNo, + $errStr, + 5 + ); + if(false === $fp) { + return; + } + switch ($count > 1) { + case true: + $id = random_bytes(8); + for ($i = 0; $i < $count; $i++) { + fwrite($fp, pack('n', 0x1e0f) . $id . pack('CC', $i, $count) + . $chunks[$i]); + } + break; + case false: + fwrite($fp, $chunks[0]); + break; + } + fclose($fp); + } +} diff --git a/lib/private/Log/LogFactory.php b/lib/private/Log/LogFactory.php index 5bb803cbaf240..22634f617d585 100644 --- a/lib/private/Log/LogFactory.php +++ b/lib/private/Log/LogFactory.php @@ -53,6 +53,8 @@ public function get(string $type):IWriter { return $this->c->resolve(Syslog::class); case 'systemd': return $this->c->resolve(Systemdlog::class); + case 'graylog': + return $this->c->resolve(Graylog::class); case 'file': return $this->buildLogFile(); diff --git a/tests/Core/Command/Log/ManageTest.php b/tests/Core/Command/Log/ManageTest.php index 4b026f148475b..636b6b8465392 100644 --- a/tests/Core/Command/Log/ManageTest.php +++ b/tests/Core/Command/Log/ManageTest.php @@ -94,6 +94,13 @@ public function testValidateBackend() { self::invokePrivate($this->command, 'validateBackend', ['notabackend']); } + /** + * @expectedException \InvalidArgumentException + */ + public function testIsBackendGraylogSet() { + self::invokePrivate($this->command, 'isBackendGraylogSet', ['file']); + } + /** * @expectedException \Exception */ diff --git a/tests/lib/Log/GraylogTest.php b/tests/lib/Log/GraylogTest.php new file mode 100644 index 0000000000000..4e4edddcd3405 --- /dev/null +++ b/tests/lib/Log/GraylogTest.php @@ -0,0 +1,147 @@ +, (t.pulzer@thesecretgamer.de) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\tests\lib\Log; + + +use OC\Log\Graylog; +use OC\SystemConfig; +use Test\TestCase; + +class GraylogTest extends TestCase { + + /** @var string */ + private $protocol_restore; + /** @var string */ + private $target_restore; + /** @var SystemConfig */ + private $config; + /** @var string */ + private $buf; + /** @var string */ + private $from; + /** @var integer */ + private $port; + + protected function setUp() { + parent::setUp(); + $this->config = \OC::$server->getSystemConfig(); + $this->protocol_restore = $this->config->getValue('graylog_proto'); + $this->target_restore = $this->config->getValue('graylog_host'); + $this->buf = ''; + $this->from = ''; + $this->port = 0; + } + + public function testUnchunkedUdp() { + $this->config->setValue('graylog_proto', 'udp'); + $this->config->setValue('graylog_host', '127.0.0.1:5140'); + + // Create a mock server to send a test message to + $s = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); + socket_bind($s, '127.0.0.1', 5140); + socket_set_nonblock($s); + + $id = 'GraylogTest'; + $msg = 'UDP Graylog test < 1kb'; + $graylog = new Graylog(\OC::$server->getConfig()); + $graylog->write('GraylogTest', $msg, 1); + + socket_recvfrom($s, $this->buf, 1025, 0, $this->from, $this->port); + socket_close($s); + + // The resulting GELF message has a length of 79 + length of host name + + // length of app name + length of log message + 3 formatting characters. + $expected = 79 + strlen(gethostname()) + strlen($msg) + strlen($id) + 3; + $this->assertEquals($expected, strlen($this->buf)); + } + + public function testChunkedUdp() { + $this->config->setValue('graylog_proto', 'udp'); + $this->config->setValue('graylog_host', '127.0.0.1:5140'); + + // Create a mock server to send a test message to + $s = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); + socket_bind($s, '127.0.0.1', 5140); + socket_set_nonblock($s); + + $id = 'GraylogTest'; + $msg = "Very log message filled with garbage to exceed 1kb limit. "; + for($i = 0; $i < 1024; $i++) { + $msg .= "A"; + } + $graylog = new Graylog(\OC::$server->getConfig()); + $graylog->write($id, $msg, 3); + + socket_recvfrom($s, $this->buf, 1034, 0, $this->from, $this->port); + socket_close($s); + + // The chunked GELF message must start start with 0x1E 0x0F, followed + // by 8 byte message id, 1 byte current sequence and 1 byte total chunk + // count. In this test the total chunk count is 2 and we examine the + // first chunk (zero-indexed). + $this->assertEquals(0x1e0f, unpack('n', $this->buf)[1]); + $this->assertEquals(0, unpack('C', substr($this->buf, 10, 1))[1]); + $this->assertEquals(2, unpack('C', substr($this->buf, 11, 1))[1]); + } + + public function testTcp() { + $this->config->setValue('graylog_proto', 'tcp'); + $this->config->setValue('graylog_host', '127.0.0.1:5140'); + + // Create a mock server to send a test message to + $s = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + socket_bind($s, '127.0.0.1', 5140); + socket_listen($s); + socket_set_nonblock($s); + + $id = 'GraylogTest'; + $msg = 'TCP Graylog test < 1kb'; + $graylog = new Graylog(\OC::$server->getConfig()); + $graylog->write($id, $msg, 3); + + $c = socket_accept($s); + $this->buf = socket_read($c, 1025); + socket_close($c); + socket_close($s); + + // The resulting GELF message has a length of 79 + length of host name + + // length of app name + length of log message + 3 formatting characters. + $expected = 79 + strlen(gethostname()) + strlen($msg) + strlen($id) + 3; + $this->assertEquals($expected, strlen($this->buf)); + } + + protected function tearDown() { + if (isset($this->protocol_restore)) { + $this->config->setValue('graylog_proto', $this->protocol_restore); + } else { + $this->config->deleteValue('graylog_proto'); + } + if (isset($this->target_restore)) { + $this->config->setValue('graylog_host', $this->target_restore); + } else { + $this->config->deleteValue('graylog_host'); + } + parent::tearDown(); + } + +} diff --git a/tests/lib/Log/LogFactoryTest.php b/tests/lib/Log/LogFactoryTest.php index ea6b12436e6e0..45b078d96ed6c 100644 --- a/tests/lib/Log/LogFactoryTest.php +++ b/tests/lib/Log/LogFactoryTest.php @@ -25,6 +25,7 @@ namespace Test\Log; use OC\Log\Errorlog; use OC\Log\File; +use OC\Log\Graylog; use OC\Log\LogFactory; use OC\Log\Syslog; use OC\Log\Systemdlog; @@ -156,4 +157,17 @@ public function testSystemdLog() { $log = $this->factory->get('systemd'); $this->assertInstanceOf(Systemdlog::class, $log); } + + /** + * @throws \OCP\AppFramework\QueryException + */ + public function testGraylogLog() { + $this->c->expects($this->once()) + ->method('resolve') + ->with(Graylog::class) + ->willReturn($this->createMock(Graylog::class)); + + $log = $this->factory->get('graylog'); + $this->assertInstanceOf(Graylog::class, $log); + } }