mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-26 10:50:43 +00:00
1933 lines
86 KiB
Python
1933 lines
86 KiB
Python
# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels.
|
||
# Copyright (C) 2025 ChrispyBacon-Dev <https://github.com/ChrispyBacon-dev/DockFlare>
|
||
#
|
||
# 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 <https://www.gnu.org/licenses/>.
|
||
#
|
||
# 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/<agent_id>/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/<path:hostname>', 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/<tunnel_id>')
|
||
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/<path:hostname>', 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/<path:rule_key_from_url>', 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/<group_id>', 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/<group_id>', 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/<provider_id>')
|
||
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/<provider_id>/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
|