diff options
-rw-r--r-- | .github/workflows/pr-conflicts.yml | 2 | ||||
-rw-r--r-- | data/templates/ssh/sshguard_config.tmpl | 27 | ||||
-rw-r--r-- | data/templates/ssh/sshguard_whitelist.tmpl | 7 | ||||
-rw-r--r-- | debian/control | 1 | ||||
-rw-r--r-- | interface-definitions/ssh.xml.in | 72 | ||||
-rw-r--r-- | python/vyos/remote.py | 29 | ||||
-rwxr-xr-x | smoketest/scripts/cli/test_service_ssh.py | 52 | ||||
-rwxr-xr-x | src/conf_mode/ssh.py | 20 |
8 files changed, 202 insertions, 8 deletions
diff --git a/.github/workflows/pr-conflicts.yml b/.github/workflows/pr-conflicts.yml index 72ff3969b..96040cd60 100644 --- a/.github/workflows/pr-conflicts.yml +++ b/.github/workflows/pr-conflicts.yml @@ -6,7 +6,7 @@ on: jobs: Conflict_Check: name: 'Check PR status: conflicts and resolution' - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 steps: - name: check if PRs are dirty uses: eps1lon/actions-label-merge-conflict@releases/2.x diff --git a/data/templates/ssh/sshguard_config.tmpl b/data/templates/ssh/sshguard_config.tmpl new file mode 100644 index 000000000..fbcbac908 --- /dev/null +++ b/data/templates/ssh/sshguard_config.tmpl @@ -0,0 +1,27 @@ +### Autogenerated by ssh.py ### + +{% if dynamic_protection is defined and dynamic_protection is not none %} +# Full path to backend executable (required, no default) +BACKEND="/usr/lib/x86_64-linux-gnu/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.tmpl b/data/templates/ssh/sshguard_whitelist.tmpl new file mode 100644 index 000000000..c972ec343 --- /dev/null +++ b/data/templates/ssh/sshguard_whitelist.tmpl @@ -0,0 +1,7 @@ +### Autogenerated by ssh.py ### + +{% if dynamic_protection.allow_from is defined and dynamic_protection.allow_from is not none %} +{% for address in dynamic_protection.allow_from %} +{{ address }} +{% endfor %} +{% endif %} diff --git a/debian/control b/debian/control index a93c1fdb8..6f92677df 100644 --- a/debian/control +++ b/debian/control @@ -128,6 +128,7 @@ Depends: squid, squidclient, squidguard, + sshguard, ssl-cert, sudo, systemd, diff --git a/interface-definitions/ssh.xml.in b/interface-definitions/ssh.xml.in index 867037295..65d8a368e 100644 --- a/interface-definitions/ssh.xml.in +++ b/interface-definitions/ssh.xml.in @@ -61,6 +61,78 @@ <valueless/> </properties> </leafNode> + <node name="dynamic-protection"> + <properties> + <help>Allow dynamic protection</help> + </properties> + <children> + <leafNode name="block-time"> + <properties> + <help>Block source IP in seconds. Subsequent blocks increase by a factor of 1.5</help> + <valueHelp> + <format>u32:1-65535</format> + <description>Time interval in seconds for blocking</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-65535"/> + </constraint> + </properties> + <defaultValue>120</defaultValue> + </leafNode> + <leafNode name="detect-time"> + <properties> + <help>Remember source IP in seconds before reset their score</help> + <valueHelp> + <format>u32:1-65535</format> + <description>Time interval in seconds</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-65535"/> + </constraint> + </properties> + <defaultValue>1800</defaultValue> + </leafNode> + <leafNode name="threshold"> + <properties> + <help>Block source IP when their cumulative attack score exceeds threshold</help> + <valueHelp> + <format>u32:1-65535</format> + <description>Threshold score</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-65535"/> + </constraint> + </properties> + <defaultValue>30</defaultValue> + </leafNode> + <leafNode name="allow-from"> + <properties> + <help>Always allow inbound connections from these systems</help> + <valueHelp> + <format>ipv4</format> + <description>Address to match against</description> + </valueHelp> + <valueHelp> + <format>ipv4net</format> + <description>IPv4 address and prefix length</description> + </valueHelp> + <valueHelp> + <format>ipv6</format> + <description>IPv6 address to match against</description> + </valueHelp> + <valueHelp> + <format>ipv6net</format> + <description>IPv6 address and prefix length</description> + </valueHelp> + <constraint> + <validator name="ip-address"/> + <validator name="ip-prefix"/> + </constraint> + <multi/> + </properties> + </leafNode> + </children> + </node> <leafNode name="key-exchange"> <properties> <help>Allowed key exchange (KEX) algorithms</help> diff --git a/python/vyos/remote.py b/python/vyos/remote.py index 66044fa52..d1db68f1c 100644 --- a/python/vyos/remote.py +++ b/python/vyos/remote.py @@ -21,11 +21,13 @@ import stat import sys import tempfile import urllib.parse +import warnings +from cryptography.utils import CryptographyDeprecationWarning from ftplib import FTP from ftplib import FTP_TLS -from paramiko import SSHClient +from paramiko import SSHClient, SSHException from paramiko import MissingHostKeyPolicy from requests import Session @@ -43,6 +45,10 @@ from vyos.version import get_version CHUNK_SIZE = 8192 +# suppress warnings for deprecated APIs used by paramiko +warnings.simplefilter('ignore', category=CryptographyDeprecationWarning) + + class InteractivePolicy(MissingHostKeyPolicy): """ Paramiko policy for interactively querying the user on whether to proceed @@ -51,7 +57,7 @@ class InteractivePolicy(MissingHostKeyPolicy): def missing_host_key(self, client, hostname, key): print_error(f"Host '{hostname}' not found in known hosts.") print_error('Fingerprint: ' + key.get_fingerprint().hex()) - if ask_yes_no('Do you wish to continue?'): + if sys.stdout.isatty() and ask_yes_no('Do you wish to continue?'): if client._host_keys_filename\ and ask_yes_no('Do you wish to permanently add this host/key pair to known hosts?'): client._host_keys.add(hostname, key.get_name(), key) @@ -151,7 +157,14 @@ class FtpC: class SshC: known_hosts = os.path.expanduser('~/.ssh/known_hosts') - def __init__(self, url, progressbar=False, check_space=False, source_host='', source_port=0): + + def __init__(self, + url, + progressbar=False, + check_space=False, + source_host='', + source_port=0, + timeout=10.0): self.hostname = url.hostname self.path = url.path self.username = url.username or os.getenv('REMOTE_USERNAME') @@ -160,6 +173,7 @@ class SshC: self.source = (source_host, source_port) self.progressbar = progressbar self.check_space = check_space + self.timeout = timeout def _establish(self): ssh = SSHClient() @@ -170,8 +184,13 @@ class SshC: ssh.set_missing_host_key_policy(InteractivePolicy()) # `socket.create_connection()` automatically picks a NIC and an IPv4/IPv6 address family # for us on dual-stack systems. - sock = socket.create_connection((self.hostname, self.port), socket.getdefaulttimeout(), self.source) - ssh.connect(self.hostname, self.port, self.username, self.password, sock=sock) + try: + sock = socket.create_connection((self.hostname, self.port), + self.timeout, self.source) + ssh.connect(self.hostname, self.port, self.username, self.password, sock=sock) + except Exception as err: + print(f'Cannot connect to "{self.hostname}:{self.port}": {err}') + sys.exit(1) return ssh def download(self, location: str): diff --git a/smoketest/scripts/cli/test_service_ssh.py b/smoketest/scripts/cli/test_service_ssh.py index 49a167e04..92397db4d 100755 --- a/smoketest/scripts/cli/test_service_ssh.py +++ b/smoketest/scripts/cli/test_service_ssh.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2022 VyOS maintainers and contributors +# Copyright (C) 2019-2023 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 @@ -203,5 +203,55 @@ 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_WHITELIST = '/etc/sshguard/whitelist' + SSHGUARD_PROCESS = 'sshguard' + block_time = '123' + detect_time = '1804' + port = '22' + threshold = '10' + allow_list = ['192.0.2.0/24', '2001:db8::/48'] + + 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]) + for allow in allow_list: + self.cli_set(base_path + ['dynamic-protection', 'allow-from', allow]) + + # 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) + + tmp_whitelist_conf = read_file(SSHGUARD_WHITELIST) + for allow in allow_list: + self.assertIn(allow, tmp_whitelist_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 8eeb0a7c1..f961d3671 100755 --- a/src/conf_mode/ssh.py +++ b/src/conf_mode/ssh.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-2023 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 @@ -31,6 +31,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' + def get_config(config=None): if config: conf = config @@ -48,6 +51,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): @@ -68,17 +76,27 @@ def generate(ssh): render(config_file, 'ssh/sshd_config.tmpl', ssh) render(systemd_override, 'ssh/override.conf.tmpl', ssh) + if 'dynamic_protection' in ssh: + render(sshguard_config_file, 'ssh/sshguard_config.tmpl', ssh) + render(sshguard_whitelist, 'ssh/sshguard_whitelist.tmpl', ssh) # Reload systemd manager configuration call('systemctl daemon-reload') return None def apply(ssh): + systemd_service_sshguard = 'sshguard.service' if not ssh: # SSH access is removed in the commit call('systemctl stop ssh.service') + call(f'systemctl stop {systemd_service_sshguard}') return None + if 'dynamic_protection' not in ssh: + call(f'systemctl stop {systemd_service_sshguard}') + else: + call(f'systemctl reload-or-restart {systemd_service_sshguard}') + call('systemctl restart ssh.service') return None |