diff options
| author | John Estabrook <jestabro@vyos.io> | 2025-10-01 08:15:11 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-10-01 08:15:11 -0500 |
| commit | 31f8bd8e762ddd999e8cefd52786353f43ea4c1c (patch) | |
| tree | 7c73c42d0fed9f653017931b9304f27c74e12b2b | |
| parent | ebf27d5fb3d9203256d2cd49e0f4cf810bae03b1 (diff) | |
| parent | 68f2cec785b6705c71d17b30114355569c489d66 (diff) | |
| download | vyos-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.j2 | 28 | ||||
| -rw-r--r-- | debian/control | 1 | ||||
| -rw-r--r-- | interface-definitions/system_syslog.xml.in | 54 | ||||
| -rwxr-xr-x | smoketest/scripts/cli/test_system_syslog.py | 173 | ||||
| -rwxr-xr-x | src/conf_mode/system_syslog.py | 95 |
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 |
