# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. # Copyright (C) 2025 ChrispyBacon-Dev # # This program is free software: you can redistribute and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # # dockflare/app/web/routes.py import copy import logging import time import os import random import queue import uuid from datetime import datetime, timezone import json import requests from concurrent.futures import ThreadPoolExecutor, as_completed from flask import send_file from flask import ( Blueprint, render_template, jsonify, redirect, url_for, request, Response, current_app, session, flash ) from flask_login import current_user, login_required, login_user, logout_user from app.core.user import User from app import config, docker_client, tunnel_state, cloudflared_agent_state, log_queue, state_update_queue, publish_state_event from app.core.cache import CACHE_ENABLED from app.core.state_manager import managed_rules, access_groups, state_lock, save_state, load_state from app.core.tunnel_manager import ( start_cloudflared_container, stop_cloudflared_container, update_cloudflare_config, initialize_tunnel ) from app.core.cloudflare_api import ( get_all_account_cloudflare_tunnels, get_dns_records_for_tunnel, create_cloudflare_dns_record, delete_cloudflare_dns_record, get_zone_id_from_name, get_zone_details_by_id, list_account_zones, delete_tunnel_via_api, get_tunnel_name_by_id ) from app.core.access_manager import ( check_for_tld_access_policy, get_cloudflare_account_email, delete_cloudflare_access_application, create_cloudflare_access_application, update_cloudflare_access_application, generate_access_app_config_hash, find_cloudflare_access_application_by_hostname ) from app.core.reconciler import reconcile_state_threaded from app.core.docker_handler import is_valid_hostname, is_valid_service from app.core.utils import get_rule_key from app.core import backup_manager from app.web import config_loader from cryptography.fernet import Fernet bp = Blueprint('web', __name__) @bp.route('/agents') @login_required def agents_page(): from app.core.state_manager import list_agents with state_lock: agents = list_agents() return render_template('agents.html', agents=agents) @bp.route('/agents//roll-key', methods=['POST']) @login_required def roll_agent_key(agent_id): """ Rolls (regenerates) the API key for a specific agent. """ from app.core.state_manager import get_agent, update_agent, revoke_agent_key, add_agent_key import secrets from datetime import datetime, timezone if not agent_id: cloudflared_agent_state["last_action_status"] = "Error: Missing agent ID." return redirect(url_for('web.agents_page')) with state_lock: agent = get_agent(agent_id) if not agent: cloudflared_agent_state["last_action_status"] = f"Error: Agent '{agent_id}' not found." return redirect(url_for('web.agents_page')) old_api_key = agent.get("api_key") new_api_key = secrets.token_urlsafe(32) success = update_agent(agent_id, {"api_key": new_api_key}) if not success: cloudflared_agent_state["last_action_status"] = f"Error: Failed to update agent '{agent_id}' with new API key." return redirect(url_for('web.agents_page')) if old_api_key: revoke_agent_key(old_api_key) now_iso = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat() add_agent_key(new_api_key, { "bound_agent_id": agent_id, "created_at": now_iso, "last_used_at": None, "rolled_from": old_api_key[:8] + "..." if old_api_key else None }) cloudflared_agent_state["last_action_status"] = f"Success: API key rolled for agent '{agent.get('display_name', agent_id)}'. Agent must be restarted with new key: {new_api_key}" return redirect(url_for('web.agents_page')) def get_display_token_ui(token_value): if not token_value: return "Not available" return f"{token_value[:5]}...{token_value[-5:]}" if len(token_value) > 10 else "Token (short)" @bp.before_app_request def gating_logic(): data_path = os.path.dirname(config.STATE_FILE_PATH) config_file = os.path.join(data_path, 'dockflare_config.dat') key_file = os.path.join(data_path, 'dockflare.key') files_present = os.path.exists(config_file) and os.path.exists(key_file) is_configured = bool(current_app.config.get('DOCKFLARE_PASSWORD_HASH')) or getattr(current_app, 'is_configured', False) or files_present if not is_configured: if request.endpoint and not request.endpoint.startswith('setup.') and request.endpoint != 'static' and not request.endpoint.startswith('api_v2.'): try: if getattr(current_app, 'import_from_env', False): session['is_env_import'] = True session['cf_api_token'] = os.getenv('CF_API_TOKEN') session['cf_account_id'] = os.getenv('CF_ACCOUNT_ID') session['tunnel_name'] = os.getenv('TUNNEL_NAME', 'dockflare-tunnel') session['cf_zone_id'] = os.getenv('CF_ZONE_ID') session['tunnel_dns_scan_zone_names'] = os.getenv('TUNNEL_DNS_SCAN_ZONE_NAMES', '') session['master_api_key'] = os.getenv('DOCKFLARE_API_KEY') grace_period_str = os.getenv('GRACE_PERIOD_SECONDS', '28800') session['grace_period_seconds'] = int(grace_period_str) if grace_period_str.isdigit() else 28800 return redirect(url_for('setup.step_import_env')) else: return redirect(url_for('setup.step1_admin_user')) except Exception as e: logging.error(f"Error during setup redirection logic: {e}", exc_info=True) return "Application is initializing setup. Please try again in a moment.", 503 return if hasattr(current_app, 'login_manager'): if current_app.config.get('DISABLE_PASSWORD_LOGIN'): oauth_providers = current_app.config.get('OAUTH_PROVIDERS', []) if oauth_providers and not current_user.is_authenticated: return redirect(url_for('web.login')) elif not oauth_providers and not current_user.is_authenticated: login_user(User("anonymous")) return if not current_user.is_authenticated: exempt_endpoints = ['static', 'web.ping', 'web.cloudflare_ping_route', 'setup.step_import_env'] oauth_endpoints = ['web.login_provider', 'web.auth_callback', 'web.login'] if request.endpoint and not request.endpoint.startswith('auth.') and request.endpoint not in exempt_endpoints and request.endpoint not in oauth_endpoints: try: return redirect(url_for('web.login')) except Exception: pass @bp.before_app_request def detect_protocol_bp(): forwarded_proto = request.headers.get('X-Forwarded-Proto', '').lower() current_app.config['PREFERRED_URL_SCHEME'] = 'https' if forwarded_proto == 'https' or request.is_secure else 'http' @bp.after_app_request def add_security_headers_bp(response): response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Frame-Options'] = 'SAMEORIGIN' response.headers['X-XSS-Protection'] = '1; mode=block' is_https = current_app.config.get('PREFERRED_URL_SCHEME') == 'https' csp = { "default-src": ["'self'"], "script-src": ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"], "style-src": ["'self'", "'unsafe-inline'", "https://rsms.me", "https://cdn.jsdelivr.net"], "img-src": ["'self'", "data:", "https://img.shields.io"], "font-src": ["'self'", "https://rsms.me"], "connect-src": ["'self'"], "frame-src": ["'none'"] } if is_https: csp["upgrade-insecure-requests"] = [] csp_string = "; ".join([f"{key} {" ".join(value)}" for key, value in csp.items()]) response.headers['Content-Security-Policy'] = csp_string response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' if is_https: response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' response.headers['Access-Control-Allow-Origin'] = '*' response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' response.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Requested-With, Authorization' return response @bp.context_processor def inject_protocol_bp(): preferred_scheme = current_app.config.get('PREFERRED_URL_SCHEME', 'http') base_url = f"{preferred_scheme}://{request.host}" master_key_value = None oauth_enabled = bool(current_app.config.get('OAUTH_PROVIDERS', [])) if current_user.is_authenticated: master_key_value = current_app.config.get('MASTER_API_KEY') return { 'protocol': preferred_scheme, 'is_https': preferred_scheme == 'https', 'base_url': base_url, 'host': request.host, 'request_scheme': request.scheme, 'app_version': config.APP_VERSION, 'master_api_key': master_key_value, 'oauth_enabled': oauth_enabled, 'current_user_auth_method': getattr(current_user, 'auth_method', None) if current_user.is_authenticated else None } @bp.route('/') @login_required def status_page(): import time start_time = time.time() request_id = str(uuid.uuid4())[:8] logging.info(f"[{request_id}] Status page request started") rules_for_template = {} template_tunnel_state = {} template_agent_state = {} initialization_status = {} tld_policy_exists_val = False account_email_for_tld_val = None relevant_zone_name_for_tld_policy_val = None template_access_groups = {} template_agents = {} with state_lock: for hostname, rule in managed_rules.items(): rule_copy = copy.deepcopy(rule) if rule_copy.get("delete_at") and isinstance(rule_copy["delete_at"], datetime): rule_copy["delete_at"] = rule_copy["delete_at"].replace(tzinfo=timezone.utc) if rule_copy["delete_at"].tzinfo is None else rule_copy["delete_at"].astimezone(timezone.utc) rules_for_template[hostname] = rule_copy template_tunnel_state = tunnel_state.copy() template_agent_state = cloudflared_agent_state.copy() template_access_groups = copy.deepcopy(access_groups) from app.core.state_manager import list_agents template_agents = list_agents() initialization_status = { "complete": template_tunnel_state.get("id") is not None or config.EXTERNAL_TUNNEL_ID, "in_progress": not (template_tunnel_state.get("id") or config.EXTERNAL_TUNNEL_ID) and \ template_tunnel_state.get("status_message", "").lower().startswith("init") } cf_zone_id = current_app.config.get('CF_ZONE_ID') if cf_zone_id and docker_client: zone_details = get_zone_details_by_id(cf_zone_id) if zone_details and zone_details.get("name"): relevant_zone_name_for_tld_policy_val = zone_details.get("name") if relevant_zone_name_for_tld_policy_val: tld_policy_exists_val = check_for_tld_access_policy(relevant_zone_name_for_tld_policy_val) if not tld_policy_exists_val: account_email_for_tld_val = get_cloudflare_account_email() else: logging.info("Relevant zone name for TLD policy check (from CF_ZONE_ID) could not be determined.") display_token_val = get_display_token_ui(template_tunnel_state.get("token")) cf_account_id = current_app.config.get('CF_ACCOUNT_ID') default_tunnel_id_value = template_tunnel_state.get("id") or (config.EXTERNAL_TUNNEL_ID if config.USE_EXTERNAL_CLOUDFLARED else None) zone_lookup = {} try: zones_for_lookup = list_account_zones() zone_lookup = {z.get('id'): z.get('name') for z in zones_for_lookup if z.get('id')} except Exception as zone_lookup_error: logging.debug(f"Unable to build zone lookup: {zone_lookup_error}") for rule_details in rules_for_template.values(): if rule_details.get("zone_name") or not zone_lookup: continue zone_name_from_lookup = zone_lookup.get(rule_details.get("zone_id")) if zone_name_from_lookup: rule_details["zone_name"] = zone_name_from_lookup dns_fetch_start = time.time() public_hostname_indices = {} try: effective_tunnel_id = template_tunnel_state.get("id") or config.EXTERNAL_TUNNEL_ID if not effective_tunnel_id: logging.warning("No effective tunnel ID available. Skipping DNS records fetch.") else: zone_ids_to_scan = set() cf_zone_id_cfg = current_app.config.get('CF_ZONE_ID') if cf_zone_id_cfg: zone_ids_to_scan.add(cf_zone_id_cfg) scan_zone_names = current_app.config.get('TUNNEL_DNS_SCAN_ZONE_NAMES', []) for zname in scan_zone_names: try: zid = get_zone_id_from_name(zname) if zid: zone_ids_to_scan.add(zid) except Exception: logging.debug(f"Failed to resolve zone name '{zname}' to ID", exc_info=True) collected_names = [] if zone_ids_to_scan: def fetch_zone_records(zone_id): try: return get_dns_records_for_tunnel(zone_id, effective_tunnel_id) except Exception as e: logging.error(f"Error fetching DNS records for zone {zone_id}: {e}") return [] with ThreadPoolExecutor(max_workers=min(len(zone_ids_to_scan), 5)) as executor: future_to_zone = {executor.submit(fetch_zone_records, zid): zid for zid in zone_ids_to_scan} for future in as_completed(future_to_zone): zone_id = future_to_zone[future] try: records = future.result() for r in records: name = r.get("name") if name: collected_names.append(name.lower()) except Exception as e: logging.error(f"Exception processing zone {zone_id}: {e}") unique_sorted_names = sorted(set(collected_names)) for idx, name in enumerate(unique_sorted_names): public_hostname_indices[name] = idx except Exception as e: logging.error(f"Failed to build public hostname indices: {e}", exc_info=True) public_hostname_indices = {} dns_fetch_end = time.time() dns_fetch_duration = dns_fetch_end - dns_fetch_start logging.info(f"[{request_id}] DNS records fetching took {dns_fetch_duration:.3f} seconds") end_time = time.time() total_duration = end_time - start_time logging.info(f"[{request_id}] Status page request completed in {total_duration:.3f} seconds") timing_info = { 'dns_fetch_duration': f"{dns_fetch_duration:.3f}s", 'total_duration': f"{total_duration:.3f}s", 'cache_used': CACHE_ENABLED } cache_status = None try: if hasattr(current_app, 'cache'): from app.core.cache import get_cache_stats cache_stats = get_cache_stats() cache_status = { 'connected': cache_stats.get('connected', False), 'dns_records_count': cache_stats.get('dns_records_count', 0) } except Exception as e: logging.error(f"Error getting cache status: {e}", exc_info=True) cache_status = { 'connected': False, 'dns_records_count': 0, 'error': str(e) } return render_template('status_page.html', tunnel_state=template_tunnel_state, agent_state=template_agent_state, initialization=initialization_status, rules=rules_for_template, CF_ACCOUNT_ID_CONFIGURED=bool(cf_account_id), ACCOUNT_ID_FOR_DISPLAY=cf_account_id if cf_account_id else "Not Configured", access_groups=template_access_groups, agents=template_agents, CF_ZONE_ID_CONFIGURED=bool(current_app.config.get('CF_ZONE_ID')), public_hostname_indices=public_hostname_indices, timing_info=timing_info, default_tunnel_id=default_tunnel_id_value ) from app.web.forms import ChangePasswordForm, SecuritySettingsForm, SettingsForm, CloudflareCredentialsForm from werkzeug.security import check_password_hash, generate_password_hash from cryptography.fernet import Fernet @bp.route('/access-policies', methods=['GET']) @login_required def access_policies_page(): """Renders the Access Policies page.""" groups_for_template = {} used_group_ids = set() with state_lock: for rule in managed_rules.values(): if rule.get('source') == 'docker': group_id_val = rule.get('access_group_id') if isinstance(group_id_val, list): for gid in group_id_val: used_group_ids.add(gid) elif group_id_val: used_group_ids.add(group_id_val) groups_for_template = copy.deepcopy(access_groups) try: with open(os.path.join(current_app.static_folder, 'json', 'countries.json')) as f: countries = json.load(f) except (FileNotFoundError, json.JSONDecodeError): countries = [] flash('Could not load country list for Access Group modal.', 'error') return render_template( 'access_policies.html', access_groups=groups_for_template, used_group_ids=used_group_ids, countries=countries ) @bp.route('/settings', methods=['GET', 'POST']) @login_required def settings_page(): """Renders and handles the main settings page.""" settings_form = SettingsForm(prefix='general') change_password_form = ChangePasswordForm() security_settings_form = SecuritySettingsForm(prefix='security') cf_credentials_form = CloudflareCredentialsForm(prefix='cf_creds') if request.method == 'POST': if settings_form.submit_settings.data and settings_form.validate(): data_path = os.path.dirname(config.STATE_FILE_PATH) key_file = os.path.join(data_path, 'dockflare.key') config_file = os.path.join(data_path, 'dockflare_config.dat') try: with open(key_file, 'rb') as f: key = f.read() fernet = Fernet(key) with open(config_file, 'rb') as f: decrypted_data = fernet.decrypt(f.read()) config_data = json.loads(decrypted_data) original_tunnel_name = config_data.get('tunnel_name') new_tunnel_name = settings_form.tunnel_name.data tunnel_name_changed = original_tunnel_name != new_tunnel_name config_data['tunnel_name'] = new_tunnel_name config_data['cf_zone_id'] = settings_form.cf_zone_id.data config_data['tunnel_dns_scan_zone_names'] = settings_form.tunnel_dns_scan_zone_names.data config_data['grace_period_seconds'] = settings_form.grace_period_seconds.data encrypted_payload = fernet.encrypt(json.dumps(config_data).encode('utf-8')) with open(config_file, 'wb') as f: f.write(encrypted_payload) from app import config as config_module current_app.config['TUNNEL_NAME'] = new_tunnel_name config_module.TUNNEL_NAME = new_tunnel_name current_app.config['CLOUDFLARED_CONTAINER_NAME'] = f"cloudflared-agent-{new_tunnel_name}" config_module.CLOUDFLARED_CONTAINER_NAME = f"cloudflared-agent-{new_tunnel_name}" current_app.config['CF_ZONE_ID'] = config_data['cf_zone_id'] config_module.CF_ZONE_ID = config_data['cf_zone_id'] scan_zones_str = config_data.get('tunnel_dns_scan_zone_names', '') current_app.config['TUNNEL_DNS_SCAN_ZONE_NAMES'] = [name.strip() for name in scan_zones_str.split(',') if name.strip()] config_module.TUNNEL_DNS_SCAN_ZONE_NAMES = current_app.config['TUNNEL_DNS_SCAN_ZONE_NAMES'] current_app.config['GRACE_PERIOD_SECONDS'] = int(config_data.get('grace_period_seconds', 28800)) config_module.GRACE_PERIOD_SECONDS = app_config['GRACE_PERIOD_SECONDS'] flash('General settings updated successfully.', 'success') if tunnel_name_changed and not config.USE_EXTERNAL_CLOUDFLARED: flash('Tunnel name changed. Restarting the agent to apply changes...', 'info') logging.info(f"Tunnel name changed from '{original_tunnel_name}' to '{new_tunnel_name}'. Triggering agent restart.") def restart_agent_task(): stop_cloudflared_container() time.sleep(5) initialize_tunnel() start_cloudflared_container() from threading import Thread restart_thread = Thread(target=restart_agent_task) restart_thread.start() return redirect(url_for('web.settings_page')) except Exception as e: logging.error(f"Failed to update settings in config file: {e}", exc_info=True) flash('An error occurred while saving settings.', 'danger') elif security_settings_form.submit_security_settings.data and security_settings_form.validate(): data_path = os.path.dirname(config.STATE_FILE_PATH) key_file = os.path.join(data_path, 'dockflare.key') config_file = os.path.join(data_path, 'dockflare_config.dat') try: with open(key_file, 'rb') as f: key = f.read() fernet = Fernet(key) with open(config_file, 'rb') as f: decrypted_data = fernet.decrypt(f.read()) config_data = json.loads(decrypted_data) config_data['disable_password_login'] = security_settings_form.disable_password_login.data encrypted_payload = fernet.encrypt(json.dumps(config_data).encode('utf-8')) with open(config_file, 'wb') as f: f.write(encrypted_payload) current_app.config['DISABLE_PASSWORD_LOGIN'] = config_data['disable_password_login'] flash('Security settings updated successfully.', 'success') return redirect(url_for('web.settings_page')) except Exception as e: logging.error(f"Failed to update security settings in config file: {e}", exc_info=True) flash('An error occurred while saving security settings.', 'danger') elif cf_credentials_form.submit_cloudflare_credentials.data and cf_credentials_form.validate(): data_path = os.path.dirname(config.STATE_FILE_PATH) key_file = os.path.join(data_path, 'dockflare.key') config_file = os.path.join(data_path, 'dockflare_config.dat') try: with open(key_file, 'rb') as f: key = f.read() fernet = Fernet(key) with open(config_file, 'rb') as f: decrypted_data = fernet.decrypt(f.read()) config_data = json.loads(decrypted_data) updated = False if cf_credentials_form.cf_account_id.data: config_data['cf_account_id'] = cf_credentials_form.cf_account_id.data current_app.config['CF_ACCOUNT_ID'] = config_data['cf_account_id'] config.CF_ACCOUNT_ID = config_data['cf_account_id'] updated = True if cf_credentials_form.cf_api_token.data: config_data['cf_api_token'] = cf_credentials_form.cf_api_token.data current_app.config['CF_API_TOKEN'] = config_data['cf_api_token'] config.CF_API_TOKEN = config_data['cf_api_token'] updated = True if updated: encrypted_payload = fernet.encrypt(json.dumps(config_data).encode('utf-8')) with open(config_file, 'wb') as f: f.write(encrypted_payload) flash('Cloudflare credentials updated. Re-initializing tunnel...', 'success') from threading import Thread Thread(target=initialize_tunnel).start() else: flash('No new credentials were provided.', 'info') return redirect(url_for('web.settings_page')) except Exception as e: logging.error(f"Failed to update Cloudflare credentials: {e}", exc_info=True) flash('An error occurred while updating credentials.', 'danger') if request.method == 'GET': settings_form.tunnel_name.data = current_app.config.get('TUNNEL_NAME') settings_form.cf_zone_id.data = current_app.config.get('CF_ZONE_ID') settings_form.tunnel_dns_scan_zone_names.data = ','.join(current_app.config.get('TUNNEL_DNS_SCAN_ZONE_NAMES', [])) settings_form.grace_period_seconds.data = current_app.config.get('GRACE_PERIOD_SECONDS') security_settings_form.disable_password_login.data = current_app.config.get('DISABLE_PASSWORD_LOGIN', False) template_tunnel_state = {} template_agent_state = {} with state_lock: template_tunnel_state = tunnel_state.copy() template_agent_state = cloudflared_agent_state.copy() display_token_val = get_display_token_ui(template_tunnel_state.get("token")) all_account_tunnels_list = get_all_account_cloudflare_tunnels(force_refresh=True) cf_account_id = current_app.config.get('CF_ACCOUNT_ID') return render_template( 'settings.html', settings_form=settings_form, change_password_form=change_password_form, security_settings_form=security_settings_form, cf_credentials_form=cf_credentials_form, all_account_tunnels=all_account_tunnels_list, tunnel_state=template_tunnel_state, agent_state=template_agent_state, display_token=display_token_val, cloudflared_container_name=current_app.config.get('CLOUDFLARED_CONTAINER_NAME'), docker_available=docker_client is not None, external_cloudflared=config.USE_EXTERNAL_CLOUDFLARED, external_tunnel_id=config.EXTERNAL_TUNNEL_ID, CF_ACCOUNT_ID_CONFIGURED=bool(cf_account_id), ACCOUNT_ID_FOR_DISPLAY=cf_account_id if cf_account_id else "Not Configured" ) @bp.route('/settings/reveal-master-key', methods=['POST']) @login_required def reveal_master_key(): master_key = current_app.config.get('MASTER_API_KEY') or config.MASTER_API_KEY if not master_key: data_path = os.path.dirname(config.STATE_FILE_PATH) key_file = os.path.join(data_path, 'dockflare.key') config_file = os.path.join(data_path, 'dockflare_config.dat') try: if os.path.exists(key_file) and os.path.exists(config_file): with open(key_file, 'rb') as f: key_bytes = f.read() with open(config_file, 'rb') as f: encrypted_payload = f.read() decrypted = Fernet(key_bytes).decrypt(encrypted_payload) payload = json.loads(decrypted.decode('utf-8')) master_key = payload.get('master_api_key') except Exception as err: logging.error(f"Failed to load master API key from config: {err}", exc_info=True) master_key = None if not master_key: return jsonify({"status": "error", "message": "master_api_key_not_configured"}), 404 return jsonify({"status": "success", "master_api_key": master_key}) @bp.route('/ui/cloudflare-tunnels/delete', methods=['POST']) @login_required def ui_delete_cloudflare_tunnel_route(): tunnel_id = request.form.get('tunnel_id', '').strip() confirmation = request.form.get('confirm_text', '').strip().lower() if not tunnel_id: flash('Tunnel ID is required to delete a Cloudflare tunnel.', 'danger') return redirect(url_for('web.settings_page') + '#cloudflare-tunnels') if confirmation != 'delete': flash('Deletion cancelled. Type "delete" to confirm.', 'warning') return redirect(url_for('web.settings_page') + '#cloudflare-tunnels') try: deletion_success = delete_tunnel_via_api(tunnel_id) if deletion_success: get_all_account_cloudflare_tunnels(force_refresh=True) flash('Tunnel deleted successfully from Cloudflare.', 'success') else: flash('Failed to delete the tunnel via Cloudflare API. Verify permissions and try again.', 'danger') except Exception as deletion_error: logging.error(f"Error deleting Cloudflare tunnel {tunnel_id}: {deletion_error}", exc_info=True) flash('Unexpected error deleting tunnel. Check logs for details.', 'danger') return redirect(url_for('web.settings_page') + '#cloudflare-tunnels') @bp.route('/change-password', methods=['POST']) @login_required def change_password(): """Handles the password change process.""" form = ChangePasswordForm() if form.validate_on_submit(): current_password = form.current_password.data new_password = form.new_password.data stored_hash = current_app.config.get('DOCKFLARE_PASSWORD_HASH') if stored_hash and check_password_hash(stored_hash, current_password): data_path = os.path.dirname(config.STATE_FILE_PATH) key_file = os.path.join(data_path, 'dockflare.key') config_file = os.path.join(data_path, 'dockflare_config.dat') try: with open(key_file, 'rb') as f: key = f.read() fernet = Fernet(key) with open(config_file, 'rb') as f: encrypted_data = f.read() decrypted_data = fernet.decrypt(encrypted_data) config_data = json.loads(decrypted_data) config_data['password'] = generate_password_hash(new_password) encrypted_payload = fernet.encrypt(json.dumps(config_data).encode('utf-8')) with open(config_file, 'wb') as f: f.write(encrypted_payload) current_app.config['DOCKFLARE_PASSWORD_HASH'] = config_data['password'] flash('Password changed successfully.', 'success') except Exception as e: logging.error(f"Failed to update password in config file: {e}", exc_info=True) flash('An error occurred while changing the password.', 'danger') else: flash('Incorrect current password.', 'danger') else: for field, errors in form.errors.items(): for error in errors: flash(f"{getattr(form, field).label.text}: {error}", 'danger') return redirect(url_for('web.settings_page')) @bp.route('/revert_access_policy_to_labels/', methods=['POST']) def revert_access_policy_to_labels(hostname): fqdn = hostname.split('|')[0] if not docker_client: return redirect(url_for('web.status_page')) action_status_message = f"Attempting to revert Access Policy for '{fqdn}' to label configuration..." app_id_to_delete_if_any = None state_changed_for_revert = False with state_lock: current_rule = managed_rules.get(hostname) if not current_rule: return redirect(url_for('web.status_page')) if not current_rule.get("access_policy_ui_override", False): return redirect(url_for('web.status_page')) app_id_to_delete_if_any = current_rule.get("access_app_id") current_rule["access_policy_ui_override"] = False state_changed_for_revert = True if state_changed_for_revert: save_state() publish_state_event('snapshot_refresh') if app_id_to_delete_if_any: if delete_cloudflare_access_application(app_id_to_delete_if_any): pass reconcile_state_threaded() action_status_message += " Reconciliation triggered." cloudflared_agent_state["last_action_status"] = action_status_message return redirect(url_for('web.status_page')) @bp.route('/tunnel-dns-records/') def tunnel_dns_records(tunnel_id): if not tunnel_id: return jsonify({"error": "Tunnel ID is required"}), 400 all_found_dns_records = [] zone_ids_to_scan = set() cf_zone_id = current_app.config.get('CF_ZONE_ID') if cf_zone_id: zone_ids_to_scan.add(cf_zone_id) scan_zone_names = current_app.config.get('TUNNEL_DNS_SCAN_ZONE_NAMES', []) for zone_name in scan_zone_names: resolved_zone_id = get_zone_id_from_name(zone_name) if resolved_zone_id: zone_ids_to_scan.add(resolved_zone_id) if not zone_ids_to_scan: return jsonify({"dns_records": [], "message": "No zones configured or resolved for DNS scan."}) for z_id in zone_ids_to_scan: records_in_zone = get_dns_records_for_tunnel(z_id, tunnel_id) if records_in_zone: all_found_dns_records.extend(records_in_zone) all_found_dns_records.sort(key=lambda r: r.get("name", "").lower()) return jsonify({"dns_records": all_found_dns_records}) @bp.route('/ping') def ping(): return jsonify({ "status": "ok", "timestamp": int(time.time()), "version": config.APP_VERSION, "protocol": request.environ.get('wsgi.url_scheme', 'unknown')}) @bp.route('/version/check') @login_required def version_check(): """ Check whether the running DockFlare image matches the remote tag (digest comparison). Fallback to comparing APP_VERSION to GitHub latest release tag when digest method is not possible. Returns JSON: - method: "digest" or "version" - up_to_date: true/false/null - local_digest, remote_digest (when method == "digest") - current, latest (when method == "version") - repo, tag (when available) - error (when any internal error occurred) """ repo = os.getenv('DOCKER_REPO', 'alplat/dockflare') tag = os.getenv('DOCKER_TAG', 'stable') cache_key = f"version_check:{repo}:{tag}" now = time.time() cache = getattr(current_app, '_version_check_cache', {}) cached = cache.get(cache_key) if cached and cached.get('expires_at', 0) > now: return jsonify(cached['data']) result = {"method": None, "up_to_date": None} local_digest = None remote_digest = None try: container_id = None try: with open('/proc/self/cgroup', 'r') as f: cg = f.read() import re m = re.search(r'([0-9a-f]{64})', cg) if m: container_id = m.group(1) except Exception: container_id = None if not container_id: container_id = os.getenv('HOSTNAME') if docker_client and container_id: try: try: container = docker_client.containers.get(container_id) except Exception: containers = docker_client.containers.list(all=True) container = None for c in containers: if c.id.startswith(container_id) or c.name == container_id: container = c break if container is None: raise RuntimeError("Local Docker container not found via docker client.") image = container.image attrs = getattr(image, 'attrs', {}) or {} repo_digests = attrs.get('RepoDigests') or [] if repo_digests: matched = None for rd in repo_digests: if rd.startswith(repo + "@"): matched = rd break if not matched: matched = repo_digests[0] if "@" in matched: local_digest = matched.split("@", 1)[1] else: local_digest = getattr(image, 'id', None) except Exception as e_local_img: logging.debug(f"Version check: failed to determine local image digest: {e_local_img}") try: try: hub_api_url = f"https://hub.docker.com/v2/repositories/{repo}/tags/{tag}/" r_hub = requests.get(hub_api_url, timeout=10) if r_hub.status_code == 200: hub_data = r_hub.json() pushed_at = hub_data.get('tag_last_pushed') or hub_data.get('last_updated') if pushed_at: result['remote_pushed_at'] = pushed_at logging.debug(f"Version check: found remote_pushed_at via Docker Hub API v2: {pushed_at}") except Exception as e_hub: logging.debug(f"Version check: Docker Hub API v2 lookup failed, will proceed with manifest check. Error: {e_hub}") token = None auth_url = f"https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repo}:pull" r_tok = requests.get(auth_url, timeout=10) if r_tok.status_code == 200: token = r_tok.json().get('token') headers = {'Accept': 'application/vnd.docker.distribution.manifest.v2+json'} if token: headers['Authorization'] = f"Bearer {token}" manifest_url = f"https://registry-1.docker.io/v2/{repo}/manifests/{tag}" r = requests.get(manifest_url, headers=headers, timeout=10) if r.status_code == 200: remote_digest = r.headers.get('Docker-Content-Digest') if 'remote_pushed_at' not in result: logging.debug("Version check: remote_pushed_at not found via Hub API, attempting manifest blob inspection.") try: manifest_json = r.json() config_digest = None if isinstance(manifest_json, dict): cfg = manifest_json.get('config') or {} config_digest = cfg.get('digest') if config_digest: blob_url = f"https://registry-1.docker.io/v2/{repo}/blobs/{config_digest}" r_blob = requests.get(blob_url, headers=headers, timeout=10) if r_blob.status_code == 200: try: cfg_json = r_blob.json() created = None if isinstance(cfg_json, dict): created = cfg_json.get('created') if not created: history = cfg_json.get('history') if isinstance(history, list) and history: created = history[0].get('created') if created: result['remote_pushed_at'] = created logging.debug(f"Version check: found remote_pushed_at via manifest blob: {created}") except Exception as e_blob_parse: logging.debug(f"Version check: failed to parse config blob for timestamp: {e_blob_parse}") except Exception as e_manifest_parse: logging.debug(f"Version check: failed to parse manifest for timestamp fallback: {e_manifest_parse}") except Exception as e_remote: logging.debug(f"Version check: failed to fetch remote manifest/digest: {e_remote}") if local_digest and remote_digest: result['method'] = 'digest' result['local_digest'] = local_digest result['remote_digest'] = remote_digest result['repo'] = repo result['tag'] = tag result['up_to_date'] = (local_digest == remote_digest) else: result['method'] = 'version' result['current'] = config.APP_VERSION latest = None try: gh_url = 'https://api.github.com/repos/ChrispyBacon-dev/DockFlare/releases/latest' rgh = requests.get(gh_url, timeout=10, headers={'Accept': 'application/vnd.github.v3+json'}) if rgh.status_code == 200: latest_release_data = rgh.json() latest = latest_release_data.get('tag_name') or latest_release_data.get('name') if not result.get('remote_pushed_at'): result['remote_pushed_at'] = latest_release_data.get('published_at') except Exception as e_gh: logging.debug(f"Version check: failed to fetch GitHub latest release: {e_gh}") result['latest'] = latest result['up_to_date'] = (latest is not None and result['current'] == latest) except Exception as e: logging.error(f"Error while performing version check: {e}", exc_info=True) result['error'] = str(e) result['up_to_date'] = None try: ttl = int(os.getenv('VERSION_CHECK_CACHE_TTL_SECONDS', '21600')) except Exception: ttl = 21600 cache[cache_key] = {'data': result, 'expires_at': now + ttl} setattr(current_app, '_version_check_cache', cache) return jsonify(result) @bp.route('/debug') def debug_info(): try: headers = {k: v for k, v in request.headers.items()} return jsonify({ "request": { "scheme": request.scheme, "is_secure": request.is_secure, "host": request.host, "path": request.path, "url": request.url, "headers": headers }, "environment": { "wsgi.url_scheme": request.environ.get('wsgi.url_scheme'), "HTTP_X_FORWARDED_PROTO": request.environ.get('HTTP_X_FORWARDED_PROTO') }, "timestamp": int(time.time()) }) except Exception as e: logging.error(f"Error in /debug route: {e}", exc_info=True) return jsonify({ "error": "An internal error occurred.", "status": "error", "timestamp": int(time.time()) }), 500 @bp.route('/reconciliation-status') def reconciliation_status_route(): reconciliation_info_data = getattr(current_app, 'reconciliation_info', {}) return jsonify({ "in_progress": reconciliation_info_data.get("in_progress", False), "progress": reconciliation_info_data.get("progress", 0), "total_items": reconciliation_info_data.get("total_items", 0), "processed_items": reconciliation_info_data.get("processed_items", 0), "status": reconciliation_info_data.get("status", "Not started") }) @bp.route('/start-tunnel', methods=['POST']) def start_tunnel_route(): start_cloudflared_container() time.sleep(1) return redirect(url_for('web.status_page')) @bp.route('/stop-tunnel', methods=['POST']) def stop_tunnel_route(): stop_cloudflared_container() time.sleep(1) return redirect(url_for('web.status_page')) @bp.route('/force_delete_rule/', methods=['POST']) def force_delete_rule_route(hostname): fqdn = hostname.split('|')[0] rule_removed_from_state = False dns_delete_success = False access_app_delete_success = False zone_id_for_delete = None access_app_id_for_delete = None with state_lock: rule_details = managed_rules.get(hostname) if rule_details: zone_id_for_delete = rule_details.get("zone_id") access_app_id_for_delete = rule_details.get("access_app_id") effective_tunnel_id = tunnel_state.get("id") if not config.USE_EXTERNAL_CLOUDFLARED else config.EXTERNAL_TUNNEL_ID if zone_id_for_delete and effective_tunnel_id: dns_delete_success = delete_cloudflare_dns_record(zone_id_for_delete, fqdn, effective_tunnel_id) if access_app_id_for_delete: access_app_delete_success = delete_cloudflare_access_application(access_app_id_for_delete) with state_lock: if hostname in managed_rules: del managed_rules[hostname] rule_removed_from_state = True save_state() publish_state_event('snapshot_refresh') if rule_removed_from_state and not config.USE_EXTERNAL_CLOUDFLARED: if update_cloudflare_config(): pass return redirect(url_for('web.status_page')) @bp.route('/stream-logs') def stream_logs_route(): client_id = f"client-{random.randint(1000, 9999)}" logging.info(f"Log stream client {client_id} connected.") def event_stream(): try: yield f"data: --- Log stream connected (client {client_id}) ---\n\n" last_heartbeat = time.time() while True: try: log_entry = log_queue.get(timeout=0.25) yield f"data: {log_entry}\n\n" last_heartbeat = time.time() except queue.Empty: if time.time() - last_heartbeat > 2: yield ": keepalive\n\n" last_heartbeat = time.time() time.sleep(0.1) except GeneratorExit: logging.info(f"Log stream client {client_id} disconnected.") except Exception as e_stream: logging.error(f"Error in log stream for {client_id}: {e_stream}", exc_info=True) finally: logging.info(f"Log stream for client {client_id} ended.") response = Response(event_stream(), mimetype='text/event-stream') response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' response.headers['Pragma'] = 'no-cache' response.headers['Expires'] = '0' response.headers['X-Accel-Buffering'] = 'no' response.headers['Access-Control-Allow-Origin'] = '*' response.headers['Access-Control-Allow-Methods'] = 'GET' return response @bp.route('/stream-state-updates') def stream_state_updates_route(): def event_stream(): try: while True: try: data = state_update_queue.get(timeout=30) yield f"data: {data}\n\n" except queue.Empty: yield ": heartbeat\n\n" except GeneratorExit: logging.info("State update stream client disconnected.") return Response(event_stream(), mimetype='text/event-stream') @bp.route('/ui/manual-rules/add', methods=['POST']) def ui_add_manual_rule_route(): if not docker_client: cloudflared_agent_state["last_action_status"] = "Error: Docker client unavailable." return redirect(url_for('web.status_page')) default_tunnel_id = tunnel_state.get("id") if not config.USE_EXTERNAL_CLOUDFLARED else config.EXTERNAL_TUNNEL_ID selected_tunnel_id = request.form.get('manual_tunnel_id', '').strip() if not default_tunnel_id and not selected_tunnel_id: cloudflared_agent_state["last_action_status"] = "Error: No tunnel is available for manual rule creation." return redirect(url_for('web.status_page')) target_tunnel_id = selected_tunnel_id or default_tunnel_id target_tunnel_name = None if selected_tunnel_id: tunnels = get_all_account_cloudflare_tunnels() matching_tunnel = None for t in tunnels or []: if t.get("id") == selected_tunnel_id: matching_tunnel = t break if not matching_tunnel: cloudflared_agent_state["last_action_status"] = "Error: Selected tunnel was not found in this account." return redirect(url_for('web.status_page')) target_tunnel_name = matching_tunnel.get("name") or "Unnamed Tunnel" else: target_tunnel_name = tunnel_state.get("name") if not target_tunnel_name or target_tunnel_name == "dockflare-tunnel": api_tunnel_name = get_tunnel_name_by_id(target_tunnel_id) if api_tunnel_name: target_tunnel_name = api_tunnel_name else: target_tunnel_name = "Default Tunnel" subdomain_input = request.form.get('manual_subdomain', '').strip() domain_name_input = request.form.get('manual_domain_name', '').strip() path_input = request.form.get('manual_path', '').strip() service_type_input = request.form.get('manual_service_type', '').strip().lower() service_address_input = request.form.get('manual_service_address', '').strip() zone_name_override_input = request.form.get('manual_zone_name_override', '').strip() zone_id_override_input = request.form.get('manual_zone_id', '').strip() no_tls_verify = request.form.get('manual_no_tls_verify') == 'on' origin_server_name_input = request.form.get('manual_origin_server_name', '').strip() manual_http_host_header = request.form.get('manual_http_host_header', '').strip() manual_access_group_ids = request.form.getlist('manual_access_groups') manual_access_policy_type = request.form.get('manual_access_policy_type', 'none').strip().lower() manual_auth_email = request.form.get('manual_auth_email', '').strip() if not domain_name_input or not service_type_input: cloudflared_agent_state["last_action_status"] = "Error: Domain Name and Service Type are required for manual rule." return redirect(url_for('web.status_page')) if service_type_input not in ["http_status", "bastion"] and not service_address_input: cloudflared_agent_state["last_action_status"] = f"Error: Service Address is required for type '{service_type_input.upper()}'." return redirect(url_for('web.status_page')) full_hostname = f"{subdomain_input}.{domain_name_input}" if subdomain_input else domain_name_input if not is_valid_hostname(full_hostname): cloudflared_agent_state["last_action_status"] = f"Error: Constructed hostname '{full_hostname}' is invalid." return redirect(url_for('web.status_page')) processed_path = f"/{path_input.lstrip('/')}" if path_input else None key_for_managed_rules = get_rule_key(full_hostname, processed_path) processed_service_for_cf = "" if service_type_input in ["http", "https"]: processed_service_for_cf = f"{service_type_input}://{service_address_input}" elif service_type_input in ["tcp", "ssh", "rdp"]: processed_service_for_cf = f"{service_type_input}://{service_address_input}" elif service_type_input == "http_status": processed_service_for_cf = f"http_status:{service_address_input}" elif service_type_input == "bastion": processed_service_for_cf = "bastion" if not is_valid_service(processed_service_for_cf): cloudflared_agent_state["last_action_status"] = f"Error: Constructed service string '{processed_service_for_cf}' is invalid." return redirect(url_for('web.status_page')) target_zone_id = None target_zone_name = None if zone_id_override_input: target_zone_id = zone_id_override_input try: zones_list = list_account_zones() for zone in zones_list or []: if zone.get('id') == target_zone_id: target_zone_name = zone.get('name') break except Exception: target_zone_name = None if not target_zone_id: zone_name_to_lookup = zone_name_override_input or '.'.join(domain_name_input.split('.')[-2:]) if zone_name_to_lookup: looked_up_zone_id = get_zone_id_from_name(zone_name_to_lookup) if looked_up_zone_id: target_zone_id = looked_up_zone_id target_zone_name = zone_name_to_lookup if not target_zone_id: cf_zone_id_default = current_app.config.get('CF_ZONE_ID') if cf_zone_id_default: target_zone_id = cf_zone_id_default if not target_zone_name: zone_details = get_zone_details_by_id(cf_zone_id_default) if zone_details: target_zone_name = zone_details.get('name') else: cloudflared_agent_state["last_action_status"] = "Error: Could not determine Zone ID." return redirect(url_for('web.status_page')) access_app_id = None access_policy_type = None access_app_config_hash = None access_group_id = None previous_tunnel_id = None with state_lock: if manual_access_group_ids: cf_access_policies = [] desired_session_duration = "24h" desired_app_launcher_visible = False desired_allowed_idps = None desired_auto_redirect = False for i, group_id in enumerate(manual_access_group_ids): if group_id in access_groups: group = access_groups[group_id] if i == 0: desired_session_duration = group.get("session_duration", "24h") desired_app_launcher_visible = group.get("app_launcher_visible", False) desired_allowed_idps = group.get("allowed_idps") desired_auto_redirect = group.get("auto_redirect_to_identity", False) cf_access_policies.extend(group.get("policies", [])) if cf_access_policies: access_group_id = manual_access_group_ids access_policy_type = "group" desired_app_name = f"DockFlare-{full_hostname}" access_app_config_hash = generate_access_app_config_hash( policy_type="group", session_duration=desired_session_duration, app_launcher_visible=desired_app_launcher_visible, allowed_idps_str=json.dumps(desired_allowed_idps, sort_keys=True), auto_redirect_to_identity=desired_auto_redirect, custom_access_rules_str=json.dumps(cf_access_policies, sort_keys=True), group_id=','.join(access_group_id) ) existing_app = find_cloudflare_access_application_by_hostname(full_hostname) if existing_app: app_result = update_cloudflare_access_application( existing_app['id'], full_hostname, desired_app_name, desired_session_duration, desired_app_launcher_visible, [full_hostname], cf_access_policies, desired_allowed_idps, desired_auto_redirect ) else: app_result = create_cloudflare_access_application( full_hostname, desired_app_name, desired_session_duration, desired_app_launcher_visible, [full_hostname], cf_access_policies, desired_allowed_idps, desired_auto_redirect ) if app_result: access_app_id = app_result.get('id') else: cloudflared_agent_state["last_action_status"] = "Error: Failed to create/update Access App for group(s)." elif manual_access_policy_type and manual_access_policy_type != 'none': cf_access_policies = [] if manual_access_policy_type == "bypass": cf_access_policies = [{"name": "UI Manual Public Bypass", "decision": "bypass", "include": [{"everyone": {}}]}] elif manual_access_policy_type == "authenticate_email": if not manual_auth_email: cloudflared_agent_state["last_action_status"] = "Error: Email is required for this policy type." return redirect(url_for('web.status_page')) cf_access_policies = [ {"name": f"UI Allow Access for {manual_auth_email}", "decision": "allow", "include": [{"email": {"email": manual_auth_email}}]} ] cf_access_policies.append({"name": "UI Deny Fallback", "decision": "deny", "include": [{"everyone": {}}]}) app_result = create_cloudflare_access_application( full_hostname, f"DockFlare-{full_hostname}", "24h", False, [full_hostname], cf_access_policies, None, False ) if app_result: access_app_id = app_result.get('id') access_policy_type = manual_access_policy_type else: cloudflared_agent_state["last_action_status"] = "Error: Failed to create Access App for manual policy." with state_lock: existing_rule = managed_rules.get(key_for_managed_rules) if existing_rule and existing_rule.get("source") == "docker": cloudflared_agent_state["last_action_status"] = f"Error: Rule for {full_hostname} is Docker-managed." return redirect(url_for('web.status_page')) if existing_rule: previous_tunnel_id = existing_rule.get("tunnel_id") or default_tunnel_id managed_rules[key_for_managed_rules] = { "hostname": full_hostname, "path": processed_path, "service": processed_service_for_cf, "container_id": None, "status": "active", "delete_at": None, "zone_id": target_zone_id, "zone_name": target_zone_name, "no_tls_verify": no_tls_verify, "origin_server_name": origin_server_name_input or None, "http_host_header": manual_http_host_header or None, "source": "manual", "access_app_id": access_app_id, "access_policy_type": access_policy_type, "access_app_config_hash": access_app_config_hash, "access_group_id": access_group_id, "access_policy_ui_override": True, "rule_ui_override": False, "tunnel_id": target_tunnel_id, "tunnel_name": target_tunnel_name } save_state() publish_state_event('snapshot_refresh') if update_cloudflare_config(target_tunnel_id): create_cloudflare_dns_record(target_zone_id, full_hostname, target_tunnel_id) if previous_tunnel_id and previous_tunnel_id != target_tunnel_id: update_cloudflare_config(previous_tunnel_id) if previous_tunnel_id is None and default_tunnel_id and default_tunnel_id != target_tunnel_id: update_cloudflare_config(default_tunnel_id) cloudflared_agent_state["last_action_status"] = f"Success: Manual rule for {full_hostname} added/updated." else: cloudflared_agent_state["last_action_status"] = "Error: Failed to update Cloudflare tunnel config." return redirect(url_for('web.status_page')) @bp.route('/ui/docker-rules/revert', methods=['POST']) def ui_revert_docker_rule_route(): """ Reverts a UI-overridden Docker rule back to label-driven configuration. """ if not docker_client: cloudflared_agent_state["last_action_status"] = "Error: Docker client unavailable." return redirect(url_for('web.status_page')) rule_key = request.form.get('rule_key') if not rule_key: cloudflared_agent_state["last_action_status"] = "Error: Missing rule key for revert." return redirect(url_for('web.status_page')) with state_lock: existing = managed_rules.get(rule_key) if not existing: cloudflared_agent_state["last_action_status"] = f"Error: Rule '{rule_key}' not found." return redirect(url_for('web.status_page')) if existing.get("source") != "docker": cloudflared_agent_state["last_action_status"] = f"Error: Rule '{rule_key}' is not a Docker rule." return redirect(url_for('web.status_page')) if not existing.get("rule_ui_override", False): cloudflared_agent_state["last_action_status"] = f"Info: Rule '{rule_key}' is not UI-overridden." return redirect(url_for('web.status_page')) # Revert the rule back to Docker label control existing["rule_ui_override"] = False save_state() cloudflared_agent_state["last_action_status"] = f"Success: Rule '{rule_key}' reverted to Docker label control. Reconciliation will update it based on container labels." # Trigger reconciliation to pick up Docker labels try: from app.core.reconciler import reconcile_state_threaded reconcile_state_threaded() except Exception as e: logging.error(f"Failed to trigger reconciliation after Docker rule revert: {e}") return redirect(url_for('web.status_page')) @bp.route('/ui/manual-rules/edit', methods=['POST']) def ui_edit_manual_rule_route(): """ Handles editing an existing manual rule submitted from the UI. Expects a hidden 'edit_rule_key' form field identifying the rule key to edit. Only rules with source != 'docker' (manual or agent) are editable via UI. """ if not docker_client: cloudflared_agent_state["last_action_status"] = "Error: Docker client unavailable." return redirect(url_for('web.status_page')) rule_key = request.form.get('edit_rule_key') if not rule_key: cloudflared_agent_state["last_action_status"] = "Error: Missing rule key for edit." return redirect(url_for('web.status_page')) with state_lock: existing = managed_rules.get(rule_key) if not existing: cloudflared_agent_state["last_action_status"] = f"Error: Rule '{rule_key}' not found." return redirect(url_for('web.status_page')) # Allow editing Docker rules but mark them as UI-overridden is_docker_rule = existing.get("source") == "docker" subdomain_input = request.form.get('edit_subdomain', '').strip() domain_name_input = request.form.get('edit_domain_name', '').strip() path_input = request.form.get('edit_path', '').strip() service_address_input = request.form.get('edit_service_address', '').strip() service_type_input = request.form.get('edit_service_type', '').strip().lower() zone_name_override_input = request.form.get('edit_zone_name_override', '').strip() no_tls_verify = request.form.get('edit_no_tls_verify') == 'on' origin_server_name_input = request.form.get('edit_origin_server_name', '').strip() manual_http_host_header = request.form.get('edit_http_host_header', '').strip() if not domain_name_input or not service_type_input: cloudflared_agent_state["last_action_status"] = "Error: Domain and service type required." return redirect(url_for('web.status_page')) full_hostname = f"{subdomain_input}.{domain_name_input}" if subdomain_input else domain_name_input if not is_valid_hostname(full_hostname): cloudflared_agent_state["last_action_status"] = f"Error: Invalid hostname '{full_hostname}'." return redirect(url_for('web.status_page')) processed_path = f"/{path_input.lstrip('/')}" if path_input else None processed_service_for_cf = "" if service_type_input in ["http", "https"]: processed_service_for_cf = f"{service_type_input}://{service_address_input}" elif service_type_input in ["tcp", "ssh", "rdp"]: processed_service_for_cf = f"{service_type_input}://{service_address_input}" elif service_type_input == "http_status": processed_service_for_cf = f"http_status:{service_address_input}" elif service_type_input == "bastion": processed_service_for_cf = "bastion" if not is_valid_service(processed_service_for_cf): cloudflared_agent_state["last_action_status"] = f"Error: Invalid service string '{processed_service_for_cf}'." return redirect(url_for('web.status_page')) zone_name_to_lookup = zone_name_override_input or '.'.join(domain_name_input.split('.')[-2:]) target_zone_id = get_zone_id_from_name(zone_name_to_lookup) or current_app.config.get('CF_ZONE_ID') if not target_zone_id: cloudflared_agent_state["last_action_status"] = "Error: Could not determine Zone ID." return redirect(url_for('web.status_page')) manual_access_group_ids = request.form.getlist('edit_access_groups') manual_access_policy_type = request.form.get('edit_access_policy_type', 'none').strip().lower() manual_auth_email = request.form.get('edit_auth_email', '').strip() access_app_id = existing.get('access_app_id') access_policy_type = existing.get('access_policy_type') access_app_config_hash = existing.get('access_app_config_hash') access_group_id = existing.get('access_group_id') try: if manual_access_group_ids: cf_access_policies = [] desired_session_duration = "24h" desired_app_launcher_visible = False desired_allowed_idps = None desired_auto_redirect = False for i, group_id in enumerate(manual_access_group_ids): if group_id in access_groups: group = access_groups[group_id] if i == 0: desired_session_duration = group.get("session_duration", "24h") desired_app_launcher_visible = group.get("app_launcher_visible", False) desired_allowed_idps = group.get("allowed_idps") desired_auto_redirect = group.get("auto_redirect_to_identity", False) cf_access_policies.extend(group.get("policies", [])) if cf_access_policies: access_group_id = manual_access_group_ids access_policy_type = "group" desired_app_name = f"DockFlare-{full_hostname}" access_app_config_hash = generate_access_app_config_hash( policy_type="group", session_duration=desired_session_duration, app_launcher_visible=desired_app_launcher_visible, allowed_idps_str=json.dumps(desired_allowed_idps, sort_keys=True), auto_redirect_to_identity=desired_auto_redirect, custom_access_rules_str=json.dumps(cf_access_policies, sort_keys=True), group_id=','.join(access_group_id) ) existing_app = find_cloudflare_access_application_by_hostname(full_hostname) if existing_app: app_result = update_cloudflare_access_application( existing_app['id'], full_hostname, desired_app_name, desired_session_duration, desired_app_launcher_visible, [full_hostname], cf_access_policies, desired_allowed_idps, desired_auto_redirect ) else: app_result = create_cloudflare_access_application( full_hostname, desired_app_name, desired_session_duration, desired_app_launcher_visible, [full_hostname], cf_access_policies, desired_allowed_idps, desired_auto_redirect ) if app_result: access_app_id = app_result.get('id') elif manual_access_policy_type and manual_access_policy_type != 'none': cf_access_policies = [] if manual_access_policy_type == "bypass": cf_access_policies = [{"name": "UI Manual Public Bypass", "decision": "bypass", "include": [{"everyone": {}}]}] elif manual_access_policy_type == "authenticate_email": if not manual_auth_email: cloudflared_agent_state["last_action_status"] = "Error: Email required for policy." return redirect(url_for('web.status_page')) cf_access_policies = [ {"name": f"UI Allow Email {manual_auth_email}", "decision": "allow", "include": [{"email": {"email": manual_auth_email}}]}, {"name": "UI Deny Fallback", "decision": "deny", "include": [{"everyone": {}}]} ] if cf_access_policies: app_result = create_cloudflare_access_application( full_hostname, f"DockFlare-{full_hostname}", "24h", False, [full_hostname], cf_access_policies, None, False ) if app_result: access_app_id = app_result.get('id') access_policy_type = manual_access_policy_type except Exception as e: logging.error(f"Error updating access app during manual edit: {e}", exc_info=True) cloudflared_agent_state["last_action_status"] = "Error: Failed to update access app." with state_lock: new_key = get_rule_key(full_hostname, processed_path) rule_entry = { "hostname": full_hostname, "path": processed_path, "service": processed_service_for_cf, "container_id": existing.get("container_id"), "status": "active", "delete_at": None, "zone_id": target_zone_id, "no_tls_verify": no_tls_verify, "origin_server_name": origin_server_name_input or None, "http_host_header": manual_http_host_header or None, "access_app_id": access_app_id, "access_policy_type": access_policy_type, "access_app_config_hash": access_app_config_hash, "access_group_id": access_group_id, "access_policy_ui_override": True, "rule_ui_override": is_docker_rule, "source": existing.get("source", "manual") } if new_key != rule_key and rule_key in managed_rules: del managed_rules[rule_key] managed_rules[new_key] = rule_entry save_state() publish_state_event('snapshot_refresh') effective_tunnel_id = tunnel_state.get("id") if not config.USE_EXTERNAL_CLOUDFLARED else config.EXTERNAL_TUNNEL_ID if update_cloudflare_config(): if effective_tunnel_id: create_cloudflare_dns_record(target_zone_id, full_hostname, effective_tunnel_id) cloudflared_agent_state["last_action_status"] = f"Success: Manual rule '{full_hostname}' updated." else: cloudflared_agent_state["last_action_status"] = "Error: Failed to update Cloudflare tunnel config." return redirect(url_for('web.status_page')) @bp.route('/ui/manual-rules/delete/', methods=['POST']) def ui_delete_manual_rule_route(rule_key_from_url): if not docker_client: cloudflared_agent_state["last_action_status"] = "Error: Docker client unavailable." return redirect(url_for('web.status_page')) default_tunnel_id = tunnel_state.get("id") if not config.USE_EXTERNAL_CLOUDFLARED else config.EXTERNAL_TUNNEL_ID zone_id_for_delete = None access_app_id_for_delete = None hostname_for_dns = None rule_tunnel_id = None with state_lock: rule_details = managed_rules.get(rule_key_from_url) if not rule_details or rule_details.get("source") != "manual": cloudflared_agent_state["last_action_status"] = "Error: Manual rule not found." return redirect(url_for('web.status_page')) zone_id_for_delete = rule_details.get("zone_id") access_app_id_for_delete = rule_details.get("access_app_id") hostname_for_dns = rule_details.get("hostname") rule_tunnel_id = rule_details.get("tunnel_id") or default_tunnel_id del managed_rules[rule_key_from_url] save_state() publish_state_event('snapshot_refresh') dns_deleted_ok = True if hostname_for_dns and zone_id_for_delete and rule_tunnel_id: should_delete_dns = True with state_lock: for other_rule in managed_rules.values(): if other_rule.get("hostname") == hostname_for_dns: should_delete_dns = False break if should_delete_dns: if not delete_cloudflare_dns_record(zone_id_for_delete, hostname_for_dns, rule_tunnel_id): dns_deleted_ok = False if access_app_id_for_delete: delete_cloudflare_access_application(access_app_id_for_delete) config_updated = update_cloudflare_config(rule_tunnel_id) if rule_tunnel_id else update_cloudflare_config() if dns_deleted_ok and config_updated: cloudflared_agent_state["last_action_status"] = "Success: Manual rule deleted." else: issues = [] if not dns_deleted_ok: issues.append("DNS not removed") if not config_updated: issues.append("tunnel config update failed") cloudflared_agent_state["last_action_status"] = "Warning: Manual rule removed (" + ", ".join(issues) + ")" return redirect(url_for('web.status_page')) def _parse_and_build_policy_from_form(email_str, ip_ranges_str=None, countries_list=None): policies = [] allow_include_rules = [] if email_str and email_str.strip(): email_parts = [part.strip() for part in email_str.split(',') if part.strip()] for part in email_parts: if part.startswith('@'): allow_include_rules.append({"email_domain": {"domain": part[1:]}}) else: allow_include_rules.append({"email": {"email": part}}) if ip_ranges_str and ip_ranges_str.strip(): ip_parts = [part.strip() for part in ip_ranges_str.split(',') if part.strip()] for ip in ip_parts: allow_include_rules.append({"ip": {"ip": ip}}) if allow_include_rules: policies.append({"name": "Allow defined users and IPs", "decision": "allow", "include": allow_include_rules}) if countries_list: country_rules = [{"geo": {"country_code": country.upper()}} for country in countries_list] policies.append({ "name": "Block selected countries", "decision": "bypass", "include": [{"everyone": {}}], "exclude": country_rules }) elif allow_include_rules: policies.append({"name": "Default Deny", "decision": "deny", "include": [{"everyone": {}}]}) else: policies.append({"name": "Default Deny (No rules defined)", "decision": "deny", "include": [{"everyone": {}}]}) return policies @bp.route('/ui/access-groups/create', methods=['POST']) def create_access_group(): form = request.form group_id = form.get('group_id', '').strip() display_name = form.get('display_name', '').strip() if not group_id or not display_name: flash("Error: Group ID and Display Name are required.", "error") return redirect(url_for('web.access_policies_page')) with state_lock: if group_id in access_groups: flash(f"Error: Access Group with ID '{group_id}' already exists.", "error") return redirect(url_for('web.access_policies_page')) new_group = { "id": group_id, "display_name": display_name, "session_duration": form.get('session_duration', '24h').strip(), "app_launcher_visible": form.get('app_launcher_visible') == 'on', "auto_redirect_to_identity": form.get('auto_redirect') == 'on', "policies": _parse_and_build_policy_from_form( form.get('emails', ''), form.get('ip_ranges', ''), request.form.getlist('countries') ) } access_groups[group_id] = new_group save_state() publish_state_event('snapshot_refresh') flash(f"Success: Access Group '{display_name}' created.", "success") return redirect(url_for('web.access_policies_page')) @bp.route('/ui/access-groups/edit/', methods=['POST']) def edit_access_group(group_id): with state_lock: if group_id not in access_groups: flash(f"Error: Access Group with ID '{group_id}' not found.", "error") return redirect(url_for('web.access_policies_page')) form = request.form display_name = form.get('display_name', '').strip() if not display_name: flash("Error: Display Name is required.", "error") return redirect(url_for('web.access_policies_page')) with state_lock: updated_group = { "id": group_id, "display_name": display_name, "session_duration": form.get('session_duration', '24h').strip(), "app_launcher_visible": form.get('app_launcher_visible') == 'on', "auto_redirect_to_identity": form.get('auto_redirect') == 'on', "policies": _parse_and_build_policy_from_form( form.get('emails', ''), form.get('ip_ranges', ''), request.form.getlist('countries') ) } access_groups[group_id] = updated_group save_state() publish_state_event('snapshot_refresh') flash(f"Success: Access Group '{display_name}' updated. Triggering reconciliation.", "success") reconcile_state_threaded() return redirect(url_for('web.access_policies_page')) @bp.route('/ui/access-groups/delete/', methods=['POST']) def delete_access_group(group_id): with state_lock: if group_id not in access_groups: flash(f"Error: Access Group with ID '{group_id}' not found.", "error") return redirect(url_for('web.access_policies_page')) is_in_use = any( (isinstance(rule.get('access_group_id'), list) and group_id in rule.get('access_group_id')) or \ (rule.get('access_group_id') == group_id) for rule in managed_rules.values() ) if is_in_use: flash(f"Error: Cannot delete Access Group '{access_groups[group_id]['display_name']}' because it is currently in use.", "error") return redirect(url_for('web.access_policies_page')) display_name = access_groups[group_id]['display_name'] del access_groups[group_id] save_state() publish_state_event('snapshot_refresh') flash(f"Success: Access Group '{display_name}' has been deleted.", "success") return redirect(url_for('web.access_policies_page')) @bp.route('/cloudflare-ping') def cloudflare_ping_route(): try: cf_headers = {k: v for k, v in request.headers.items() if k.lower().startswith('cf-')} visitor_data = json.loads(request.headers.get('Cf-Visitor', '{}')) return jsonify({ "status": "ok", "timestamp": int(time.time()), "cloudflare": { "connecting_ip": request.headers.get('Cf-Connecting-Ip') or request.remote_addr, "visitor": visitor_data, "ray": request.headers.get('Cf-Ray') }, "request": { "host": request.host, "path": request.path, "scheme": request.scheme }, "server": { "wsgi_url_scheme": request.environ.get('wsgi.url_scheme') } }) except Exception as e_cfping: logging.error(f"Error in /cloudflare-ping route: {e_cfping}", exc_info=True) return jsonify({ "error": "An internal error occurred.", "status": "error", "timestamp": int(time.time()) }), 500 @bp.route('/backup/download') def download_state_backup(): try: buffer, filename = backup_manager.create_backup_archive() return send_file( buffer, as_attachment=True, download_name=filename, mimetype='application/zip' ) except FileNotFoundError as missing_file: logging.error("Error generating backup archive: %s", missing_file) return "Backup failed: required data file missing.", 400 except Exception as e: logging.error(f"Error generating backup archive: {e}", exc_info=True) return "Failed to generate backup.", 500 @bp.route('/backup/restore', methods=['POST']) def restore_state_backup(): if 'backup_file' not in request.files: cloudflared_agent_state["last_action_status"] = "Error: No backup file provided." return redirect(url_for('web.settings_page')) file = request.files['backup_file'] if file.filename == '': cloudflared_agent_state["last_action_status"] = "Error: No file selected for restore." return redirect(url_for('web.settings_page')) try: result = backup_manager.restore_backup(file, allow_legacy_json=True) is_full_archive = result.mode != "legacy_state" backup_manager.refresh_runtime_after_restore(result) status_parts = ["Success: Backup restored."] if is_full_archive: status_parts.append("DockFlare will restart automatically to apply the backup.") else: status_parts.append("(legacy state.json applied – reconfigure secrets manually)") cloudflared_agent_state["last_action_status"] = " ".join(status_parts) if "state.json" in result.files_applied: reconcile_state_threaded() if is_full_archive: return render_template('restore_restarting.html', countdown_seconds=5) except Exception as e: logging.error(f"Error restoring backup: {e}", exc_info=True) cloudflared_agent_state["last_action_status"] = "Error: Restore failed. The file may be corrupt or invalid. Check logs." return redirect(url_for('web.settings_page')) @bp.route('/login', methods=['GET', 'POST']) def login(): if current_user.is_authenticated: return redirect(url_for('web.status_page')) from .forms import LoginForm form = LoginForm() password_login_enabled = not current_app.config.get('DISABLE_PASSWORD_LOGIN', False) if password_login_enabled and form.validate_on_submit(): username = form.username.data password = form.password.data stored_username = current_app.config.get('DOCKFLARE_USERNAME') stored_hash = current_app.config.get('DOCKFLARE_PASSWORD_HASH') from werkzeug.security import check_password_hash if (username == stored_username and stored_hash and check_password_hash(stored_hash, password)): user = User(stored_username, auth_method='password') login_user(user) next_page = request.args.get('next') if next_page and is_safe_url(next_page): return redirect(next_page) return redirect(url_for('web.status_page')) else: flash('Invalid username or password.', 'error') oauth_providers = [ p for p in current_app.config.get('OAUTH_PROVIDERS', []) if p.get('enabled') ] return render_template( 'login.html', title="Login", form=form, password_login_enabled=password_login_enabled, oauth_providers=oauth_providers ) @bp.route('/login/') def login_provider(provider_id): import secrets state_token = secrets.token_urlsafe(32) session['oauth_state'] = state_token from app import oauth callback_url = url_for('web.auth_callback', provider_id=provider_id, _external=True) return oauth.create_client(provider_id).authorize_redirect(callback_url, state=state_token) @bp.route('/auth//callback') def auth_callback(provider_id): received_state = request.args.get('state') expected_state = session.pop('oauth_state', None) if not received_state or not expected_state or received_state != expected_state: flash('Invalid authentication state. Please try again.', 'error') return redirect(url_for('web.login')) from app import oauth client = oauth.create_client(provider_id) try: token = client.authorize_access_token() userinfo = client.userinfo() except Exception as e: logging.error(f"OAuth callback error for provider {provider_id}: {e}", exc_info=True) flash('Authentication failed.', 'error') return redirect(url_for('web.login')) user_email = userinfo.get('email') if not user_email: flash('Could not retrieve email from provider. Cannot log in.', 'error') return redirect(url_for('web.login')) authorized_emails = current_app.config.get('OAUTH_AUTHORIZED_USERS', []) if user_email not in authorized_emails: flash(f'Access denied for user {user_email}.', 'error') return redirect(url_for('web.login')) user = User(user_email, auth_method='oauth') login_user(user) logging.info(f"OAUTH_SUCCESS: User {user_email} authenticated via {provider_id} from {request.remote_addr}") next_page = request.args.get('next') if next_page and is_safe_url(next_page): return redirect(next_page) return redirect(url_for('web.status_page')) @bp.route('/logout') @login_required def logout(): auth_method = getattr(current_user, 'auth_method', 'password') logout_user() if auth_method == 'oauth': flash('You have been logged out of DockFlare. You may still be logged into your OAuth provider.', 'info') else: flash('You have been logged out.', 'success') if current_app.config.get('DISABLE_PASSWORD_LOGIN'): oauth_providers = current_app.config.get('OAUTH_PROVIDERS', []) if oauth_providers: return redirect(url_for('web.login')) else: return redirect(url_for('web.status_page')) return redirect(url_for('web.login')) def is_safe_url(target): from urllib.parse import urlparse, urljoin from flask import request ref_url = urlparse(request.host_url) test_url = urlparse(urljoin(request.host_url, target)) return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc