mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-21 07:55:00 +00:00
Matrix requires valid certificates for federation with other servers from version 1.0 onward. If the FreedomBox server already has LE cert and private key, copy them into /etc/matrix-synapse - Add certificate renewal hooks for Matrix Synapse. Reusing the certificate renewal mechanism built for ejabberd with matrix-synapse as well. One notable difference is that Matrix Synapse doesn't support switching the domain name or dropping the Let's Encrypt certificate. - Use self-signed certificate if there is no LE certificate. Matrix Synapse server startup fails if the files homeserver.tls.crt and homeserver.tls.key are missing. - Copy Apache's snakeoil certificates to /etc/matrix-synapse when LE certificates are not available. Prefer LE certificates if available. - Display warning if no valid LE certificate is found. Signed-off-by: Joseph Nuthalapati <njoseph@thoughtworks.com>
530 lines
20 KiB
Python
Executable File
530 lines
20 KiB
Python
Executable File
#!/usr/bin/python3
|
|
#
|
|
# This file is part of FreedomBox.
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as
|
|
# published by the Free Software Foundation, either version 3 of the
|
|
# License, or (at your option) any later version.
|
|
#
|
|
# 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 Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
"""
|
|
Configuration helper for Let's Encrypt.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import re
|
|
import configobj
|
|
|
|
from plinth import action_utils
|
|
from plinth.errors import ActionError
|
|
from plinth.modules import config
|
|
from plinth.modules import letsencrypt as le
|
|
|
|
TEST_MODE = False
|
|
RENEWAL_DIRECTORY = '/etc/letsencrypt/renewal/'
|
|
AUTHENTICATOR = 'webroot'
|
|
WEB_ROOT_PATH = '/var/www/html'
|
|
APACHE_PREFIX = '/etc/apache2/sites-available/'
|
|
APACHE_CONFIGURATION = '''
|
|
Use FreedomBoxTLSSiteMacro {domain}
|
|
'''
|
|
|
|
|
|
def parse_arguments():
|
|
"""Return parsed command line arguments as dictionary."""
|
|
parser = argparse.ArgumentParser()
|
|
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
|
|
|
|
setup_parser = subparsers.add_parser(
|
|
'setup', help='Run any setup/upgrade activities.')
|
|
setup_parser.add_argument(
|
|
'--old-version', type=int, required=True, help=
|
|
'Version number being upgraded from or None if setting up first time.')
|
|
|
|
subparsers.add_parser('get-status',
|
|
help='Return the status of configured domains.')
|
|
revoke_parser = subparsers.add_parser(
|
|
'revoke', help='Revoke certificate of a domain and disable website.')
|
|
revoke_parser.add_argument('--domain', required=True,
|
|
help='Domain name to revoke certificate for')
|
|
obtain_parser = subparsers.add_parser(
|
|
'obtain', help='Obtain certificate for a domain and setup website.')
|
|
obtain_parser.add_argument('--domain', required=True,
|
|
help='Domain name to obtain certificate for')
|
|
delete_parser = subparsers.add_parser(
|
|
'delete', help='Delete certificate for a domain and disable website.')
|
|
delete_parser.add_argument('--domain', required=True,
|
|
help='Domain name to delete certificate of')
|
|
|
|
help_hooks = 'FreedomBox management of certificates (for current domain).'
|
|
manage_hook_parser = subparsers.add_parser('manage_hooks', help=help_hooks)
|
|
manage_hook_parser.add_argument('command', help=help_hooks,
|
|
choices=('enable', 'disable', 'status'))
|
|
manage_hook_parser.add_argument('--domain',
|
|
help='Domain for hook management command.')
|
|
help_module_arg = 'For enable: Also use LE cert with other FreedomBox ' \
|
|
'apps.'
|
|
manage_hook_parser.add_argument('--modules', help=help_module_arg,
|
|
nargs='+', default=[],
|
|
choices=le.MODULES_WITH_HOOKS)
|
|
|
|
help_domain_arg = 'Domain name to run the hook scripts with.'
|
|
help_module_arg = 'Include hooks from the provided module names.'
|
|
help_pre_hooks = 'Maintenance tasks before a cert is obtained or renewed.'
|
|
run_pre_hooks_parser = subparsers.add_parser('run_pre_hooks',
|
|
help=help_pre_hooks)
|
|
run_pre_hooks_parser.add_argument('--domain', help=help_domain_arg)
|
|
run_pre_hooks_parser.add_argument('--modules', help=help_module_arg,
|
|
nargs='+', default=[],
|
|
choices=le.MODULES_WITH_HOOKS)
|
|
|
|
help_renew_hooks = 'Maintenance tasks after a cert is actually renewed.'
|
|
run_renew_hooks_parser = subparsers.add_parser('run_renew_hooks',
|
|
help=help_renew_hooks)
|
|
run_renew_hooks_parser.add_argument('--domain', help=help_domain_arg)
|
|
run_renew_hooks_parser.add_argument('--modules', help=help_module_arg,
|
|
nargs='+', default=[],
|
|
choices=le.MODULES_WITH_HOOKS)
|
|
|
|
help_post_hooks = 'Maintenance tasks after a cert is obtained or renewed.'
|
|
run_post_hooks_parser = subparsers.add_parser('run_post_hooks',
|
|
help=help_post_hooks)
|
|
run_post_hooks_parser.add_argument('--domain', help=help_domain_arg)
|
|
run_post_hooks_parser.add_argument('--modules', help=help_module_arg,
|
|
nargs='+', default=[],
|
|
choices=le.MODULES_WITH_HOOKS)
|
|
|
|
subparsers.required = True
|
|
return parser.parse_args()
|
|
|
|
|
|
def get_certificate_expiry(domain):
|
|
"""Return the expiry date of a certificate."""
|
|
certificate_file = os.path.join(le.LIVE_DIRECTORY, domain, 'cert.pem')
|
|
output = subprocess.check_output(
|
|
['openssl', 'x509', '-enddate', '-noout', '-in', certificate_file])
|
|
return output.decode().strip().split('=')[1]
|
|
|
|
|
|
def get_validity_status(domain):
|
|
"""Return validity status of a certificate, e.g. valid, revoked, expired"""
|
|
output = subprocess.check_output(['certbot', 'certificates', '-d', domain])
|
|
output = output.decode(sys.stdout.encoding)
|
|
|
|
match = re.search(r'INVALID: (.*)\)', output)
|
|
if match is not None:
|
|
validity = match.group(1).lower()
|
|
elif re.search('VALID', output) is not None:
|
|
validity = 'valid'
|
|
else:
|
|
validity = 'unknown'
|
|
|
|
return validity
|
|
|
|
|
|
def get_status():
|
|
"""
|
|
Return Python dictionary of currently configured domains.
|
|
Should be run as root, otherwise might yield a wrong, empty answer.
|
|
"""
|
|
try:
|
|
domains = os.listdir(le.LIVE_DIRECTORY)
|
|
except OSError:
|
|
domains = []
|
|
|
|
domains = [
|
|
domain for domain in domains
|
|
if os.path.isdir(os.path.join(le.LIVE_DIRECTORY, domain))
|
|
]
|
|
|
|
domain_status = {}
|
|
for domain in domains:
|
|
domain_status[domain] = {
|
|
'certificate_available':
|
|
True,
|
|
'expiry_date':
|
|
get_certificate_expiry(domain),
|
|
'web_enabled':
|
|
action_utils.webserver_is_enabled(domain, kind='site'),
|
|
'validity':
|
|
get_validity_status(domain)
|
|
}
|
|
return domain_status
|
|
|
|
|
|
def subcommand_setup(arguments):
|
|
"""Upgrade old site configuration to new macro based style.
|
|
|
|
Nothing to do for first time setup and for newer versions.
|
|
"""
|
|
if arguments.old_version != 1:
|
|
return
|
|
|
|
domain_status = get_status()
|
|
with action_utils.WebserverChange() as webserver_change:
|
|
for domain in domain_status:
|
|
setup_webserver_config(domain, webserver_change)
|
|
|
|
|
|
def subcommand_get_status(_):
|
|
"""Print a JSON dictionary of currently configured domains."""
|
|
domain_status = get_status()
|
|
print(json.dumps({'domains': domain_status}))
|
|
|
|
|
|
def subcommand_revoke(arguments):
|
|
"""Disable a domain and revoke the certificate."""
|
|
domain = arguments.domain
|
|
|
|
command = [
|
|
'certbot', 'revoke', '--domain', domain, '--cert-path',
|
|
os.path.join(le.LIVE_DIRECTORY, domain, 'cert.pem')
|
|
]
|
|
if TEST_MODE:
|
|
command.append('--staging')
|
|
|
|
process = subprocess.Popen(command, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE)
|
|
_, stderr = process.communicate()
|
|
if process.returncode:
|
|
print(stderr.decode(), file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
action_utils.webserver_disable(domain, kind='site')
|
|
|
|
|
|
def subcommand_obtain(arguments):
|
|
"""Obtain a certificate for a domain and setup website."""
|
|
domain = arguments.domain
|
|
|
|
command = [
|
|
'certbot', 'certonly', '--text', '--agree-tos',
|
|
'--register-unsafely-without-email', '--domain', arguments.domain,
|
|
'--authenticator', AUTHENTICATOR, '--webroot-path', WEB_ROOT_PATH,
|
|
'--renew-by-default'
|
|
]
|
|
if TEST_MODE:
|
|
command.append('--staging')
|
|
|
|
process = subprocess.Popen(command, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE)
|
|
_, stderr = process.communicate()
|
|
if process.returncode:
|
|
print(stderr.decode(), file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
with action_utils.WebserverChange() as webserver_change:
|
|
setup_webserver_config(domain, webserver_change)
|
|
|
|
|
|
def subcommand_manage_hooks(arguments):
|
|
"""
|
|
Enable/disable/status of certbot's pre-, renew-, & post-hooks for renewal.
|
|
Enable edits renewal config of current domain (RENEWAL_DIR/DOMAIN.config),
|
|
and creates a backup beforehand that is restored if disable is called.
|
|
Commands disable and status work without error on any domain string.
|
|
"""
|
|
if arguments.command not in ('enable', 'disable', 'status'):
|
|
print('Aborted: Unknown command "%s".' % arguments.command)
|
|
sys.exit(1)
|
|
|
|
cmd_is_enable = arguments.command == 'enable'
|
|
current_domain = config.get_domainname()
|
|
if not arguments.domain:
|
|
arguments.domain = current_domain
|
|
|
|
if cmd_is_enable:
|
|
if not current_domain:
|
|
print('Aborted: No current domain set.')
|
|
sys.exit(2)
|
|
|
|
if not arguments.domain == current_domain:
|
|
print('Aborted: Passed domain (%s) is not current domain (%s).' %
|
|
(arguments.domain, current_domain))
|
|
sys.exit(3)
|
|
|
|
domain_status = get_status()
|
|
if current_domain not in domain_status:
|
|
print('Aborted: Current domain (%s) has no LE certificate.' %
|
|
current_domain)
|
|
sys.exit(4)
|
|
|
|
config_path = RENEWAL_DIRECTORY + arguments.domain + '.conf'
|
|
if not os.path.exists(config_path):
|
|
msg, code = ('Aborted', 5) if cmd_is_enable else ('Disabled', 0)
|
|
print('%s: No certbot config file at %s.' % (msg, config_path))
|
|
sys.exit(code)
|
|
|
|
config_certbot = configobj.ConfigObj(config_path)
|
|
if 'renewalparams' not in config_certbot:
|
|
msg, code = ('Aborted', 6) if cmd_is_enable else ('Disabled', 0)
|
|
print('%s: No section [renewalparams] in config file at %s.' %
|
|
(msg, config_path))
|
|
sys.exit(code)
|
|
|
|
script_path = os.path.realpath(__file__)
|
|
config_backup_path = config_path + '_plinth_backup'
|
|
call_pre = script_path + ' run_pre_hooks --domain ' + arguments.domain
|
|
call_renew = script_path + ' run_renew_hooks --domain ' + arguments.domain
|
|
call_post = script_path + ' run_post_hooks --domain ' + arguments.domain
|
|
config_plinth = {
|
|
'renewalparams': {
|
|
'authenticator': AUTHENTICATOR,
|
|
# 'webroot_path': [WEB_ROOT_PATH], # removed by renew...
|
|
'webroot_map': {
|
|
arguments.domain: WEB_ROOT_PATH
|
|
},
|
|
'installer': 'None',
|
|
'pre_hook': call_pre,
|
|
'renew_hook': call_renew,
|
|
'post_hook': call_post
|
|
}
|
|
}
|
|
comment_plinth = '# This file was edited by Plinth.'
|
|
config_edited_by_plinth = any([
|
|
'edited by plinth' in line.lower()
|
|
for line in config_certbot.initial_comment
|
|
])
|
|
|
|
if arguments.command == 'status':
|
|
# check for presence of expected minimal configuration
|
|
config_checks = [
|
|
(entry in config_certbot['renewalparams'])
|
|
and (str(config_plinth['renewalparams'][entry]) in str(
|
|
config_certbot['renewalparams'][entry]))
|
|
for entry in config_plinth['renewalparams'].keys()
|
|
]
|
|
|
|
if not all(config_checks):
|
|
print('disabled')
|
|
sys.exit(0)
|
|
|
|
# is enabled; check for which selected modules (only for renew_hook)
|
|
cmd_str = config_certbot['renewalparams']['renew_hook']
|
|
module_list = []
|
|
for mod_str in le.MODULES_WITH_HOOKS:
|
|
mod_pattern = 'letsencrypt .*--modules .*%s.*' % mod_str
|
|
match = re.search(mod_pattern, cmd_str)
|
|
if match is not None:
|
|
module_list.append(mod_str)
|
|
|
|
if module_list != []:
|
|
print('enabled, with modules: ' + ', '.join(module_list))
|
|
else:
|
|
print('enabled, without modules')
|
|
|
|
elif arguments.command == 'enable':
|
|
if not config_edited_by_plinth:
|
|
shutil.copy(config_path, config_backup_path)
|
|
config_certbot.initial_comment.append(comment_plinth)
|
|
|
|
if arguments.modules != []:
|
|
call_renew += ' --modules ' + ' '.join(arguments.modules)
|
|
config_plinth['renewalparams']['renew_hook'] = call_renew
|
|
|
|
config_certbot['renewalparams'].update(config_plinth['renewalparams'])
|
|
config_certbot.write()
|
|
|
|
if arguments.modules != []:
|
|
print('enabled, with modules: ' + ', '.join(arguments.modules))
|
|
else:
|
|
print('enabled, without modules')
|
|
|
|
elif arguments.command == 'disable':
|
|
# if changed, restore from backup; refuse disabling if no backup exists
|
|
if not config_edited_by_plinth:
|
|
print('Disabled: Nothing to do, hook management was not enabled.')
|
|
elif os.path.exists(config_backup_path):
|
|
shutil.move(config_backup_path, config_path)
|
|
print('disabled successfully')
|
|
else:
|
|
print('Aborted: No backup config file at %s.' % config_backup_path)
|
|
sys.exit(7)
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
def subcommand_run_pre_hooks(arguments):
|
|
"""
|
|
Execute all needed maintenance tasks BEFORE a cert is obtained/renewed.
|
|
If registered as certbot's pre-hook, this script gets ALWAYS executed when
|
|
certbot attempts a renewal, irrespective of necessity/success (2x per day).
|
|
"""
|
|
# Require current domain, to avoid confusion (e.g. call from old cron job).
|
|
if not arguments.domain:
|
|
print('Aborted: You must specify the current domain.')
|
|
sys.exit(1)
|
|
|
|
current_domain = config.get_domainname()
|
|
if not arguments.domain == current_domain:
|
|
print('Aborted: Current domain is %s, but called for %s.' %
|
|
(current_domain, arguments.domain))
|
|
sys.exit(2)
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
def subcommand_run_renew_hooks(arguments):
|
|
"""
|
|
Execute ALL maintenance tasks when (just after) certbot renews a cert, i.e.
|
|
run all tasks of every app/plinth module that supports usage of LE certs.
|
|
If registered as certbot's renew-hook, this script gets ONLY executed when
|
|
certbot successfully renewed a certificate; with Debian default config,
|
|
this means it would run about once every 60 days (renewals get executed
|
|
if a cert is <30 days before expiry, and current default is 90 days).
|
|
Errors will be logged by certbot to /var/log/letsencrypt/letsencrypt.log.
|
|
"""
|
|
# Require current domain, to avoid confusion (e.g. call from old cron job).
|
|
if not arguments.domain:
|
|
print('Aborted: You must specify the current domain.')
|
|
sys.exit(1)
|
|
|
|
current_domain = config.get_domainname()
|
|
if not arguments.domain == current_domain:
|
|
print('Aborted: Current domain is %s, but called for %s.' %
|
|
(current_domain, arguments.domain))
|
|
sys.exit(2)
|
|
|
|
if action_utils.service_is_running('apache2'):
|
|
action_utils.service_restart('apache2')
|
|
|
|
for module in arguments.modules:
|
|
# If >1 modules, collect errors and raise just one in the end,
|
|
# for certbot to log ALL failed attempts, not just 1st fail.
|
|
error_messages = []
|
|
try:
|
|
_run_action(module, ['letsencrypt', 'add'])
|
|
except Exception as err:
|
|
error_messages.append(err.message)
|
|
|
|
if error_messages:
|
|
raise ActionError(message="\n".join(error_messages))
|
|
sys.exit(3)
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
def _run_action(action, action_options=None):
|
|
"""
|
|
Run a specific action from another module, for the run_renew_hooks command.
|
|
This function is a simplified version of plinth/actions.py, to enable
|
|
somewhat safe calls of other actions from outside the FreedomBox Service
|
|
(Plinth) process. The comments about the action contracts refer to
|
|
plinth/actions.py.
|
|
"""
|
|
if action_options is None:
|
|
action_options = []
|
|
|
|
# Contract 3A and 3B: don't call anything outside of the actions directory.
|
|
# Assume the current path is the actions directly.
|
|
script_path = os.path.realpath(__file__)
|
|
actions_dir, _ = os.path.split(script_path)
|
|
if os.sep in action:
|
|
raise ValueError('Action cannot contain: ' + os.sep)
|
|
|
|
cmd = os.path.join(actions_dir, action)
|
|
if not os.path.realpath(cmd).startswith(actions_dir):
|
|
raise ValueError('Action has to be in directory %s' % actions_dir)
|
|
|
|
# Contract 3C: interpret shell escape sequences as literal file names.
|
|
# Contract 3E: fail if the action doesn't exist or exists elsewhere.
|
|
if not os.access(cmd, os.F_OK):
|
|
raise ValueError('Action must exist in action directory.')
|
|
|
|
cmd = [cmd]
|
|
|
|
# Contract: 3C, 3D: don't allow shell special characters in
|
|
# options be interpreted by the shell.
|
|
if action_options:
|
|
if not isinstance(action_options, (list, tuple)):
|
|
raise ValueError('Options must be list or tuple.')
|
|
|
|
cmd += list(action_options) # No escaping necessary
|
|
|
|
# Contract 3C: don't interpret shell escape sequences.
|
|
# Contract 5 (and 6-ish).
|
|
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, shell=False)
|
|
|
|
output, error = proc.communicate()
|
|
output, error = output.decode(), error.decode()
|
|
if proc.returncode != 0:
|
|
raise ActionError(action, output, error)
|
|
|
|
|
|
def subcommand_run_post_hooks(arguments):
|
|
"""
|
|
Execute all needed maintenance tasks AFTER a cert is obtained/renewed.
|
|
If registered as certbot's post-hook, this script gets ALWAYS executed when
|
|
certbot attempts a renewal, irrespective of necessity/success (2x per day).
|
|
"""
|
|
# Require current domain, to avoid confusion (e.g. call from old cron job).
|
|
if not arguments.domain:
|
|
print('Aborted: You must specify the current domain.')
|
|
sys.exit(1)
|
|
|
|
current_domain = config.get_domainname()
|
|
if not arguments.domain == current_domain:
|
|
print('Aborted: Current domain is %s, but called for %s.' %
|
|
(current_domain, arguments.domain))
|
|
sys.exit(2)
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
def subcommand_delete(arguments):
|
|
"""Disable a domain and delete the certificate."""
|
|
domain = arguments.domain
|
|
command = ['certbot', 'delete', '--cert-name', domain]
|
|
process = subprocess.Popen(command, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE)
|
|
_, stderr = process.communicate()
|
|
if process.returncode:
|
|
print(stderr.decode(), file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
action_utils.webserver_disable(domain, kind='site')
|
|
|
|
|
|
def setup_webserver_config(domain, webserver_change):
|
|
"""Create SSL web server configuration for a domain.
|
|
|
|
Do so only if there is no configuration existing.
|
|
"""
|
|
file_name = os.path.join(APACHE_PREFIX, domain + '.conf')
|
|
if os.path.isfile(file_name):
|
|
os.rename(file_name, file_name + '.fbx-bak')
|
|
|
|
with open(file_name, 'w') as file_handle:
|
|
file_handle.write(APACHE_CONFIGURATION.format(domain=domain))
|
|
|
|
webserver_change.enable('macro', kind='module')
|
|
webserver_change.enable('freedombox-tls-site-macro', kind='config')
|
|
webserver_change.enable(domain, kind='site')
|
|
|
|
|
|
def main():
|
|
"""Parse arguments and perform all duties."""
|
|
arguments = parse_arguments()
|
|
|
|
subcommand = arguments.subcommand.replace('-', '_')
|
|
subcommand_method = globals()['subcommand_' + subcommand]
|
|
subcommand_method(arguments)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|