├── .gitignore ├── README.md ├── letsencrypt_directadmin ├── __init__.py ├── challenge.py ├── configurator.py ├── deployer.py └── tests │ └── directadmin_test.py └── setup-directadmin.py /.gitignore: -------------------------------------------------------------------------------- 1 | /letsencrypt_directadmin.egg-info/ 2 | *.pyc 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # letsencrypt-directadmin 2 | # Installation 3 | * Clone this repository in the same directory using `git clone https://github.com/sjerdo/letsencrypt-directadmin.git` 4 | * Install by running `python setup-directadmin.py develop` 5 | 6 | # Installation for development 7 | * Download the letsencrypt client at https://github.com/letsencrypt/letsencrypt and set it up. 8 | Eg: 9 | ``` 10 | git clone https://github.com/letsencrypt/letsencrypt 11 | cd letsencrypt 12 | ./bootstrap/install-deps.sh 13 | ./bootstrap/dev/venv.sh 14 | ``` 15 | * Clone this repository in the same directory using `git clone https://github.com/sjerdo/letsencrypt-directadmin.git tmp && mv tmp/.git .gittwo && rm -rf tmp && git --git-dir=.gittwo reset --hard .` 16 | * Install by running `./venv/bin/python setup-directadmin.py develop` 17 | 18 | # Run 19 | You can run the client by executing the command 20 | ```letsencrypt --configurator letsencrypt-directadmin:directadmin --letsencrypt-directadmin:directadmin-server https://www.example.com:2222/ --letsencrypt-directadmin:directadmin-username USERNAME --letsencrypt-directadmin:directadmin-login-key LOGINKEY``` 21 | You can also use your DirectAdmin password instead of a Login Key, but this is not recommended. 22 | 23 | Another example for running the plugin which includes the credentials in the server url: 24 | ```letsencrypt --configurator letsencrypt-directadin:directadmin --letsencrypt-directadmin:directadmin-server https://DAUSER:LOGINKEY@example.com:2222/``` 25 | 26 | You can specify for which domain a certificate needs to be generated and installed by appending -d domain.com 27 | Eg: ```letsencrypt --configurator letsencrypt-directadin:directadmin --letsencrypt-directadmin:directadmin-server https://DAUSER:LOGINKEY@example.com:2222/ -d example.com -d www.example.com``` 28 | 29 | # TODO: Run with configuration file 30 | 31 | # DirectAdmin Login Keys 32 | If you would like to use DirectAdmin Login Keys (which is recommended) instead of your password, the login key should be allowed to use the following commands: 33 | * CMD_API_LOGIN_TEST 34 | * CMD_API_SHOW_DOMAINS 35 | * CMD_API_DOMAIN_POINTER 36 | * CMD_API_SUBDOMAINS 37 | * CMD_API_FILE_MANAGER 38 | * CMD_API_SSL 39 | -------------------------------------------------------------------------------- /letsencrypt_directadmin/__init__.py: -------------------------------------------------------------------------------- 1 | """Let's Encrypt DirectAdmin plugin.""" 2 | 3 | # version number like 1.2.3a0, must have at least 2 parts, like 1.2 4 | __version__ = '0.1.0.dev0' -------------------------------------------------------------------------------- /letsencrypt_directadmin/challenge.py: -------------------------------------------------------------------------------- 1 | """DirectAdminHTTP01Challenge""" 2 | import logging 3 | import os 4 | 5 | from letsencrypt import errors 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class DirectAdminHTTP01Challenge(object): 11 | """Class performs HTTP01 challenge within the DirectAdmin configurator.""" 12 | 13 | def __init__(self, da_api_client): 14 | self.da_api_client = da_api_client 15 | 16 | def perform(self, achall): 17 | """Perform a challenge on DirectAdmin.""" 18 | response, validation = achall.response_and_validation() 19 | self._put_validation_file( 20 | domain=achall.domain, 21 | path=achall.URI_ROOT_PATH, 22 | filename=achall.chall.encode("token"), 23 | content=validation.encode()) 24 | return response 25 | 26 | def _put_validation_file(self, domain, path, filename, content): 27 | """Put file to the domain with validation content""" 28 | path = self.da_api_client.get_public_html_path(domain) + path 29 | response = self.da_api_client.create_file( 30 | path=path, 31 | filename=filename, 32 | contents=content) 33 | 34 | def cleanup(self, achall): 35 | """Remove validation file and directories.""" 36 | path = self.da_api_client.get_public_html_path(achall.domain) 37 | dirname = os.path.dirname(achall.URI_ROOT_PATH) 38 | self.da_api_client.remove_folder(os.path.join(path, dirname)) 39 | -------------------------------------------------------------------------------- /letsencrypt_directadmin/configurator.py: -------------------------------------------------------------------------------- 1 | """DirectAdmin API plugin.""" 2 | import errno 3 | import logging 4 | import os 5 | 6 | import zope.interface 7 | 8 | from acme import challenges 9 | 10 | from letsencrypt import interfaces 11 | from letsencrypt.plugins import common 12 | from letsencrypt.errors import PluginError 13 | 14 | import directadmin 15 | from letsencrypt_directadmin import challenge, deployer 16 | from urlparse import urlsplit 17 | 18 | 19 | class Configurator(common.Plugin): 20 | """DirectAdmin API Configurator.""" 21 | zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller) 22 | zope.interface.classProvides(interfaces.IPluginFactory) 23 | 24 | description = "DirectAdminAPI Configurator" 25 | 26 | MORE_INFO = """\ 27 | Configurator plugin that performs http-01 challenge by saving 28 | necessary validation resources to appropriate paths on a server 29 | using the DirectAdmin API. It expects that correct API credentials 30 | are given in commandline or in GUI. Certificates will be installed 31 | automatically. """ 32 | 33 | def more_info(self): # pylint: disable=missing-docstring,no-self-use 34 | return self.MORE_INFO 35 | 36 | @classmethod 37 | def add_parser_arguments(cls, add): 38 | add("server", default=os.getenv('DA_SERVER'), 39 | help="DirectAdmin server (can include port, standard 2222, include http:// if the DA server does not support SSL)") 40 | add("username", default=os.getenv('DA_USERNAME'), 41 | help="DirectAdmin username") 42 | add("login-key", default=os.getenv('DA_LOGIN_KEY'), 43 | help="DirectAdmin login key") 44 | 45 | def __init__(self, *args, **kwargs): 46 | """Initialize DirectAdmin Configurator.""" 47 | super(Configurator, self).__init__(*args, **kwargs) 48 | 49 | self.da_challenges = {} 50 | self.da_deployers = {} 51 | # This will be set in the prepare function 52 | self.da_api_client = None 53 | 54 | def prepare(self): 55 | if self.da_api_client is None: 56 | self.prepare_da_client() 57 | # TODO: check if da server exists, credentials are correct, permissions are okay and ssl certificates are supported 58 | self.da_api_client.test_login() 59 | 60 | def prepare_da_client(self): 61 | """ Prepare the DirectAdmin Web API Client """ 62 | if self.conf('server') is None: 63 | # TODO: check if there is a local server at https://localhost:2222 (with non-ssl fallback?) 64 | raise PluginError('User did not supply a DirectAdmin server url.') 65 | parsed_url = urlsplit(self.conf('server')) 66 | 67 | if self.conf('username') is not None: 68 | username = self.conf('username') 69 | elif parsed_url.username is not None: 70 | username = parsed_url.username 71 | else: 72 | raise PluginError('User did not supply a DirectAdmin username') 73 | 74 | if self.conf('login-key') is not None: 75 | loginkey = self.conf('login-key') 76 | elif parsed_url.password is not None: 77 | loginkey = parsed_url.password 78 | else: 79 | raise PluginError('User did not supply a DirectAdmin login key') 80 | 81 | self.da_api_client = directadmin.Api( 82 | https=(False if parsed_url.scheme == 'http' else True), 83 | hostname=(parsed_url.hostname if parsed_url.hostname else 'localhost'), 84 | port=(parsed_url.port if parsed_url.port else 2222), 85 | username=username, 86 | password=loginkey) 87 | 88 | def get_chall_pref(self, domain): 89 | """Return list of challenge preferences.""" 90 | # TODO: implement other challenges? 91 | return [challenges.HTTP01] 92 | 93 | def perform(self, achalls): 94 | """Perform the configuration related challenge.""" 95 | responses = [] 96 | for x in achalls: 97 | da_challenge = challenge.DirectAdminHTTP01Challenge(self.da_api_client) 98 | responses.append(da_challenge.perform(x)) 99 | self.da_challenges[x.domain] = da_challenge 100 | return responses 101 | 102 | def cleanup(self, achalls): 103 | """Revert all challenges.""" 104 | for x in achalls: 105 | if x.domain in self.da_challenges: 106 | self.da_challenges[x.domain].cleanup(x) 107 | 108 | def get_all_names(self): 109 | """Returns all names that may be authenticated.""" 110 | alldomains = [] 111 | domains = self.da_api_client.list_domains() 112 | for domain in domains: 113 | # make a list of prefixes (including www. and all subdomains) 114 | subdomains = self.da_api_client.list_subdomains(domain) 115 | prefixes = ['www.', ''] 116 | prefixes += [p + s + '.' for s in subdomains for p in prefixes] 117 | 118 | # add the main domain to the list of names 119 | alldomains += [p + domain for p in prefixes] 120 | 121 | # add the pointers to the list of names 122 | pointers = self.da_api_client.list_domain_pointers(domain) 123 | alldomains += [p + d for d in pointers for p in prefixes] 124 | return alldomains 125 | 126 | def deploy_cert(self, domain, cert_path, key_path, 127 | chain_path=None, fullchain_path=None): 128 | """Initialize deploy certificate in DirectAdmin via API.""" 129 | (base, subdomain) = self.da_api_client.get_base_domain(domain) 130 | if base is None: 131 | raise PluginError('Unknown domain {} got authorized'.format(domain)) 132 | if base in self.da_deployers: 133 | self.da_deployers[base].add_domain(domain) 134 | else: 135 | da_deployer = deployer.DirectAdminDeployer(self.da_api_client, base) 136 | da_deployer.add_domain(domain) 137 | with open(cert_path) as cert_file: 138 | cert_data = cert_file.read() 139 | with open(key_path) as key_file: 140 | key_data = key_file.read() 141 | if fullchain_path: 142 | with open(fullchain_path) as chain_file: 143 | chain_data = chain_file.read() 144 | elif chain_path: 145 | with open(chain_path) as chain_file: 146 | chain_data = chain_file.read() 147 | else: 148 | chain_data = None 149 | 150 | da_deployer.init_cert(cert_data, key_data, chain_data) 151 | self.da_deployers[base] = da_deployer 152 | 153 | def enhance(self, domain, enhancement, options=None): 154 | print 'enhance called', domain, enhancement, options 155 | pass # pragma: no cover 156 | 157 | def supported_enhancements(self): 158 | print 'supported_enhancements called' 159 | return [] 160 | 161 | def get_all_certs_keys(self): 162 | print 'get_all_certs_keys called' 163 | return [] 164 | 165 | def save(self, title=None, temporary=False): 166 | """Push DirectAdmin to deploy certificate(s).""" 167 | for domain in self.da_deployers: 168 | da_deployer = self.da_deployers[domain] 169 | if not da_deployer.cert_installed: 170 | da_deployer.install_cert() 171 | 172 | def rollback_checkpoints(self, rollback=1): 173 | print 'rollback_checkpoints called' 174 | pass # pragma: no cover 175 | 176 | def recovery_routine(self): 177 | """Revert deployer changes.""" 178 | for domain in self.da_deployers: 179 | self.da_deployers[domain].revert() 180 | 181 | def view_config_changes(self): 182 | print 'view_config_changes called' 183 | pass # pragma: no cover 184 | 185 | def config_test(self): 186 | print 'config_test called' 187 | pass # pragma: no cover 188 | 189 | def restart(self): 190 | print 'restart called' 191 | # TODO: cleanup 192 | pass # pragma: no cover 193 | -------------------------------------------------------------------------------- /letsencrypt_directadmin/deployer.py: -------------------------------------------------------------------------------- 1 | """DirectAdmin deployer""" 2 | import logging 3 | 4 | from letsencrypt.errors import PluginError 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class DirectAdminDeployer(object): 10 | """Class performs deploy operations within the DirectAdmin configurator""" 11 | 12 | def __init__(self, da_api_client, basedomain): 13 | """Initialize DirectAdmin Certificate Deployer""" 14 | self.da_api_client = da_api_client 15 | self.basedomain = basedomain 16 | self.domains = [] 17 | self.cert_data = self.key_data = self.chain_data = None 18 | self.cert_installed = self.cacert_installed = False 19 | 20 | def add_domain(self, domain): 21 | """Add domain to the list of domains this certificate is deployed to""" 22 | self.domains.append(domain) 23 | 24 | def cert_name(self): 25 | """Return name of the domain certificate in DirectAdmin.""" 26 | return "Lets Encrypt %s" % self.basedomain 27 | 28 | def init_cert(self, cert_data, key_data, chain_data=None): 29 | """Initialize certificate data.""" 30 | self.cert_data = cert_data 31 | self.key_data = key_data 32 | self.chain_data = chain_data if chain_data else {} 33 | 34 | def install_cert(self): 35 | """Install certificate to the domain repository in DirectAdmin.""" 36 | cacert_response = None 37 | certificate = self.key_data + '\n' + self.cert_data 38 | cert_response = self.da_api_client.set_ssl_certificate(self.basedomain, certificate) 39 | if self.chain_data is not None: 40 | cacert_response = self.da_api_client.set_ca_root_ssl_certificate( 41 | self.basedomain, self.chain_data) 42 | 43 | if cert_response is not True: 44 | raise PluginError('Install certificate failure: %s' % cert_response) 45 | self.cert_installed = True 46 | 47 | if cacert_response is not True: 48 | raise PluginError('Install CA root certificate failure: %s' % cacert_response) 49 | self.cacert_installed = True 50 | 51 | return self.cert_installed and self.cacert_installed 52 | 53 | def remove_cert(self): 54 | """Remove certificate in DirectAdmin.""" 55 | # TODO: should set back old certificate? 56 | cert_response = self.da_api_client.remove_ssl_certificate(self.basedomain) 57 | cacert_response = self.da_api_client.remove_ca_root_ssl_certificate(self.basedomain) 58 | if cert_response is True: 59 | self.cert_installed = False 60 | else: 61 | raise PluginError('Could not uninstall certificate for domain {}'.format(self.basedomain)) 62 | if cacert_response is True: 63 | self.cacert_installed = False 64 | else: 65 | raise PluginError('Could not uninstall chain for domain {}'.format(self.basedomain)) 66 | 67 | def revert(self): 68 | """Revert changes in DirectAdmin.""" 69 | if self.cert_installed: 70 | self.remove_cert() 71 | -------------------------------------------------------------------------------- /letsencrypt_directadmin/tests/directadmin_test.py: -------------------------------------------------------------------------------- 1 | """Tests for directadmin.""" 2 | -------------------------------------------------------------------------------- /setup-directadmin.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='letsencrypt-directadmin', 5 | install_requires=[ 6 | 'letsencrypt', 7 | 'zope.interface', 8 | 'python-directadmin>=0.6.1' 9 | ], 10 | packages=['letsencrypt_directadmin'], 11 | dependency_links = ['https://github.com/sjerdo/python-directadmin/tarball/master#egg=python-directadmin-0.6.1'], 12 | package_dir={'letsencrypt_directadmin': 'letsencrypt_directadmin'}, 13 | entry_points={ 14 | 'letsencrypt.plugins': [ 15 | 'directadmin = letsencrypt_directadmin.configurator:Configurator', 16 | ], 17 | }, 18 | ) --------------------------------------------------------------------------------