From 2e81f9e057f598a9a9e5c2d617e3d0818005d850 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Tue, 10 May 2022 15:14:19 +0000 Subject: sshguard: T4408: Add service ssh dynamic-protection Sshguard protects hosts from brute-force attacks Can inspect logs and block "bad" addresses by threshold Auto-generate rules for nftables When service stopped all generated rules are deleted nft "type filter hook input priority filter - 10" set service ssh dynamic-protection set service ssh dynamic-protection block-time 120 set service ssh dynamic-protection detect-time 1800 set service ssh dynamic-protection threshold 30 set service ssh dynamic-protection whitelist-address 192.0.2.1 --- data/templates/ssh/sshguard_config.j2 | 27 ++++++++++++ data/templates/ssh/sshguard_whitelist.j2 | 7 +++ interface-definitions/ssh.xml.in | 72 +++++++++++++++++++++++++++++++ smoketest/scripts/cli/test_service_ssh.py | 44 +++++++++++++++++++ src/conf_mode/ssh.py | 19 +++++++- 5 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 data/templates/ssh/sshguard_config.j2 create mode 100644 data/templates/ssh/sshguard_whitelist.j2 diff --git a/data/templates/ssh/sshguard_config.j2 b/data/templates/ssh/sshguard_config.j2 new file mode 100644 index 000000000..58c6ad48d --- /dev/null +++ b/data/templates/ssh/sshguard_config.j2 @@ -0,0 +1,27 @@ +### Autogenerated by ssh.py ### + +{% if dynamic_protection is vyos_defined %} +# Full path to backend executable (required, no default) +BACKEND="/usr/libexec/sshguard/sshg-fw-nft-sets" + +# Shell command that provides logs on standard output. (optional, no default) +# Example 1: ssh and sendmail from systemd journal: +LOGREADER="LANG=C journalctl -afb -p info -n1 -t sshd -o cat" + +#### OPTIONS #### +# Block attackers when their cumulative attack score exceeds THRESHOLD. +# Most attacks have a score of 10. (optional, default 30) +THRESHOLD={{ dynamic_protection.threshold }} + +# Block attackers for initially BLOCK_TIME seconds after exceeding THRESHOLD. +# Subsequent blocks increase by a factor of 1.5. (optional, default 120) +BLOCK_TIME={{ dynamic_protection.block_time }} + +# Remember potential attackers for up to DETECTION_TIME seconds before +# resetting their score. (optional, default 1800) +DETECTION_TIME={{ dynamic_protection.detect_time }} + +# IP addresses listed in the WHITELIST_FILE are considered to be +# friendlies and will never be blocked. +WHITELIST_FILE=/etc/sshguard/whitelist +{% endif %} diff --git a/data/templates/ssh/sshguard_whitelist.j2 b/data/templates/ssh/sshguard_whitelist.j2 new file mode 100644 index 000000000..1e05ac00f --- /dev/null +++ b/data/templates/ssh/sshguard_whitelist.j2 @@ -0,0 +1,7 @@ +### Autogenerated by ssh.py ### + +{% if dynamic_protection is vyos_defined and dynamic_protection.whitelist_address is vyos_defined %} +{% for address in dynamic_protection.whitelist_address %} +{{ address }} +{% endfor %} +{% endif %} diff --git a/interface-definitions/ssh.xml.in b/interface-definitions/ssh.xml.in index 8edbad110..7e2512f54 100644 --- a/interface-definitions/ssh.xml.in +++ b/interface-definitions/ssh.xml.in @@ -61,6 +61,78 @@ + + + Allow dynamic protection + + + + + Block source IP in seconds. Subsequent blocks increase by a factor of 1.5 + + u32:1-65535 + Time interval in seconds for blocking + + + + + + 120 + + + + Remember source IP in seconds before reset their score + + u32:1-65535 + Time interval in seconds + + + + + + 1800 + + + + Block source IP when their cumulative attack score exceeds threshold + + u32:1-65535 + Threshold score + + + + + + 30 + + + + Source address or prefix + + ipv4 + Address to match against + + + ipv4net + IPv4 address and prefix length + + + ipv6 + IPv6 address to match against + + + ipv6net + IPv6 address and prefix length + + + + + + + + + + Allowed key exchange (KEX) algorithms diff --git a/smoketest/scripts/cli/test_service_ssh.py b/smoketest/scripts/cli/test_service_ssh.py index 77ad5bc0d..2e96a7035 100755 --- a/smoketest/scripts/cli/test_service_ssh.py +++ b/smoketest/scripts/cli/test_service_ssh.py @@ -213,5 +213,49 @@ class TestServiceSSH(VyOSUnitTestSHIM.TestCase): usernames = [x[0] for x in getpwall()] self.assertNotIn(test_user, usernames) + def test_ssh_dynamic_protection(self): + """ + check sshguard service + """ + SSHGUARD_CONFIG = '/etc/sshguard/sshguard.conf' + SSHGUARD_PROCESS = 'sshguard' + block_time = '123' + detect_time = '1804' + port = '22' + threshold = '10' + + self.cli_set(base_path + ['dynamic-protection', 'block-time', block_time]) + self.cli_set(base_path + ['dynamic-protection', 'detect-time', detect_time]) + self.cli_set(base_path + ['dynamic-protection', 'threshold', threshold]) + + # commit changes + self.cli_commit() + + # Check configured port + tmp = get_config_value('Port') + self.assertIn(port, tmp) + + # Check sshgurad service + self.assertTrue(process_named_running(SSHGUARD_PROCESS)) + + sshguard_lines = [ + f'THRESHOLD={threshold}', + f'BLOCK_TIME={block_time}', + f'DETECTION_TIME={detect_time}' + ] + + tmp_sshguard_conf = read_file(SSHGUARD_CONFIG) + + for line in sshguard_lines: + self.assertIn(line, tmp_sshguard_conf) + + # Delete service ssh dynamic-protection + # but not service ssh itself + self.cli_delete(base_path + ['dynamic-protection']) + self.cli_commit() + + self.assertFalse(process_named_running(SSHGUARD_PROCESS)) + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/ssh.py b/src/conf_mode/ssh.py index 487e8c229..28669694b 100755 --- a/src/conf_mode/ssh.py +++ b/src/conf_mode/ssh.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2021 VyOS maintainers and contributors +# Copyright (C) 2018-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -33,6 +33,9 @@ airbag.enable() config_file = r'/run/sshd/sshd_config' systemd_override = r'/etc/systemd/system/ssh.service.d/override.conf' +sshguard_config_file = '/etc/sshguard/sshguard.conf' +sshguard_whitelist = '/etc/sshguard/whitelist' + key_rsa = '/etc/ssh/ssh_host_rsa_key' key_dsa = '/etc/ssh/ssh_host_dsa_key' key_ed25519 = '/etc/ssh/ssh_host_ed25519_key' @@ -54,6 +57,11 @@ def get_config(config=None): # pass config file path - used in override template ssh['config_file'] = config_file + # Ignore default XML values if config doesn't exists + # Delete key from dict + if not conf.exists(base + ['dynamic-protection']): + del ssh['dynamic_protection'] + return ssh def verify(ssh): @@ -86,6 +94,10 @@ def generate(ssh): render(config_file, 'ssh/sshd_config.j2', ssh) render(systemd_override, 'ssh/override.conf.j2', ssh) + + if 'dynamic_protection' in ssh: + render(sshguard_config_file, 'ssh/sshguard_config.j2', ssh) + render(sshguard_whitelist, 'ssh/sshguard_whitelist.j2', ssh) # Reload systemd manager configuration call('systemctl daemon-reload') @@ -95,7 +107,12 @@ def apply(ssh): if not ssh: # SSH access is removed in the commit call('systemctl stop ssh.service') + call('systemctl stop sshguard.service') return None + if 'dynamic_protection' not in ssh: + call('systemctl stop sshguard.service') + else: + call('systemctl restart sshguard.service') call('systemctl restart ssh.service') return None -- cgit v1.2.3