lemur/lemur/plugins/lemur_sftp/plugin.py

299 lines
11 KiB
Python

"""
.. module: lemur.plugins.lemur_sftp.plugin
:platform: Unix
:synopsis: Allow the uploading of certificates to SFTP.
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
Allow the uploading of certificates to SFTP.
NGINX and Apache export formats are supported.
Password and RSA private key are supported.
Passwords are not encrypted and stored as a plain text.
Detailed logging when Lemur debug mode is enabled.
.. moduleauthor:: Dmitry Zykov https://github.com/DmitryZykov
"""
from os import path
import paramiko
from paramiko.ssh_exception import AuthenticationException, NoValidConnectionsError
from flask import current_app
from lemur.plugins import lemur_sftp
from lemur.common.defaults import common_name
from lemur.common.utils import parse_certificate
from lemur.plugins.bases import DestinationPlugin
class SFTPDestinationPlugin(DestinationPlugin):
title = "SFTP"
slug = "sftp-destination"
description = "Allow the uploading of certificates to SFTP"
version = lemur_sftp.VERSION
author = "Dmitry Zykov"
author_url = "https://github.com/DmitryZykov"
options = [
{
"name": "host",
"type": "str",
"required": True,
"helpMessage": "The SFTP host.",
},
{
"name": "port",
"type": "int",
"required": True,
"helpMessage": "The SFTP port, default is 22.",
"validation": r"^(6553[0-5]|655[0-2][0-9]\d|65[0-4](\d){2}|6[0-4](\d){3}|[1-5](\d){4}|[1-9](\d){0,3})",
"default": "22",
},
{
"name": "user",
"type": "str",
"required": True,
"helpMessage": "The SFTP user. Default is root.",
"default": "root",
},
{
"name": "password",
"type": "str",
"required": False,
"helpMessage": "The SFTP password (optional when the private key is used).",
"default": None,
},
{
"name": "privateKeyPath",
"type": "str",
"required": False,
"helpMessage": "The path to the RSA private key on the Lemur server (optional).",
"default": None,
},
{
"name": "privateKeyPass",
"type": "str",
"required": False,
"helpMessage": "The password for the encrypted RSA private key (optional).",
"default": None,
},
{
"name": "destinationPath",
"type": "str",
"required": True,
"helpMessage": "The SFTP path where certificates will be uploaded.",
"default": "/etc/nginx/certs",
},
{
"name": "exportFormat",
"required": True,
"value": "NGINX",
"helpMessage": "The export format for certificates.",
"type": "select",
"available": ["NGINX", "Apache"],
},
]
def open_sftp_connection(self, options):
host = self.get_option("host", options)
port = self.get_option("port", options)
user = self.get_option("user", options)
password = self.get_option("password", options)
ssh_priv_key = self.get_option("privateKeyPath", options)
ssh_priv_key_pass = self.get_option("privateKeyPass", options)
# delete files
try:
current_app.logger.debug(
"Connecting to {0}@{1}:{2}".format(user, host, port)
)
ssh = paramiko.SSHClient()
# allow connection to the new unknown host
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# open the ssh connection
if password:
current_app.logger.debug("Using password")
ssh.connect(host, username=user, port=port, password=password)
elif ssh_priv_key:
current_app.logger.debug("Using RSA private key")
pkey = paramiko.RSAKey.from_private_key_file(
ssh_priv_key, ssh_priv_key_pass
)
ssh.connect(host, username=user, port=port, pkey=pkey)
else:
current_app.logger.error(
"No password or private key provided. Can't proceed"
)
raise AuthenticationException
# open the sftp session inside the ssh connection
return ssh.open_sftp(), ssh
except AuthenticationException as e:
current_app.logger.error("ERROR in {0}: {1}".format(e.__class__, e))
raise AuthenticationException("Couldn't connect to {0}, due to an Authentication exception.")
except NoValidConnectionsError as e:
current_app.logger.error("ERROR in {0}: {1}".format(e.__class__, e))
raise NoValidConnectionsError("Couldn't connect to {0}, possible timeout or invalid hostname")
# this is called when using this as a default destination plugin
def upload(self, name, body, private_key, cert_chain, options, **kwargs):
current_app.logger.debug("SFTP destination plugin is started")
cn = common_name(parse_certificate(body))
dst_path = self.get_option("destinationPath", options)
dst_path_cn = dst_path + "/" + cn
export_format = self.get_option("exportFormat", options)
# prepare files for upload
files = {cn + ".key": private_key, cn + ".pem": body}
if cert_chain:
if export_format == "NGINX":
# assemble body + chain in the single file
files[cn + ".pem"] += "\n" + cert_chain
elif export_format == "Apache":
# store chain in the separate file
files[cn + ".ca.bundle.pem"] = cert_chain
self.upload_file(dst_path_cn, files, options)
# this is called from the acme http challenge
def upload_acme_token(self, token_path, token, options, **kwargs):
current_app.logger.debug("SFTP destination plugin is started for HTTP-01 challenge")
dst_path = self.get_option("destinationPath", options)
_, filename = path.split(token_path)
# prepare files for upload
files = {filename: token}
self.upload_file(dst_path, files, options)
# this is called from the acme http challenge
def delete_acme_token(self, token_path, options, **kwargs):
dst_path = self.get_option("destinationPath", options)
_, filename = path.split(token_path)
# prepare files for upload
files = {filename: None}
self.delete_file(dst_path, files, options)
# here the file is deleted
def delete_file(self, dst_path, files, options):
try:
# open the ssh and sftp sessions
sftp, ssh = self.open_sftp_connection(options)
# delete files
for filename, _ in files.items():
current_app.logger.debug(
"Deleting {0} from {1}".format(filename, dst_path)
)
try:
sftp.remove(path.join(dst_path, filename))
except PermissionError as permerror:
if permerror.errno == 13:
current_app.logger.debug(
"Deleting {0} from {1} returned Permission Denied Error, making file writable and retrying".format(
filename, dst_path)
)
sftp.chmod(path.join(dst_path, filename), 0o600)
sftp.remove(path.join(dst_path, filename))
ssh.close()
except (AuthenticationException, NoValidConnectionsError) as e:
raise e
except Exception as e:
current_app.logger.error("ERROR in {0}: {1}".format(e.__class__, e))
try:
ssh.close()
except BaseException:
pass
# here the file is uploaded for real, this helps to keep this class DRY
def upload_file(self, dst_path, files, options):
try:
# open the ssh and sftp sessions
sftp, ssh = self.open_sftp_connection(options)
# split the path into it's segments, so we can create it recursively
allparts = []
path_copy = dst_path
while True:
parts = path.split(path_copy)
if parts[0] == path_copy: # sentinel for absolute paths
allparts.insert(0, parts[0])
break
elif parts[1] == path_copy: # sentinel for relative paths
allparts.insert(0, parts[1])
break
else:
path_copy = parts[0]
allparts.insert(0, parts[1])
# make sure that the destination path exists, recursively
remote_path = allparts[0]
for part in allparts:
try:
if part != "/" and part != "":
remote_path = path.join(remote_path, part)
sftp.stat(remote_path)
except IOError:
current_app.logger.debug("{0} doesn't exist, trying to create it".format(remote_path))
try:
sftp.mkdir(remote_path)
except IOError as ioerror:
current_app.logger.debug(
"Couldn't create {0}, error message: {1}".format(remote_path, ioerror))
# upload certificate files to the sftp destination
for filename, data in files.items():
current_app.logger.debug(
"Uploading {0} to {1}".format(filename, dst_path)
)
try:
with sftp.open(path.join(dst_path, filename), "w") as f:
f.write(data)
except PermissionError as permerror:
if permerror.errno == 13:
current_app.logger.debug(
"Uploading {0} to {1} returned Permission Denied Error, making file writable and retrying".format(
filename, dst_path)
)
sftp.chmod(path.join(dst_path, filename), 0o600)
with sftp.open(path.join(dst_path, filename), "w") as f:
f.write(data)
# most likely the upload user isn't the webuser, -rw-r--r--
sftp.chmod(path.join(dst_path, filename), 0o644)
ssh.close()
except (AuthenticationException, NoValidConnectionsError) as e:
raise e
except Exception as e:
current_app.logger.error("ERROR in {0}: {1}".format(e.__class__, e))
try:
ssh.close()
except BaseException:
pass
message = ''
if hasattr(e, 'errors'):
for _, error in e.errors.items():
message = error.strerror
raise Exception(
'Couldn\'t upload file to {}, error message: {}'.format(self.get_option("host", options), message))