From ca497feee07cc3752763daa6e7c4e147d5f2af0c Mon Sep 17 00:00:00 2001 From: khramshinr Date: Wed, 29 May 2024 19:46:20 +0600 Subject: [PATCH] T5735: Stunnel CLI and configuration Add CLI commands Add config Add conf_mode Add systemd config Add stunnel smoketests Add log level config --- data/config-mode-dependencies/vyos-1x.json | 3 +- data/configd-include.json | 1 + data/templates/stunnel/stunnel_config.j2 | 118 ++++ .../include/stunnel/address.xml.i | 20 + .../include/stunnel/connect.xml.i | 11 + .../include/stunnel/listen.xml.i | 11 + .../include/stunnel/protocol-options.xml.i | 75 +++ .../include/stunnel/protocol-value-cifs.xml.i | 6 + .../stunnel/protocol-value-connect.xml.i | 6 + .../include/stunnel/protocol-value-imap.xml.i | 6 + .../include/stunnel/protocol-value-nntp.xml.i | 6 + .../stunnel/protocol-value-pgsql.xml.i | 6 + .../include/stunnel/protocol-value-pop3.xml.i | 6 + .../stunnel/protocol-value-proxy.xml.i | 6 + .../include/stunnel/protocol-value-smtp.xml.i | 6 + .../stunnel/protocol-value-socks.xml.i | 6 + .../include/stunnel/psk.xml.i | 30 + .../include/stunnel/ssl.xml.i | 11 + interface-definitions/service_stunnel.xml.in | 130 ++++ smoketest/scripts/cli/test_service_stunnel.py | 607 ++++++++++++++++++ src/conf_mode/pki.py | 4 + src/conf_mode/service_stunnel.py | 260 ++++++++ src/systemd/stunnel.service | 15 + src/validators/psk-secret | 39 ++ 24 files changed, 1388 insertions(+), 1 deletion(-) create mode 100644 data/templates/stunnel/stunnel_config.j2 create mode 100644 interface-definitions/include/stunnel/address.xml.i create mode 100644 interface-definitions/include/stunnel/connect.xml.i create mode 100644 interface-definitions/include/stunnel/listen.xml.i create mode 100644 interface-definitions/include/stunnel/protocol-options.xml.i create mode 100644 interface-definitions/include/stunnel/protocol-value-cifs.xml.i create mode 100644 interface-definitions/include/stunnel/protocol-value-connect.xml.i create mode 100644 interface-definitions/include/stunnel/protocol-value-imap.xml.i create mode 100644 interface-definitions/include/stunnel/protocol-value-nntp.xml.i create mode 100644 interface-definitions/include/stunnel/protocol-value-pgsql.xml.i create mode 100644 interface-definitions/include/stunnel/protocol-value-pop3.xml.i create mode 100644 interface-definitions/include/stunnel/protocol-value-proxy.xml.i create mode 100644 interface-definitions/include/stunnel/protocol-value-smtp.xml.i create mode 100644 interface-definitions/include/stunnel/protocol-value-socks.xml.i create mode 100644 interface-definitions/include/stunnel/psk.xml.i create mode 100644 interface-definitions/include/stunnel/ssl.xml.i create mode 100644 interface-definitions/service_stunnel.xml.in create mode 100644 smoketest/scripts/cli/test_service_stunnel.py create mode 100644 src/conf_mode/service_stunnel.py create mode 100644 src/systemd/stunnel.service create mode 100644 src/validators/psk-secret diff --git a/data/config-mode-dependencies/vyos-1x.json b/data/config-mode-dependencies/vyos-1x.json index 9623948c24c..9361f4e7c97 100644 --- a/data/config-mode-dependencies/vyos-1x.json +++ b/data/config-mode-dependencies/vyos-1x.json @@ -32,7 +32,8 @@ "reverse_proxy": ["load-balancing_reverse-proxy"], "rpki": ["protocols_rpki"], "sstp": ["vpn_sstp"], - "sstpc": ["interfaces_sstpc"] + "sstpc": ["interfaces_sstpc"], + "stunnel": ["service_stunnel"] }, "vpn_ipsec": { "nhrp": ["protocols_nhrp"] diff --git a/data/configd-include.json b/data/configd-include.json index 633d898a55f..8c9cbbc76c1 100644 --- a/data/configd-include.json +++ b/data/configd-include.json @@ -80,6 +80,7 @@ "service_salt-minion.py", "service_sla.py", "service_ssh.py", +"service_stunnel.py", "service_tftp-server.py", "service_webproxy.py", "system_acceleration.py", diff --git a/data/templates/stunnel/stunnel_config.j2 b/data/templates/stunnel/stunnel_config.j2 new file mode 100644 index 00000000000..52c289fa9d5 --- /dev/null +++ b/data/templates/stunnel/stunnel_config.j2 @@ -0,0 +1,118 @@ +; Autogenerated by service_stunnel.py + +; Example https://www.stunnel.org/config_unix.html# +; ************************************************************************** +; * Global options * +; ************************************************************************** + +; PID file is created inside the chroot jail (if enabled) +pid = {{ config_file | replace('.conf', '.pid') }} + +; Debugging stuff (may be useful for troubleshooting) +;foreground = yes + +{% if log is vyos_defined %} +debug = {{ log.level }} +{% endif %} + +;output = /usr/local/var/log/stunnel.log + + +; ************************************************************************** +; * Service definitions * +; ************************************************************************** + +; ***************************************** Client mode services *********** + +{% if client is vyos_defined %} +{% for name, config in client.items() %} +[{{ name }}] +client = yes +{% if config.listen.address is vyos_defined %} +accept = {{ config.listen.address }}:{{ config.listen.port }} +{% else %} +accept = {{ config.listen.port }} +{% endif %} +{% if config.connect is vyos_defined %} +{% if config.connect.address is vyos_defined %} +connect = {{ config.connect.address }}:{{ config.connect.port }} +{% else %} +connect = {{ config.connect.port }} +{% endif %} +{% endif %} +{% if config.protocol is vyos_defined %} +protocol = {{ config.protocol }} +{% endif %} +{% if config.options is vyos_defined %} +{% if config.options.authentication is vyos_defined %} +protocolAuthentication = {{ config.options.authentication }} +{% endif %} +{% if config.options.domain is vyos_defined %} +protocolDomain = {{ config.options.domain }} +{% endif %} +{% if config.options.host is vyos_defined %} +protocolHost = {{ config.options.host.address }}:{{ config.options.host.port }} +{% endif %} +{% if config.options.password is vyos_defined %} +protocolPassword = {{ config.options.password }} +{% endif %} +{% if config.options.username is vyos_defined %} +protocolUsername = {{ config.options.username }} +{% endif %} +{% endif %} +{% if config.ssl.ca_path is vyos_defined %} +CApath = {{ config.ssl.ca_path }} +{% endif %} +{% if config.ssl.ca_file is vyos_defined %} +CAfile = {{ config.ssl.ca_file }} +{% endif %} +{% if config.ssl.cert is vyos_defined %} +cert = {{ config.ssl.cert }} +{% endif %} +{% if config.ssl.cert_key is vyos_defined %} +key = {{ config.ssl.cert_key }} +{% endif %} +{% if config.psk.file is vyos_defined %} +PSKsecrets = {{ config.psk.file }} +{% endif %} +{% endfor %} +{% endif %} + + +; ***************************************** Server mode services *********** + +{% if server is vyos_defined %} +{% for name, config in server.items() %} +[{{ name }}] +{% if config.listen.address is vyos_defined %} +accept = {{ config.listen.address }}:{{ config.listen.port }} +{% else %} +accept = {{ config.listen.port }} +{% endif %} +{% if config.connect is vyos_defined %} +{% if config.connect.address is vyos_defined %} +connect = {{ config.connect.address }}:{{ config.connect.port }} +{% else %} +connect = {{ config.connect.port }} +{% endif %} +{% endif %} +{% if config.protocol is vyos_defined %} +protocol = {{ config.protocol }} +{% endif %} +{% if config.ssl.ca_path is vyos_defined %} +CApath = {{ config.ssl.ca_path }} +{% endif %} +{% if config.ssl.ca_file is vyos_defined %} +CAfile = {{ config.ssl.ca_file }} +{% endif %} +{% if config.ssl.cert is vyos_defined %} +cert = {{ config.ssl.cert }} +{% endif %} +{% if config.ssl.cert_key is vyos_defined %} +key = {{ config.ssl.cert_key }} +{% endif %} +{% if config.psk.file is vyos_defined %} +PSKsecrets = {{ config.psk.file }} +{% endif %} +{% endfor %} +{% endif %} diff --git a/interface-definitions/include/stunnel/address.xml.i b/interface-definitions/include/stunnel/address.xml.i new file mode 100644 index 00000000000..d2901d595f1 --- /dev/null +++ b/interface-definitions/include/stunnel/address.xml.i @@ -0,0 +1,20 @@ + + + + Hostname or IP address + + ipv4 + IPv4 address + + + hostname + hostname + + + + + + Invalid FQDN or IP address + + + diff --git a/interface-definitions/include/stunnel/connect.xml.i b/interface-definitions/include/stunnel/connect.xml.i new file mode 100644 index 00000000000..cd6246a00fa --- /dev/null +++ b/interface-definitions/include/stunnel/connect.xml.i @@ -0,0 +1,11 @@ + + + + Connect to a remote address + + + #include + #include + + + diff --git a/interface-definitions/include/stunnel/listen.xml.i b/interface-definitions/include/stunnel/listen.xml.i new file mode 100644 index 00000000000..13d0986ee68 --- /dev/null +++ b/interface-definitions/include/stunnel/listen.xml.i @@ -0,0 +1,11 @@ + + + + Accept connections on specified address + + + #include + #include + + + diff --git a/interface-definitions/include/stunnel/protocol-options.xml.i b/interface-definitions/include/stunnel/protocol-options.xml.i new file mode 100644 index 00000000000..2f020287541 --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-options.xml.i @@ -0,0 +1,75 @@ + + + + Advanced protocol options + + + + + Authentication type for the protocol negotiations + + basic ntlm plain login + + + basic + The default 'connect' authentication type + + + ntlm + Supported authentication types for the 'connect' protocol + + + plain + The default 'smtp' authentication type + + + login + Supported authentication types for the 'smtp' protocol + + + (basic|ntlm|plain|login) + + + + + + Domain for the 'connect' protocol. + + domain + domain + + + + + + + + + Destination address for the 'connect' protocol + + + #include + #include + + + + + Password for the protocol negotiations + + txt + Authentication password + + + + + + Username for the protocol negotiations + + txt + Authentication username + + + + + + diff --git a/interface-definitions/include/stunnel/protocol-value-cifs.xml.i b/interface-definitions/include/stunnel/protocol-value-cifs.xml.i new file mode 100644 index 00000000000..5b948475085 --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-cifs.xml.i @@ -0,0 +1,6 @@ + + + cifs + Proprietary (undocummented) extension of CIFS protocol + + diff --git a/interface-definitions/include/stunnel/protocol-value-connect.xml.i b/interface-definitions/include/stunnel/protocol-value-connect.xml.i new file mode 100644 index 00000000000..3c30e71ca4b --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-connect.xml.i @@ -0,0 +1,6 @@ + + + connect + Based on RFC 2817 - Upgrading to TLS Within HTTP/1.1, section 5.2 - Requesting a Tunnel with CONNECT + + diff --git a/interface-definitions/include/stunnel/protocol-value-imap.xml.i b/interface-definitions/include/stunnel/protocol-value-imap.xml.i new file mode 100644 index 00000000000..033e5479b93 --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-imap.xml.i @@ -0,0 +1,6 @@ + + + imap + Based on RFC 2595 - Using TLS with IMAP, POP3 and ACAP + + diff --git a/interface-definitions/include/stunnel/protocol-value-nntp.xml.i b/interface-definitions/include/stunnel/protocol-value-nntp.xml.i new file mode 100644 index 00000000000..60a6c02c6ee --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-nntp.xml.i @@ -0,0 +1,6 @@ + + + nntp + Based on RFC 4642 - Using Transport Layer Security (TLS) with Network News Transfer Protocol (NNTP) + + diff --git a/interface-definitions/include/stunnel/protocol-value-pgsql.xml.i b/interface-definitions/include/stunnel/protocol-value-pgsql.xml.i new file mode 100644 index 00000000000..fd3a166ec90 --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-pgsql.xml.i @@ -0,0 +1,6 @@ + + + pgsql + Based on PostgreSQL frontend/backend protocol + + diff --git a/interface-definitions/include/stunnel/protocol-value-pop3.xml.i b/interface-definitions/include/stunnel/protocol-value-pop3.xml.i new file mode 100644 index 00000000000..1c8af53e5b3 --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-pop3.xml.i @@ -0,0 +1,6 @@ + + + pop3 + Based on RFC 2449 - POP3 Extension Mechanism + + diff --git a/interface-definitions/include/stunnel/protocol-value-proxy.xml.i b/interface-definitions/include/stunnel/protocol-value-proxy.xml.i new file mode 100644 index 00000000000..a4c20d1b019 --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-proxy.xml.i @@ -0,0 +1,6 @@ + + + proxy + Passing of the original client IP address with HAProxy PROXY protocol version 1 + + diff --git a/interface-definitions/include/stunnel/protocol-value-smtp.xml.i b/interface-definitions/include/stunnel/protocol-value-smtp.xml.i new file mode 100644 index 00000000000..66ca20426d1 --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-smtp.xml.i @@ -0,0 +1,6 @@ + + + smtp + Based on RFC 2487 - SMTP Service Extension for Secure SMTP over TLS + + diff --git a/interface-definitions/include/stunnel/protocol-value-socks.xml.i b/interface-definitions/include/stunnel/protocol-value-socks.xml.i new file mode 100644 index 00000000000..e110be5db2f --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-socks.xml.i @@ -0,0 +1,6 @@ + + + socks + SOCKS versions 4, 4a, and 5 are supported + + diff --git a/interface-definitions/include/stunnel/psk.xml.i b/interface-definitions/include/stunnel/psk.xml.i new file mode 100644 index 00000000000..db11a93d335 --- /dev/null +++ b/interface-definitions/include/stunnel/psk.xml.i @@ -0,0 +1,30 @@ + + + + Pre-shared key name + + + + + ID for authentication + + txt + ID used for authentication + + + + + + pre-shared secret key + + txt + pre-shared secret key are required to be at least 16 bytes long, which implies at least 32 characters for hexadecimal key + + + + + + + + + diff --git a/interface-definitions/include/stunnel/ssl.xml.i b/interface-definitions/include/stunnel/ssl.xml.i new file mode 100644 index 00000000000..b4b63cef0a0 --- /dev/null +++ b/interface-definitions/include/stunnel/ssl.xml.i @@ -0,0 +1,11 @@ + + + + SSL Certificate, SSL Key and CA + + + #include + #include + + + diff --git a/interface-definitions/service_stunnel.xml.in b/interface-definitions/service_stunnel.xml.in new file mode 100644 index 00000000000..d88909bc9bd --- /dev/null +++ b/interface-definitions/service_stunnel.xml.in @@ -0,0 +1,130 @@ + + + + + System services + + + + + Stunnel TLS Proxy + 1000 + + + + + Stunnel server config + + + #include + #include + #include + #include + + + Application protocol to negotiate TLS + + cifs imap pgsql pop3 proxy smtp socks + + #include + #include + #include + #include + #include + #include + #include + + (cifs|imap|pgsql|pop3|proxy|smtp|socks) + + + + + + + + Stunnel client config + + + #include + #include + #include + #include + + + Application protocol to negotiate TLS + + cifs connect imap nntp pgsql pop3 proxy smtp socks + + #include + #include + #include + #include + #include + #include + #include + #include + #include + + (cifs|connect|imap|nntp|pgsql|pop3|proxy|smtp|socks) + + + + #include + + + + + Service logging + + + + + Specifies log level. + + emerg alert crit err warning notice info debug + + + emerg + Emerg log level + + + alert + Alert log level + + + crit + Critical log level + + + err + Error log level + + + warning + Warning log level + + + notice + Notice log level + + + info + Info log level + + + debug + Debug log level + + + (emerg|alert|crit|err|warning|notice|info|debug) + + + notice + + + + + + + + diff --git a/smoketest/scripts/cli/test_service_stunnel.py b/smoketest/scripts/cli/test_service_stunnel.py new file mode 100644 index 00000000000..15fd29822d5 --- /dev/null +++ b/smoketest/scripts/cli/test_service_stunnel.py @@ -0,0 +1,607 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2024 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 . + +import re +import unittest + +from base_vyostest_shim import VyOSUnitTestSHIM + +from vyos.configsession import ConfigSessionError +from vyos.utils.process import process_named_running +from vyos.utils.file import read_file + + +PROCESS_NAME = 'stunnel' +STUNNEL_CONF = '/run/stunnel/stunnel.conf' +base_path = ['service', 'stunnel'] + +ca_certificate = """ +MIIDnTCCAoWgAwIBAgIUcSMo/zT/GUAyH3uM3Hr3qjCDmMUwDQYJKoZIhvcNAQELBQAwVzELMAkGA1U +EBhMCR0IxEzARBgNVBAgMClNvbWUtU3RhdGUxEjAQBgNVBAcMCVNvbWUtQ2l0eTENMAsGA1UECgwEVn +lPUzEQMA4GA1UEAwwHdnlvcy5pbzAeFw0yNDA2MDQwNjU1MDFaFw0yOTA2MDMwNjU1MDFaMFcxCzAJB +gNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMRIwEAYDVQQHDAlTb21lLUNpdHkxDTALBgNVBAoM +BFZ5T1MxEDAOBgNVBAMMB3Z5b3MuaW8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCzN7B +Zw0OBBgeGL7KCKdDIUfBEhh08+3V8Nm7K23mU/pYd3bR5WXt9VWkW5YWUw1hr1N3qEQ2AZX8TrIDj37 +zzy1jyDCvJHGWnKTOOAboNIInP+PvUQrSH8SDAw/+/KjKKgM069NFhGq9TTHg4BAYC0GsZL+JE3Ptee +cIVmekf5Dw+vnD0Mlwx5Ouaf/9OwRcGhfwEkIORQLXDuMayOI/JdFbaDVlA6Z/d8GLp3Xlc8/l5XFtg +fvMNQSB9B69Cs4qwU/yey8tPWeDBiW6Cx2XOnKqiNBaCY1BzvSH+hmHcos1DOLHgEZ3d2zaNn2mrhmB +Ry7/5Ww7O5PoF00OB9WHFAgMBAAGjYTBfMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB +0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAdBgNVHQ4EFgQU4zgMpOMOweRZUbeNewJnh5xZL +XwwDQYJKoZIhvcNAQELBQADggEBAAEK+jXvCKuC/n8qu9XFcLYfO3kUKPlXD30V61KRZilHyYGYu0MY +sSNeX8+K7CpeAo06HHocrrDfCKltoLFuix7qblr2DEub+v3V21pllMfThkz9FsXWFGfmOyI7sXNXUg9 +cVQHzj2SvMj+IfnJoCIuYnigmlKVTuxV31iYv2RpML/PBw29xI0G/AsmXZK4wOQ0eA9gU+ggURE98hG +8f4DRpGVnlyP1d+P2Va0bsl3Yek9QfrotnmE1EzwZzPZyCL9rv8oDjfJ98O3YqoNSRNvD+Glke2ZlTj +WFw+uCj0GTki5+V40E9X9Rwcje+s/5zWDBfu0akufcI1nsu++rZz/s= +""" + +ca_private_key = """ +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCzN7BZw0OBBgeGL7KCKdDIUfBEhh0 +8+3V8Nm7K23mU/pYd3bR5WXt9VWkW5YWUw1hr1N3qEQ2AZX8TrIDj37zzy1jyDCvJHGWnKTOOAboNII +nP+PvUQrSH8SDAw/+/KjKKgM069NFhGq9TTHg4BAYC0GsZL+JE3PteecIVmekf5Dw+vnD0Mlwx5Ouaf +/9OwRcGhfwEkIORQLXDuMayOI/JdFbaDVlA6Z/d8GLp3Xlc8/l5XFtgfvMNQSB9B69Cs4qwU/yey8tP +WeDBiW6Cx2XOnKqiNBaCY1BzvSH+hmHcos1DOLHgEZ3d2zaNn2mrhmBRy7/5Ww7O5PoF00OB9WHFAgM +BAAECggEAHFC/pacCutdrh+lwUD1wFb5QclsoMnLeYJYvEhD0GDTTHfvh4ExhhO9iL7Jq1RK6HStgNn +OkSPWASuj14kr+zRwDPRbsMhWw/+S0FwsxzJIoA/poO2SgplvUG3C8LwVpP9XS1y5ICIoRSl1qHxuPo +ZExYqTcoJmzg31ES2pqWVXPx14DdpE6yvSL2XwFS4mb291OkydnvKSBcK0MwgEWLQHouzMjihJ1MCXx +7NXsOxFX76OpmywMW7EtTLEngxL9b61hCYwWeNxmx9pN8qRzmvayKl40VLyqAlVcElZ7aEK0+O/Qpsu +QhsFRjA4HcXUqlHbvh92OqX+QmBU2RIZ27wKBgQDnJ8E8cJOlJ9ZvFBfw8az49IX9E72oxb2yaXm0Eo +OQ2Jz88+b2jzWqf3wdGvigNO25DbdYYodR7iJJo4OYPuyAnkJMWdPQ91ADo7ABwJiBqtUHC+Gvq5Rmm +I2T3T4+Vqu5Sa8lVfHWfv7Pnb++I5/7bH+VuGspyf+0NcpPh9UfIwKBgQDGet1wh0+2378HnnQNb10w +wxDiMC2hP+3RGPB6bKHLJ60LE+Ed2KFY+j8Q1+jQk9eMe6A75bwB/q6rMO1evpauCHTJoA863HxXtuL +P9ozVpDk9k4TbiSOsD0s8TXL3XG1ANshk4VfuLboKj4MEwiuxfGt6QGpsgLfHcmlkFIM99wKBgQDeea +C97wvrVOBJoGk6eSAlrBKZdTqBCXB+Go4MBhWifxj5TDXq8AKSyohF6wOIDekOxmjEJHBhJnTRsxKgo +U82qxrcKUh4Qs878Xsg9KDTi/vkAEeCr/zwkbsRqUqS7Q/yET0FDibobuoIIKe+9MKxVcel7g0V91in +tW22BeHVSQKBgQCKctwSiaCWTP8A/ouvb3ZO9FLLpJW/vEtUpxPgIfS+NH/lkUlfu2PZID5rrmAtVmN +uEDJWdcsujQwkSC3cABA1d5qXpnnZMkHeIamXLUFSKYrwI/3x8XibpdNyTgga+jMPLuecTwA6GVWD1l +WrNRKrbMG/9j0GUMdhbbKMaC6gQwKBgQCC9EUZBqCXS167OZNPQN4oKx6nJ9uTKUVyPigr12cMpPL6t +JwZAVVwSnyg+cVznNrMhAnG547c0NnoOe+nd9zczLJOuQHHLSMZUH08c2ZWtwpwbHDWI55hfZL4te8e +dEcxanXNYAfSMMtOoA+LmcCtfvqld/EucAN4mKTPGmWPQg== +""" + +srv_certificate = """ +MIIDsTCCApmgAwIBAgIUIvMZU3zc3iYl6JzbDLSvr8NOK5swDQYJKoZIhvcNAQELBQAwVzELMAkGA1U +EBhMCR0IxEzARBgNVBAgMClNvbWUtU3RhdGUxEjAQBgNVBAcMCVNvbWUtQ2l0eTENMAsGA1UECgwEVn +lPUzEQMA4GA1UEAwwHdnlvcy5pbzAeFw0yNDA2MDQwNjU1NDVaFw0yNTA2MDQwNjU1NDVaMFcxCzAJB +gNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMRIwEAYDVQQHDAlTb21lLUNpdHkxDTALBgNVBAoM +BFZ5T1MxEDAOBgNVBAMMB3Z5b3MuaW8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtani +zx0h1fEH0pMRBB7V7nUAnSOAiCRUNpeTz6RoUqH0y/UaxM+kqitUm+MSAWxEJAXW4ZlxNzU+tC6DOwP +d+7/rZsT6fKeCbMIs8Es9VaXd2sZzb7DajEygeyIy1b3JGXIiNJ9KcOxzhmu5VHe+6qLCO3FDt4iFIr +HXJxwQKm8qL6zgn7f9kboQYBHKOhY8x+ghkhLYAwMlvIHGwjF+I/p65J1LOBhAsmOLcX0/CygKXz5qe +wyG16zNft6OWPIOBTs56NnNlW6EdqomxBM5SWr888qEjUy0ruUpAH4Ug8SloL+AeDW+TqUUcfoOiTi9 +3ZJ/t9YRj0+wQw4vakpUTAgMBAAGjdTBzMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1 +UdJQQMMAoGCCsGAQUFBwMBMB0GA1UdDgQWBBTCubAbczcJE76YabOv+2oVV1zNSzAfBgNVHSMEGDAWg +BTjOAyk4w7B5FlRt417AmeHnFktfDANBgkqhkiG9w0BAQsFAAOCAQEAjW9ovWDgEoUi8DWwNVtudKiT +6ylJTSMqY7C+qJlRHpnZ64TNZFXI0BldYZr0QXGsZ257m9m9BiUcZr6UR0hywy4SiyxuteufniKIp9E +vqv0aJhdTXO+l5msaGWu7YvWYqXW0m3rA9oiNYyBcNSFzlwiyvztYUmFFPrvhFHVSt+DuxZSltdf78G +exS4YRMCTI+cuCfBt65Vkss4bNJH7kyWVc5aSQ/vKitMxB10gzsUa7psgS6LsBWxnehd3HKBPaHiWG9 +ssHKhHJWfjifgz0K1Y0/vi33USPJ1cBhWWx/dolXWmSmpfqXpD3Q84YjVWIRnFpQzwbT650v/H+fwB1 +zw== +""" + +srv_private_key = """ +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtanizx0h1fEH0pMRBB7V7nUAnSOA +iCRUNpeTz6RoUqH0y/UaxM+kqitUm+MSAWxEJAXW4ZlxNzU+tC6DOwPd+7/rZsT6fKeCbMIs8Es9VaX +d2sZzb7DajEygeyIy1b3JGXIiNJ9KcOxzhmu5VHe+6qLCO3FDt4iFIrHXJxwQKm8qL6zgn7f9kboQYB +HKOhY8x+ghkhLYAwMlvIHGwjF+I/p65J1LOBhAsmOLcX0/CygKXz5qewyG16zNft6OWPIOBTs56NnNl +W6EdqomxBM5SWr888qEjUy0ruUpAH4Ug8SloL+AeDW+TqUUcfoOiTi93ZJ/t9YRj0+wQw4vakpUTAgM +BAAECggEAEa54SyBSb4QxYg/bYM636Y2G3oU229GK6il+4YMOy99tZeG0L6+IInR7DO5ddBbqSD2esq +QL3PTw9EcUvi/9AYjXeL3H5vOeo+7Rq4OMIfx5wp+Ty6AB5s5hD1kfG7AWzzzHwYNiHS2Gdtb/XldfO +5bP6xO5/rSenynSbWCTir8yakfoDenT12CXWzU+T10MKhoTXb/Uao+bMjziKEviK6OWq0vsLlDqyOAE +Va685s7T0vHTfSs+yK9pqVypHXbkH1nJCoi9P4pcJ4Sslc3meStv3bqg8T62Ufv8QkQLTfJyKZlR1aV +9ZjWT84YoH1XRnnkAZ+BMC267sHeBJbu6EQKBgQDbIUjQh/iPlkK77tFa//gSMD5ouJtuwtdS1MJ44p +C81A140vjpkSCdU8zWRifi+akR1k6fXCp6VFUFvTCXkGlpbD4TNjCCRJjS4SoQ89jEnePQ2iS59jkn3 +V3OPNitOzk0jEm/x3R5wNdPlSX6+pLiUZAtMgcmCMv205VOkeqx8QKBgQDKmB3FtEfkKRrGkOJgaEkY +iXp9b0Uy8peBoTcdqMMnXSlm9+CfIdhSwbQDiAhEcUeCE/S6TDqaMS+ekKFfs6kDlaJMStGsy81lwr5 +W/oZOldajDCu1CDInc+czkep10lsHdQwr71zXntiK3Fwq8Mr3ROBSpaH+DWIjILiQIOMzQwKBgQC7Mt +UUqIQUjkZWbG/XcMLJLwOxzLukRLlUXsQAJ3WEixczN/BDAKM/JB7ikq5yfdwMi+tAwqjbNn4n1/bSF +CGpWToyiWGpd9aimI6qStbNKSE9A47KeulbAAaqMFreqrB1Dr/WIRuFA9QsfXsjzLp8szcbFRj8ShmM +tDZiF8/K0QKBgQCYbb0wzESu8RJZRhddC/m7QWzsxXReMdI2UTLj2N8EVf7ZnzTc5h0Znu4vHgGCZWy +0/QjLxqDs9Ibsmcsg807+CG51UnHRvgFLSCvnzlcE943nXTfhXEpIDtdsoKO0hFHDGZjP0aeb/8LTL5 +sVH9jGFIdnB4ILYMxuu6bBokzvewKBgBWbjPppjrM46bZ0rwEYCcG0F/k6TKkw4pjyrDR4B0XsrqTjK +0yz0ga7FHe10saeS2cXMqygdkjhWLZ6Zhrp0LAEzhEvdiBYeRH37J9Bvwo2YIHakox4hJCSXNnELs/A +GhUb5YIISNnZnZZeUD/Z0IJXJryjk9eUbhDCgEZRVzeT +""" + + +def get_config_value(key): + tmp = read_file(STUNNEL_CONF) + tmp = re.findall(f'\n?{key}\s+(.*)', tmp) + return tmp + + +def read_config(): + config = {'global': {}, + 'services': {} + } + service_pattern = re.compile(r'\[(.+?)\]') + key_value_pattern = re.compile(r'(\S+)\s*=\s*(.+)') + service = None + + for line in read_file(STUNNEL_CONF).split('\n'): + line.strip() + if not line or line.startswith(';'): + continue + if service_pattern.match(line): + service = line.strip('[]') + config['services'][service] = {} + key_value_match = key_value_pattern.match(line) + if key_value_match: + key, value = key_value_match.group(1), key_value_match.group(2) + if service: + apply_value(config['services'][service], key, value) + else: + apply_value(config['global'], key, value) + + return config + + +def apply_value(service_config, key, value): + if service_config.get(key) is None: + service_config[key] = value + else: + if not isinstance(service_config[key], list): + service_config[key] = [ + service_config[key]] + else: + service_config[key].append(value) + + +class TestServiceStunnel(VyOSUnitTestSHIM.TestCase): + maxDiff = None + @classmethod + def setUpClass(cls): + super(TestServiceStunnel, cls).setUpClass() + + # ensure we can also run this test on a live system - so lets clean + # out the current configuration :) + cls.cli_delete(cls, base_path) + cls.is_valid_conf = True + + def tearDown(self): + # Check for running process + if self.is_valid_conf: + self.assertTrue(process_named_running(PROCESS_NAME)) + self.is_valid_conf = True + # delete testing Stunnel config + self.cli_delete(base_path) + self.cli_delete(['pki']) + self.cli_commit() + + # Check for stopped process + self.assertFalse(process_named_running(PROCESS_NAME)) + + def set_pki(self): + self.cli_set(['pki', 'ca', 'ca-1', 'certificate', ca_certificate.replace('\n','')]) + self.cli_set(['pki', 'ca', 'ca-1', 'private', 'key', ca_private_key.replace('\n','')]) + self.cli_set(['pki', 'certificate', 'srv-1', 'certificate', srv_certificate.replace('\n','')]) + self.cli_set(['pki', 'certificate', 'srv-1', 'private', 'key', srv_private_key.replace('\n','')]) + self.cli_commit() + + def test_01_stunnel_simple_client(self): + service = 'app1' + self.cli_set(base_path + ['client', service, 'connect', 'port', '9001']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_set(base_path + ['client', service, 'listen', 'port', '8001']) + + self.cli_commit() + config = read_config() + + self.assertEqual('/run/stunnel/stunnel.pid', config['global']['pid']) + self.assertEqual('notice', config['global']['debug']) + self.assertListEqual([service], list(config['services'])) + self.assertEqual('8001', config['services'][service]['accept']) + self.assertEqual('9001', config['services'][service]['connect']) + self.assertEqual('yes', config['services'][service]['client']) + + def test_02_stunnel_simple_server(self): + service = 'ser1' + self.set_pki() + self.cli_set(base_path + ['server', service, 'connect', 'port', '8080']) + self.cli_set(base_path + ['server', service, 'ssl', 'certificate', 'srv-1']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_set(base_path + ['server', service, 'listen', 'port', '9001']) + + self.cli_commit() + config = read_config() + + self.assertEqual('/run/stunnel/stunnel.pid', config['global']['pid']) + self.assertEqual('notice', config['global']['debug']) + self.assertListEqual([service], list(config['services'])) + self.assertEqual('9001', config['services'][service]['accept']) + self.assertEqual('8080', config['services'][service]['connect']) + self.assertIsNone(config['services'][service].get('client')) + self.assertEqual('/run/stunnel/server-ser1-srv-1.pem', config['services'][service]['cert']) + self.assertEqual('/run/stunnel/server-ser1-srv-1.pem.key', config['services'][service]['key']) + + def test_03_multy_services(self): + self.set_pki() + clients = ['app1', 'app2', 'app3'] + servers = ['serv1', 'serv2', 'serv3'] + port = 80 + for service in clients: + port += 1 + self.cli_set(base_path + ['client', service, 'listen', 'port', f'{port}']) + port += 1 + self.cli_set(base_path + ['client', service, 'connect', 'port', f'{port}']) + if service == 'app2': + self.cli_set(base_path + ['client', service, 'connect', 'address', f'192.168.0.10']) + self.cli_set(base_path + ['client', service, 'listen', 'address', '127.0.0.1']) + self.cli_set(base_path + ['client', service, 'protocol', 'connect']) + self.cli_set(base_path + ['client', service, 'options', 'authentication', 'basic']) + self.cli_set(base_path + ['client', service, 'options', 'domain', 'basic.com']) + self.cli_set(base_path + ['client', service, 'options', 'host', 'address', '127.0.0.1']) + self.cli_set(base_path + ['client', service, 'options', 'host', 'port', '5000']) + self.cli_set(base_path + ['client', service, 'options', 'password', 'test_pass']) + self.cli_set(base_path + ['client', service, 'options', 'username', 'test']) + if service == 'app3': + self.cli_set(base_path + ['client', service, 'ssl', 'ca-certificate', 'ca-1']) + self.cli_set(base_path + ['client', service, 'ssl', 'certificate', 'srv-1']) + + for service in servers: + port += 1 + self.cli_set(base_path + ['server', service, 'listen', 'port', f'{port}']) + port += 1 + self.cli_set(base_path + ['server', service, 'connect', 'port', f'{port}']) + self.cli_set(base_path + ['server', service, 'ssl', 'certificate', 'srv-1']) + if service == 'serv2': + self.cli_set(base_path + ['server', service, 'ssl', 'ca-certificate', 'ca-1']) + self.cli_set(base_path + ['server', service, 'connect', 'address', f'google.com']) + self.cli_set(base_path + ['server', service, 'listen', 'address', f'127.0.0.1']) + if service == 'serv3': + self.cli_set(base_path + ['server', service, 'connect', 'address', f'10.18.105.10']) + self.cli_set(base_path + ['server', service, 'protocol', 'imap']) + + self.cli_commit() + config = read_config() + + self.assertEqual('/run/stunnel/stunnel.pid', config['global']['pid']) + self.assertListEqual(clients + servers, list(config['services'])) + self.assertDictEqual(config['services'], { + 'app1': { + 'client': 'yes', + 'accept': '81', + 'connect': '82' + }, + 'app2': { + 'client': 'yes', + 'accept': '127.0.0.1:83', + 'connect': '192.168.0.10:84', + 'protocol': 'connect', + 'protocolAuthentication': 'basic', + 'protocolDomain': 'basic.com', + 'protocolHost': '127.0.0.1:5000', + 'protocolPassword': 'test_pass', + 'protocolUsername': 'test' + }, + 'app3': { + 'client': 'yes', + 'accept': '85', + 'connect': '86', + 'CApath': '/run/stunnel/ca', + 'CAfile': 'client-app3-ca-1.pem', + 'cert': '/run/stunnel/client-app3-srv-1.pem', + 'key': '/run/stunnel/client-app3-srv-1.pem.key' + }, + 'serv1': { + 'accept': '87', + 'connect': '88', + 'cert': '/run/stunnel/server-serv1-srv-1.pem', + 'key': '/run/stunnel/server-serv1-srv-1.pem.key' + }, + 'serv2': { + 'accept': '127.0.0.1:89', + 'connect': 'google.com:90', + 'CApath': '/run/stunnel/ca', + 'CAfile': 'server-serv2-ca-1.pem', + 'cert': '/run/stunnel/server-serv2-srv-1.pem', + 'key': '/run/stunnel/server-serv2-srv-1.pem.key' + }, + 'serv3': { + 'accept': '91', + 'connect': '10.18.105.10:92', + 'protocol': 'imap', + 'cert': '/run/stunnel/server-serv3-srv-1.pem', + 'key': '/run/stunnel/server-serv3-srv-1.pem.key' + } + }) + + def test_04_cert_problems(self): + service = 'app1' + self.cli_set(base_path + ['client', service, 'connect', 'port', '9001']) + self.cli_set(base_path + ['client', service, 'listen', 'port', '8001']) + self.cli_set(base_path + ['client', service, 'ssl', 'ca-certificate', 'ca-2']) + + # ca not exist in pki + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_delete(base_path + ['client', service, 'ssl', 'ca-certificate', 'ca-2']) + self.cli_set(base_path + ['client', service, 'ssl', 'certificate', 'srv-2']) + + # cert not exist in pki + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_delete(base_path) + + self.cli_set(base_path + ['server', service, 'connect', 'port', '8080']) + self.cli_set(base_path + ['server', service, 'listen', 'port', '9001']) + + # Create server without any cert + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_set(base_path + ['server', service, 'ssl', 'ca-certificate', 'ca-2']) + # ca not exist in pki + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_delete(base_path + ['server', service, 'ssl', 'ca-certificate', 'ca-2']) + self.cli_set(base_path + ['server', service, 'ssl', 'certificate', 'srv-2']) + # cert not exist in pki + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.is_valid_conf = False + + def test_05_psk_auth(self): + modes = ['client', 'server'] + psk_id_1 = 'psk_id_1' + psk_secret_1 = '1234567890ABCDEF1234567890ABCDEF' + psk_id_2 = 'psk_id_2' + psk_secret_2 = '1234567890ABCDEF1234567890ABCDEA' + expected_config = { + 'global': {'pid': '/run/stunnel/stunnel.pid', + 'debug': 'notice'}, + 'services': {}} + port = 8000 + for mode in modes: + service = f'{mode}-one' + psk_secrets = f'/run/stunnel/psk/{mode}_{service}.txt' + expected_config['services'][service] = { + 'PSKsecrets': psk_secrets, + } + port += 1 + expected_config['services'][service]['accept'] = f'{port}' + self.cli_set(base_path + [mode, service, 'listen', 'port', f'{port}']) + port += 1 + expected_config['services'][service]['connect'] = f'{port}' + self.cli_set(base_path + [mode, service, 'connect', 'port', f'{port}']) + self.cli_set(base_path + [mode, service, 'psk', 'smoketest1', 'id', psk_id_1]) + with self.assertRaises(ConfigSessionError): + self.cli_set(base_path + [mode, service, 'psk', 'smoketest1', 'secret', '123']) + with self.assertRaises(ConfigSessionError): + self.cli_set(base_path + [mode, service, 'psk', 'smoketest1', 'secret', '1234567890ABCDEF1234567890ABCDEZ']) + self.cli_set(base_path + [mode, service, 'psk', 'smoketest1', 'secret', psk_secret_1]) + self.cli_set(base_path + [mode, service, 'psk', 'smoketest2', 'id', psk_id_2]) + self.cli_set(base_path + [mode, service, 'psk', 'smoketest2', 'secret', psk_secret_2]) + if mode != 'server': + expected_config['services'][service]['client'] = 'yes' + + self.cli_commit() + config = read_config() + + self.assertDictEqual(expected_config, config) + + self.assertListEqual([f'{psk_id_1}:{psk_secret_1}', + f'{psk_id_2}:{psk_secret_2}'], + [line for line in read_file(psk_secrets).split('\n')]) + + def test_06_socks_proxy(self): + server_port = '9001' + client_port = '9000' + srv_name = 'srv-one' + cli_name = 'cli-one' + expected_config = { + 'global': {'pid': '/run/stunnel/stunnel.pid', + 'debug': 'notice'}, + 'services': { + 'cli-one': { + 'PSKsecrets': f'/run/stunnel/psk/client_{cli_name}.txt', + 'client': 'yes', + 'accept': client_port, + 'connect': server_port + }, + 'srv-one': { + 'PSKsecrets': f'/run/stunnel/psk/server_{srv_name}.txt', + 'accept': server_port, + 'protocol': 'socks' + } + }} + + self.cli_set(base_path + ['server', srv_name, 'listen', 'port', server_port]) + self.cli_set(base_path + ['server', srv_name, 'connect', 'port', '9005']) + self.cli_set(base_path + ['server', srv_name, 'protocol', 'socks']) + self.cli_set(base_path + ['server', srv_name, 'psk', 'sock_proxy', 'id', cli_name]) + self.cli_set(base_path + ['server', srv_name, 'psk', 'sock_proxy', 'secret', '1234567890ABCDEF1234567890ABCDEF']) + + self.cli_set(base_path + ['client', cli_name, 'listen', 'port', client_port]) + self.cli_set(base_path + ['client', cli_name, 'connect', 'port', server_port]) + self.cli_set(base_path + ['client', cli_name, 'psk', 'sock_proxy', 'id', cli_name]) + self.cli_set(base_path + ['client', cli_name, 'psk', 'sock_proxy', 'secret', '1234567890ABCDEF1234567890ABCDEF']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_delete(base_path + ['server', srv_name, 'connect']) + self.cli_commit() + config = read_config() + + self.assertDictEqual(expected_config, config) + + def test_07_available_port(self): + expected_config = { + 'global': {'pid': '/run/stunnel/stunnel.pid', + 'debug': 'notice'}, + 'services': { + 'app1': { + 'client': 'yes', + 'accept': '8001', + 'connect': '9001' + }, + 'srv1': { + 'PSKsecrets': f'/run/stunnel/psk/server_srv1.txt', + 'accept': '127.0.0.1:8002', + 'connect': '9001' + } + }} + self.cli_set(base_path + ['client', 'app1', 'connect', 'port', '9001']) + self.cli_set(base_path + ['client', 'app1', 'listen', 'port', '8001']) + + self.cli_set(base_path + ['server', 'srv1', 'connect', 'port', '9001']) + self.cli_set(base_path + ['server', 'srv1', 'listen', 'address', + '127.0.0.1']) + self.cli_set(base_path + ['server', 'srv1', 'listen', 'port', '8001']) + self.cli_set(base_path + ['server', 'srv1', 'psk', 'smoketest1', + 'id', 'foo']) + self.cli_set(base_path + ['server', 'srv1', 'psk', 'smoketest1', + 'secret', '1234567890ABCDEF1234567890ABCDEF']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_set(base_path + ['server', 'srv1', 'listen', 'port', '8002']) + self.cli_commit() + + config = read_config() + self.assertDictEqual(expected_config, config) + + def test_08_two_endpoints(self): + expected_config = { + 'global': {'pid': '/run/stunnel/stunnel.pid', + 'debug': 'notice'}, + 'services': { + 'app1': { + 'client': 'yes', + 'accept': '8001', + 'connect': '9001' + } + }} + + self.cli_set(base_path + ['client', 'app1', 'listen', 'port', '8001']) + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_set(base_path + ['client', 'app1', 'connect', 'port', '9001']) + self.cli_commit() + + config = read_config() + self.assertDictEqual(expected_config, config) + + def test_99_protocols(self): + self.set_pki() + service = 'one' + proto_address = 'google.com' + proto_port = '80' + modes = ['client', 'server'] + protocols = ['cifs', 'connect', 'imap', 'nntp', 'pgsql', 'pop3', + 'proxy', 'smtp', 'socks'] + options = ['authentication', 'domain', 'host', 'password', 'username'] + + for protocol in protocols: + for mode in modes: + expected_config = { + 'global': {'pid': '/run/stunnel/stunnel.pid', + 'debug': 'notice'}, + 'services': {'one': { + 'accept': '8001', + 'protocol': protocol, + }}} + if not(mode == 'server' and protocol == 'socks'): + self.cli_set(base_path + [mode, service, 'connect', 'port', '9001']) + expected_config['services']['one']['connect'] = '9001' + self.cli_set(base_path + [mode, service, 'listen', 'port', '8001']) + + if mode == 'server': + expected_config['services'][service]['cert'] = '/run/stunnel/server-one-srv-1.pem' + expected_config['services'][service]['key'] = '/run/stunnel/server-one-srv-1.pem.key' + self.cli_set(base_path + [mode, service, 'ssl', + 'certificate', 'srv-1']) + else: + expected_config['services'][service]['client'] = 'yes' + + # protocols connect and nntp is only supported in client mode. + if mode == 'server' and protocol in ['connect', 'nntp']: + with self.assertRaises(ConfigSessionError): + self.cli_set(base_path + [mode, service, 'protocol', protocol]) + # self.cli_commit() + else: + self.cli_set(base_path + [mode, service, 'protocol', protocol]) + self.cli_commit() + config = read_config() + + self.assertDictEqual(expected_config, config) + + expected_config['services'][service]['protocolDomain'] = 'valdomain' + expected_config['services'][service]['protocolPassword'] = 'valpassword' + expected_config['services'][service]['protocolUsername'] = 'valusername' + + for option in options: + if option == 'authentication': + expected_config['services'][service]['protocolAuthentication'] = \ + 'basic' if protocol == 'connect' else 'plain' + continue + + if option == 'host' and mode != 'server': + expected_config['services'][service]['protocolHost'] = \ + f'{proto_address}:{proto_port}' + self.cli_set(base_path + [mode, service, 'options', + option, 'address', f'{proto_address}']) + self.cli_set(base_path + [mode, service, 'options', + option, 'port', f'{proto_port}']) + continue + if mode == 'server': + with self.assertRaises(ConfigSessionError): + self.cli_set( + base_path + [mode, service, 'options', option, f'val{option}']) + else: + self.cli_set( + base_path + [mode, service, 'options', option, f'val{option}']) + # Additional option is only supported in the 'connect' and 'smtp' protocols. + if mode != 'server': + if protocol not in ['connect', 'smtp']: + with self.assertRaises(ConfigSessionError): + self.cli_commit() + else: + if protocol == 'smtp': + # Protocol smtp does not support options domain and host + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_delete( + base_path + [mode, service, 'options', 'domain']) + self.cli_delete( + base_path + [mode, service, 'options', 'host']) + del expected_config['services'][service]['protocolDomain'] + del expected_config['services'][service]['protocolHost'] + + self.cli_commit() + config = read_config() + + self.assertDictEqual(expected_config, config) + + self.cli_delete(base_path) + self.cli_commit() + + self.is_valid_conf = False + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index 4a0e86f325e..215b22b37f0 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -85,6 +85,10 @@ { 'keys': ['certificate', 'ca_certificate'], 'path': ['vpn', 'sstp'], + }, + { + 'keys': ['certificate', 'ca_certificate'], + 'path': ['service', 'stunnel'], } ] diff --git a/src/conf_mode/service_stunnel.py b/src/conf_mode/service_stunnel.py new file mode 100644 index 00000000000..7480c232ed5 --- /dev/null +++ b/src/conf_mode/service_stunnel.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 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 . + +import os +from shutil import rmtree + +from sys import exit + +from netifaces import AF_INET +from psutil import net_if_addrs + +from vyos.config import Config +from vyos.configverify import verify_pki_ca_certificate +from vyos.configverify import verify_pki_certificate +from vyos.pki import encode_certificate +from vyos.pki import encode_private_key +from vyos.pki import find_chain +from vyos.pki import load_certificate +from vyos.pki import load_private_key +from vyos.utils.dict import dict_search +from vyos.utils.file import makedir +from vyos.utils.file import write_file +from vyos.utils.network import check_port_availability +from vyos.utils.network import is_listen_port_bind_service +from vyos.utils.process import call +from vyos.template import render +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +stunnel_dir = '/run/stunnel' +config_file = f'{stunnel_dir}/stunnel.conf' +stunnel_ca_dir = f'{stunnel_dir}/ca' +stunnel_psk_dir = f'{stunnel_dir}/psk' + +# config based on +# http://man.he.net/man8/stunnel4 + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'stunnel'] + if not conf.exists(base): + return None + + stunnel = conf.get_config_dict(base, + get_first_key=True, + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + with_pki=True) + stunnel['config_file'] = config_file + return stunnel + + +def verify(stunnel): + if not stunnel: + return None + + stunnel_listen_addresses = list() + for mode, conf in stunnel.items(): + if mode not in ['server', 'client']: + continue + + for app, app_conf in conf.items(): + # connect, listen, exec and some protocols e.g. socks on server mode are endpoints. + endpoints = 0 + if 'socks' == app_conf.get('protocol') and mode == 'server': + if 'connect' in app_conf: + raise ConfigError("The 'connect' option cannot be used with the 'socks' protocol in server mode.") + endpoints += 1 + + for item in ['connect', 'listen']: + if item in app_conf: + endpoints += 1 + if 'port' not in app_conf[item]: + raise ConfigError(f'{mode} [{app}]: {item} port number is required!') + elif item == 'listen': + raise ConfigError(f'{mode} [{app}]: {item} port number is required!') + + if endpoints != 2: + raise ConfigError(f'{mode} [{app}]: must define two endpoints') + + if 'address' in app_conf['listen']: + laddresses = [dict_search('listen.address', app_conf)] + else: + laddresses = list() + ifaces = net_if_addrs() + for iface_name, iface_addresses in ifaces.items(): + for iface_addr in iface_addresses: + if iface_addr.family == AF_INET: + laddresses.append(iface_addr.address) + + lport = int(dict_search('listen.port', app_conf)) + + for address in laddresses: + if f'{address}:{lport}' in stunnel_listen_addresses: + raise ConfigError( + f'{mode} [{app}]: Address {address}:{lport} already ' + f'in use by other stunnel service') + + stunnel_listen_addresses.append(f'{address}:{lport}') + if not check_port_availability(address, lport, 'tcp') and \ + not is_listen_port_bind_service(lport, 'stunnel'): + raise ConfigError( + f'{mode} [{app}]: Address {address}:{lport} already in use') + + if 'options' in app_conf: + protocol = app_conf.get('protocol') + if protocol not in ['connect', 'smtp']: + raise ConfigError("Additional option is only supported in the 'connect' and 'smtp' protocols.") + if protocol == 'smtp' and ('domain' in app_conf['options'] or 'host' in app_conf['options']): + raise ConfigError("Protocol 'smtp' does not support options 'domain' and 'host'.") + + # set default authentication option + if 'authentication' not in app_conf['options']: + app_conf['options']['authentication'] = 'basic' if protocol == 'connect' else 'plain' + + for option, option_config in app_conf['options'].items(): + if option == 'authentication': + if protocol == 'connect' and option_config not in ['basic', 'ntlm']: + raise ConfigError("Supported authentication types for the 'connect' protocol are 'basic' or 'ntlm'") + elif protocol == 'smtp' and option_config not in ['plain', 'login']: + raise ConfigError("Supported authentication types for the 'smtp' protocol are 'plain' or 'login'") + if option == 'host': + if 'address' not in option_config: + raise ConfigError('Address is required for option host.') + if 'port' not in option_config: + raise ConfigError('Port is required for option host.') + + # check pki certs + for key in ['ca_certificate', 'certificate']: + tmp = dict_search(f'ssl.{key}', app_conf) + if mode == 'server' and 'ca_' not in key and not tmp and 'psk' not in app_conf: + raise ConfigError(f'{mode} [{app}]: TLS server needs a certificate or PSK') + if tmp: + if 'ca_' in key: verify_pki_ca_certificate(stunnel, tmp) + else: verify_pki_certificate(stunnel, tmp) + + #check psk + if 'psk' in app_conf: + for psk, psk_conf in app_conf['psk'].items(): + if 'id' not in psk_conf or 'secret' not in psk_conf: + raise ConfigError( + f'Authentication psk "{psk}" missing "id" or "secret"') + + +def generate(stunnel): + if not stunnel or ('client' not in stunnel and 'server' not in stunnel): + if os.path.isdir(stunnel_dir): + rmtree(stunnel_dir, ignore_errors=True) + + return None + makedir(stunnel_dir) + + exist_files = list() + current_files = [config_file, config_file.replace('.conf', 'pid')] + for root, dirs, files in os.walk(stunnel_dir): + for file in files: + exist_files.append(os.path.join(root, file)) + + loaded_ca_certs = {load_certificate(c['certificate']) + for c in stunnel['pki']['ca'].values()} if 'pki' in stunnel and 'ca' in stunnel['pki'] else {} + + for mode, conf in stunnel.items(): + if mode not in ['server', 'client']: + continue + + for app, app_conf in conf.items(): + if 'ssl' in app_conf: + if 'certificate' in app_conf['ssl']: + cert_name = app_conf['ssl']['certificate'] + + pki_cert = stunnel['pki']['certificate'][cert_name] + cert_file_path = os.path.join(stunnel_dir, + f'{mode}-{app}-{cert_name}.pem') + cert_key_path = os.path.join(stunnel_dir, + f'{mode}-{app}-{cert_name}.pem.key') + app_conf['ssl']['cert'] = cert_file_path + + loaded_pki_cert = load_certificate(pki_cert['certificate']) + cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs) + + write_file(cert_file_path, + '\n'.join(encode_certificate(c) for c in cert_full_chain)) + current_files.append(cert_file_path) + + if 'private' in pki_cert and 'key' in pki_cert['private']: + app_conf['ssl']['cert_key'] = cert_key_path + loaded_key = load_private_key(pki_cert['private']['key'], + passphrase=None, wrap_tags=True) + key_pem = encode_private_key(loaded_key, passphrase=None) + write_file(cert_key_path, key_pem, mode=0o600) + current_files.append(cert_key_path) + + if 'ca_certificate' in app_conf['ssl']: + ca_name = app_conf['ssl']['ca_certificate'] + app_conf['ssl']['ca_path'] = stunnel_ca_dir + app_conf['ssl']['ca_file'] = f'{mode}-{app}-{ca_name}.pem' + ca_cert_file_path = os.path.join(stunnel_ca_dir, app_conf['ssl']['ca_file']) + ca_chains = [] + + pki_ca_cert = stunnel['pki']['ca'][ca_name] + loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) + ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) + ca_chains.append( + '\n'.join(encode_certificate(c) for c in ca_full_chain)) + write_file(ca_cert_file_path, '\n'.join(ca_chains)) + current_files.append(ca_cert_file_path) + + if 'psk' in app_conf: + psk_data = list() + psk_file_path = os.path.join(stunnel_psk_dir, f'{mode}_{app}.txt') + + for _, psk_conf in app_conf['psk'].items(): + psk_data.append(f'{psk_conf["id"]}:{psk_conf["secret"]}') + + write_file(psk_file_path, '\n'.join(psk_data)) + app_conf['psk']['file'] = psk_file_path + current_files.append(psk_file_path) + + for file in exist_files: + if file not in current_files: + os.unlink(file) + + render(config_file, 'stunnel/stunnel_config.j2', stunnel) + + +def apply(stunnel): + if not stunnel or ('client' not in stunnel and 'server' not in stunnel): + call('systemctl stop stunnel.service') + else: + call('systemctl restart stunnel.service') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/systemd/stunnel.service b/src/systemd/stunnel.service new file mode 100644 index 00000000000..b260e2984dd --- /dev/null +++ b/src/systemd/stunnel.service @@ -0,0 +1,15 @@ +[Unit] +Description=SSL tunneling service +Documentation=http://man.he.net/man8/stunnel4 +After=network.target + +[Service] +ExecStart=/usr/bin/stunnel /run/stunnel/stunnel.conf +ExecReload=/bin/kill -HUP $MAINPID +KillMode=process +PIDFile=/run/stunnel/stunnel.pid +Type=forking +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/src/validators/psk-secret b/src/validators/psk-secret new file mode 100644 index 00000000000..c91aa95a83b --- /dev/null +++ b/src/validators/psk-secret @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 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 . + +import re +from sys import argv,exit + +if __name__ == '__main__': + if len(argv) != 2: + exit(1) + + input = argv[1] + is_valid = True + try: + # Convert hexadecimal input to binary form + key_bytes = bytes.fromhex(input) + except ValueError: + is_valid = False + + if is_valid and len(key_bytes) < 16: + is_valid = False + + if not is_valid: + print(f'Error: {input} is not valid psk secret.') + exit(1) + + exit(0) \ No newline at end of file