summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorChristian Breunig <christian@breunig.cc>2024-06-24 18:42:53 +0200
committerGitHub <noreply@github.com>2024-06-24 18:42:53 +0200
commit6fbe91ea8ea639e3933b3e23f99ce32204cebfe0 (patch)
tree160fa688a867c2cb12f93074efa8588d5b4370ce /src
parent4cd052bddcecd0d24c72521564f9844f21ffc4ea (diff)
parentf6131611e15a644312e3c4baf5b8f6387c2930e7 (diff)
downloadvyos-1x-6fbe91ea8ea639e3933b3e23f99ce32204cebfe0.tar.gz
vyos-1x-6fbe91ea8ea639e3933b3e23f99ce32204cebfe0.zip
Merge pull request #3588 from HollyGurza/T5735
T5735: Stunnel CLI and configuration
Diffstat (limited to 'src')
-rwxr-xr-xsrc/conf_mode/pki.py4
-rw-r--r--src/conf_mode/service_stunnel.py264
-rw-r--r--src/systemd/stunnel.service15
-rw-r--r--src/validators/psk-secret39
4 files changed, 322 insertions, 0 deletions
diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py
index 4a0e86f32..215b22b37 100755
--- a/src/conf_mode/pki.py
+++ b/src/conf_mode/pki.py
@@ -85,6 +85,10 @@ sync_search = [
{
'keys': ['certificate', 'ca_certificate'],
'path': ['vpn', 'sstp'],
+ },
+ {
+ 'keys': ['certificate', 'ca_certificate'],
+ 'path': ['service', 'stunnel'],
}
]
diff --git a/src/conf_mode/service_stunnel.py b/src/conf_mode/service_stunnel.py
new file mode 100644
index 000000000..8ec762548
--- /dev/null
+++ b/src/conf_mode/service_stunnel.py
@@ -0,0 +1,264 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2024 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+from shutil import rmtree
+
+from sys import exit
+
+from netifaces import AF_INET
+from psutil import net_if_addrs
+
+from vyos.config import Config
+from vyos.configverify import verify_pki_ca_certificate
+from vyos.configverify import verify_pki_certificate
+from vyos.pki import encode_certificate
+from vyos.pki import encode_private_key
+from vyos.pki import find_chain
+from vyos.pki import load_certificate
+from vyos.pki import load_private_key
+from vyos.utils.dict import dict_search
+from vyos.utils.file import makedir
+from vyos.utils.file import write_file
+from vyos.utils.network import check_port_availability
+from vyos.utils.network import is_listen_port_bind_service
+from vyos.utils.process import call
+from vyos.template import render
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+stunnel_dir = '/run/stunnel'
+config_file = f'{stunnel_dir}/stunnel.conf'
+stunnel_ca_dir = f'{stunnel_dir}/ca'
+stunnel_psk_dir = f'{stunnel_dir}/psk'
+
+# config based on
+# http://man.he.net/man8/stunnel4
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'stunnel']
+ if not conf.exists(base):
+ return None
+
+ stunnel = conf.get_config_dict(base,
+ get_first_key=True,
+ key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ with_recursive_defaults=True,
+ with_pki=True)
+ stunnel['config_file'] = config_file
+ return stunnel
+
+
+def verify(stunnel):
+ if not stunnel:
+ return None
+
+ stunnel_listen_addresses = list()
+ for mode, conf in stunnel.items():
+ if mode not in ['server', 'client']:
+ continue
+
+ for app, app_conf in conf.items():
+ # connect, listen, exec and some protocols e.g. socks on server mode are endpoints.
+ endpoints = 0
+ if 'socks' == app_conf.get('protocol') and mode == 'server':
+ if 'connect' in app_conf:
+ raise ConfigError("The 'connect' option cannot be used with the 'socks' protocol in server mode.")
+ endpoints += 1
+
+ for item in ['connect', 'listen']:
+ if item in app_conf:
+ endpoints += 1
+ if 'port' not in app_conf[item]:
+ raise ConfigError(f'{mode} [{app}]: {item} port number is required!')
+ elif item == 'listen':
+ raise ConfigError(f'{mode} [{app}]: {item} port number is required!')
+
+ if endpoints != 2:
+ raise ConfigError(f'{mode} [{app}]: connect port number is required!')
+
+ if 'address' in app_conf['listen']:
+ laddresses = [dict_search('listen.address', app_conf)]
+ else:
+ laddresses = list()
+ ifaces = net_if_addrs()
+ for iface_name, iface_addresses in ifaces.items():
+ for iface_addr in iface_addresses:
+ if iface_addr.family == AF_INET:
+ laddresses.append(iface_addr.address)
+
+ lport = int(dict_search('listen.port', app_conf))
+
+ for address in laddresses:
+ if f'{address}:{lport}' in stunnel_listen_addresses:
+ raise ConfigError(
+ f'{mode} [{app}]: Address {address}:{lport} already '
+ f'in use by other stunnel service')
+
+ stunnel_listen_addresses.append(f'{address}:{lport}')
+ if not check_port_availability(address, lport, 'tcp') and \
+ not is_listen_port_bind_service(lport, 'stunnel'):
+ raise ConfigError(
+ f'{mode} [{app}]: Address {address}:{lport} already in use')
+
+ if 'options' in app_conf:
+ protocol = app_conf.get('protocol')
+ if protocol not in ['connect', 'smtp']:
+ raise ConfigError("Additional option is only supported in the 'connect' and 'smtp' protocols.")
+ if protocol == 'smtp' and ('domain' in app_conf['options'] or 'host' in app_conf['options']):
+ raise ConfigError("Protocol 'smtp' does not support options 'domain' and 'host'.")
+
+ # set default authentication option
+ if 'authentication' not in app_conf['options']:
+ app_conf['options']['authentication'] = 'basic' if protocol == 'connect' else 'plain'
+
+ for option, option_config in app_conf['options'].items():
+ if option == 'authentication':
+ if protocol == 'connect' and option_config not in ['basic', 'ntlm']:
+ raise ConfigError("Supported authentication types for the 'connect' protocol are 'basic' or 'ntlm'")
+ elif protocol == 'smtp' and option_config not in ['plain', 'login']:
+ raise ConfigError("Supported authentication types for the 'smtp' protocol are 'plain' or 'login'")
+ if option == 'host':
+ if 'address' not in option_config:
+ raise ConfigError('Address is required for option host.')
+ if 'port' not in option_config:
+ raise ConfigError('Port is required for option host.')
+
+ # check pki certs
+ for key in ['ca_certificate', 'certificate']:
+ tmp = dict_search(f'ssl.{key}', app_conf)
+ if mode == 'server' and key != 'ca_certificate' and not tmp and 'psk' not in app_conf:
+ raise ConfigError(f'{mode} [{app}]: TLS server needs a certificate or PSK')
+ if tmp:
+ if key == 'ca_certificate':
+ for ca_cert in tmp:
+ verify_pki_ca_certificate(stunnel, ca_cert)
+ else:
+ verify_pki_certificate(stunnel, tmp)
+
+ #check psk
+ if 'psk' in app_conf:
+ for psk, psk_conf in app_conf['psk'].items():
+ if 'id' not in psk_conf or 'secret' not in psk_conf:
+ raise ConfigError(
+ f'Authentication psk "{psk}" missing "id" or "secret"')
+
+
+def generate(stunnel):
+ if not stunnel or ('client' not in stunnel and 'server' not in stunnel):
+ if os.path.isdir(stunnel_dir):
+ rmtree(stunnel_dir, ignore_errors=True)
+
+ return None
+ makedir(stunnel_dir)
+
+ exist_files = list()
+ current_files = [config_file, config_file.replace('.conf', 'pid')]
+ for root, dirs, files in os.walk(stunnel_dir):
+ for file in files:
+ exist_files.append(os.path.join(root, file))
+
+ loaded_ca_certs = {load_certificate(c['certificate'])
+ for c in stunnel['pki']['ca'].values()} if 'pki' in stunnel and 'ca' in stunnel['pki'] else {}
+
+ for mode, conf in stunnel.items():
+ if mode not in ['server', 'client']:
+ continue
+
+ for app, app_conf in conf.items():
+ if 'ssl' in app_conf:
+ if 'certificate' in app_conf['ssl']:
+ cert_name = app_conf['ssl']['certificate']
+
+ pki_cert = stunnel['pki']['certificate'][cert_name]
+ cert_file_path = os.path.join(stunnel_dir,
+ f'{mode}-{app}-{cert_name}.pem')
+ cert_key_path = os.path.join(stunnel_dir,
+ f'{mode}-{app}-{cert_name}.pem.key')
+ app_conf['ssl']['cert'] = cert_file_path
+
+ loaded_pki_cert = load_certificate(pki_cert['certificate'])
+ cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs)
+
+ write_file(cert_file_path,
+ '\n'.join(encode_certificate(c) for c in cert_full_chain))
+ current_files.append(cert_file_path)
+
+ if 'private' in pki_cert and 'key' in pki_cert['private']:
+ app_conf['ssl']['cert_key'] = cert_key_path
+ loaded_key = load_private_key(pki_cert['private']['key'],
+ passphrase=None, wrap_tags=True)
+ key_pem = encode_private_key(loaded_key, passphrase=None)
+ write_file(cert_key_path, key_pem, mode=0o600)
+ current_files.append(cert_key_path)
+
+ if 'ca_certificate' in app_conf['ssl']:
+ app_conf['ssl']['ca_path'] = stunnel_ca_dir
+ app_conf['ssl']['ca_file'] = f'{mode}-{app}-ca.pem'
+ ca_cert_file_path = os.path.join(stunnel_ca_dir, app_conf['ssl']['ca_file'])
+ ca_chains = []
+
+ for ca_name in app_conf['ssl']['ca_certificate']:
+ pki_ca_cert = stunnel['pki']['ca'][ca_name]
+ loaded_ca_cert = load_certificate(pki_ca_cert['certificate'])
+ ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs)
+ ca_chains.append(
+ '\n'.join(encode_certificate(c) for c in ca_full_chain))
+
+ write_file(ca_cert_file_path, '\n'.join(ca_chains))
+ current_files.append(ca_cert_file_path)
+
+ if 'psk' in app_conf:
+ psk_data = list()
+ psk_file_path = os.path.join(stunnel_psk_dir, f'{mode}_{app}.txt')
+
+ for _, psk_conf in app_conf['psk'].items():
+ psk_data.append(f'{psk_conf["id"]}:{psk_conf["secret"]}')
+
+ write_file(psk_file_path, '\n'.join(psk_data))
+ app_conf['psk']['file'] = psk_file_path
+ current_files.append(psk_file_path)
+
+ for file in exist_files:
+ if file not in current_files:
+ os.unlink(file)
+
+ render(config_file, 'stunnel/stunnel_config.j2', stunnel)
+
+
+def apply(stunnel):
+ if not stunnel or ('client' not in stunnel and 'server' not in stunnel):
+ call('systemctl stop stunnel.service')
+ else:
+ call('systemctl restart stunnel.service')
+
+
+if __name__ == '__main__':
+ try:
+ c = get_config()
+ verify(c)
+ generate(c)
+ apply(c)
+ except ConfigError as e:
+ print(e)
+ exit(1)
diff --git a/src/systemd/stunnel.service b/src/systemd/stunnel.service
new file mode 100644
index 000000000..b260e2984
--- /dev/null
+++ b/src/systemd/stunnel.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=SSL tunneling service
+Documentation=http://man.he.net/man8/stunnel4
+After=network.target
+
+[Service]
+ExecStart=/usr/bin/stunnel /run/stunnel/stunnel.conf
+ExecReload=/bin/kill -HUP $MAINPID
+KillMode=process
+PIDFile=/run/stunnel/stunnel.pid
+Type=forking
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/validators/psk-secret b/src/validators/psk-secret
new file mode 100644
index 000000000..c91aa95a8
--- /dev/null
+++ b/src/validators/psk-secret
@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2024 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import re
+from sys import argv,exit
+
+if __name__ == '__main__':
+ if len(argv) != 2:
+ exit(1)
+
+ input = argv[1]
+ is_valid = True
+ try:
+ # Convert hexadecimal input to binary form
+ key_bytes = bytes.fromhex(input)
+ except ValueError:
+ is_valid = False
+
+ if is_valid and len(key_bytes) < 16:
+ is_valid = False
+
+ if not is_valid:
+ print(f'Error: {input} is not valid psk secret.')
+ exit(1)
+
+ exit(0) \ No newline at end of file