DockFlare/dockflare/app/web/routes.py
ChrispyBacon-dev 586769ec4d
Some checks are pending
Docker Image Build and Push / build (push) Waiting to run
check version
2025-08-28 18:09:13 +02:00

1504 lines
70 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 it 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/>.
# app/web/routes.py
import copy
import logging
import time
import os
import random
import queue
from datetime import datetime, timezone
import traceback
import json
import io
import requests
from flask import send_file
from app.core import access_manager
from urllib.parse import urlparse, urlunparse
from flask import (
Blueprint, render_template, jsonify, redirect, url_for, request, Response,
stream_with_context, current_app, session, flash
)
from flask_login import current_user, login_required, login_user
from app.core.user import User
from app import config, docker_client, tunnel_state, cloudflared_agent_state, log_queue
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
)
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
bp = Blueprint('web', __name__)
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():
is_configured = getattr(current_app, 'is_configured', False)
if not is_configured:
if request.endpoint and not request.endpoint.startswith('setup.') and request.endpoint != 'static':
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', '')
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'):
if 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']
if request.endpoint and not request.endpoint.startswith('auth.') and request.endpoint not in exempt_endpoints:
try:
return redirect(url_for('auth.login'))
except:
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}"
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
}
@bp.route('/')
@login_required
def status_page():
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 = {}
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)
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')
# Build public hostname index mapping for Cloudflare Zero Trust UI links
public_hostname_indices = {}
try:
effective_tunnel_id = template_tunnel_state.get("id") or config.EXTERNAL_TUNNEL_ID
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 effective_tunnel_id and zone_ids_to_scan:
for zid in zone_ids_to_scan:
try:
recs = get_dns_records_for_tunnel(zid, effective_tunnel_id)
for r in recs:
name = r.get("name")
if name:
collected_names.append(name.lower())
except Exception:
logging.debug(f"Failed to fetch DNS records for zone {zid} / tunnel {effective_tunnel_id}", exc_info=True)
unique_sorted_names = sorted(set(collected_names))
for idx, name in enumerate(unique_sorted_names):
public_hostname_indices[name] = idx
except Exception:
logging.debug("Failed to build public hostname indices", exc_info=True)
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,
CF_ZONE_ID_CONFIGURED=bool(current_app.config.get('CF_ZONE_ID')),
public_hostname_indices=public_hostname_indices
)
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')
# Distinguish between form submissions
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 = current_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')
# Populate forms for GET request
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()
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('/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()
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()
# simple in-memory cache attached to app to limit upstream requests
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:
# Attempt to determine local container id (works when running in a container)
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:
# fallback to HOSTNAME env (often the short container id)
container_id = os.getenv('HOSTNAME')
# If docker client available, attempt to read image/digest
if docker_client and container_id:
try:
# Try exact 64-char id first, otherwise attempt by short id/hostname
try:
container = docker_client.containers.get(container_id)
except Exception:
# try to find by matching short id
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:
# Prefer the digest entry that matches configured repo if present
matched = None
for rd in repo_digests:
# rd example: "alplat/dockflare@sha256:..."
if rd.startswith(repo + "@"):
matched = rd
break
if not matched:
matched = repo_digests[0]
if "@" in matched:
local_digest = matched.split("@", 1)[1]
else:
# fallback to image id (sha256:...)
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 to fetch manifest from Docker Hub (Registry v2) to get Docker-Content-Digest
try:
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')
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:
# Fallback: compare APP_VERSION against GitHub releases latest tag
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 = rgh.json().get('tag_name') or rgh.json().get('name')
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
# Store in cache for TTL
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()
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 f": 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('/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'))
effective_tunnel_id = tunnel_state.get("id") if not config.USE_EXTERNAL_CLOUDFLARED else config.EXTERNAL_TUNNEL_ID
if not effective_tunnel_id:
cloudflared_agent_state["last_action_status"] = "Error: Tunnel not initialized or essential Tunnel ID missing."
return redirect(url_for('web.status_page'))
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()
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'))
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"] = f"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
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: # Use first group for app settings
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"] = f"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}}]},
{"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"] = f"Error: Failed to create Access App for manual policy."
with state_lock:
if key_for_managed_rules in managed_rules and managed_rules[key_for_managed_rules].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'))
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,
"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": bool(access_app_id)
}
save_state()
if update_cloudflare_config():
create_cloudflare_dns_record(target_zone_id, full_hostname, effective_tunnel_id)
cloudflared_agent_state["last_action_status"] = f"Success: Manual rule for {full_hostname} added/updated."
else:
cloudflared_agent_state["last_action_status"] = f"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: System not ready to delete manual rule. Docker client unavailable."
return redirect(url_for('web.status_page'))
fqdn_for_dns = rule_key_from_url.split('|')[0]
effective_tunnel_id = tunnel_state.get("id") if not config.USE_EXTERNAL_CLOUDFLARED else config.EXTERNAL_TUNNEL_ID
if not effective_tunnel_id:
cloudflared_agent_state["last_action_status"] = "Error: System not ready to delete manual rule. Tunnel not initialized or ID missing."
return redirect(url_for('web.status_page'))
rule_key_in_state = rule_key_from_url
logging.info(f"UI request: Delete manual rule for key: {rule_key_in_state}")
zone_id_for_delete = None
access_app_id_for_delete = None
rule_existed_as_manual_and_deleted = False
with state_lock:
rule_details = managed_rules.get(rule_key_in_state)
if rule_details and rule_details.get("source") == "manual":
logging.info(f"Found manual rule for {rule_key_in_state} to delete. Details: {rule_details}")
zone_id_for_delete = rule_details.get("zone_id")
access_app_id_for_delete = rule_details.get("access_app_id")
del managed_rules[rule_key_in_state]
save_state()
rule_existed_as_manual_and_deleted = True
action_status_message = f"Manual rule {rule_key_in_state} removed from local state."
elif rule_details:
action_status_message = f"Error: Rule for {rule_key_in_state} is not a manual rule. Cannot delete via this action."
cloudflared_agent_state["last_action_status"] = action_status_message
return redirect(url_for('web.status_page'))
else:
action_status_message = f"Info: Manual rule for {rule_key_in_state} not found to delete."
cloudflared_agent_state["last_action_status"] = action_status_message
return redirect(url_for('web.status_page'))
if rule_existed_as_manual_and_deleted:
dns_deleted_successfully = False
access_app_deleted_successfully = False
should_delete_dns_record = True
if fqdn_for_dns:
with state_lock:
for other_key in managed_rules.keys():
if other_key.split('|')[0] == fqdn_for_dns:
should_delete_dns_record = False
logging.info(f"DNS for {fqdn_for_dns} will NOT be deleted as other rules still use it (e.g., {other_key}).")
break
else:
should_delete_dns_record = False
logging.error(f"Cannot perform DNS deletion as FQDN could not be parsed from rule key {rule_key_in_state}.")
if should_delete_dns_record and zone_id_for_delete and fqdn_for_dns:
logging.info(f"Attempting DNS delete for {fqdn_for_dns} (from rule {rule_key_in_state}) in zone {zone_id_for_delete}")
if delete_cloudflare_dns_record(zone_id_for_delete, fqdn_for_dns, effective_tunnel_id):
dns_deleted_successfully = True
logging.info(f"DNS record for {fqdn_for_dns} deleted successfully.")
else:
logging.error(f"Failed to delete DNS record for {fqdn_for_dns}.")
elif not should_delete_dns_record:
dns_deleted_successfully = True
if fqdn_for_dns:
logging.info(f"DNS deletion for {fqdn_for_dns} skipped because it's still in use.")
if access_app_id_for_delete:
logging.info(f"Attempting Access App delete for manual rule {rule_key_in_state}, App ID {access_app_id_for_delete}")
is_app_id_shared = False
with state_lock:
for other_key, other_rule in managed_rules.items():
if other_rule.get("access_app_id") == access_app_id_for_delete:
is_app_id_shared = True
logging.info(f"Access App ID {access_app_id_for_delete} is still in use by rule {other_key}. Will not delete.")
break
if not is_app_id_shared:
if delete_cloudflare_access_application(access_app_id_for_delete):
access_app_deleted_successfully = True
logging.info(f"Access App ID {access_app_id_for_delete} deleted successfully.")
else:
logging.error(f"Failed to delete Access App ID {access_app_id_for_delete}.")
else:
access_app_deleted_successfully = True
else:
logging.info(f"No Access App ID found for manual rule {rule_key_in_state}, skipping Access App deletion.")
access_app_deleted_successfully = True
if update_cloudflare_config():
action_status_message = f"Success: Manual rule {rule_key_in_state} deleted. DNS: {'OK' if dns_deleted_successfully else 'Fail/Skip'}. AccessApp: {'OK' if access_app_deleted_successfully else 'Fail/Skip'}. Tunnel config updated."
else:
action_status_message = f"Warning: Manual rule {rule_key_in_state} deleted. DNS/AccessApp processed. Tunnel config update FAILED."
cloudflared_agent_state["last_action_status"] = action_status_message
return redirect(url_for('web.status_page'))
@bp.route('/ui/manual-rules/edit', methods=['POST'])
def ui_edit_manual_rule_route():
if not docker_client:
cloudflared_agent_state["last_action_status"] = "Error: Docker client unavailable."
return redirect(url_for('web.status_page'))
effective_tunnel_id = tunnel_state.get("id") if not config.USE_EXTERNAL_CLOUDFLARED else config.EXTERNAL_TUNNEL_ID
if not effective_tunnel_id:
cloudflared_agent_state["last_action_status"] = "Error: Tunnel not initialized or essential Tunnel ID missing."
return redirect(url_for('web.status_page'))
original_rule_key = request.form.get('original_rule_key', '').strip()
if not original_rule_key:
cloudflared_agent_state["last_action_status"] = "Error: Original rule key was missing from the edit request."
return redirect(url_for('web.status_page'))
with state_lock:
original_rule_details = managed_rules.get(original_rule_key)
if not original_rule_details:
cloudflared_agent_state["last_action_status"] = f"Error: Could not find original rule '{original_rule_key}' to edit."
return redirect(url_for('web.status_page'))
is_manual_source = original_rule_details.get("source") == "manual"
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()
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."
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
new_rule_key = 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'))
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"] = f"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
app_to_delete = 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: # Use first group for app settings
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)
app_to_update_id = existing_app['id'] if existing_app else original_rule_details.get('access_app_id')
if app_to_update_id and (original_rule_details.get('hostname') != full_hostname):
app_to_delete = app_to_update_id
app_to_update_id = None
if app_to_update_id:
app_result = update_cloudflare_access_application(
app_to_update_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 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}}]},
{"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:
if original_rule_details.get('access_app_id'):
app_to_delete = original_rule_details.get('access_app_id')
if app_to_delete:
delete_cloudflare_access_application(app_to_delete)
if original_rule_key != new_rule_key:
old_hostname = original_rule_details.get('hostname')
old_zone_id = original_rule_details.get('zone_id')
with state_lock:
is_old_hostname_still_used = any(r.get("hostname") == old_hostname for k, r in managed_rules.items() if k != original_rule_key)
if not is_old_hostname_still_used:
delete_cloudflare_dns_record(old_zone_id, old_hostname, effective_tunnel_id)
with state_lock:
if new_rule_key in managed_rules and new_rule_key != original_rule_key:
cloudflared_agent_state["last_action_status"] = f"Error: A rule for {full_hostname} already exists."
return redirect(url_for('web.status_page'))
if original_rule_key in managed_rules:
del managed_rules[original_rule_key]
new_rule_data = {
"hostname": full_hostname,
"path": processed_path,
"service": processed_service_for_cf,
"container_id": original_rule_details.get("container_id") if not is_manual_source else None,
"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,
"source": original_rule_details.get("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
}
if is_manual_source:
new_rule_data["access_policy_ui_override"] = bool(access_app_id)
managed_rules[new_rule_key] = new_rule_data
save_state()
if update_cloudflare_config():
create_cloudflare_dns_record(target_zone_id, full_hostname, effective_tunnel_id)
cloudflared_agent_state["last_action_status"] = f"Success: Manual rule for {full_hostname} updated."
else:
cloudflared_agent_state["last_action_status"] = f"Error: Failed to update Cloudflare tunnel config."
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()
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()
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()
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:
with state_lock:
if not os.path.exists(config.STATE_FILE_PATH):
return "State file not found.", 404
with open(config.STATE_FILE_PATH, 'rb') as f:
buffer = io.BytesIO(f.read())
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
filename = f"dockflare_backup_{timestamp}.json"
return send_file(
buffer,
as_attachment=True,
download_name=filename,
mimetype='application/json'
)
except Exception as e:
logging.error(f"Error generating state backup: {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'))
if file and file.filename.endswith('.json'):
try:
content = file.stream.read().decode("utf-8")
backup_data = json.loads(content)
if not isinstance(backup_data, dict) or "managed_rules" not in backup_data:
raise ValueError("Invalid JSON structure for a state file.")
with state_lock:
with open(config.STATE_FILE_PATH, 'w') as f:
json.dump(backup_data, f, indent=2)
load_state()
cloudflared_agent_state["last_action_status"] = "Success: State restored from backup. Triggering reconciliation..."
reconcile_state_threaded()
except Exception as e:
logging.error(f"Error restoring state from backup: {e}", exc_info=True)
cloudflared_agent_state["last_action_status"] = f"Error: Restore failed. The file may be corrupt or invalid. Check logs."
else:
cloudflared_agent_state["last_action_status"] = "Error: Invalid file type. Please upload a .json file."
return redirect(url_for('web.settings_page'))