Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,28 @@ Set network interfaces using command-line arguments to easily configure automati



## 🔐 Automatic TLS Certificates
Dispatch can enroll Let’s Encrypt certificates directly from the Settings UI.

1. **Verify inbound access to ports 80/443.** HTTP-01 challenges require plaintext HTTP. From another host connect to a temporary `nc -lvnp 80` / `nc -lvnp 443` listener on the Dispatch server to confirm routing through firewalls and load balancers.
2. **Run Dispatch in HTTP mode while requesting a cert:**
```bash
sudo python3 dispatch-server.py --http --bind-host 0.0.0.0 --bind-port 80 \
--external-host <domain> --external-port 443
```
Use a fresh browser session because cookies issued over HTTPS are marked `secure` and won’t be sent to HTTP.
3. **Open Settings → TLS Certificate Enrollment.**
- Click **Install Certbot** if it is missing.
- Provide the domain and contact email.
- Leave **Use Let’s Encrypt staging** enabled for test runs; uncheck it for production.
- Press **Request Certificate** and monitor the toast/log output (`dispatch/data/logs/dispatch.log`).
4. **Switch to production:** When staging is unchecked Dispatch automatically removes the staging lineage so Certbot reissues via the production ACME server.
5. **Restart Dispatch on HTTPS/443** after a successful enrollment:
```bash
sudo python3 dispatch-server.py --bind-host 0.0.0.0 --bind-port 443 \
--external-host <domain> --external-port 443
```
Confirm with `openssl s_client -connect <domain>:443 -servername <domain>` or a browser visit.

## ⚠️ Disclaimer
Dispatch is intended for authorized security testing. Never test against systems you don’t own or have explicit permission.
9 changes: 8 additions & 1 deletion dispatch-server.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import logging
import argparse
from dispatch import config
from dispatch import certs
from dispatch.db import DispatchDB
from gevent.pywsgi import WSGIServer
from dispatch.app import DispatchServer
Expand All @@ -31,6 +32,7 @@ def filter(self, record):
return "SSLEOFError" not in record.getMessage()

logging.getLogger("gevent").addFilter(IgnoreSSLEOFError())
log = logging.getLogger('dispatch-logger')

def main():
parser = argparse.ArgumentParser(description="Dispatch Server Options")
Expand Down Expand Up @@ -84,6 +86,11 @@ def main():
else:
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(config.CERT_PATH, config.KEY_PATH)
try:
meta = certs.read_cert_metadata(config.CERT_PATH)
log.info(f"Loaded TLS certificate subject={meta.get('subject')} issuer={meta.get('issuer')} notAfter={meta.get('notAfter')}")
except Exception as e:
log.warning(f"Unable to log certificate metadata: {e}")

# Start server
print(f'[+] Starting Dispatch locally on: {"http" if args.http else "https"}://{config.INTERFACE}:{config.PORT}/\n')
Expand All @@ -109,4 +116,4 @@ def main():


if __name__ == "__main__":
main()
main()
63 changes: 60 additions & 3 deletions dispatch/app.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from dispatch import auth
from dispatch import config
from dispatch import certs
from dispatch.db import DispatchDB
log = logging.getLogger('dispatch-logger')

Expand All @@ -24,6 +25,11 @@ class DispatchServer(object):
app.config['allow__ua'] = []
app.config['allow_login'] = []
app.config['redirect_url'] = ''
app.config['cert_domain'] = ''
app.config['cert_email'] = ''
app.config['cert_staging'] = False
app.config['cert_status'] = 'Not Configured'
app.config['cert_last_error'] = ''

@app.route('/', methods=['GET'])
@auth.login_required
Expand Down Expand Up @@ -81,6 +87,8 @@ def logout():
@app.route('/settings', methods=['GET', 'POST'])
@auth.operator_required
def settings(token):
cert_status_msg = ''
certbot_installed = certs.certbot_available()
if request.method == 'POST':
db = DispatchDB(current_app.config['db_name'])
if request.form['name'] == 'ui_settings':
Expand All @@ -96,9 +104,50 @@ def settings(token):
db.update_allow_login(request.form['allow_login'])
config.log(f"Login restrictions updated", token, request.remote_addr)

elif request.form['name'] == 'certbot_settings':
cert_domain = request.form.get('cert_domain', '').strip()
cert_email = request.form.get('cert_email', '').strip()
cert_staging = 1 if request.form.get('cert_staging', False) else 0
action = request.form.get('action', 'save')
db.update_certbot_settings(cert_domain, cert_email, cert_staging)
cert_status_msg = Markup('<script>showNotification("Certificate settings saved.");</script>')

if action == 'install':
if certs.certbot_available():
cert_status_msg = Markup('<script>showNotification("Certbot is already installed.");</script>')
else:
try:
certs.install_certbot()
cert_status_msg = Markup('<script>showNotification("Certbot installation completed.", true);</script>')
certbot_installed = True
except Exception as e:
err_msg = str(e)
cert_status_msg = Markup(f'<script>showNotification("Certbot installation failed: {escape(err_msg)}", false);</script>')
logging.error(f"Certbot installation failed: {err_msg}")

elif action == 'enroll':
try:
certs.request_certificate(cert_domain, cert_email, bool(cert_staging))
cert_status_msg = Markup('<script>showNotification("Certificate enrollment completed. Restart Dispatch to load the new certificate.", true);</script>')
db.update_certbot_status('Certificate issued', '')
config.log(f"Certificate enrollment completed for {cert_domain}", token, request.remote_addr)
except Exception as e:
err_msg = str(e)
cert_status_msg = Markup(f'<script>showNotification("Certificate enrollment failed: {escape(err_msg)}", false);</script>')
db.update_certbot_status('Error', err_msg[:250])
logging.error(f"Certificate enrollment failed: {err_msg}")

config.refresh_app_configs(db, current_app)
db.close()
return render_template('settings/settings.html', token=token, config=current_app.config)
else:
certbot_installed = certs.certbot_available()
return render_template(
'settings/settings.html',
token=token,
config=current_app.config,
cert_status_msg=cert_status_msg,
certbot_installed=certbot_installed
)

@app.route('/settings/access', methods=['GET', 'POST'])
@auth.operator_required
Expand Down Expand Up @@ -150,6 +199,16 @@ def dispatch_log(token):
with open(config.DISPATCH_LOG, 'r') as f:
return render_template('settings/log.html', token=token, content=escape(f.read()), config=current_app.config)

@app.route('/.well-known/acme-challenge/<token>', methods=['GET'])
def acme_challenge(token):
if '/' in token or '..' in token:
return abort(404)
challenge_dir = os.path.join(config.CHALLENGE_PATH, '.well-known', 'acme-challenge')
challenge_file = os.path.join(challenge_dir, token)
if os.path.exists(challenge_file):
return send_file(challenge_file)
return abort(404)

#
# File Interactions
#
Expand Down Expand Up @@ -864,5 +923,3 @@ def reverse_proxy(redirect_url):
return Response(response.content, response.status_code, response_headers)
except requests.exceptions.RequestException as e:
return f"Proxy Error: {str(e)}", 502


Empty file modified dispatch/auth.py
100755 → 100644
Empty file.
141 changes: 141 additions & 0 deletions dispatch/certs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import os
import sys
import ssl
import shutil
import subprocess
import logging
from os import path

from dispatch import config


log = logging.getLogger('dispatch-logger')


def certbot_available():
return shutil.which(config.CERTBOT_BIN) is not None


def install_certbot():
"""
Attempt to install certbot using pip. Returns stdout on success.
"""
cmd = [sys.executable, '-m', 'pip', 'install', 'certbot']
log.info("Attempting to install certbot via pip")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
error_output = result.stderr if result.stderr else result.stdout
log.error(f"Certbot install failed: {error_output}")
raise RuntimeError(error_output)
log.info("Certbot installation completed.")
return result.stdout


def _get_acme_server(staging):
return config.LE_STAGING_ACME_URL if staging else config.LE_PROD_ACME_URL


def _build_certbot_command(domain, email, staging):
server = _get_acme_server(staging)
cmd = [
config.CERTBOT_BIN,
'certonly',
'--non-interactive',
'--agree-tos',
'--email', email,
'--webroot',
'-w', config.CHALLENGE_PATH,
'-d', domain,
'--config-dir', config.CERTBOT_CONFIG_PATH,
'--work-dir', config.CERTBOT_WORK_PATH,
'--logs-dir', config.CERTBOT_LOG_PATH,
'--preferred-challenges', 'http',
'--server', server
]
if staging:
cmd.append('--test-cert')
return cmd


def _remove_lineage(domain):
live_dir = path.join(config.CERTBOT_CONFIG_PATH, 'live', domain)
archive_dir = path.join(config.CERTBOT_CONFIG_PATH, 'archive', domain)
renewal_conf = path.join(config.CERTBOT_CONFIG_PATH, 'renewal', f'{domain}.conf')

for target in [live_dir, archive_dir]:
if path.exists(target):
shutil.rmtree(target, ignore_errors=True)
if path.exists(renewal_conf):
os.remove(renewal_conf)


def _renewal_server_matches(domain, staging):
renewal_conf = path.join(config.CERTBOT_CONFIG_PATH, 'renewal', f'{domain}.conf')
desired = _get_acme_server(staging)
if not path.exists(renewal_conf):
return True
try:
with open(renewal_conf, 'r') as f:
for line in f:
if line.strip().startswith('server ='):
current = line.split('=', 1)[1].strip()
return current == desired
except Exception as e:
log.debug(f"Unable to read renewal config {renewal_conf}: {e}")
return False


def read_cert_metadata(cert_path):
"""Return subject/issuer/dates for logging."""
try:
info = ssl._ssl._test_decode_cert(cert_path)
return {
'subject': info.get('subject'),
'issuer': info.get('issuer'),
'notBefore': info.get('notBefore'),
'notAfter': info.get('notAfter')
}
except Exception as e:
log.debug(f"Unable to decode certificate metadata for {cert_path}: {e}")
return {}


def request_certificate(domain, email, staging=False):
"""
Execute certbot to request/renew a certificate for the provided domain.
Certificates are placed under dispatch/data/certs/.
"""
if not domain or not email:
raise ValueError("Domain and email must be provided for certificate enrollment.")

if not certbot_available():
raise FileNotFoundError(f"Certbot binary '{config.CERTBOT_BIN}' was not found in PATH.")

os.makedirs(config.CHALLENGE_PATH, exist_ok=True)

if not _renewal_server_matches(domain, staging):
log.info(f"Detected ACME server change for {domain}. Removing existing Certbot lineage before requesting new certificate.")
_remove_lineage(domain)

cmd = _build_certbot_command(domain, email, staging)
log.info(f"Running certbot for domain {domain} with staging={staging}")
result = subprocess.run(cmd, capture_output=True, text=True)

if result.returncode != 0:
error_output = result.stderr if result.stderr else result.stdout
log.error(f"Certbot enrollment failed: {error_output}")
raise RuntimeError(error_output)

live_dir = path.join(config.CERTBOT_CONFIG_PATH, 'live', domain)
fullchain_src = path.join(live_dir, 'fullchain.pem')
privkey_src = path.join(live_dir, 'privkey.pem')

if not path.exists(fullchain_src) or not path.exists(privkey_src):
raise FileNotFoundError("Certbot did not produce the expected certificate files.")

shutil.copy2(fullchain_src, config.CERT_PATH)
shutil.copy2(privkey_src, config.KEY_PATH)

meta = read_cert_metadata(config.CERT_PATH)
log.info(f"Successfully updated certificate for {domain} (issuer={meta.get('issuer')}, notAfter={meta.get('notAfter')})")
return result.stdout
48 changes: 42 additions & 6 deletions dispatch/config.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ def refresh_app_configs(db, app):
app.config['server_header'] = s['server_header']
app.config['MAX_CONTENT_LENGTH'] = s['max_file_size']
app.config['param_rotation'] = int(s['param_rotation'])
app.config['cert_domain'] = s.get('cert_domain', '')
app.config['cert_email'] = s.get('cert_email', '')
app.config['cert_staging'] = bool(s.get('cert_staging', 0))
app.config['cert_status'] = s.get('cert_status', 'Not Configured')
app.config['cert_last_error'] = s.get('cert_last_error', '')


def setup_debug_logger():
Expand Down Expand Up @@ -274,17 +279,48 @@ def dispatch_native_decrypt(b64_data, password) -> str:
#
# File Storage
#
DB_NAME = path.join(path.dirname(path.realpath(__file__)), 'data', 'dispatch.db')
CERT_PATH = path.join(path.dirname(path.realpath(__file__)), 'data', 'certs', 'cert.crt')
KEY_PATH = path.join(path.dirname(path.realpath(__file__)), 'data', 'certs', 'key.pem')
FILE_PATH = path.join(path.dirname(path.realpath(__file__)), 'data', 'uploads')
BASE_PATH = path.dirname(path.realpath(__file__))
DATA_PATH = path.join(BASE_PATH, 'data')
DB_NAME = path.join(DATA_PATH, 'dispatch.db')
CERT_DIR = path.join(DATA_PATH, 'certs')
CERT_PATH = path.join(CERT_DIR, 'cert.crt')
KEY_PATH = path.join(CERT_DIR, 'key.pem')
FILE_PATH = path.join(DATA_PATH, 'uploads')
CHALLENGE_PATH = path.join(DATA_PATH, 'challenges')
CERTBOT_DATA_PATH = path.join(DATA_PATH, 'certbot')
CERTBOT_CONFIG_PATH = path.join(CERTBOT_DATA_PATH, 'config')
CERTBOT_WORK_PATH = path.join(CERTBOT_DATA_PATH, 'work')
CERTBOT_LOG_PATH = path.join(CERTBOT_DATA_PATH, 'logs')

#
# Password protect site resources
#
TMPL_PATH = path.join(path.dirname(path.realpath(__file__)), 'templates')
TMPL_PATH = path.join(BASE_PATH, 'templates')

#
# Log Path
#
DISPATCH_LOG = path.join(path.dirname(path.realpath(__file__)), 'data', 'logs', 'dispatch.log')
DISPATCH_LOG = path.join(DATA_PATH, 'logs', 'dispatch.log')

#
# Certificate enrollment
#
CERTBOT_BIN = os.environ.get('CERTBOT_BIN', 'certbot')
LE_PROD_ACME_URL = os.environ.get('DISPATCH_ACME_URL', 'https://acme-v02.api.letsencrypt.org/directory')
LE_STAGING_ACME_URL = 'https://acme-staging-v02.api.letsencrypt.org/directory'

# Ensure required directories exist
CHALLENGE_WELL_KNOWN = path.join(CHALLENGE_PATH, '.well-known', 'acme-challenge')

for required_path in [
DATA_PATH,
CERT_DIR,
FILE_PATH,
path.dirname(DISPATCH_LOG),
CHALLENGE_PATH,
CHALLENGE_WELL_KNOWN,
CERTBOT_CONFIG_PATH,
CERTBOT_WORK_PATH,
CERTBOT_LOG_PATH
]:
os.makedirs(required_path, exist_ok=True)
Loading