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 @@ -1,169 +1,170 @@ Source: vyos-1x Section: contrib/net Priority: extra Maintainer: VyOS Package Maintainers <maintainers@vyos.net> Build-Depends: debhelper (>= 9), fakeroot, gcc-multilib [amd64], clang [amd64], llvm [amd64], libelf-dev (>= 0.2) [amd64], libpcap-dev [amd64], build-essential, libvyosconfig0 (>= 0.0.7), libzmq3-dev, python3, python3-coverage, python3-lxml, python3-netifaces, python3-nose, python3-jinja2, python3-psutil, python3-setuptools, python3-sphinx, python3-xmltodict, quilt, whois Standards-Version: 3.9.6 Package: vyos-1x Architecture: amd64 arm64 Depends: accel-ppp, beep, bmon, bsdmainutils, bridge-utils, conntrack, conserver-client, conserver-server, console-data, crda, cron, dbus, ddclient (>= 3.9.1), dropbear, easy-rsa, etherwake, fastnetmon, file, frr, frr-pythontools, grc, hostapd, hvinfo, igmpproxy, ipaddrcheck, iperf, iperf3, iproute2, iputils-arping, isc-dhcp-client, isc-dhcp-relay, isc-dhcp-server, iw, keepalived (>=2.0.5), lcdproc, lcdproc-extra-drivers, libatomic1, libndp-tools, libpam-radius-auth (>= 1.5.0), libqmi-utils, libvyosconfig0, lldpd, lm-sensors, lsscsi, mdns-repeater, minisign, modemmanager, mtr-tiny, netplug, nftables (>= 0.9.3), nginx-light, ntp, ntpdate, nvme-cli, ocserv, openssh-server, openssl, openvpn, openvpn-auth-ldap, openvpn-auth-radius, pciutils, pdns-recursor, pmacct (>= 1.6.0), podman, pppoe, procps, python3, python3-certbot-nginx, ${python3:Depends}, python3-flask, python3-hurry.filesize, python3-inotify, python3-isc-dhcp-leases, python3-jinja2, python3-jmespath, python3-netaddr, python3-netifaces, python3-paramiko, python3-psutil, python3-pystache, python3-pyudev, python3-six, python3-tabulate, python3-vici (>= 5.7.2), python3-voluptuous, python3-waitress, python3-xmltodict, python3-zmq, qrencode, radvd, salt-minion, sed, smartmontools, snmp, snmpd, squid, squidclient, squidguard, + sshguard, ssl-cert, sudo, systemd, telegraf (>= 1.20), tcpdump, tcptraceroute, telnet, tftpd-hpa, traceroute, tuned, udp-broadcast-relay, uidmap, usb-modeswitch, usbutils, vyatta-bash, vyatta-cfg, vyos-http-api-tools, vyos-utils, wide-dhcpv6-client, wireguard-tools, wireless-regdb, wpasupplicant (>= 0.6.7) Description: VyOS configuration scripts and data VyOS configuration scripts, interface definitions, and everything Package: vyos-1x-vmware Architecture: amd64 Depends: vyos-1x, open-vm-tools Description: VyOS configuration scripts and data for VMware Adds configuration files required for VyOS running on VMware hosts. Package: vyos-1x-smoketest Architecture: all Depends: skopeo, vyos-1x Description: VyOS build sanity checking toolkit 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 @@ -1,154 +1,226 @@ <?xml version="1.0"?> <interfaceDefinition> <node name="service"> <properties> <help>System services</help> </properties> <children> <node name="ssh" owner="${vyos_conf_scripts_dir}/ssh.py"> <properties> <help>Secure Shell (SSH)</help> <priority>1000</priority> </properties> <children> <node name="access-control"> <properties> <help>SSH user/group access controls</help> </properties> <children> <node name="allow"> <properties> <help>Allow user/group SSH access</help> </properties> <children> #include <include/ssh-group.xml.i> #include <include/ssh-user.xml.i> </children> </node> <node name="deny"> <properties> <help>Deny user/group SSH access</help> </properties> <children> #include <include/ssh-group.xml.i> #include <include/ssh-user.xml.i> </children> </node> </children> </node> <leafNode name="ciphers"> <properties> <help>Allowed ciphers</help> <completionHelp> <!-- generated by ssh -Q cipher | tr '\n' ' ' as this will not change dynamically --> <list>3des-cbc aes128-cbc aes192-cbc aes256-cbc rijndael-cbc@lysator.liu.se aes128-ctr aes192-ctr aes256-ctr aes128-gcm@openssh.com aes256-gcm@openssh.com chacha20-poly1305@openssh.com</list> </completionHelp> <constraint> <regex>(3des-cbc|aes128-cbc|aes192-cbc|aes256-cbc|rijndael-cbc@lysator.liu.se|aes128-ctr|aes192-ctr|aes256-ctr|aes128-gcm@openssh.com|aes256-gcm@openssh.com|chacha20-poly1305@openssh.com)</regex> </constraint> <multi/> </properties> </leafNode> <leafNode name="disable-host-validation"> <properties> <help>Disable IP Address to Hostname lookup</help> <valueless/> </properties> </leafNode> <leafNode name="disable-password-authentication"> <properties> <help>Disable password-based authentication</help> <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> <completionHelp> <!-- generated by ssh -Q kex | tr '\n' ' ' as this will not change dynamically --> <list>diffie-hellman-group1-sha1 diffie-hellman-group14-sha1 diffie-hellman-group14-sha256 diffie-hellman-group16-sha512 diffie-hellman-group18-sha512 diffie-hellman-group-exchange-sha1 diffie-hellman-group-exchange-sha256 ecdh-sha2-nistp256 ecdh-sha2-nistp384 ecdh-sha2-nistp521 curve25519-sha256 curve25519-sha256@libssh.org</list> </completionHelp> <multi/> <constraint> <regex>(diffie-hellman-group1-sha1|diffie-hellman-group14-sha1|diffie-hellman-group14-sha256|diffie-hellman-group16-sha512|diffie-hellman-group18-sha512|diffie-hellman-group-exchange-sha1|diffie-hellman-group-exchange-sha256|ecdh-sha2-nistp256|ecdh-sha2-nistp384|ecdh-sha2-nistp521|curve25519-sha256|curve25519-sha256@libssh.org)</regex> </constraint> </properties> </leafNode> #include <include/listen-address.xml.i> <leafNode name="loglevel"> <properties> <help>Log level</help> <completionHelp> <list>quiet fatal error info verbose</list> </completionHelp> <valueHelp> <format>quiet</format> <description>stay silent</description> </valueHelp> <valueHelp> <format>fatal</format> <description>log fatals only</description> </valueHelp> <valueHelp> <format>error</format> <description>log errors and fatals only</description> </valueHelp> <valueHelp> <format>info</format> <description>default log level</description> </valueHelp> <valueHelp> <format>verbose</format> <description>enable logging of failed login attempts</description> </valueHelp> <constraint> <regex>(quiet|fatal|error|info|verbose)</regex> </constraint> </properties> <defaultValue>INFO</defaultValue> </leafNode> <leafNode name="mac"> <properties> <help>Allowed message authentication code (MAC) algorithms</help> <completionHelp> <!-- generated by ssh -Q mac | tr '\n' ' ' as this will not change dynamically --> <list>hmac-sha1 hmac-sha1-96 hmac-sha2-256 hmac-sha2-512 hmac-md5 hmac-md5-96 umac-64@openssh.com umac-128@openssh.com hmac-sha1-etm@openssh.com hmac-sha1-96-etm@openssh.com hmac-sha2-256-etm@openssh.com hmac-sha2-512-etm@openssh.com hmac-md5-etm@openssh.com hmac-md5-96-etm@openssh.com umac-64-etm@openssh.com umac-128-etm@openssh.com</list> </completionHelp> <constraint> <regex>(hmac-sha1|hmac-sha1-96|hmac-sha2-256|hmac-sha2-512|hmac-md5|hmac-md5-96|umac-64@openssh.com|umac-128@openssh.com|hmac-sha1-etm@openssh.com|hmac-sha1-96-etm@openssh.com|hmac-sha2-256-etm@openssh.com|hmac-sha2-512-etm@openssh.com|hmac-md5-etm@openssh.com|hmac-md5-96-etm@openssh.com|umac-64-etm@openssh.com|umac-128-etm@openssh.com)</regex> </constraint> <multi/> </properties> </leafNode> <leafNode name="port"> <properties> <help>Port for SSH service</help> <valueHelp> <format>u32:1-65535</format> <description>Numeric IP port</description> </valueHelp> <multi/> <constraint> <validator name="numeric" argument="--range 1-65535"/> </constraint> </properties> <defaultValue>22</defaultValue> </leafNode> <leafNode name="client-keepalive-interval"> <properties> <help>Enable transmission of keepalives from server to client</help> <valueHelp> <format>u32:1-65535</format> <description>Time interval in seconds for keepalive message</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 1-65535"/> </constraint> </properties> </leafNode> #include <include/interface/vrf.xml.i> </children> </node> </children> </node> </interfaceDefinition> 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,207 +1,257 @@ #!/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 # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import os import paramiko import re import os import unittest from pwd import getpwall from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError from vyos.util import cmd from vyos.util import is_systemd_service_running from vyos.util import process_named_running from vyos.util import read_file PROCESS_NAME = 'sshd' SSHD_CONF = '/run/sshd/sshd_config' base_path = ['service', 'ssh'] vrf = 'mgmt' def get_config_value(key): tmp = read_file(SSHD_CONF) tmp = re.findall(f'\n?{key}\s+(.*)', tmp) return tmp class TestServiceSSH(VyOSUnitTestSHIM.TestCase): def setUp(self): # ensure we can also run this test on a live system - so lets clean # out the current configuration :) self.cli_delete(base_path) def tearDown(self): # Check for running process self.assertTrue(process_named_running(PROCESS_NAME)) # delete testing SSH config self.cli_delete(base_path) self.cli_commit() # Established SSH connections remains running after service is stopped. # We can not use process_named_running here - we rather need to check # that the systemd service is no longer running self.assertFalse(is_systemd_service_running(PROCESS_NAME)) def test_ssh_default(self): # Check if SSH service runs with default settings - used for checking # behavior of <defaultValue> in XML definition self.cli_set(base_path) # commit changes self.cli_commit() # Check configured port port = get_config_value('Port')[0] self.assertEqual('22', port) def test_ssh_single_listen_address(self): # Check if SSH service can be configured and runs self.cli_set(base_path + ['port', '1234']) self.cli_set(base_path + ['disable-host-validation']) self.cli_set(base_path + ['disable-password-authentication']) self.cli_set(base_path + ['loglevel', 'verbose']) self.cli_set(base_path + ['client-keepalive-interval', '100']) self.cli_set(base_path + ['listen-address', '127.0.0.1']) # commit changes self.cli_commit() # Check configured port port = get_config_value('Port')[0] self.assertTrue("1234" in port) # Check DNS usage dns = get_config_value('UseDNS')[0] self.assertTrue("no" in dns) # Check PasswordAuthentication pwd = get_config_value('PasswordAuthentication')[0] self.assertTrue("no" in pwd) # Check loglevel loglevel = get_config_value('LogLevel')[0] self.assertTrue("VERBOSE" in loglevel) # Check listen address address = get_config_value('ListenAddress')[0] self.assertTrue("127.0.0.1" in address) # Check keepalive keepalive = get_config_value('ClientAliveInterval')[0] self.assertTrue("100" in keepalive) def test_ssh_multiple_listen_addresses(self): # Check if SSH service can be configured and runs with multiple # listen ports and listen-addresses ports = ['22', '2222', '2223', '2224'] for port in ports: self.cli_set(base_path + ['port', port]) addresses = ['127.0.0.1', '::1'] for address in addresses: self.cli_set(base_path + ['listen-address', address]) # commit changes self.cli_commit() # Check configured port tmp = get_config_value('Port') for port in ports: self.assertIn(port, tmp) # Check listen address tmp = get_config_value('ListenAddress') for address in addresses: self.assertIn(address, tmp) def test_ssh_vrf(self): # Check if SSH service can be bound to given VRF port = '22' self.cli_set(base_path + ['port', port]) self.cli_set(base_path + ['vrf', vrf]) # VRF does yet not exist - an error must be thrown with self.assertRaises(ConfigSessionError): self.cli_commit() self.cli_set(['vrf', 'name', vrf, 'table', '1338']) # commit changes self.cli_commit() # Check configured port tmp = get_config_value('Port') self.assertIn(port, tmp) # Check for process in VRF tmp = cmd(f'ip vrf pids {vrf}') self.assertIn(PROCESS_NAME, tmp) # delete VRF self.cli_delete(['vrf', 'name', vrf]) def test_ssh_login(self): # Perform SSH login and command execution with a predefined user. The # result (output of uname -a) must match the output if the command is # run natively. # # We also try to login as an invalid user - this is not allowed to work. def ssh_send_cmd(command, username, password, host='localhost'): """ SSH command execution helper """ # Try to login via SSH ssh_client = paramiko.SSHClient() ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) ssh_client.connect(hostname='localhost', username=username, password=password) _, stdout, stderr = ssh_client.exec_command(command) output = stdout.read().decode().strip() error = stderr.read().decode().strip() ssh_client.close() return output, error test_user = 'ssh_test' test_pass = 'v2i57DZs8idUwMN3VC92' test_command = 'uname -a' self.cli_set(base_path) self.cli_set(['system', 'login', 'user', test_user, 'authentication', 'plaintext-password', test_pass]) # commit changes self.cli_commit() # Login with proper credentials output, error = ssh_send_cmd(test_command, test_user, test_pass) # verify login self.assertFalse(error) self.assertEqual(output, cmd(test_command)) # Login with invalid credentials with self.assertRaises(paramiko.ssh_exception.AuthenticationException): output, error = ssh_send_cmd(test_command, 'invalid_user', 'invalid_password') self.cli_delete(['system', 'login', 'user', test_user]) self.cli_commit() # After deletion the test user is not allowed to remain in /etc/passwd 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,93 +1,111 @@ #!/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 # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import os from sys import exit from vyos.config import Config from vyos.configdict import dict_merge from vyos.configverify import verify_vrf from vyos.util import call from vyos.template import render from vyos.xml import defaults from vyos import ConfigError from vyos import airbag 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 else: conf = Config() base = ['service', 'ssh'] if not conf.exists(base): return None ssh = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) # We have gathered the dict representation of the CLI, but there are default # options which we need to update into the dictionary retrived. default_values = defaults(base) ssh = dict_merge(default_values, ssh) # 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): if not ssh: return None verify_vrf(ssh) return None def generate(ssh): if not ssh: if os.path.isfile(config_file): os.unlink(config_file) if os.path.isfile(systemd_override): os.unlink(systemd_override) return None 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 if __name__ == '__main__': try: c = get_config() verify(c) generate(c) apply(c) except ConfigError as e: print(e) exit(1)