diff options
author | khramshinr <khramshinr@gmail.com> | 2024-05-29 19:46:20 +0600 |
---|---|---|
committer | khramshinr <khramshinr@gmail.com> | 2024-06-24 20:16:31 +0600 |
commit | f6131611e15a644312e3c4baf5b8f6387c2930e7 (patch) | |
tree | b7b710ca5c9f5f44838f4f4b5b25ed97f70fa1b6 | |
parent | c90a55375f6b60ba0d0d545b33927a2aae4d6aad (diff) | |
download | vyos-1x-f6131611e15a644312e3c4baf5b8f6387c2930e7.tar.gz vyos-1x-f6131611e15a644312e3c4baf5b8f6387c2930e7.zip |
T5735: Stunnel CLI and configuration
Add CLI commands
Add config
Add conf_mode
Add systemd config
Add stunnel smoketests
Add log level config
24 files changed, 1409 insertions, 1 deletions
diff --git a/data/config-mode-dependencies/vyos-1x.json b/data/config-mode-dependencies/vyos-1x.json index 9623948c2..9361f4e7c 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 b92d58c72..224a9c390 100644 --- a/data/configd-include.json +++ b/data/configd-include.json @@ -81,6 +81,7 @@ "service_sla.py", "service_snmp.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 000000000..52c289fa9 --- /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 000000000..d2901d595 --- /dev/null +++ b/interface-definitions/include/stunnel/address.xml.i @@ -0,0 +1,20 @@ +<!-- include start from stunnel/address.xml.i --> +<leafNode name="address"> + <properties> + <help>Hostname or IP address</help> + <valueHelp> + <format>ipv4</format> + <description>IPv4 address</description> + </valueHelp> + <valueHelp> + <format>hostname</format> + <description>hostname</description> + </valueHelp> + <constraint> + <validator name="ip-address"/> + <validator name="fqdn"/> + </constraint> + <constraintErrorMessage>Invalid FQDN or IP address</constraintErrorMessage> + </properties> +</leafNode> +<!-- include end --> diff --git a/interface-definitions/include/stunnel/connect.xml.i b/interface-definitions/include/stunnel/connect.xml.i new file mode 100644 index 000000000..cd6246a00 --- /dev/null +++ b/interface-definitions/include/stunnel/connect.xml.i @@ -0,0 +1,11 @@ +<!-- include start from stunnel/connect.xml.i --> +<node name="connect"> + <properties> + <help>Connect to a remote address</help> + </properties> + <children> + #include <include/stunnel/address.xml.i> + #include <include/port-number.xml.i> + </children> +</node> +<!-- include end --> diff --git a/interface-definitions/include/stunnel/listen.xml.i b/interface-definitions/include/stunnel/listen.xml.i new file mode 100644 index 000000000..13d0986ee --- /dev/null +++ b/interface-definitions/include/stunnel/listen.xml.i @@ -0,0 +1,11 @@ +<!-- include start from stunnel/listen.xml.i --> +<node name="listen"> + <properties> + <help>Accept connections on specified address</help> + </properties> + <children> + #include <include/stunnel/address.xml.i> + #include <include/port-number.xml.i> + </children> +</node> +<!-- include end --> 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 000000000..2f0202875 --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-options.xml.i @@ -0,0 +1,75 @@ +<!-- include start from stunel/protocol-options.xml.i --> +<node name="options"> + <properties> + <help>Advanced protocol options</help> + </properties> + <children> + <leafNode name="authentication"> + <properties> + <help>Authentication type for the protocol negotiations</help> + <completionHelp> + <list>basic ntlm plain login</list> + </completionHelp> + <valueHelp> + <format>basic</format> + <description>The default 'connect' authentication type</description> + </valueHelp> + <valueHelp> + <format>ntlm</format> + <description>Supported authentication types for the 'connect' protocol</description> + </valueHelp> + <valueHelp> + <format>plain</format> + <description>The default 'smtp' authentication type</description> + </valueHelp> + <valueHelp> + <format>login</format> + <description>Supported authentication types for the 'smtp' protocol</description> + </valueHelp> + <constraint> + <regex>(basic|ntlm|plain|login)</regex> + </constraint> + </properties> + </leafNode> + <leafNode name="domain"> + <properties> + <help>Domain for the 'connect' protocol.</help> + <valueHelp> + <format>domain</format> + <description>domain</description> + </valueHelp> + <constraint> + <validator name="fqdn"/> + </constraint> + </properties> + </leafNode> + <node name="host"> + <properties> + <help>Destination address for the 'connect' protocol</help> + </properties> + <children> + #include <include/stunnel/address.xml.i> + #include <include/port-number.xml.i> + </children> + </node> + <leafNode name="password"> + <properties> + <help>Password for the protocol negotiations</help> + <valueHelp> + <format>txt</format> + <description>Authentication password</description> + </valueHelp> + </properties> + </leafNode> + <leafNode name="username"> + <properties> + <help>Username for the protocol negotiations</help> + <valueHelp> + <format>txt</format> + <description>Authentication username</description> + </valueHelp> + </properties> + </leafNode> + </children> +</node> +<!-- include end --> 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 000000000..5b9484750 --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-cifs.xml.i @@ -0,0 +1,6 @@ +<!-- include start from stunnel/protocol-value-cifs.xml.i --> +<valueHelp> + <format>cifs</format> + <description>Proprietary (undocummented) extension of CIFS protocol</description> +</valueHelp> +<!-- include end --> 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 000000000..3c30e71ca --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-connect.xml.i @@ -0,0 +1,6 @@ +<!-- include start from stunnel/protocol-value-connect.xml.i --> +<valueHelp> + <format>connect</format> + <description>Based on RFC 2817 - Upgrading to TLS Within HTTP/1.1, section 5.2 - Requesting a Tunnel with CONNECT</description> +</valueHelp> +<!-- include end --> 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 000000000..033e5479b --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-imap.xml.i @@ -0,0 +1,6 @@ +<!-- include start from stunnel/protocol-value-imap.xml.i --> +<valueHelp> + <format>imap</format> + <description>Based on RFC 2595 - Using TLS with IMAP, POP3 and ACAP</description> +</valueHelp> +<!-- include end --> 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 000000000..60a6c02c6 --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-nntp.xml.i @@ -0,0 +1,6 @@ +<!-- include start from stunnel/protocol-value-nntp.xml.i --> +<valueHelp> + <format>nntp</format> + <description>Based on RFC 4642 - Using Transport Layer Security (TLS) with Network News Transfer Protocol (NNTP)</description> +</valueHelp> +<!-- include end --> 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 000000000..fd3a166ec --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-pgsql.xml.i @@ -0,0 +1,6 @@ +<!-- include start from stunnel/protocol-value-pgsql.xml.i --> +<valueHelp> + <format>pgsql</format> + <description>Based on PostgreSQL frontend/backend protocol</description> +</valueHelp> +<!-- include end --> 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 000000000..1c8af53e5 --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-pop3.xml.i @@ -0,0 +1,6 @@ +<!-- include start from stunnel/protocol-value-pop3.xml.i --> +<valueHelp> + <format>pop3</format> + <description>Based on RFC 2449 - POP3 Extension Mechanism</description> +</valueHelp> +<!-- include end --> 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 000000000..a4c20d1b0 --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-proxy.xml.i @@ -0,0 +1,6 @@ +<!-- include start from stunnel/protocol-value-proxy.xml.i --> +<valueHelp> + <format>proxy</format> + <description>Passing of the original client IP address with HAProxy PROXY protocol version 1</description> +</valueHelp> +<!-- include end --> 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 000000000..66ca20426 --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-smtp.xml.i @@ -0,0 +1,6 @@ +<!-- include start from stunnel/protocol-value-smtp.xml.i --> +<valueHelp> + <format>smtp</format> + <description>Based on RFC 2487 - SMTP Service Extension for Secure SMTP over TLS</description> +</valueHelp> +<!-- include end --> 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 000000000..e110be5db --- /dev/null +++ b/interface-definitions/include/stunnel/protocol-value-socks.xml.i @@ -0,0 +1,6 @@ +<!-- include start from stunnel/protocol-value-socks.xml.i --> +<valueHelp> + <format>socks</format> + <description>SOCKS versions 4, 4a, and 5 are supported</description> +</valueHelp> +<!-- include end --> diff --git a/interface-definitions/include/stunnel/psk.xml.i b/interface-definitions/include/stunnel/psk.xml.i new file mode 100644 index 000000000..db11a93d3 --- /dev/null +++ b/interface-definitions/include/stunnel/psk.xml.i @@ -0,0 +1,30 @@ +<!-- include start from stunnel/psk.xml.i --> +<tagNode name="psk"> + <properties> + <help>Pre-shared key name</help> + </properties> + <children> + <leafNode name="id"> + <properties> + <help>ID for authentication</help> + <valueHelp> + <format>txt</format> + <description>ID used for authentication</description> + </valueHelp> + </properties> + </leafNode> + <leafNode name="secret"> + <properties> + <help>pre-shared secret key</help> + <valueHelp> + <format>txt</format> + <description>pre-shared secret key are required to be at least 16 bytes long, which implies at least 32 characters for hexadecimal key</description> + </valueHelp> + <constraint> + <validator name="psk-secret"/> + </constraint> + </properties> + </leafNode> + </children> +</tagNode> +<!-- include end --> diff --git a/interface-definitions/include/stunnel/ssl.xml.i b/interface-definitions/include/stunnel/ssl.xml.i new file mode 100644 index 000000000..8aba299e9 --- /dev/null +++ b/interface-definitions/include/stunnel/ssl.xml.i @@ -0,0 +1,11 @@ +<!-- include start from stunnel/ssl.xml.i --> +<node name="ssl"> + <properties> + <help>SSL Certificate, SSL Key and CA</help> + </properties> + <children> + #include <include/pki/ca-certificate-multi.xml.i> + #include <include/pki/certificate.xml.i> + </children> +</node> +<!-- include end --> diff --git a/interface-definitions/service_stunnel.xml.in b/interface-definitions/service_stunnel.xml.in new file mode 100644 index 000000000..d88909bc9 --- /dev/null +++ b/interface-definitions/service_stunnel.xml.in @@ -0,0 +1,130 @@ +<?xml version="1.0"?> +<interfaceDefinition> + <node name="service"> + <properties> + <help>System services</help> + </properties> + <children> + <node name="stunnel" owner="${vyos_conf_scripts_dir}/service_stunnel.py"> + <properties> + <help>Stunnel TLS Proxy</help> + <priority>1000</priority> + </properties> + <children> + <tagNode name="server"> + <properties> + <help>Stunnel server config</help> + </properties> + <children> + #include <include/stunnel/connect.xml.i> + #include <include/stunnel/listen.xml.i> + #include <include/stunnel/ssl.xml.i> + #include <include/stunnel/psk.xml.i> + <leafNode name="protocol"> + <properties> + <help>Application protocol to negotiate TLS</help> + <completionHelp> + <list>cifs imap pgsql pop3 proxy smtp socks</list> + </completionHelp> + #include <include/stunnel/protocol-value-cifs.xml.i> + #include <include/stunnel/protocol-value-imap.xml.i> + #include <include/stunnel/protocol-value-pgsql.xml.i> + #include <include/stunnel/protocol-value-pop3.xml.i> + #include <include/stunnel/protocol-value-proxy.xml.i> + #include <include/stunnel/protocol-value-smtp.xml.i> + #include <include/stunnel/protocol-value-socks.xml.i> + <constraint> + <regex>(cifs|imap|pgsql|pop3|proxy|smtp|socks)</regex> + </constraint> + </properties> + </leafNode> + </children> + </tagNode> + <tagNode name="client"> + <properties> + <help>Stunnel client config</help> + </properties> + <children> + #include <include/stunnel/connect.xml.i> + #include <include/stunnel/listen.xml.i> + #include <include/stunnel/ssl.xml.i> + #include <include/stunnel/psk.xml.i> + <leafNode name="protocol"> + <properties> + <help>Application protocol to negotiate TLS</help> + <completionHelp> + <list>cifs connect imap nntp pgsql pop3 proxy smtp socks</list> + </completionHelp> + #include <include/stunnel/protocol-value-cifs.xml.i> + #include <include/stunnel/protocol-value-connect.xml.i> + #include <include/stunnel/protocol-value-imap.xml.i> + #include <include/stunnel/protocol-value-nntp.xml.i> + #include <include/stunnel/protocol-value-pgsql.xml.i> + #include <include/stunnel/protocol-value-pop3.xml.i> + #include <include/stunnel/protocol-value-proxy.xml.i> + #include <include/stunnel/protocol-value-smtp.xml.i> + #include <include/stunnel/protocol-value-socks.xml.i> + <constraint> + <regex>(cifs|connect|imap|nntp|pgsql|pop3|proxy|smtp|socks)</regex> + </constraint> + </properties> + </leafNode> + #include <include/stunnel/protocol-options.xml.i> + </children> + </tagNode> + <node name="log"> + <properties> + <help>Service logging</help> + </properties> + <children> + <leafNode name="level"> + <properties> + <help>Specifies log level.</help> + <completionHelp> + <list>emerg alert crit err warning notice info debug</list> + </completionHelp> + <valueHelp> + <format>emerg</format> + <description>Emerg log level</description> + </valueHelp> + <valueHelp> + <format>alert</format> + <description>Alert log level</description> + </valueHelp> + <valueHelp> + <format>crit</format> + <description>Critical log level</description> + </valueHelp> + <valueHelp> + <format>err</format> + <description>Error log level</description> + </valueHelp> + <valueHelp> + <format>warning</format> + <description>Warning log level</description> + </valueHelp> + <valueHelp> + <format>notice</format> + <description>Notice log level</description> + </valueHelp> + <valueHelp> + <format>info</format> + <description>Info log level</description> + </valueHelp> + <valueHelp> + <format>debug</format> + <description>Debug log level</description> + </valueHelp> + <constraint> + <regex>(emerg|alert|crit|err|warning|notice|info|debug)</regex> + </constraint> + </properties> + <defaultValue>notice</defaultValue> + </leafNode> + </children> + </node> + </children> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/smoketest/scripts/cli/test_service_stunnel.py b/smoketest/scripts/cli/test_service_stunnel.py new file mode 100644 index 000000000..3aeffd09e --- /dev/null +++ b/smoketest/scripts/cli/test_service_stunnel.py @@ -0,0 +1,624 @@ +#!/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 <http://www.gnu.org/licenses/>. + +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.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.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_09_pki_still_used(self): + service = 'ser1' + self.set_pki() + self.cli_set(base_path + ['server', service, 'connect', 'port', '8080']) + self.cli_set(base_path + ['server', service, 'listen', 'port', '9001']) + self.cli_set(base_path + ['server', service, 'ssl', 'certificate', 'srv-1']) + self.cli_commit() + + self.cli_delete(['pki']) + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_delete(base_path) + self.cli_commit() + + self.is_valid_conf = False + + 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 4a0e86f32..215b22b37 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -85,6 +85,10 @@ sync_search = [ { '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 000000000..8ec762548 --- /dev/null +++ b/src/conf_mode/service_stunnel.py @@ -0,0 +1,264 @@ +#!/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 <http://www.gnu.org/licenses/>. + +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}]: connect port number is required!') + + 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 key != 'ca_certificate' and not tmp and 'psk' not in app_conf: + raise ConfigError(f'{mode} [{app}]: TLS server needs a certificate or PSK') + if tmp: + if key == 'ca_certificate': + for ca_cert in tmp: + verify_pki_ca_certificate(stunnel, ca_cert) + 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']: + app_conf['ssl']['ca_path'] = stunnel_ca_dir + app_conf['ssl']['ca_file'] = f'{mode}-{app}-ca.pem' + ca_cert_file_path = os.path.join(stunnel_ca_dir, app_conf['ssl']['ca_file']) + ca_chains = [] + + for ca_name in app_conf['ssl']['ca_certificate']: + 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 000000000..b260e2984 --- /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 000000000..c91aa95a8 --- /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 <http://www.gnu.org/licenses/>. + +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 |