summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOleksandr Kuchmystyi <o.kuchmystyi@vyos.io>2025-09-17 16:33:39 +0300
committerOleksandr Kuchmystyi <o.kuchmystyi@vyos.io>2025-09-30 17:37:52 +0300
commit68f2cec785b6705c71d17b30114355569c489d66 (patch)
treed3fae021d1be76a55d6e5c73535b0b4459a07d12
parenta3b62f290a90a374dca04c6400fd1619f36e808f (diff)
downloadvyos-1x-68f2cec785b6705c71d17b30114355569c489d66.tar.gz
vyos-1x-68f2cec785b6705c71d17b30114355569c489d66.zip
syslog: T4251: Add TLS support to syslog
Add TLS support for remote syslog by extending the CLI and backend to support configuration of CA certificates, client certificates, keys, and authentication modes. This update integrates with the PKI subsystem for certificate management, ensures proper validation of protocol settings when TLS is enabled, and generates secure rsyslog configuration for forwarding logs over TLS.
-rw-r--r--data/templates/rsyslog/rsyslog.conf.j228
-rw-r--r--debian/control1
-rw-r--r--interface-definitions/system_syslog.xml.in54
-rwxr-xr-xsmoketest/scripts/cli/test_system_syslog.py173
-rwxr-xr-xsrc/conf_mode/system_syslog.py95
5 files changed, 348 insertions, 3 deletions
diff --git a/data/templates/rsyslog/rsyslog.conf.j2 b/data/templates/rsyslog/rsyslog.conf.j2
index 6ef2afcaf..07dbc603d 100644
--- a/data/templates/rsyslog/rsyslog.conf.j2
+++ b/data/templates/rsyslog/rsyslog.conf.j2
@@ -92,6 +92,7 @@ if prifilt("{{ tmp | join(',') }}") then {
{% set _ = tmp.append(facility.replace('all', '*') ~ "." ~ facility_options.level.replace('all', 'debug')) %}
{% endfor %}
{% set _ = tmp.sort() %}
+{% set tls = remote_options.tls %}
# Remote syslog to {{ remote_name }}
if prifilt("{{ tmp | join(',') }}") then {
action(
@@ -100,7 +101,7 @@ if prifilt("{{ tmp | join(',') }}") then {
target="{{ remote_name }}"
# Port on the remote syslog server
port="{{ remote_options.port }}"
- protocol="{{ remote_options.protocol }}"
+ protocol="{{ 'tcp' if tls.enable is vyos_defined else remote_options.protocol }}"
{% if remote_options.format.include_timezone is vyos_defined %}
template="RSYSLOG_SyslogProtocol23Format"
{% endif %}
@@ -111,6 +112,31 @@ if prifilt("{{ tmp | join(',') }}") then {
{% if remote_options.vrf is vyos_defined %}
Device="{{ remote_options.vrf }}"
{% endif %}
+{% if tls.enable is vyos_defined %}
+{% set auth_mode = tls.auth_mode %}
+ # Specify the use of the OpenSSL TLS driver for this action
+ StreamDriver="ossl"
+ # Set mode to TLS-only connections (do not accept plain TCP)
+ StreamDriverMode="1"
+ # Select the authentication mode
+ StreamDriverAuthMode="{{ auth_mode if auth_mode == 'anon' else 'x509/' + auth_mode }}"
+{% if tls.permitted_peers is vyos_defined and auth_mode in ('fingerprint', 'name') %}
+ # Only include permitted peers (list of allowed fingerprints or names)
+ StreamDriverPermittedPeers="{{ tls.permitted_peers }}"
+{% endif %}
+{% if tls.ca_certificate_path is vyos_defined %}
+ # Include the path to the CA certificate file
+ StreamDriver.CAFile="{{ tls.ca_certificate_path }}"
+{% endif %}
+{% if tls.certificate_path is vyos_defined %}
+ # Include the path to the client's certificate
+ StreamDriver.CertFile="{{ tls.certificate_path }}"
+{% endif %}
+{% if tls.certificate_key_path is vyos_defined %}
+ # Include the path to the client's private key
+ StreamDriver.KeyFile="{{ tls.certificate_key_path }}"
+{% endif %}
+{% endif %}
)
}
{% endif %}
diff --git a/debian/control b/debian/control
index c5db8f0e9..99a2b3e31 100644
--- a/debian/control
+++ b/debian/control
@@ -326,6 +326,7 @@ Depends:
# End "vpn openconnect"
# For "system syslog"
rsyslog,
+ rsyslog-openssl,
# End "system syslog"
# For "system option keyboard-layout"
kbd,
diff --git a/interface-definitions/system_syslog.xml.in b/interface-definitions/system_syslog.xml.in
index 116cbde73..78217882f 100644
--- a/interface-definitions/system_syslog.xml.in
+++ b/interface-definitions/system_syslog.xml.in
@@ -65,6 +65,60 @@
#include <include/protocol-tcp-udp.xml.i>
#include <include/source-address-ipv4-ipv6.xml.i>
#include <include/interface/vrf.xml.i>
+ <node name="tls">
+ <properties>
+ <help>Transport Layer Security (TLS) options for secure syslog</help>
+ </properties>
+ <children>
+ <leafNode name="enable">
+ <properties>
+ <help>Enable TLS encryption for log transmission to this remote syslog server</help>
+ <valueless/>
+ </properties>
+ </leafNode>
+ <!-- CA cert help should describe trust anchor for server/client validation -->
+ #include <include/pki/ca-certificate.xml.i>
+ <!-- Certificate help should specify identity for mutual authentication -->
+ #include <include/pki/certificate.xml.i>
+ <leafNode name="auth-mode">
+ <properties>
+ <help>Specify the authentication and verification method for the remote peer's certificate during the TLS handshake</help>
+ <completionHelp>
+ <list>anon fingerprint certvalid name</list>
+ </completionHelp>
+ <valueHelp>
+ <format>anon</format>
+ <description>Allow encrypted connection without verifying the peer's identity (anonymous TLS)</description>
+ </valueHelp>
+ <valueHelp>
+ <format>fingerprint</format>
+ <description>Authenticate peer by matching its certificate fingerprint to a configured, permitted list (`permitted-peers` option)</description>
+ </valueHelp>
+ <valueHelp>
+ <format>certvalid</format>
+ <description>Authenticate peer if it presents a certificate signed by a trusted CA</description>
+ </valueHelp>
+ <valueHelp>
+ <format>name</format>
+ <description>Authenticate peer by verifying its certificate subject name against a configured value (`permitted-peers` option)</description>
+ </valueHelp>
+ <constraint>
+ <regex>(anon|fingerprint|certvalid|name)</regex>
+ </constraint>
+ </properties>
+ <defaultValue>anon</defaultValue>
+ </leafNode>
+ <leafNode name="permitted-peers">
+ <properties>
+ <help>Comma-separated list of allowed peer certificate fingerprints or subject names</help>
+ <valueHelp>
+ <format>txt</format>
+ <description>Comma-separated fingerprints or peer names.\nFor example:\n - 'SHA1:DD:23:E3:E7:70:F5:B4:13:44:16:78:A5:5A:8C:39:48:53:A6:DD:25,SHA256:10:C4:26:1D:CB:3C:AB:12:DB:1A:F0:47:37:AE:6D:D2:DE:66:B5:71:B7:2E:5B:BB:AE:0C:7E:7F:5F:0D:E9:64'\n - 'logs.example.com'</description>
+ </valueHelp>
+ </properties>
+ </leafNode>
+ </children>
+ </node>
</children>
</tagNode>
<node name="local">
diff --git a/smoketest/scripts/cli/test_system_syslog.py b/smoketest/scripts/cli/test_system_syslog.py
index c4e043a7c..b98f2b1d7 100755
--- a/smoketest/scripts/cli/test_system_syslog.py
+++ b/smoketest/scripts/cli/test_system_syslog.py
@@ -27,11 +27,43 @@ from vyos.xml_ref import default_value
PROCESS_NAME = 'rsyslogd'
RSYSLOG_CONF = '/run/rsyslog/rsyslog.conf'
+CERT_DIR = '/etc/rsyslog.d/certs'
base_path = ['system', 'syslog']
+pki_base = ['pki']
dummy_interface = 'dum372874'
+ca_cert_name = "syslog_ca_certificate"
+ca_cert = """
+MIIBrTCCAV+gAwIBAgIUdTEOleLyGTteZC+yEi252lRUq8EwBQYDK2VwMEsxCzAJ
+BgNVBAYTAlVTMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEMMAoGA1UE
+CgwDT3JnMQ8wDQYDVQQDDAZSb290Q0EwIBcNMjUwOTE1MTQxNDI4WhgPMjEyNTA4
+MjIxNDE0MjhaMEsxCzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UE
+BwwEQ2l0eTEMMAoGA1UECgwDT3JnMQ8wDQYDVQQDDAZSb290Q0EwKjAFBgMrZXAD
+IQCtTlgU+aqU/i6k6b318vebALk0zs9RvE96vw7taIt2iqNTMFEwHQYDVR0OBBYE
+FHl8GywRMCWSotNGmyjuvRbPqCq8MB8GA1UdIwQYMBaAFHl8GywRMCWSotNGmyju
+vRbPqCq8MA8GA1UdEwEB/wQFMAMBAf8wBQYDK2VwA0EAouZ4s+/ZeZxZxOZ7yFG0
+RQ9BfPWySrX4kgavyJJeg8LNCYUIRIP6iC41MTyHUVsWwar91xBT0DKBkpwrOQ0n
+Dg==
+"""
+
+client_cert_name = "syslog_client_certificate"
+client_cert = """
+MIIBVjCCAQgCFArrkIM+zg8luHbXwsS8cUB5xrh/MAUGAytlcDBLMQswCQYDVQQG
+EwJVUzEOMAwGA1UECAwFU3RhdGUxDTALBgNVBAcMBENpdHkxDDAKBgNVBAoMA09y
+ZzEPMA0GA1UEAwwGUm9vdENBMB4XDTI1MDkxNTE0MTUwN1oXDTM1MDkxMzE0MTUw
+N1owUDELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5
+MQwwCgYDVQQKDANPcmcxFDASBgNVBAMMC2V4YW1wbGUuY29tMCowBQYDK2VwAyEA
+eZZRz7yVQ+exm6vyh/GdGZrTSEmtbvfafG0digqpfnUwBQYDK2VwA0EAU8/kw1i0
+s4j2fPQmU1q6Qql3xaxUlDyzhRPSIeH7ZhOlNg8R7gR1QnA7Rel6oU4EqJJHvz9l
+83HQAy7ZcNIoBw==
+"""
+
+client_cert_key = """
+MC4CAQAwBQYDK2VwBCIEIG59XPVZoMCxBVD/eJVqJSmV+Uc0bUHjHS4bkfkjM6Jj
+"""
+
def get_config(string=''):
"""
Retrieve current "running configuration" from FRR
@@ -51,11 +83,15 @@ class TestRSYSLOGService(VyOSUnitTestSHIM.TestCase):
# out the current configuration :)
cls.cli_delete(cls, base_path)
cls.cli_delete(cls, ['vrf'])
+ cls.cli_delete(cls, pki_base)
def tearDown(self):
# Check for running process
self.assertTrue(process_named_running(PROCESS_NAME))
+ # delete test certificates for syslog
+ self.cli_delete(pki_base)
+
# delete testing SYSLOG config
self.cli_delete(base_path)
self.cli_commit()
@@ -68,6 +104,30 @@ class TestRSYSLOGService(VyOSUnitTestSHIM.TestCase):
# Check for running process
self.assertFalse(process_named_running(PROCESS_NAME))
+ def _set_tls_certificates(self):
+ self.cli_set(
+ pki_base + ['ca', ca_cert_name, 'certificate', ca_cert.replace('\n', '')]
+ )
+ self.cli_set(
+ pki_base
+ + [
+ 'certificate',
+ client_cert_name,
+ 'certificate',
+ client_cert.replace('\n', ''),
+ ]
+ )
+ self.cli_set(
+ pki_base
+ + [
+ 'certificate',
+ client_cert_name,
+ 'private',
+ 'key',
+ client_cert_key.replace('\n', ''),
+ ]
+ )
+
def test_console(self):
level = 'warning'
self.cli_set(base_path + ['console', 'facility', 'all', 'level'], value=level)
@@ -239,6 +299,119 @@ class TestRSYSLOGService(VyOSUnitTestSHIM.TestCase):
# cleanup dummy interface
self.cli_delete(dummy_if_path)
+ def test_remote_tls(self):
+ self._set_tls_certificates()
+
+ rhosts = {
+ '172.10.0.2': {
+ 'facility': {'all': {'level': 'debug'}},
+ 'port': '6514',
+ 'protocol': 'udp',
+ 'tls': {
+ 'enable': True,
+ 'auth-mode': 'anon',
+ },
+ },
+ '172.10.0.3': {
+ 'facility': {'all': {'level': 'debug'}},
+ 'port': '6514',
+ 'protocol': 'tcp',
+ 'tls': {
+ 'enable': True,
+ 'ca-certificate': ca_cert_name,
+ 'auth-mode': 'certvalid',
+ },
+ },
+ '172.10.0.4': {
+ 'facility': {'all': {'level': 'debug'}},
+ 'port': '6514',
+ 'protocol': 'tcp',
+ 'tls': {
+ 'enable': True,
+ 'ca-certificate': ca_cert_name,
+ 'certificate': client_cert_name,
+ 'auth-mode': 'fingerprint',
+ 'permitted-peers': 'SHA1:E1:DB:C4:FF:83:54:85:40:2D:56:E7:1A:C3:FF:70:22:0F:21:74:ED',
+ },
+ },
+ '172.10.0.5': {
+ 'facility': {'all': {'level': 'debug'}},
+ 'port': '6514',
+ 'protocol': 'tcp',
+ 'tls': {
+ 'enable': True,
+ 'ca-certificate': ca_cert_name,
+ 'certificate': client_cert_name,
+ 'auth-mode': 'name',
+ 'permitted-peers': 'logs.example.com',
+ },
+ },
+ }
+
+ for remote, remote_options in rhosts.items():
+ remote_base = base_path + ['remote', remote]
+
+ if 'port' in remote_options:
+ self.cli_set(remote_base + ['port'], value=remote_options['port'])
+
+ if 'facility' in remote_options:
+ for facility, facility_options in remote_options['facility'].items():
+ level = facility_options['level']
+ self.cli_set(
+ remote_base + ['facility', facility, 'level'], value=level
+ )
+
+ if 'protocol' in remote_options:
+ protocol = remote_options['protocol']
+ self.cli_set(remote_base + ['protocol'], value=protocol)
+
+ tls = remote_options['tls']
+ for key, value in tls.items():
+ if key == 'enable':
+ self.cli_set(remote_base + ['tls', 'enable'])
+ else:
+ self.cli_set(remote_base + ['tls', key], value=value)
+
+ self.cli_commit()
+
+ read_file(RSYSLOG_CONF)
+ for remote, remote_options in rhosts.items():
+ with self.subTest(remote=remote):
+ config = get_config(f'# Remote syslog to {remote}')
+
+ if 'port' in remote_options:
+ port = remote_options['port']
+ self.assertIn(f'port="{port}"', config)
+
+ self.assertIn('protocol="tcp"', config)
+ self.assertIn('StreamDriver="ossl"', config)
+ self.assertIn('StreamDriverMode="1"', config)
+
+ tls = remote_options['tls']
+ if 'ca-certificate' in tls:
+ self.assertIn(
+ f'StreamDriver.CAFile="{CERT_DIR}/{ca_cert_name}.pem"', config
+ )
+
+ if 'certificate' in tls:
+ self.assertIn(
+ f'StreamDriver.CertFile="{CERT_DIR}/{client_cert_name}.pem"',
+ config,
+ )
+ self.assertIn(
+ f'StreamDriver.KeyFile="{CERT_DIR}/{client_cert_name}.key"',
+ config,
+ )
+
+ if 'auth-mode' in tls:
+ value = tls['auth-mode']
+ auth_mode = value if value == 'anon' else f'x509/{value}'
+ self.assertIn(f'StreamDriverAuthMode="{auth_mode}"', config)
+
+ if 'permitted-peers' in tls:
+ value = tls['permitted-peers']
+ self.assertIn(f'StreamDriverPermittedPeers="{value}"', config)
+
def test_vrf_source_address(self):
rhosts = {
'169.254.0.10': { },
diff --git a/src/conf_mode/system_syslog.py b/src/conf_mode/system_syslog.py
index c1a8baa1d..82be09e4f 100755
--- a/src/conf_mode/system_syslog.py
+++ b/src/conf_mode/system_syslog.py
@@ -15,15 +15,22 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
+import shutil
from sys import exit
from vyos.base import Warning
from vyos.config import Config
from vyos.configverify import verify_vrf
+from vyos.configverify import verify_pki_certificate
+from vyos.configverify import verify_pki_ca_certificate
from vyos.defaults import systemd_services
from vyos.utils.network import is_addr_assigned
from vyos.utils.process import call
+from vyos.utils.dict import dict_search
+from vyos.utils.file import write_file
+from vyos.pki import wrap_certificate
+from vyos.pki import wrap_private_key
from vyos.template import render
from vyos.template import is_ipv4
from vyos.template import is_ipv6
@@ -31,12 +38,75 @@ from vyos import ConfigError
from vyos import airbag
airbag.enable()
+cert_dir = '/etc/rsyslog.d/certs'
rsyslog_conf = '/run/rsyslog/rsyslog.conf'
logrotate_conf = '/etc/logrotate.d/vyos-rsyslog'
systemd_socket = 'syslog.socket'
systemd_service = systemd_services['syslog']
+
+def _cleanup_tls_certs():
+ if os.path.exists(cert_dir):
+ shutil.rmtree(cert_dir, ignore_errors=True)
+
+
+def _remote_has_tls(remote_options):
+ return 'tls' in remote_options and 'enable' in remote_options['tls']
+
+
+def _verify_tls_remote_options(remote, remote_options, syslog):
+ auth_mode = dict_search('tls.auth_mode', remote_options)
+ certificate = dict_search('tls.certificate', remote_options)
+ ca_certificate = dict_search('tls.ca_certificate', remote_options)
+
+ if auth_mode != "anon" and not ca_certificate:
+ raise ConfigError(
+ f'Option "ca-certificate" is required for remote "{remote}" when TLS is enabled with auth-mode "{auth_mode}"!'
+ )
+
+ if certificate:
+ verify_pki_certificate(syslog, certificate, no_password_protected=True)
+
+ if ca_certificate:
+ verify_pki_ca_certificate(syslog, ca_certificate)
+
+ permitted_peers = dict_search('tls.permitted_peers', remote_options)
+ if not permitted_peers:
+ if auth_mode == "fingerprint":
+ raise ConfigError(
+ f'Auth mode "fingerprint" for remote "{remote}" requires "permitted-peers" to be configured!'
+ )
+ elif auth_mode == "name":
+ raise ConfigError(
+ f'Auth mode "name" for remote "{remote}" requires "permitted-peers" to specify allowed subject names!'
+ )
+
+
+def _save_tls_certificates_for_remote(syslog, remote_options):
+ ca_certificate = remote_options['tls'].get('ca_certificate')
+ ca_cert_file_path = None
+ if ca_certificate:
+ ca_cert_file_path = os.path.join(cert_dir, f'{ca_certificate}.pem')
+ pki_ca = syslog['pki']['ca'][ca_certificate]
+
+ ca_cert = wrap_certificate(pki_ca['certificate'])
+ write_file(ca_cert_file_path, ca_cert)
+ remote_options['tls']['ca_certificate_path'] = ca_cert_file_path
+
+ cert_name = remote_options['tls'].get('certificate')
+ cert_file_path = cert_key_path = None
+ if cert_name:
+ cert_file_path = os.path.join(cert_dir, f'{cert_name}.pem')
+ cert_key_path = os.path.join(cert_dir, f'{cert_name}.key')
+ pki_cert = syslog['pki']['certificate'][cert_name]
+
+ write_file(cert_file_path, wrap_certificate(pki_cert['certificate']))
+ write_file(cert_key_path, wrap_private_key(pki_cert['private']['key']))
+
+ remote_options['tls']['certificate_path'] = cert_file_path
+ remote_options['tls']['certificate_key_path'] = cert_key_path
+
def get_config(config=None):
if config:
conf = config
@@ -46,8 +116,13 @@ def get_config(config=None):
if not conf.exists(base):
return None
- syslog = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True, no_tag_node_value_mangle=True)
+ syslog = conf.get_config_dict(
+ base,
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_pki=True,
+ )
syslog.update({ 'logrotate' : logrotate_conf })
@@ -97,7 +172,18 @@ def verify(syslog):
raise ConfigError(f'Source-address "{source_address}" does not match '\
f'address-family of remote "{remote}"!')
+ if _remote_has_tls(remote_options):
+ _verify_tls_remote_options(remote, remote_options, syslog)
+
+ if 'protocol' in remote_options and remote_options['protocol'] == 'udp':
+ Warning(
+ f'TLS is enabled for remote "{remote}", but protocol is set to UDP. TLS is only supported with protocol TCP!'
+ )
+
+
def generate(syslog):
+ _cleanup_tls_certs()
+
if not syslog:
if os.path.exists(rsyslog_conf):
os.unlink(rsyslog_conf)
@@ -106,6 +192,11 @@ def generate(syslog):
return None
+ if 'remote' in syslog:
+ for _, remote_options in syslog['remote'].items():
+ if _remote_has_tls(remote_options):
+ _save_tls_certificates_for_remote(syslog, remote_options)
+
render(rsyslog_conf, 'rsyslog/rsyslog.conf.j2', syslog)
render(logrotate_conf, 'rsyslog/logrotate.j2', syslog)
return None