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 @@ -1,66 +1,67 @@ { "system_conntrack": { "conntrack_sync": ["service_conntrack-sync"], "vrf": ["vrf"] }, "firewall": { "conntrack": ["system_conntrack"], "group_resync": ["system_conntrack", "nat", "policy_route"] }, "interfaces_bonding": { "ethernet": ["interfaces_ethernet"] }, "interfaces_bridge": { "vxlan": ["interfaces_vxlan"], "wlan": ["interfaces_wireless"] }, "load_balancing_wan": { "conntrack": ["system_conntrack"] }, "nat": { "conntrack": ["system_conntrack"] }, "nat66": { "conntrack": ["system_conntrack"] }, "pki": { "ethernet": ["interfaces_ethernet"], "openvpn": ["interfaces_openvpn"], "https": ["service_https"], "ipsec": ["vpn_ipsec"], "openconnect": ["vpn_openconnect"], "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"] }, "vpn_l2tp": { "ipsec": ["vpn_ipsec"] }, "qos": { "bonding": ["interfaces_bonding"], "bridge": ["interfaces_bridge"], "dummy": ["interfaces_dummy"], "ethernet": ["interfaces_ethernet"], "geneve": ["interfaces_geneve"], "input": ["interfaces_input"], "l2tpv3": ["interfaces_l2tpv3"], "loopback": ["interfaces_loopback"], "macsec": ["interfaces_macsec"], "openvpn": ["interfaces_openvpn"], "pppoe": ["interfaces_pppoe"], "pseudo-ethernet": ["interfaces_pseudo-ethernet"], "tunnel": ["interfaces_tunnel"], "vti": ["interfaces_vti"], "vxlan": ["interfaces_vxlan"], "wireguard": ["interfaces_wireguard"], "wireless": ["interfaces_wireless"], "wwan": ["interfaces_wwan"] }, "system_wireless": { "wireless": ["interfaces_wireless"] } } 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 @@ -1,114 +1,115 @@ [ "container.py", "firewall.py", "high-availability.py", "interfaces_bonding.py", "interfaces_bridge.py", "interfaces_dummy.py", "interfaces_ethernet.py", "interfaces_geneve.py", "interfaces_input.py", "interfaces_l2tpv3.py", "interfaces_loopback.py", "interfaces_macsec.py", "interfaces_openvpn.py", "interfaces_pppoe.py", "interfaces_pseudo-ethernet.py", "interfaces_sstpc.py", "interfaces_tunnel.py", "interfaces_virtual-ethernet.py", "interfaces_vti.py", "interfaces_vxlan.py", "interfaces_wireguard.py", "interfaces_wireless.py", "interfaces_wwan.py", "load-balancing_reverse-proxy.py", "load-balancing_wan.py", "nat.py", "nat64.py", "nat66.py", "netns.py", "pki.py", "policy.py", "policy_route.py", "policy_local-route.py", "protocols_babel.py", "protocols_bfd.py", "protocols_bgp.py", "protocols_eigrp.py", "protocols_failover.py", "protocols_igmp-proxy.py", "protocols_isis.py", "protocols_mpls.py", "protocols_nhrp.py", "protocols_ospf.py", "protocols_ospfv3.py", "protocols_pim.py", "protocols_pim6.py", "protocols_rip.py", "protocols_ripng.py", "protocols_rpki.py", "protocols_segment-routing.py", "protocols_static.py", "protocols_static_arp.py", "protocols_static_multicast.py", "protocols_static_neighbor-proxy.py", "qos.py", "service_aws_glb.py", "service_broadcast-relay.py", "service_config-sync.py", "service_conntrack-sync.py", "service_console-server.py", "service_dhcp-relay.py", "service_dhcp-server.py", "service_dhcpv6-relay.py", "service_dhcpv6-server.py", "service_dns_dynamic.py", "service_dns_forwarding.py", "service_event-handler.py", "service_https.py", "service_ids_ddos-protection.py", "service_ipoe-server.py", "service_lldp.py", "service_mdns_repeater.py", "service_monitoring_telegraf.py", "service_monitoring_zabbix-agent.py", "service_ndp-proxy.py", "service_ntp.py", "service_pppoe-server.py", "service_router-advert.py", "service_salt-minion.py", "service_sla.py", "service_snmp.py", "service_ssh.py", +"service_stunnel.py", "service_tftp-server.py", "service_webproxy.py", "system_acceleration.py", "system_config-management.py", "system_conntrack.py", "system_console.py", "system_flow-accounting.py", "system_frr.py", "system_host-name.py", "system_ip.py", "system_ipv6.py", "system_lcd.py", "system_login.py", "system_login_banner.py", "system_logs.py", "system_option.py", "system_proxy.py", "system_sflow.py", "system_sysctl.py", "system_syslog.py", "system_task-scheduler.py", "system_timezone.py", "system_update-check.py", "system_wireless.py", "vpn_ipsec.py", "vpn_l2tp.py", "vpn_openconnect.py", "vpn_pptp.py", "vpn_sstp.py", "vrf.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 @@ -1,482 +1,486 @@ #!/usr/bin/env python3 # # Copyright (C) 2021-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 sys import argv from sys import exit from vyos.config import Config from vyos.config import config_dict_merge from vyos.configdep import set_dependents from vyos.configdep import call_dependents from vyos.configdict import node_changed from vyos.configdiff import Diff from vyos.configdiff import get_config_diff from vyos.defaults import directories from vyos.pki import is_ca_certificate from vyos.pki import load_certificate from vyos.pki import load_public_key from vyos.pki import load_openssh_public_key from vyos.pki import load_openssh_private_key from vyos.pki import load_private_key from vyos.pki import load_crl from vyos.pki import load_dh_parameters from vyos.utils.boot import boot_configuration_complete from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.dict import dict_search_recursive from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import is_systemd_service_active from vyos import ConfigError from vyos import airbag airbag.enable() vyos_certbot_dir = directories['certbot'] # keys to recursively search for under specified path sync_search = [ { 'keys': ['certificate'], 'path': ['service', 'https'], }, { 'keys': ['certificate', 'ca_certificate'], 'path': ['interfaces', 'ethernet'], }, { 'keys': ['certificate', 'ca_certificate', 'dh_params', 'shared_secret_key', 'auth_key', 'crypt_key'], 'path': ['interfaces', 'openvpn'], }, { 'keys': ['ca_certificate'], 'path': ['interfaces', 'sstpc'], }, { 'keys': ['certificate', 'ca_certificate'], 'path': ['load_balancing', 'reverse_proxy'], }, { 'keys': ['key'], 'path': ['protocols', 'rpki', 'cache'], }, { 'keys': ['certificate', 'ca_certificate', 'local_key', 'remote_key'], 'path': ['vpn', 'ipsec'], }, { 'keys': ['certificate', 'ca_certificate'], 'path': ['vpn', 'openconnect'], }, { 'keys': ['certificate', 'ca_certificate'], 'path': ['vpn', 'sstp'], + }, + { + 'keys': ['certificate', 'ca_certificate'], + 'path': ['service', 'stunnel'], } ] # key from other config nodes -> key in pki['changed'] and pki sync_translate = { 'certificate': 'certificate', 'ca_certificate': 'ca', 'dh_params': 'dh', 'local_key': 'key_pair', 'remote_key': 'key_pair', 'shared_secret_key': 'openvpn', 'auth_key': 'openvpn', 'crypt_key': 'openvpn', 'key': 'openssh', } def certbot_delete(certificate): if not boot_configuration_complete(): return if os.path.exists(f'{vyos_certbot_dir}/renewal/{certificate}.conf'): cmd(f'certbot delete --non-interactive --config-dir {vyos_certbot_dir} --cert-name {certificate}') def certbot_request(name: str, config: dict, dry_run: bool=True): # We do not call certbot when booting the system - there is no need to do so and # request new certificates during boot/image upgrade as the certbot configuration # is stored persistent under /config - thus we do not open the door to transient # errors if not boot_configuration_complete(): return domains = '--domains ' + ' --domains '.join(config['domain_name']) tmp = f'certbot certonly --non-interactive --config-dir {vyos_certbot_dir} --cert-name {name} '\ f'--standalone --agree-tos --no-eff-email --expand --server {config["url"]} '\ f'--email {config["email"]} --key-type rsa --rsa-key-size {config["rsa_key_size"]} '\ f'{domains}' if 'listen_address' in config: tmp += f' --http-01-address {config["listen_address"]}' # verify() does not need to actually request a cert but only test for plausability if dry_run: tmp += ' --dry-run' cmd(tmp, raising=ConfigError, message=f'ACME certbot request failed for "{name}"!') def get_config(config=None): if config: conf = config else: conf = Config() base = ['pki'] pki = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) if len(argv) > 1 and argv[1] == 'certbot_renew': pki['certbot_renew'] = {} tmp = node_changed(conf, base + ['ca'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) if tmp: if 'changed' not in pki: pki.update({'changed':{}}) pki['changed'].update({'ca' : tmp}) tmp = node_changed(conf, base + ['certificate'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) if tmp: if 'changed' not in pki: pki.update({'changed':{}}) pki['changed'].update({'certificate' : tmp}) tmp = node_changed(conf, base + ['dh'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) if tmp: if 'changed' not in pki: pki.update({'changed':{}}) pki['changed'].update({'dh' : tmp}) tmp = node_changed(conf, base + ['key-pair'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) if tmp: if 'changed' not in pki: pki.update({'changed':{}}) pki['changed'].update({'key_pair' : tmp}) tmp = node_changed(conf, base + ['openssh'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) if tmp: if 'changed' not in pki: pki.update({'changed':{}}) pki['changed'].update({'openssh' : tmp}) tmp = node_changed(conf, base + ['openvpn', 'shared-secret'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) if tmp: if 'changed' not in pki: pki.update({'changed':{}}) pki['changed'].update({'openvpn' : tmp}) # We only merge on the defaults of there is a configuration at all if conf.exists(base): # We have gathered the dict representation of the CLI, but there are default # options which we need to update into the dictionary retrived. default_values = conf.get_config_defaults(**pki.kwargs, recursive=True) # remove ACME default configuration if unused by CLI if 'certificate' in pki: for name, cert_config in pki['certificate'].items(): if 'acme' not in cert_config: # Remove ACME default values del default_values['certificate'][name]['acme'] # merge CLI and default dictionary pki = config_dict_merge(default_values, pki) # Certbot triggered an external renew of the certificates. # Mark all ACME based certificates as "changed" to trigger # update of dependent services if 'certificate' in pki and 'certbot_renew' in pki: renew = [] for name, cert_config in pki['certificate'].items(): if 'acme' in cert_config: renew.append(name) # If triggered externally by certbot, certificate key is not present in changed if 'changed' not in pki: pki.update({'changed':{}}) pki['changed'].update({'certificate' : renew}) # We need to get the entire system configuration to verify that we are not # deleting a certificate that is still referenced somewhere! pki['system'] = conf.get_config_dict([], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) D = get_config_diff(conf) for search in sync_search: for key in search['keys']: changed_key = sync_translate[key] if 'changed' not in pki or changed_key not in pki['changed']: continue for item_name in pki['changed'][changed_key]: node_present = False if changed_key == 'openvpn': node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name) else: node_present = dict_search_args(pki, changed_key, item_name) if node_present: search_dict = dict_search_args(pki['system'], *search['path']) if not search_dict: continue for found_name, found_path in dict_search_recursive(search_dict, key): if isinstance(found_name, list) and item_name not in found_name: continue if isinstance(found_name, str) and found_name != item_name: continue path = search['path'] path_str = ' '.join(path + found_path) #print(f'PKI: Updating config: {path_str} {item_name}') if path[0] == 'interfaces': ifname = found_path[0] if not D.node_changed_presence(path + [ifname]): set_dependents(path[1], conf, ifname) else: if not D.node_changed_presence(path): set_dependents(path[1], conf) return pki def is_valid_certificate(raw_data): # If it loads correctly we're good, or return False return load_certificate(raw_data, wrap_tags=True) def is_valid_ca_certificate(raw_data): # Check if this is a valid certificate with CA attributes cert = load_certificate(raw_data, wrap_tags=True) if not cert: return False return is_ca_certificate(cert) def is_valid_public_key(raw_data): # If it loads correctly we're good, or return False return load_public_key(raw_data, wrap_tags=True) def is_valid_private_key(raw_data, protected=False): # If it loads correctly we're good, or return False # With encrypted private keys, we always return true as we cannot ask for password to verify if protected: return True return load_private_key(raw_data, passphrase=None, wrap_tags=True) def is_valid_openssh_public_key(raw_data, type): # If it loads correctly we're good, or return False return load_openssh_public_key(raw_data, type) def is_valid_openssh_private_key(raw_data, protected=False): # If it loads correctly we're good, or return False # With encrypted private keys, we always return true as we cannot ask for password to verify if protected: return True return load_openssh_private_key(raw_data, passphrase=None, wrap_tags=True) def is_valid_crl(raw_data): # If it loads correctly we're good, or return False return load_crl(raw_data, wrap_tags=True) def is_valid_dh_parameters(raw_data): # If it loads correctly we're good, or return False return load_dh_parameters(raw_data, wrap_tags=True) def verify(pki): if not pki: return None if 'ca' in pki: for name, ca_conf in pki['ca'].items(): if 'certificate' in ca_conf: if not is_valid_ca_certificate(ca_conf['certificate']): raise ConfigError(f'Invalid certificate on CA certificate "{name}"') if 'private' in ca_conf and 'key' in ca_conf['private']: private = ca_conf['private'] protected = 'password_protected' in private if not is_valid_private_key(private['key'], protected): raise ConfigError(f'Invalid private key on CA certificate "{name}"') if 'crl' in ca_conf: ca_crls = ca_conf['crl'] if isinstance(ca_crls, str): ca_crls = [ca_crls] for crl in ca_crls: if not is_valid_crl(crl): raise ConfigError(f'Invalid CRL on CA certificate "{name}"') if 'certificate' in pki: for name, cert_conf in pki['certificate'].items(): if 'certificate' in cert_conf: if not is_valid_certificate(cert_conf['certificate']): raise ConfigError(f'Invalid certificate on certificate "{name}"') if 'private' in cert_conf and 'key' in cert_conf['private']: private = cert_conf['private'] protected = 'password_protected' in private if not is_valid_private_key(private['key'], protected): raise ConfigError(f'Invalid private key on certificate "{name}"') if 'acme' in cert_conf: if 'domain_name' not in cert_conf['acme']: raise ConfigError(f'At least one domain-name is required to request '\ f'certificate for "{name}" via ACME!') if 'email' not in cert_conf['acme']: raise ConfigError(f'An email address is required to request '\ f'certificate for "{name}" via ACME!') if 'certbot_renew' not in pki: # Only run the ACME command if something on this entity changed, # as this is time intensive tmp = dict_search('changed.certificate', pki) if tmp != None and name in tmp: certbot_request(name, cert_conf['acme']) if 'dh' in pki: for name, dh_conf in pki['dh'].items(): if 'parameters' in dh_conf: if not is_valid_dh_parameters(dh_conf['parameters']): raise ConfigError(f'Invalid DH parameters on "{name}"') if 'key_pair' in pki: for name, key_conf in pki['key_pair'].items(): if 'public' in key_conf and 'key' in key_conf['public']: if not is_valid_public_key(key_conf['public']['key']): raise ConfigError(f'Invalid public key on key-pair "{name}"') if 'private' in key_conf and 'key' in key_conf['private']: private = key_conf['private'] protected = 'password_protected' in private if not is_valid_private_key(private['key'], protected): raise ConfigError(f'Invalid private key on key-pair "{name}"') if 'openssh' in pki: for name, key_conf in pki['openssh'].items(): if 'public' in key_conf and 'key' in key_conf['public']: if 'type' not in key_conf['public']: raise ConfigError(f'Must define OpenSSH public key type for "{name}"') if not is_valid_openssh_public_key(key_conf['public']['key'], key_conf['public']['type']): raise ConfigError(f'Invalid OpenSSH public key "{name}"') if 'private' in key_conf and 'key' in key_conf['private']: private = key_conf['private'] protected = 'password_protected' in private if not is_valid_openssh_private_key(private['key'], protected): raise ConfigError(f'Invalid OpenSSH private key "{name}"') if 'x509' in pki: if 'default' in pki['x509']: default_values = pki['x509']['default'] if 'country' in default_values: country = default_values['country'] if len(country) != 2 or not country.isalpha(): raise ConfigError(f'Invalid default country value. Value must be 2 alpha characters.') if 'changed' in pki: # if the list is getting longer, we can move to a dict() and also embed the # search key as value from line 173 or 176 for search in sync_search: for key in search['keys']: changed_key = sync_translate[key] if changed_key not in pki['changed']: continue for item_name in pki['changed'][changed_key]: node_present = False if changed_key == 'openvpn': node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name) else: node_present = dict_search_args(pki, changed_key, item_name) if not node_present: search_dict = dict_search_args(pki['system'], *search['path']) if not search_dict: continue for found_name, found_path in dict_search_recursive(search_dict, key): if found_name == item_name: path_str = " ".join(search['path'] + found_path) raise ConfigError(f'PKI object "{item_name}" still in use by "{path_str}"') return None def generate(pki): if not pki: return None # Certbot renewal only needs to re-trigger the services to load up the # new PEM file if 'certbot_renew' in pki: return None certbot_list = [] certbot_list_on_disk = [] if os.path.exists(f'{vyos_certbot_dir}/live'): certbot_list_on_disk = [f.path.split('/')[-1] for f in os.scandir(f'{vyos_certbot_dir}/live') if f.is_dir()] if 'certificate' in pki: changed_certificates = dict_search('changed.certificate', pki) for name, cert_conf in pki['certificate'].items(): if 'acme' in cert_conf: certbot_list.append(name) # generate certificate if not found on disk if name not in certbot_list_on_disk: certbot_request(name, cert_conf['acme'], dry_run=False) elif changed_certificates != None and name in changed_certificates: # when something for the certificate changed, we should delete it if name in certbot_list_on_disk: certbot_delete(name) certbot_request(name, cert_conf['acme'], dry_run=False) # Cleanup certbot configuration and certificates if no longer in use by CLI # Get foldernames under vyos_certbot_dir which each represent a certbot cert if os.path.exists(f'{vyos_certbot_dir}/live'): for cert in certbot_list_on_disk: if cert not in certbot_list: # certificate is no longer active on the CLI - remove it certbot_delete(cert) return None def apply(pki): systemd_certbot_name = 'certbot.timer' if not pki: call(f'systemctl stop {systemd_certbot_name}') return None has_certbot = False if 'certificate' in pki: for name, cert_conf in pki['certificate'].items(): if 'acme' in cert_conf: has_certbot = True break if not has_certbot: call(f'systemctl stop {systemd_certbot_name}') elif has_certbot and not is_systemd_service_active(systemd_certbot_name): call(f'systemctl restart {systemd_certbot_name}') if 'changed' in pki: call_dependents() return None 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/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