summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJohn Estabrook <jestabro@vyos.io>2025-10-01 08:15:11 -0500
committerGitHub <noreply@github.com>2025-10-01 08:15:11 -0500
commit31f8bd8e762ddd999e8cefd52786353f43ea4c1c (patch)
tree7c73c42d0fed9f653017931b9304f27c74e12b2b
parentebf27d5fb3d9203256d2cd49e0f4cf810bae03b1 (diff)
parent68f2cec785b6705c71d17b30114355569c489d66 (diff)
downloadvyos-1x-31f8bd8e762ddd999e8cefd52786353f43ea4c1c.tar.gz
vyos-1x-31f8bd8e762ddd999e8cefd52786353f43ea4c1c.zip
Merge pull request #4734 from alexandr-san4ez/T4251-current
syslog: T4251: Add TLS support to syslog
-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 a22647ebf..a1a75605c 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