summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/pr-conflicts.yml2
-rw-r--r--data/templates/ssh/sshguard_config.tmpl27
-rw-r--r--data/templates/ssh/sshguard_whitelist.tmpl7
-rw-r--r--debian/control1
-rw-r--r--interface-definitions/ssh.xml.in72
-rw-r--r--python/vyos/remote.py29
-rwxr-xr-xsmoketest/scripts/cli/test_service_ssh.py52
-rwxr-xr-xsrc/conf_mode/ssh.py20
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