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