299 lines
11 KiB
Python
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))
|