new feature reusable access groups policies

This commit is contained in:
ChrispyBacon-dev 2025-07-31 21:41:29 +02:00
parent bc097e345e
commit c09f0cd009
10 changed files with 1015 additions and 776 deletions

View file

@ -1,20 +1,3 @@
# 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/core/access_manager.py
import copy
import logging
import json
@ -23,6 +6,7 @@ import requests
import time
from app import config
from app.core import cloudflare_api
from app.core.state_manager import access_groups
_ACCOUNT_EMAIL_CACHE_TTL = 3600
_cached_account_email = None
@ -225,8 +209,9 @@ def delete_cloudflare_access_application(app_uuid):
logging.error(f"Unexpected error deleting Access Application '{app_uuid}': {e}", exc_info=True)
return False
def generate_access_app_config_hash(policy_type, session_duration, app_launcher_visible, allowed_idps_str, auto_redirect_to_identity, custom_access_rules_str=None):
def generate_access_app_config_hash(policy_type, session_duration, app_launcher_visible, allowed_idps_str, auto_redirect_to_identity, custom_access_rules_str=None, group_id=None):
config_items = {
"group_id": group_id,
"policy_type": policy_type,
"session_duration": str(session_duration),
"app_launcher_visible": bool(app_launcher_visible),
@ -241,143 +226,151 @@ def generate_access_app_config_hash(policy_type, session_duration, app_launcher_
def handle_access_policy_from_labels(hostname_config_item, current_rule_in_state, state_manager_save_func):
hostname = hostname_config_item["hostname"]
desired_access_policy_type_from_label = hostname_config_item.get("access_policy_type")
desired_access_app_name_from_label = hostname_config_item.get("access_app_name") or f"DockFlare-{hostname}"
desired_session_duration_from_label = hostname_config_item.get("access_session_duration", "24h")
desired_app_launcher_visible_from_label = hostname_config_item.get("access_app_launcher_visible", False)
desired_allowed_idps_str_from_label = hostname_config_item.get("access_allowed_idps_str")
desired_auto_redirect_from_label = hostname_config_item.get("access_auto_redirect", False)
desired_custom_rules_str_from_label = hostname_config_item.get("access_custom_rules_str")
local_state_changed_by_access_policy = False
current_access_app_id_from_state = current_rule_in_state.get("access_app_id")
current_access_policy_type_in_state = current_rule_in_state.get("access_policy_type")
current_access_app_id_from_state = current_rule_in_state.get("access_app_id")
current_access_app_config_hash_in_state = current_rule_in_state.get("access_app_config_hash")
desired_access_group_id = hostname_config_item.get("access_group")
if desired_access_policy_type_from_label:
desired_access_app_config_hash_from_label = generate_access_app_config_hash(
desired_access_policy_type_from_label,
desired_session_duration_from_label,
desired_app_launcher_visible_from_label,
desired_allowed_idps_str_from_label,
desired_auto_redirect_from_label,
desired_custom_rules_str_from_label
)
if desired_access_group_id:
group_definition = access_groups.get(desired_access_group_id)
if group_definition:
logging.info(f"Processing Access Group '{desired_access_group_id}' for {hostname}.")
desired_app_name = f"DockFlare-{hostname}"
desired_session_duration = group_definition.get("session_duration", "24h")
desired_app_launcher_visible = group_definition.get("app_launcher_visible", False)
desired_allowed_idps = group_definition.get("allowed_idps")
desired_auto_redirect = group_definition.get("auto_redirect_to_identity", False)
cf_access_policies = group_definition.get("policies")
new_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=desired_access_group_id
)
needs_api_action = current_rule_in_state.get("access_app_config_hash") != new_config_hash
else:
logging.warning(f"Access Group '{desired_access_group_id}' for {hostname} not found. No access policy will be applied.")
needs_api_action = False
else:
desired_access_policy_type_from_label = hostname_config_item.get("access_policy_type")
if not desired_access_policy_type_from_label:
if current_rule_in_state.get("access_app_id"):
logging.info(f"No access policy label for {hostname}, but found managed Access App {current_access_app_id_from_state}. Deleting it.")
if delete_cloudflare_access_application(current_access_app_id_from_state):
current_rule_in_state["access_app_id"] = None
current_rule_in_state["access_policy_type"] = None
current_rule_in_state["access_app_config_hash"] = None
current_rule_in_state["access_group_id"] = None
local_state_changed_by_access_policy = True
elif current_rule_in_state.get("access_policy_type") is not None or current_rule_in_state.get("access_group_id") is not None:
current_rule_in_state["access_app_id"] = None
current_rule_in_state["access_policy_type"] = None
current_rule_in_state["access_app_config_hash"] = None
current_rule_in_state["access_group_id"] = None
local_state_changed_by_access_policy = True
if local_state_changed_by_access_policy and state_manager_save_func:
logging.debug(f"Access policy changes for {hostname} indicate state should be saved by caller.")
return local_state_changed_by_access_policy
if desired_access_policy_type_from_label == "default_tld":
if current_access_app_id_from_state:
if current_access_app_id_from_state:
logging.info(f"Label policy for {hostname} is 'default_tld'. Deleting existing Access App {current_access_app_id_from_state}.")
if delete_cloudflare_access_application(current_access_app_id_from_state):
current_rule_in_state["access_app_id"] = None
current_rule_in_state["access_policy_type"] = "default_tld"
current_rule_in_state["access_app_config_hash"] = None
current_rule_in_state["access_group_id"] = None
local_state_changed_by_access_policy = True
else:
logging.error(f"Failed to delete Access App {current_access_app_id_from_state} for {hostname} as per label 'default_tld'.")
elif current_access_policy_type_in_state != "default_tld":
current_rule_in_state["access_app_id"] = None
elif current_rule_in_state.get("access_policy_type") != "default_tld" or current_rule_in_state.get("access_group_id") is not None:
current_rule_in_state["access_policy_type"] = "default_tld"
current_rule_in_state["access_app_config_hash"] = None
current_rule_in_state["access_group_id"] = None
local_state_changed_by_access_policy = True
logging.info(f"Label policy for {hostname} set to 'default_tld'. No specific app managed or was previously managed.")
if local_state_changed_by_access_policy and state_manager_save_func:
logging.debug(f"Access policy changes for {hostname} indicate state should be saved by caller.")
return local_state_changed_by_access_policy
desired_access_app_name_from_label = hostname_config_item.get("access_app_name") or f"DockFlare-{hostname}"
desired_session_duration = hostname_config_item.get("access_session_duration", "24h")
desired_app_launcher_visible = hostname_config_item.get("access_app_launcher_visible", False)
desired_allowed_idps_str = hostname_config_item.get("access_allowed_idps_str")
desired_auto_redirect = hostname_config_item.get("access_auto_redirect", False)
desired_custom_rules_str = hostname_config_item.get("access_custom_rules_str")
cf_access_policies = []
if desired_custom_rules_str:
try:
parsed_rules = json.loads(desired_custom_rules_str)
if isinstance(parsed_rules, list):
cf_access_policies = parsed_rules
except json.JSONDecodeError:
logging.error(f"Error parsing 'custom_rules' JSON for {hostname}")
if not cf_access_policies:
if desired_access_policy_type_from_label == "bypass":
cf_access_policies = [{"name": "Label Default Bypass", "decision": "bypass", "include": [{"everyone": {}}]}]
elif desired_access_policy_type_from_label == "authenticate":
include_rules = [{"everyone": {}}]
if desired_allowed_idps_str:
include_rules = [{"login_method": {"id": idp.strip()}} for idp in desired_allowed_idps_str.split(',') if idp.strip()]
cf_access_policies = [{"name": "Label Default Authenticated Access", "decision": "allow", "include": include_rules}]
desired_app_name = desired_access_app_name_from_label
desired_allowed_idps = [idp.strip() for idp in desired_allowed_idps_str.split(',') if idp.strip()] if desired_allowed_idps_str else None
elif desired_access_policy_type_from_label in ["bypass", "authenticate"]:
cf_access_policies = []
if desired_custom_rules_str_from_label:
try:
parsed_rules = json.loads(desired_custom_rules_str_from_label)
if isinstance(parsed_rules, list): cf_access_policies = parsed_rules
else: logging.error(f"Parsed 'custom_rules' label for {hostname} is not a list...")
except json.JSONDecodeError as json_err: logging.error(f"Error parsing 'custom_rules' JSON for {hostname}: {json_err}...")
if not cf_access_policies:
if desired_access_policy_type_from_label == "bypass": cf_access_policies = [{"name": "Label Default Bypass", "decision": "bypass", "include": [{"everyone": {}}]}]
elif desired_access_policy_type_from_label == "authenticate":
policy_include_rules = []
if desired_allowed_idps_str_from_label:
idp_ids = [idp.strip() for idp in desired_allowed_idps_str_from_label.split(',') if idp.strip()]
for an_idp_id in idp_ids:
policy_include_rules.append({"login_method": {"id": an_idp_id}})
if not policy_include_rules: policy_include_rules.append({"everyone": {}})
cf_access_policies = [{"name": "Label Default Authenticated Access", "decision": "allow", "include": policy_include_rules}]
new_config_hash = generate_access_app_config_hash(
desired_access_policy_type_from_label,
desired_session_duration,
desired_app_launcher_visible,
desired_allowed_idps_str,
desired_auto_redirect,
desired_custom_rules_str
)
needs_api_action = current_rule_in_state.get("access_app_config_hash") != new_config_hash
needs_api_action = False
if current_access_app_id_from_state:
if current_access_policy_type_in_state != desired_access_policy_type_from_label or \
current_access_app_config_hash_in_state != desired_access_app_config_hash_from_label:
needs_api_action = True
logging.info(f"Access App {current_access_app_id_from_state} for {hostname} (from local state) needs update/re-evaluation.")
else:
needs_api_action = True
logging.info(f"No Access App ID for {hostname} in local state. API action required (find or create).")
if needs_api_action:
idp_ids = [idp.strip() for idp in desired_allowed_idps_str_from_label.split(',') if idp.strip()] if desired_allowed_idps_str_from_label else None
effective_app_id_for_operation = current_access_app_id_from_state
if not effective_app_id_for_operation:
logging.info(f"No local Access App ID for {hostname}. Checking Cloudflare API...")
existing_cf_app = find_cloudflare_access_application_by_hostname(hostname)
if existing_cf_app and existing_cf_app.get("id"):
effective_app_id_for_operation = existing_cf_app.get("id")
logging.info(f"Found existing Access App ID '{effective_app_id_for_operation}' on Cloudflare for {hostname}. Will attempt update.")
current_rule_in_state["access_app_id"] = effective_app_id_for_operation
local_state_changed_by_access_policy = True
if effective_app_id_for_operation:
logging.info(f"Updating Access App {effective_app_id_for_operation} for {hostname} based on labels (type: {desired_access_policy_type_from_label}).")
updated_app = update_cloudflare_access_application(
effective_app_id_for_operation, hostname, desired_access_app_name_from_label,
desired_session_duration_from_label, desired_app_launcher_visible_from_label,
[hostname], cf_access_policies, idp_ids, desired_auto_redirect_from_label
)
if updated_app:
current_rule_in_state["access_app_id"] = updated_app.get("id")
current_rule_in_state["access_policy_type"] = desired_access_policy_type_from_label
current_rule_in_state["access_app_config_hash"] = desired_access_app_config_hash_from_label
local_state_changed_by_access_policy = True
else:
logging.error(f"Failed to update Access App {effective_app_id_for_operation} for {hostname} based on labels.")
else:
logging.info(f"Creating new Access App for {hostname} based on labels (type: '{desired_access_policy_type_from_label}').")
created_app = create_cloudflare_access_application(
hostname, desired_access_app_name_from_label,
desired_session_duration_from_label, desired_app_launcher_visible_from_label,
[hostname], cf_access_policies, idp_ids, desired_auto_redirect_from_label
)
if created_app and created_app.get("id"):
current_rule_in_state["access_app_id"] = created_app.get("id")
current_rule_in_state["access_policy_type"] = desired_access_policy_type_from_label
current_rule_in_state["access_app_config_hash"] = desired_access_app_config_hash_from_label
local_state_changed_by_access_policy = True
else:
logging.error(f"Failed to create Access App for {hostname} based on labels.")
else:
logging.warning(f"Unknown access.policy type '{desired_access_policy_type_from_label}' from label for {hostname}. No Access App action taken.")
else:
if current_access_app_id_from_state:
logging.info(f"No access policy label for {hostname}, but found managed Access App {current_access_app_id_from_state}. Deleting it.")
if delete_cloudflare_access_application(current_access_app_id_from_state):
current_rule_in_state["access_app_id"] = None
current_rule_in_state["access_policy_type"] = None
current_rule_in_state["access_app_config_hash"] = None
if needs_api_action:
effective_app_id = current_access_app_id_from_state
if not effective_app_id:
logging.info(f"No local Access App ID for {hostname}. Checking Cloudflare API...")
existing_cf_app = find_cloudflare_access_application_by_hostname(hostname)
if existing_cf_app and existing_cf_app.get("id"):
effective_app_id = existing_cf_app.get("id")
logging.info(f"Found existing Access App ID '{effective_app_id}' on Cloudflare for {hostname}. Will attempt update.")
current_rule_in_state["access_app_id"] = effective_app_id
local_state_changed_by_access_policy = True
else:
logging.error(f"Failed to delete Access App {current_access_app_id_from_state} for {hostname} during label cleanup.")
elif current_rule_in_state.get("access_policy_type") is not None :
current_rule_in_state["access_app_id"] = None
current_rule_in_state["access_policy_type"] = None
current_rule_in_state["access_app_config_hash"] = None
app_result = None
if effective_app_id:
logging.info(f"Updating Access App {effective_app_id} for {hostname} based on labels.")
app_result = update_cloudflare_access_application(
effective_app_id, hostname, desired_app_name,
desired_session_duration, desired_app_launcher_visible,
[hostname], cf_access_policies, desired_allowed_idps, desired_auto_redirect
)
else:
logging.info(f"Creating new Access App for {hostname} based on labels.")
app_result = create_cloudflare_access_application(
hostname, desired_app_name,
desired_session_duration, desired_app_launcher_visible,
[hostname], cf_access_policies, desired_allowed_idps, desired_auto_redirect
)
if app_result and app_result.get("id"):
current_rule_in_state["access_app_id"] = app_result.get("id")
current_rule_in_state["access_app_config_hash"] = new_config_hash
current_rule_in_state["access_group_id"] = desired_access_group_id
current_rule_in_state["access_policy_type"] = "group" if desired_access_group_id else desired_access_policy_type_from_label
local_state_changed_by_access_policy = True
logging.debug(f"Ensuring access policy type is None for {hostname} as no access labels are present.")
else:
logging.error(f"Failed to create/update Access App for {hostname}.")
if local_state_changed_by_access_policy and state_manager_save_func:
logging.debug(f"Access policy changes for {hostname} indicate state should be saved by caller.")

View file

@ -25,7 +25,7 @@ from docker.errors import NotFound, APIError
from app import config, docker_client, cloudflared_agent_state, tunnel_state
from app.core.state_manager import managed_rules, state_lock, save_state
from app.core.state_manager import managed_rules, access_groups, state_lock, save_state
from app.core.tunnel_manager import update_cloudflare_config
from app.core.cloudflare_api import create_cloudflare_dns_record, get_zone_id_from_name
from app.core.access_manager import handle_access_policy_from_labels
@ -54,27 +54,14 @@ def is_valid_service(service_str):
return False
service_str = service_str.strip()
# Regex patterns for different service types
# Hostname/IP for service targets: Allows domain names (includes '_' for Docker service names, per #132), IPv4, and bracketed IPv6. (Note: '_' is not valid for public DNS hostnames).
host_ip_pattern = r"([a-zA-Z0-9_](?:[a-zA-Z0-9\-_]{0,61}[a-zA-Z0-9_])?(?:\.[a-zA-Z0-9_](?:[a-zA-Z0-9\-_]{0,61}[a-zA-Z0-9_])?)*\.?|[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|\[[0-9a-fA-F:]+\])"
port_pattern = r"[0-9]{1,5}" # Ports 0-65535
port_pattern = r"[0-9]{1,5}"
# HTTP/HTTPS: http(s)://host(:port)? - port is now optional raised by issue 132
# The (?:...) makes the group non-capturing, and ? makes the entire group (colon + port) optional.
http_https_pattern = rf"^(?:https?)://{host_ip_pattern}(?::{port_pattern})?$"
# TCP: tcp://host:port
tcp_pattern = rf"^(?:tcp)://{host_ip_pattern}:{port_pattern}$"
# SSH: ssh://host:port
ssh_pattern = rf"^(?:ssh)://{host_ip_pattern}:{port_pattern}$"
# RDP: rdp://host:port
rdp_pattern = rf"^(?:rdp)://{host_ip_pattern}:{port_pattern}$"
# HTTP Status: http_status:CODE
http_status_pattern = r"^http_status:([1-5][0-9]{2})$" # Matches 100-599
http_status_pattern = r"^http_status:([1-5][0-9]{2})$"
if re.fullmatch(http_https_pattern, service_str):
return True
@ -112,6 +99,9 @@ def process_container_start(container_obj):
default_path_label = get_label(labels, "path")
default_originsrvname_label = get_label(labels, "originsrvname")
default_http_host_header_label = get_label(labels, "httpHostHeader")
default_access_group = get_label(labels, "access.group")
default_access_policy_type_label = get_label(labels, "access.policy")
default_access_app_name_label = get_label(labels, "access.name")
default_access_session_duration_label = get_label(labels, "access.session_duration", "24h")
@ -119,6 +109,7 @@ def process_container_start(container_obj):
default_access_allowed_idps_label_str = get_label(labels, "access.allowed_idps")
default_access_auto_redirect_label = get_label(labels, "access.auto_redirect_to_identity", "false").lower() in ["true", "1", "t", "yes"]
default_access_custom_rules_label_str = get_label(labels, "access.custom_rules")
hostname_label = get_label(labels, "hostname")
service_label = get_label(labels, "service")
zone_name_label = get_label(labels, "zonename")
@ -132,6 +123,7 @@ def process_container_start(container_obj):
"no_tls_verify": no_tls_verify_label,
"origin_server_name": default_originsrvname_label.strip() if default_originsrvname_label else None,
"http_host_header": default_http_host_header_label.strip() if default_http_host_header_label else None,
"access_group": default_access_group,
"access_policy_type": default_access_policy_type_label,
"access_app_name": default_access_app_name_label,
"access_session_duration": default_access_session_duration_label,
@ -157,6 +149,9 @@ def process_container_start(container_obj):
no_tls_verify_indexed = no_tls_verify_indexed_val.lower() in ["true", "1", "t", "yes"]
originsrvname_indexed_val = get_label(labels, f"{index}.originsrvname", default_originsrvname_label)
http_host_header_indexed_val = get_label(labels, f"{index}.httpHostHeader", default_http_host_header_label)
access_group_indexed = get_label(labels, f"{index}.access.group", default_access_group)
access_policy_type_indexed = get_label(labels, f"{index}.access.policy", default_access_policy_type_label)
access_app_name_indexed = get_label(labels, f"{index}.access.name", default_access_app_name_label)
access_session_duration_indexed = get_label(labels, f"{index}.access.session_duration", default_access_session_duration_label)
@ -174,6 +169,7 @@ def process_container_start(container_obj):
"no_tls_verify": no_tls_verify_indexed,
"origin_server_name": originsrvname_indexed_val.strip() if originsrvname_indexed_val else None,
"http_host_header": http_host_header_indexed_val.strip() if http_host_header_indexed_val else None,
"access_group": access_group_indexed,
"access_policy_type": access_policy_type_indexed,
"access_app_name": access_app_name_indexed,
"access_session_duration": access_session_duration_indexed,
@ -268,7 +264,8 @@ def process_container_start(container_obj):
"access_policy_type": None,
"access_app_config_hash": None,
"access_policy_ui_override": False,
"source": "docker"
"source": "docker",
"access_group_id": None
}
existing_rule = managed_rules[rule_key]
state_changed_locally_for_this_container = True
@ -279,7 +276,7 @@ def process_container_start(container_obj):
if existing_rule.get("access_policy_ui_override", False):
logging.info(f"DOCKER_HANDLER: Access policy for {rule_key} is UI-managed. Skipping.")
else:
if handle_access_policy_from_labels(config_item, existing_rule, hostname):
if handle_access_policy_from_labels(config_item, existing_rule, save_state):
state_changed_locally_for_this_container = True
logging.debug(f"DOCKER_HANDLER_ACCESS_MOD: Access policy for {rule_key} changed. state_changed: {state_changed_locally_for_this_container}.")

View file

@ -49,6 +49,7 @@ def _get_hostname_configs_from_container(container_obj):
default_originsrvname_label = get_label(labels, "originsrvname")
default_http_host_header_label = get_label(labels, "httpHostHeader")
default_access_group = get_label(labels, "access.group")
default_access_policy_type = get_label(labels, "access.policy")
default_access_app_name = get_label(labels, "access.name")
default_session_duration = get_label(labels, "access.session_duration", "24h")
@ -71,6 +72,7 @@ def _get_hostname_configs_from_container(container_obj):
"origin_server_name": default_originsrvname_label.strip() if default_originsrvname_label else None,
"http_host_header": default_http_host_header_label.strip() if default_http_host_header_label else None,
"container_id": container_id_val, "container_name": container_name_val,
"access_group": default_access_group,
"access_policy_type": default_access_policy_type,
"access_app_name": default_access_app_name,
"access_session_duration": default_session_duration,
@ -96,6 +98,7 @@ def _get_hostname_configs_from_container(container_obj):
osn_idx_val = get_label(labels, f"{idx}.originsrvname", default_originsrvname_label)
h_h_h_idx_val = get_label(labels, f"{idx}.httpHostHeader", default_http_host_header_label)
acc_group_idx = get_label(labels, f"{idx}.access.group", default_access_group)
acc_pol_idx = get_label(labels, f"{idx}.access.policy", default_access_policy_type)
acc_name_idx = get_label(labels, f"{idx}.access.name", default_access_app_name)
acc_sess_idx = get_label(labels, f"{idx}.access.session_duration", default_session_duration)
@ -113,6 +116,7 @@ def _get_hostname_configs_from_container(container_obj):
"origin_server_name": osn_idx_val.strip() if osn_idx_val else None,
"http_host_header": h_h_h_idx_val.strip() if h_h_h_idx_val else None,
"container_id": container_id_val, "container_name": container_name_val,
"access_group": acc_group_idx,
"access_policy_type": acc_pol_idx, "access_app_name": acc_name_idx,
"access_session_duration": acc_sess_idx, "access_app_launcher_visible": acc_vis_idx,
"access_allowed_idps_str": acc_idps_idx, "access_auto_redirect": acc_redir_idx,
@ -200,7 +204,7 @@ def _run_reconciliation_logic():
if not target_zone_id:
logging.error(f"[Reconcile] No zone ID for {rule_key}, skipping its reconciliation.")
continue
if not existing_rule:
managed_rules[rule_key] = {
"hostname": desired_details["hostname"],
@ -212,7 +216,8 @@ def _run_reconciliation_logic():
"origin_server_name": desired_details.get("origin_server_name"),
"http_host_header": desired_details.get("http_host_header"),
"access_app_id": None, "access_policy_type": None, "access_app_config_hash": None,
"access_policy_ui_override": False, "source": "docker"
"access_policy_ui_override": False, "source": "docker",
"access_group_id": None
}
existing_rule = managed_rules[rule_key]
state_changed_locally = True

View file

@ -22,11 +22,12 @@ import threading
from datetime import datetime, timezone
from app import config
from app.core.utils import get_rule_key
from app.core.utils import get_rule_key
managed_rules = {}
state_lock = threading.RLock()
logging.info(f"STATE_MANAGER_INIT: managed_rules object ID at module load: {id(managed_rules)}")
access_groups = {}
state_lock = threading.RLock()
logging.info(f"STATE_MANAGER_INIT: managed_rules ID: {id(managed_rules)}, access_groups ID: {id(access_groups)}")
def _deserialize_datetime(dt_str):
if not dt_str:
@ -45,8 +46,9 @@ def load_state():
logging.info(f"LOAD_STATE: Start. Initial managed_rules ID: {id(managed_rules)}, Current len: {len(managed_rules)}")
state_dir = os.path.dirname(config.STATE_FILE_PATH)
with state_lock:
managed_rules.clear()
with state_lock:
managed_rules.clear()
access_groups.clear()
logging.info(f"LOAD_STATE: After .clear(), managed_rules ID: {id(managed_rules)}, len: {len(managed_rules)}")
if not os.path.exists(state_dir):
@ -66,9 +68,23 @@ def load_state():
with open(config.STATE_FILE_PATH, 'r') as f:
loaded_data = json.load(f)
rules_to_load = {}
groups_to_load = {}
if isinstance(loaded_data, dict) and "managed_rules" in loaded_data:
logging.info("Loading state from new format (with access_groups).")
rules_to_load = loaded_data.get("managed_rules", {})
groups_to_load = loaded_data.get("access_groups", {})
else:
logging.info("Loading state from old format (rules only). Will migrate on next save.")
rules_to_load = loaded_data
access_groups.update(groups_to_load)
logging.info(f"LOAD_STATE: Loaded {len(access_groups)} access groups.")
migrated_count = 0
for key, rule_data in loaded_data.items():
rule_copy = rule_data.copy()
for key, rule_data in rules_to_load.items():
rule_copy = rule_data.copy()
final_key = key
if '|' not in key:
@ -108,50 +124,56 @@ def load_state():
logging.error(f"LOAD_STATE: Unexpected error loading state: {e_load_unexp}. Starting fresh (already cleared).", exc_info=True)
def save_state():
global managed_rules
global managed_rules, access_groups
current_thread_name = threading.current_thread().name
with state_lock:
logging.info(f"SAVE_STATE: Start (RLock acquired). THREAD: {current_thread_name}. managed_rules item count: {len(managed_rules)}")
with state_lock:
logging.info(f"SAVE_STATE: Start (RLock acquired). THREAD: {current_thread_name}. Items to save: {len(managed_rules)} rules, {len(access_groups)} access groups.")
serializable_state = {}
rules_to_iterate_items = list(managed_rules.items())
serializable_rules = {}
rules_to_iterate = list(managed_rules.items())
groups_to_iterate = dict(access_groups)
if not rules_to_iterate_items:
logging.info(f"SAVE_STATE: THREAD: {current_thread_name}. managed_rules is empty. Proceeding to write empty state file.")
if not rules_to_iterate and not groups_to_iterate:
logging.info(f"SAVE_STATE: THREAD: {current_thread_name}. State is empty. Proceeding to write empty state file.")
else:
logging.info(f"SAVE_STATE: THREAD: {current_thread_name}. Serializing {len(rules_to_iterate_items)} rules.")
logging.info(f"SAVE_STATE: THREAD: {current_thread_name}. Serializing {len(rules_to_iterate)} rules and {len(groups_to_iterate)} groups.")
for rule_key, rule in rules_to_iterate_items:
for rule_key, rule in rules_to_iterate:
logging.debug(f"SAVE_STATE_LOOP: THREAD: {current_thread_name}. Preparing rule for key: {rule_key}")
try:
data_to_serialize = {
"hostname": rule.get("hostname"),
"path": rule.get("path"),
"service": rule.get("service"),
"service": rule.get("service"),
"container_id": rule.get("container_id"),
"status": rule.get("status"),
"delete_at": None,
"zone_id": rule.get("zone_id"),
"status": rule.get("status"),
"delete_at": None,
"zone_id": rule.get("zone_id"),
"no_tls_verify": rule.get("no_tls_verify", False),
"origin_server_name": rule.get("origin_server_name"),
"origin_server_name": rule.get("origin_server_name"),
"http_host_header": rule.get("http_host_header"),
"access_app_id": rule.get("access_app_id"),
"access_app_id": rule.get("access_app_id"),
"access_policy_type": rule.get("access_policy_type"),
"access_app_config_hash": rule.get("access_app_config_hash"),
"access_policy_ui_override": rule.get("access_policy_ui_override", False),
"source": rule.get("source", "docker"),
}
delete_at_val = rule.get("delete_at")
if isinstance(delete_at_val, datetime):
if isinstance(delete_at_val, datetime):
logging.debug(f"SAVE_STATE_LOOP: THREAD: {current_thread_name}. Serializing datetime for {rule_key} (value: {delete_at_val}).")
data_to_serialize["delete_at"] = delete_at_val.astimezone(timezone.utc).isoformat().replace('+00:00', 'Z')
serializable_state[rule_key] = data_to_serialize
serializable_rules[rule_key] = data_to_serialize
except Exception as e_serialize_item:
logging.error(f"SAVE_STATE_LOOP_ERROR: THREAD: {current_thread_name}. Error preparing rule for serialization '{rule_key}': {e_serialize_item}. Rule data: {rule}", exc_info=True)
continue
logging.info(f"SAVE_STATE: THREAD: {current_thread_name}. Prepared serializable_state with {len(serializable_state)} items.")
final_state_to_save = {
"managed_rules": serializable_rules,
"access_groups": groups_to_iterate
}
logging.info(f"SAVE_STATE: THREAD: {current_thread_name}. Prepared final state with {len(serializable_rules)} rules and {len(groups_to_iterate)} groups.")
try:
state_dir = os.path.dirname(config.STATE_FILE_PATH)
@ -159,10 +181,10 @@ def save_state():
try: os.makedirs(state_dir, exist_ok=True)
except OSError as e_mkdir: logging.error(f"SAVE_STATE: THREAD: {current_thread_name}. Mkdir error {e_mkdir}. Save failed."); return
temp_file_path = config.STATE_FILE_PATH + ".tmp"
with open(temp_file_path, 'w') as f: json.dump(serializable_state, f, indent=2)
with open(temp_file_path, 'w') as f: json.dump(final_state_to_save, f, indent=2)
os.replace(temp_file_path, config.STATE_FILE_PATH)
logging.info(f"SAVE_STATE: THREAD: {current_thread_name}. Successfully saved state for {len(serializable_state)} rules to {config.STATE_FILE_PATH}")
except Exception as e_save_io:
logging.info(f"SAVE_STATE: THREAD: {current_thread_name}. Successfully saved state for {len(serializable_rules)} rules and {len(groups_to_iterate)} groups to {config.STATE_FILE_PATH}")
except Exception as e_save_io:
logging.error(f"SAVE_STATE: THREAD: {current_thread_name}. File I/O or other error: {e_save_io}", exc_info=True)
logging.info(f"SAVE_STATE: End. THREAD: {current_thread_name}.")

View file

@ -1,4 +1,5 @@
// app/static/js/main.js
// app/static/js/main.js
const maxLogLines = 250;
let initialConnectMessageCleared = false;
let activeLogSource = null;
@ -201,7 +202,6 @@ function fixResourcesAndBase() {
function addLogLine(message, type = 'log') {
const logOutput = document.getElementById('log-output');
if (!logOutput) {
console.error("Log output element not found.");
return;
}
if (!initialConnectMessageCleared && logOutput.textContent.includes('Connecting to log stream...')) {
@ -227,7 +227,6 @@ function addLogLine(message, type = 'log') {
function connectEventSource() {
const logOutput = document.getElementById('log-output');
if (!logOutput) {
console.error("Log output element not found for EventSource.");
return;
}
if (!window.EventSource) {
@ -403,79 +402,66 @@ function setupPathInput(displayElement, hiddenElement) {
});
}
function openCreateAccessGroupModal() {
const modal = document.getElementById('access_group_modal');
if (!modal) return;
const form = document.getElementById('access_group_form');
const title = document.getElementById('access_group_modal_title');
const groupIdInput = document.getElementById('group_id');
form.reset();
form.action = `${document.baseURI}ui/access-groups/create`;
title.textContent = 'Create New Access Group';
groupIdInput.disabled = false;
document.getElementById('original_group_id').value = '';
modal.showModal();
}
function openEditAccessGroupModal(groupId, details) {
const modal = document.getElementById('access_group_modal');
if (!modal) return;
const form = document.getElementById('access_group_form');
const title = document.getElementById('access_group_modal_title');
const groupIdInput = document.getElementById('group_id');
form.reset();
form.action = `${document.baseURI}ui/access-groups/edit/${encodeURIComponent(groupId)}`;
title.textContent = `Edit Access Group: ${details.display_name}`;
document.getElementById('original_group_id').value = groupId;
groupIdInput.value = groupId;
groupIdInput.disabled = true;
document.getElementById('group_display_name').value = details.display_name || '';
document.getElementById('group_session_duration').value = details.session_duration || '24h';
document.getElementById('group_app_launcher_visible').checked = details.app_launcher_visible || false;
document.getElementById('group_auto_redirect').checked = details.auto_redirect_to_identity || false;
let emailText = '';
if (details.policies) {
const emails = [];
details.policies.forEach(policy => {
if (policy.include) {
policy.include.forEach(rule => {
if (rule.email && rule.email.email) {
emails.push(rule.email.email);
}
});
}
});
emailText = emails.join(', ');
}
document.getElementById('group_emails').value = emailText;
modal.showModal();
}
document.addEventListener('DOMContentLoaded', function() {
fixResourcesAndBase();
themeManager.initialize();
const manualServiceTypeSelect = document.getElementById('manual_service_type');
const noTlsVerifyDiv = document.getElementById('manual_no_tls_verify_div');
const originServerNameDiv = document.getElementById('manual_origin_server_name_div');
const manualServiceAddressInput = document.getElementById('manual_service_address');
const manualServiceAddressLabel = document.getElementById('manual_service_address_label');
const manualServiceHelpText = document.getElementById('manual_service_help');
const manualServicePrefixSpan = document.getElementById('manual_service_prefix_span');
function updateManualRuleServiceFields() {
const selectedType = manualServiceTypeSelect.value.toLowerCase();
let showNoTlsVerify = false;
let showOriginServerName = false;
if (manualServicePrefixSpan) manualServicePrefixSpan.classList.add('hidden');
if (manualServiceAddressInput) manualServiceAddressInput.placeholder = 'host:port or status code';
if (manualServiceAddressLabel) manualServiceAddressLabel.textContent = 'URL (Required for most types)';
if (manualServiceHelpText) manualServiceHelpText.textContent = 'e.g., 192.168.1.10:8000 or my-service.local:3000 for HTTP/S/TCP etc.';
if (selectedType === 'http' || selectedType === 'https') {
if (manualServicePrefixSpan) {
manualServicePrefixSpan.textContent = selectedType + '://';
manualServicePrefixSpan.classList.remove('hidden');
}
if (manualServiceAddressInput) manualServiceAddressInput.placeholder = 'host:port or resolvable hostname';
if (manualServiceAddressLabel) manualServiceAddressLabel.textContent = 'Origin URL (Required)';
if (manualServiceHelpText) manualServiceHelpText.textContent = 'e.g., 192.168.1.10:8000 or my-service.local:3000';
showNoTlsVerify = true;
showOriginServerName = true;
} else if (selectedType === 'tcp' || selectedType === 'ssh' || selectedType === 'rdp') {
if (manualServicePrefixSpan) {
manualServicePrefixSpan.textContent = selectedType + '://';
manualServicePrefixSpan.classList.remove('hidden');
}
if (manualServiceAddressInput) manualServiceAddressInput.placeholder = 'host:port';
if (manualServiceAddressLabel) manualServiceAddressLabel.textContent = `Origin Address for ${selectedType.toUpperCase()} (host:port)`;
if (manualServiceHelpText) manualServiceHelpText.textContent = `e.g., my-internal-server:22`;
showNoTlsVerify = false;
showOriginServerName = false;
} else if (selectedType === 'http_status') {
if (manualServiceAddressInput) manualServiceAddressInput.placeholder = 'e.g., 404';
if (manualServiceAddressLabel) manualServiceAddressLabel.textContent = 'HTTP Status Code (e.g., 200, 404, 503)';
if (manualServiceHelpText) manualServiceHelpText.textContent = 'Enter a valid HTTP status code (100-599).';
showNoTlsVerify = false;
showOriginServerName = false;
}
if (noTlsVerifyDiv) {
if (showNoTlsVerify) {
noTlsVerifyDiv.style.display = '';
} else {
noTlsVerifyDiv.style.display = 'none';
const noTlsVerifyCheckbox = document.getElementById('manual_no_tls_verify');
if (noTlsVerifyCheckbox) noTlsVerifyCheckbox.checked = false;
}
}
if (originServerNameDiv) {
if (showOriginServerName) {
originServerNameDiv.style.display = '';
} else {
originServerNameDiv.style.display = 'none';
const originServerNameInput = document.getElementById('manual_origin_server_name');
if (originServerNameInput) originServerNameInput.value = '';
}
}
}
if (manualServiceTypeSelect) {
manualServiceTypeSelect.addEventListener('change', updateManualRuleServiceFields);
updateManualRuleServiceFields();
@ -486,134 +472,63 @@ document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('form.protocol-aware-form').forEach(form => {
if (form.getAttribute('action')) {
let actionUrl = form.getAttribute('action');
try {
const fullActionUrl = new URL(actionUrl, document.baseURI);
if (fullActionUrl.protocol !== window.location.protocol && fullActionUrl.host === window.location.host) {
fullActionUrl.protocol = window.location.protocol;
form.setAttribute('action', fullActionUrl.toString());
} else if (!actionUrl.startsWith('http')) {
form.setAttribute('action', fullActionUrl.toString());
}
} catch (e) {}
}
});
document.querySelectorAll('a[href]').forEach(link => {
const href = link.getAttribute('href');
if (href && href !== "#" && !href.startsWith('mailto:') && !href.startsWith('tel:')) {
try {
const fullLinkUrl = new URL(href, document.baseURI);
if (fullLinkUrl.protocol !== window.location.protocol && fullLinkUrl.host === window.location.host) {
fullLinkUrl.protocol = window.location.protocol;
link.setAttribute('href', fullLinkUrl.toString());
} else if (!href.startsWith('http')) {
link.setAttribute('href', fullLinkUrl.toString());
}
const fullActionUrl = new URL(form.getAttribute('action'), document.baseURI);
form.setAttribute('action', fullActionUrl.toString());
} catch (e) {}
}
});
updateCountdowns();
setInterval(updateCountdowns, 30000);
connectEventSource();
updateReconciliationStatus();
setInterval(updateReconciliationStatus, 2000);
function toggleAuthEmailField(policyType, selectElement) {
const form = selectElement.closest('form');
if (!form) return;
const emailFieldDiv = form.querySelector('.auth-email-field');
if (emailFieldDiv) {
if (policyType === 'authenticate_email') {
emailFieldDiv.classList.remove('hidden');
} else {
emailFieldDiv.classList.add('hidden');
const emailInput = emailFieldDiv.querySelector('input[name="auth_email"]');
if (emailInput) emailInput.value = '';
}
}
if (document.getElementById('log-output')) {
connectEventSource();
}
if (document.getElementById('reconciliation-status')) {
updateReconciliationStatus();
setInterval(updateReconciliationStatus, 2000);
}
document.querySelectorAll('.policy-type-select').forEach(select => {
select.addEventListener('change', function() {
toggleAuthEmailField(this.value, this);
const form = this.closest('form');
if (!form) return;
const emailFieldDiv = form.querySelector('.auth-email-field');
if (emailFieldDiv) {
emailFieldDiv.style.display = (this.value === 'authenticate_email') ? '' : 'none';
}
});
toggleAuthEmailField(select.value, select);
select.dispatchEvent(new Event('change'));
});
document.querySelectorAll('.tunnel-dns-toggle').forEach(button => {
button.addEventListener('click', async function() {
const tunnelId = this.dataset.tunnelId;
const tunnelDetailsRow = this.closest('tr');
const dnsRecordsDisplayRow = tunnelDetailsRow.nextElementSibling;
const targetDivId = this.getAttribute('aria-controls');
const targetDiv = document.getElementById(targetDivId);
const dnsRecordsDisplayRow = this.closest('tr').nextElementSibling;
const targetDiv = document.getElementById(this.getAttribute('aria-controls'));
const isExpanded = this.getAttribute('aria-expanded') === 'true';
const expandIcon = this.querySelector('.expand-icon');
const collapseIcon = this.querySelector('.collapse-icon');
if (!dnsRecordsDisplayRow || !targetDiv) return;
if (isExpanded) {
dnsRecordsDisplayRow.classList.add('hidden');
this.setAttribute('aria-expanded', 'false');
if (expandIcon) expandIcon.classList.remove('hidden');
if (collapseIcon) collapseIcon.classList.add('hidden');
} else {
this.setAttribute('aria-expanded', 'true');
if (expandIcon) expandIcon.classList.add('hidden');
if (collapseIcon) collapseIcon.classList.remove('hidden');
if (targetDiv.dataset.loaded !== 'true' || targetDiv.dataset.loaded === 'error') {
if (targetDiv.dataset.loaded !== 'true') {
targetDiv.innerHTML = '<p class="opacity-60 italic animate-pulse p-2">Loading DNS records...</p>';
dnsRecordsDisplayRow.classList.remove('hidden');
try {
const fetchUrl = `${document.baseURI}tunnel-dns-records/${encodeURIComponent(tunnelId)}?t=${Date.now()}`;
const response = await fetch(fetchUrl);
if (!response.ok) {
let errorDetail = `HTTP error ${response.status}`;
try {
const errorData = await response.json();
errorDetail = errorData.error || errorData.message || errorDetail;
} catch (e) {}
throw new Error(errorDetail);
}
const response = await fetch(`${document.baseURI}tunnel-dns-records/${encodeURIComponent(tunnelId)}?t=${Date.now()}`);
if (!response.ok) throw new Error(`HTTP error ${response.status}`);
const data = await response.json();
const currentTargetDiv = document.getElementById(`dns-records-${tunnelId}`);
if (!currentTargetDiv) {
return;
}
if (data.dns_records && data.dns_records.length > 0) {
let dnsHtml = '<ul class="list-none pl-4 space-y-1.5">';
data.dns_records.forEach(record => {
const recordUrl = `https://${record.name}`;
const zoneDisplay = record.zone_name ? record.zone_name : record.zone_id;
dnsHtml += `<li class="opacity-90 text-xs">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 inline-block mr-1 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
<a href="${recordUrl}" target="_blank" rel="noopener noreferrer" class="link link-hover">${record.name}</a>
<span class="ml-2 opacity-60">(Zone: ${zoneDisplay})</span>
</li>`;
});
dnsHtml += '</ul>';
currentTargetDiv.innerHTML = dnsHtml;
currentTargetDiv.dataset.loaded = 'true';
} else if (data.message) {
currentTargetDiv.innerHTML = `<p class="opacity-60 italic p-2">${data.message}</p>`;
currentTargetDiv.dataset.loaded = 'info';
targetDiv.innerHTML = '<ul class="list-none pl-4 space-y-1.5">' + data.dns_records.map(r => `...`).join('') + '</ul>';
} else {
currentTargetDiv.innerHTML = '<p class="opacity-60 italic p-2">No CNAME DNS records found pointing to this tunnel in the configured zones.</p>';
currentTargetDiv.dataset.loaded = 'true';
targetDiv.innerHTML = `<p class="opacity-60 italic p-2">${data.message || 'No CNAME records found.'}</p>`;
}
targetDiv.dataset.loaded = 'true';
} catch (error) {
const errorTargetDiv = document.getElementById(`dns-records-${tunnelId}`);
if (errorTargetDiv) {
errorTargetDiv.innerHTML = `<p class="text-error p-2">Error loading DNS records: ${error.message}</p>`;
errorTargetDiv.dataset.loaded = 'error';
}
targetDiv.innerHTML = `<p class="text-error p-2">Error loading DNS records: ${error.message}</p>`;
}
}
dnsRecordsDisplayRow.classList.remove('hidden');
@ -621,8 +536,15 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
startServerPing();
document.querySelectorAll('.edit-access-group-btn').forEach(button => {
button.addEventListener('click', function() {
const groupId = this.dataset.groupId;
const details = JSON.parse(this.dataset.groupDetails);
openEditAccessGroupModal(groupId, details);
});
});
startServerPing();
initializeEditManualRuleModal();
window.addEventListener('beforeunload', function() {

View file

@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Access Groups{% endblock %}
{% block content %}
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center border-b border-base-300 pb-3 mb-6">
<div>
<h2 class="card-title text-2xl sm:text-3xl">
Access Groups
</h2>
<p class="text-sm opacity-70 mt-1">Create reusable access policies to apply to your services with a single label.</p>
</div>
<button class="btn btn-sm btn-primary mt-4 sm:mt-0" onclick="openCreateAccessGroupModal()">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-1"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Create New Group
</button>
</div>
{% if access_groups and access_groups.items() %}
<div class="overflow-x-auto -mx-6 sm:-mx-8">
<table class="table table-zebra table-sm w-full">
<thead>
<tr>
<th class="p-3">Display Name</th>
<th class="p-3">Group ID (for label)</th>
<th class="p-3">Policy Summary</th>
<th class="p-3">Session</th>
<th class="p-3">Actions</th>
</tr>
</thead>
<tbody>
{% for group_id, details in access_groups.items()|sort %}
<tr>
<td class="p-3 font-medium">{{ details.display_name }}</td>
<td class="p-3"><code class="badge badge-ghost">{{ group_id }}</code></td>
<td class="p-3 text-xs opacity-80">
{% if details.policies %}
{{ details.policies | length }} rule(s) defined
{% else %}
<span class="italic opacity-60">No rules</span>
{% endif %}
</td>
<td class="p-3 text-xs opacity-70">{{ details.session_duration | default('24h', true) }}</td>
<td class="p-3">
<div class="flex items-center gap-2">
<button class="btn btn-xs btn-info btn-outline edit-access-group-btn"
data-group-id="{{ group_id }}"
data-group-details="{{ details|tojson|forceescape }}">
Edit
</button>
<form action="{{ url_for('web.delete_access_group', group_id=group_id) }}" method="post" onsubmit="return confirm('Are you sure you want to delete the Access Group \'{{ details.display_name }}\'? This cannot be undone.');" class="protocol-aware-form">
<button type="submit" class="btn btn-xs btn-error btn-outline" {{ 'disabled' if group_id in used_group_ids else '' }} title="{{ 'Cannot delete: group is in use' if group_id in used_group_ids else 'Delete Group' }}">
Delete
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center opacity-70 py-8">
<p>No Access Groups have been created yet.</p>
<p class="mt-2 text-sm">Click "Create New Group" to get started.</p>
</div>
{% endif %}
</div>
</section>
{% endblock %}
{% block modals %}
{{ super() }}
{% include 'modals/_access_group_modal.html' %}
{% endblock %}

View file

@ -0,0 +1,104 @@
<!--
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/>.
-->
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>{% block title %}DockFlare{% endblock %} - Cloudflare Tunnel Manager</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
<link rel="preconnect" href="https://rsms.me" crossorigin>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" crossorigin="anonymous">
<style>
body { font-family: system-ui, -apple-system, sans-serif; }
.container { width: 100%; max-width: 1200px; margin: 0 auto; padding: 0 1rem; }
pre { font-family: monospace; }
#log-output::-webkit-scrollbar { width: 8px; }
#log-output::-webkit-scrollbar-track { background: transparent; }
#log-output::-webkit-scrollbar-thumb { background-color: rgba(156, 163, 175, 0.5); border-radius: 4px; border: 2px solid transparent; background-clip: content-box; }
#log-output::-webkit-scrollbar-thumb:hover { background-color: rgba(156, 163, 175, 0.7); }
html[data-theme="dark"] #log-output::-webkit-scrollbar-thumb { background-color: rgba(107, 114, 128, 0.5); }
html[data-theme="dark"] #log-output::-webkit-scrollbar-thumb:hover { background-color: rgba(107, 114, 128, 0.7); }
</style>
</head>
<body class="min_h-screen flex flex-col font-sans transition-colors duration-300 bg-base-200/50">
<div class="fixed bottom-6 right-[-38px] sm:bottom-7 sm:right-[-35px] z-50 transform -rotate-45 bg-indigo-600 text-white text-xs font-semibold tracking-wider text-center py-1 w-36 shadow-md"
style="pointer-events: none;">
version 1.9.5
</div>
<header class="sticky top-0 z-40 w-full backdrop-blur-sm shadow-sm bg-base-100/90">
<div class="navbar mx-auto px-4 sm:px-6 lg:px-8 max-w-screen-2xl">
<div class="navbar-start">
<div class="dropdown">
<label tabindex="0" class="btn btn-ghost lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg>
</label>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
<li><a href="{{ url_for('web.status_page') }}" class="{{ 'active' if request.endpoint == 'web.status_page' else '' }}">Dashboard</a></li>
<li><a href="{{ url_for('web.access_groups_page') }}" class="{{ 'active' if request.endpoint == 'web.access_groups_page' else '' }}">Access Groups</a></li>
</ul>
</div>
<a href="{{ url_for('web.status_page') }}" class="btn btn-ghost normal-case text-xl hidden sm:flex">
<img src="{{ url_for('static', filename='images/bannertr.png') }}" alt="Dockflare Logo Banner" class="h-12 sm:h-16 block align-middle">
</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><a href="{{ url_for('web.status_page') }}" class="{{ 'active' if request.endpoint == 'web.status_page' else '' }}">Dashboard</a></li>
<li><a href="{{ url_for('web.access_groups_page') }}" class="{{ 'active' if request.endpoint == 'web.access_groups_page' else '' }}">Access Groups</a></li>
</ul>
</div>
<div class="navbar-end">
<div class="dropdown dropdown-end">
<button tabindex="0" role="button" id="theme-selector-btn" aria-label="Select Theme" class="btn btn-ghost">
<span class="mr-2 hidden sm:inline">Themes</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42" />
</svg>
</button>
<ul tabindex="0" id="theme-menu" class="dropdown-content z-[50] menu p-2 shadow bg-base-200 rounded-box w-52 max-h-96 overflow-y-auto mt-4 flex-nowrap min-h-0">
</ul>
</div>
</div>
</div>
</header>
<main class="mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12 flex-grow w-full max-w-screen-2xl">
{% block content %}{% endblock %}
</main>
<footer class="text-center py-6 mt-auto text-sm opacity-70 border-t border-base-300 bg-base-100">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<p class="mb-1"><a href="https://github.com/ChrispyBacon-dev/DockFlare" target="_blank" rel="noopener noreferrer" class="link link-hover link-primary">DockFlare on GitHub</a></p>
<p class="mb-1"><a href="https://github.com/ChrispyBacon-dev/DockFlare/wiki" target="_blank" rel="noopener noreferrer" class="link link-hover link-primary">DockFlare Documentation</a></p>
<p class="mb-1">Enjoying DockFlare? Consider supporting the project!</p>
<p><a href="https://github.com/sponsors/ChrispyBacon-dev" target="_blank" rel="noopener noreferrer" class="inline-flex items-center link link-hover"><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1 text-pink-500" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" /></svg>Sponsor ChrispyBacon-dev on GitHub</a></p>
<p class="mt-2 text-xs opacity-60">DockFlare v1.9.5</p>
</div>
</footer>
{% block modals %}{% endblock %}
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
</html>

View file

@ -0,0 +1,62 @@
<dialog id="access_group_modal" class="modal">
<div class="modal-box w-11/12 max-w-3xl bg-base-100/70 dark:bg-neutral/70 backdrop-blur-md shadow-xl">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 id="access_group_modal_title" class="font-bold text-lg mb-6">Create New Access Group</h3>
<form id="access_group_form" method="POST" class="space-y-6 protocol-aware-form">
<input type="hidden" name="original_group_id" id="original_group_id">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="group_display_name"><span class="label-text">Display Name (Required)</span></label>
<input type="text" id="group_display_name" name="display_name" placeholder="e.g., NAS Family Access" class="input input-bordered w-full" required />
<div class="label"><span class="label-text-alt">A friendly name shown in the UI.</span></div>
</div>
<div class="form-control">
<label class="label" for="group_id"><span class="label-text">Group ID (Required)</span></label>
<input type="text" id="group_id" name="group_id" placeholder="e.g., nas-family" class="input input-bordered w-full" required pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$" />
<div class="label"><span class="label-text-alt">Used in Docker labels. Lowercase, numbers, and hyphens only.</span></div>
</div>
</div>
<div>
<h4 class="text-md font-semibold mb-2">Policy Rules</h4>
<div class="form-control">
<label class="label" for="group_emails"><span class="label-text">Allowed Emails or Domains</span></label>
<textarea id="group_emails" name="emails" class="textarea textarea-bordered h-24" placeholder="me@example.com, myfriend@example.com, @mycompany.com"></textarea>
<div class="label"><span class="label-text-alt">Comma-separated. To allow anyone from a domain, use <code class="text-xs">@domain.com</code>.</span></div>
</div>
</div>
<div>
<h4 class="text-md font-semibold mb-2">Application Settings (Optional)</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="group_session_duration"><span class="label-text">Session Duration</span></label>
<input type="text" id="group_session_duration" name="session_duration" value="24h" class="input input-bordered w-full" />
<div class="label"><span class="label-text-alt">e.g., 24h, 30m, 720h.</span></div>
</div>
<div class="form-control pt-8">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="auto_redirect" id="group_auto_redirect" class="checkbox checkbox-sm" />
<span class="label-text">Auto Redirect to Identity</span>
</label>
</div>
<div class="form-control pt-8">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="app_launcher_visible" id="group_app_launcher_visible" class="checkbox checkbox-sm" />
<span class="label-text">Visible in App Launcher</span>
</label>
</div>
</div>
</div>
<div class="modal-action mt-8">
<button type="submit" class="btn btn-primary">Save Group</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>

View file

@ -1,354 +1,348 @@
<!--
DockFlare: Automates Cloudflare Tunnel ingress from Docker labels.
Copyright (C) 2025 ChrispyBacon-Dev <https://github.com/ChrispyBacon-dev/DockFlare>
{% extends "base.html" %}
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.
{% block title %}Dashboard{% endblock %}
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/>.
-->
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>DockFlare v1.9.5 - Cloudflare Tunnel ingress Manager</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
<link rel="preconnect" href="https://rsms.me" crossorigin>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" crossorigin="anonymous">
<style>
body { font-family: system-ui, -apple-system, sans-serif; }
.container { width: 100%; max-width: 1200px; margin: 0 auto; padding: 0 1rem; }
pre { font-family: monospace; }
#log-output::-webkit-scrollbar { width: 8px; }
#log-output::-webkit-scrollbar-track { background: transparent; }
#log-output::-webkit-scrollbar-thumb { background-color: rgba(156, 163, 175, 0.5); border-radius: 4px; border: 2px solid transparent; background-clip: content-box; }
#log-output::-webkit-scrollbar-thumb:hover { background-color: rgba(156, 163, 175, 0.7); }
html[data-theme="dark"] #log-output::-webkit-scrollbar-thumb { background-color: rgba(107, 114, 128, 0.5); }
html[data-theme="dark"] #log-output::-webkit-scrollbar-thumb:hover { background-color: rgba(107, 114, 128, 0.7); }
</style>
</head>
<body class="min_h-screen flex flex-col font-sans transition-colors duration-300">
<div class="fixed bottom-6 right-[-38px] sm:bottom-7 sm:right-[-35px] z-50 transform -rotate-45 bg-indigo-600 text-white text-xs font-semibold tracking-wider text-center py-1 w-36 shadow-md"
style="pointer-events: none;">
version 1.9.5
{% block content %}
{% if initialization and initialization.in_progress %}
<div role="alert" class="alert alert-info shadow-md text-sm mb-8">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6 animate-spin"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6l4 2M21.56 10.5A10.001 10.001 0 0012 2a10 10 0 100 20 9.974 9.974 0 005.201-1.71l-.001-.001z"></path></svg>
<div>
<h3 class="font-bold">Initialization in Progress...</h3>
<div class="text-xs">You can view logs below. The UI will update when ready.</div>
</div>
</div>
{% endif %}
<header class="sticky top-0 z-40 w-full backdrop-blur-sm shadow-sm bg-base-100/90">
<div class="mx-auto px-4 sm:px-6 lg:px-8 max-w-screen-2xl">
<div class="py-4 relative flex items-center justify-end h-20 sm:h-24">
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<a href="/" aria-label="DockFlare Home" class="inline-block">
<img src="{{ url_for('static', filename='images/bannertr.png') }}" alt="Dockflare Logo Banner" class="h-12 sm:h-16 block align-middle">
</a>
</div>
<div id="reconciliation-status" class="mb-8"></div>
<div id="reconciliation-status-message" class="text-sm opacity-75 mb-1"></div>
<div class="dropdown dropdown-end">
<button tabindex="0" role="button" id="theme-selector-btn" aria-label="Select Theme" class="btn btn-ghost">
<span class="mr-2 hidden sm:inline">Themes</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42" />
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<h2 class="card-title text-2xl sm:text-3xl border-b border-base-300 pb-3 mb-6">
Tunnel & Agent Status
</h2>
{% if agent_state.last_action_status or tunnel_state.get('error') %}
{% set alert_message = agent_state.last_action_status or tunnel_state.status_message %}
{% set is_error = 'Error:' in alert_message or tunnel_state.get('error') %}
{% set is_warning = 'Warning:' in alert_message and not is_error %}
{% set is_success = 'Success:' in alert_message and not is_error and not is_warning %}
{% set alert_type = 'alert-error' if is_error else ('alert-warning' if is_warning else ('alert-success' if is_success else 'alert-info')) %}
<div role="alert" class="alert {{ alert_type }} shadow-sm text-sm mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24">
{% if is_error %}<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
{% elif is_warning %}<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
{% elif is_success %}<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
{% else %}<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>{% endif %}
</svg>
</button>
<ul tabindex="0" id="theme-menu" class="dropdown-content z-[50] menu p-2 shadow bg-base-200 rounded-box w-52 max-h-96 overflow-y-auto mt-4 flex-nowrap min-h-0">
</ul>
</div>
</div>
</div>
</header>
<main class="mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12 flex-grow w-full max-w-screen-2xl">
{% if initialization and initialization.in_progress %}
<div role="alert" class="alert alert-info shadow-md text-sm mb-8">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6 animate-spin"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6l4 2M21.56 10.5A10.001 10.001 0 0012 2a10 10 0 100 20 9.974 9.974 0 005.201-1.71l-.001-.001z"></path></svg>
<div>
<h3 class="font-bold">Initialization in Progress...</h3>
<div class="text-xs">You can view logs below. The UI will update when ready.</div>
</div>
</div>
{% endif %}
<div id="reconciliation-status" class="mb-8"></div>
<div id="reconciliation-status-message" class="text-sm opacity-75 mb-1"></div>
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<h2 class="card-title text-2xl sm:text-3xl border-b border-base-300 pb-3 mb-6">
Tunnel & Agent Status
</h2>
{% if agent_state.last_action_status or tunnel_state.get('error') %}
{% set alert_message = agent_state.last_action_status or tunnel_state.status_message %}
{% set is_error = 'Error:' in alert_message or tunnel_state.get('error') %}
{% set is_warning = 'Warning:' in alert_message and not is_error %}
{% set is_success = 'Success:' in alert_message and not is_error and not is_warning %}
{% set alert_type = 'alert-error' if is_error else ('alert-warning' if is_warning else ('alert-success' if is_success else 'alert-info')) %}
<div role="alert" class="alert {{ alert_type }} shadow-sm text-sm mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24">
{% if is_error %}<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
{% elif is_warning %}<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
{% elif is_success %}<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
{% else %}<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>{% endif %}
</svg>
<div class="flex-1">
<h3 class="font-bold text-xs uppercase opacity-80">
{% if agent_state.last_action_status %}Last Action{% else %}API Status{% endif %}
</h3>
<div class="text-xs">{{ alert_message }}</div>
{% if tunnel_state.get('error') and (not agent_state.last_action_status or 'Error:' not in agent_state.last_action_status) %}
<div class="text-xs mt-1 opacity-70"><strong>Error Details:</strong> {{ tunnel_state.error }}</div>
{% endif %}
</div>
</div>
{% elif not (initialization and initialization.in_progress) %}
<div role="alert" class="alert alert-success shadow-sm text-sm mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<div>
<h3 class="font-bold">API Status</h3>
<div class="text-xs">{{ tunnel_state.status_message }}</div>
</div>
</div>
{% endif %}
<div class="mb-6">
<h4 class="text-md font-semibold mb-3 opacity-80">Tunnel Details</h4>
<div class="space-y-2 text-sm">
<p><strong class="inline-block w-36 opacity-70">Desired Name:</strong> <code class="badge badge-ghost">{{ tunnel_state.name }}</code></p>
<p><strong class="inline-block w-36 opacity-70">Tunnel ID:</strong> <code class="badge badge-ghost">{{ tunnel_state.id if tunnel_state.id else 'N/A' }}</code></p>
{% if not external_cloudflared %}
<p><strong class="inline-block w-36 opacity-70">Tunnel Token:</strong> <code class="badge badge-ghost">{{ display_token }}</code></p>
<div class="flex-1">
<h3 class="font-bold text-xs uppercase opacity-80">
{% if agent_state.last_action_status %}Last Action{% else %}API Status{% endif %}
</h3>
<div class="text-xs">{{ alert_message }}</div>
{% if tunnel_state.get('error') and (not agent_state.last_action_status or 'Error:' not in agent_state.last_action_status) %}
<div class="text-xs mt-1 opacity-70"><strong>Error Details:</strong> {{ tunnel_state.error }}</div>
{% endif %}
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-3 opacity-80">Agent Control</h3>
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-6 mt-2 p-4 bg-base-200/50 rounded-md">
{% if external_cloudflared %}
<div class="flex-grow space-y-1.5">
<div class="flex items-center space-x-3">
<strong class="text-sm font-medium flex-shrink-0 w-28 opacity-70">External Mode:</strong>
<span class="badge badge-info badge-sm animate-pulse">Active</span>
</div>
<div class="flex items-center text-sm">
<strong class="font-medium opacity-70 flex-shrink-0 w-28">External ID:</strong>
<code class="ml-3 badge badge-ghost" title="{{ external_tunnel_id }}">{{ external_tunnel_id }}</code>
</div>
<div class="text-xs opacity-60 mt-2">
<p class="italic">External cloudflared enabled. DockFlare manages DNS, not the agent.</p>
</div>
</div>
{% else %}
<div class="flex-grow space-y-1.5">
{% elif not (initialization and initialization.in_progress) %}
<div role="alert" class="alert alert-success shadow-sm text-sm mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<div>
<h3 class="font-bold">API Status</h3>
<div class="text-xs">{{ tunnel_state.status_message }}</div>
</div>
</div>
{% endif %}
<div class="mb-6">
<h4 class="text-md font-semibold mb-3 opacity-80">Tunnel Details</h4>
<div class="space-y-2 text-sm">
<p><strong class="inline-block w-36 opacity-70">Desired Name:</strong> <code class="badge badge-ghost">{{ tunnel_state.name }}</code></p>
<p><strong class="inline-block w-36 opacity-70">Tunnel ID:</strong> <code class="badge badge-ghost">{{ tunnel_state.id if tunnel_state.id else 'N/A' }}</code></p>
{% if not external_cloudflared %}
<p><strong class="inline-block w-36 opacity-70">Tunnel Token:</strong> <code class="badge badge-ghost">{{ display_token }}</code></p>
{% endif %}
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-3 opacity-80">Agent Control</h3>
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-6 mt-2 p-4 bg-base-200/50 rounded-md">
{% if external_cloudflared %}
<div class="flex-grow space-y-1.5">
<div class="flex items-center space-x-3">
<strong class="text-sm font-medium flex-shrink-0 w-28 opacity-70">Agent Status:</strong>
{% set indicator_color = 'bg-gray-400' %}
{% if agent_state.container_status == 'running' %} {% set indicator_color = 'badge-success' %}
{% elif agent_state.container_status in ['exited', 'dead', 'not_found', 'docker_unavailable'] %} {% set indicator_color = 'badge-error' %}
{% elif agent_state.container_status in ['created', 'paused', 'restarting', 'initializing'] %} {% set indicator_color = 'badge-warning' %}
{% endif %}
<span class="badge {{ indicator_color }} badge-sm {% if agent_state.container_status == 'running' %}animate-pulse{% endif %}">{{ agent_state.container_status.replace('_',' ') if agent_state.container_status else 'Unknown' }}</span>
<strong class="text-sm font-medium flex-shrink-0 w-28 opacity-70">External Mode:</strong>
<span class="badge badge-info badge-sm animate-pulse">Active</span>
</div>
<div class="flex items-center text-sm">
<strong class="font-medium opacity-70 flex-shrink-0 w-28">Agent Name:</strong>
<code class="ml-3 badge badge-ghost" title="{{ cloudflared_container_name }}">{{ cloudflared_container_name }}</code>
<strong class="font-medium opacity-70 flex-shrink-0 w-28">External ID:</strong>
<code class="ml-3 badge badge-ghost" title="{{ external_tunnel_id }}">{{ external_tunnel_id }}</code>
</div>
</div>
<div class="w-full sm:w-auto flex-shrink-0">
{% set is_startable = tunnel_state.get('id') and tunnel_state.get('token') and docker_available %}
{% set is_stoppable = docker_available %}
{% if agent_state.container_status=='running' %}
<form action="{{ url_for('web.stop_tunnel_route') }}" method="post" class="inline-block w-full sm:w-auto protocol-aware-form">
<button type="submit" class="btn btn-sm btn-error w-full sm:w-auto" {{ 'disabled' if not is_stoppable else '' }}>Stop Agent</button>
</form>
{% else %}
<form action="{{ url_for('web.start_tunnel_route') }}" method="post" class="inline-block w-full sm:w-auto protocol-aware-form">
<button type="submit" class="btn btn-sm btn-success w-full sm:w-auto" {{ 'disabled' if not is_startable else '' }}>Start Agent</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
<div class="text-xs opacity-60 mt-2">
<p class="italic">External cloudflared enabled. DockFlare manages DNS, not the agent.</p>
</div>
</div>
{% else %}
<div class="flex-grow space-y-1.5">
<div class="flex items-center space-x-3">
<strong class="text-sm font-medium flex-shrink-0 w-28 opacity-70">Agent Status:</strong>
{% set indicator_color = 'bg-gray-400' %}
{% if agent_state.container_status == 'running' %} {% set indicator_color = 'badge-success' %}
{% elif agent_state.container_status in ['exited', 'dead', 'not_found', 'docker_unavailable'] %} {% set indicator_color = 'badge-error' %}
{% elif agent_state.container_status in ['created', 'paused', 'restarting', 'initializing'] %} {% set indicator_color = 'badge-warning' %}
{% endif %}
<span class="badge {{ indicator_color }} badge-sm {% if agent_state.container_status == 'running' %}animate-pulse{% endif %}">{{ agent_state.container_status.replace('_',' ') if agent_state.container_status else 'Unknown' }}</span>
</div>
<div class="flex items-center text-sm">
<strong class="font-medium opacity-70 flex-shrink-0 w-28">Agent Name:</strong>
<code class="ml-3 badge badge-ghost" title="{{ cloudflared_container_name }}">{{ cloudflared_container_name }}</code>
</div>
</div>
<div class="w-full sm:w-auto flex-shrink-0">
{% set is_startable = tunnel_state.get('id') and tunnel_state.get('token') and docker_available %}
{% set is_stoppable = docker_available %}
{% if agent_state.container_status=='running' %}
<form action="{{ url_for('web.stop_tunnel_route') }}" method="post" class="inline-block w-full sm:w-auto protocol-aware-form">
<button type="submit" class="btn btn-sm btn-error w-full sm:w-auto" {{ 'disabled' if not is_stoppable else '' }}>Stop Agent</button>
</form>
{% else %}
<form action="{{ url_for('web.start_tunnel_route') }}" method="post" class="inline-block w-full sm:w-auto protocol-aware-form">
<button type="submit" class="btn btn-sm btn-success w-full sm:w-auto" {{ 'disabled' if not is_startable else '' }}>Start Agent</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
</section>
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<h2 class="card-title text-2xl sm:text-3xl border-b border-base-300 pb-3 mb-6">
Managed Ingress Rules
<button class="btn btn-sm btn-primary ml-auto" onclick="document.getElementById('add_manual_rule_modal').showModal()">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-1"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Add Manual Rule
</button>
</h2>
</div>
</section>
{% if rules and rules.items() %}
<div class="overflow-x-auto -mx-6 sm:-mx-8 pb-24">
<table class="table table-zebra table-sm w-full">
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<h2 class="card-title text-2xl sm:text-3xl border-b border-base-300 pb-3 mb-6">
Managed Ingress Rules
<button class="btn btn-sm btn-primary ml-auto" onclick="document.getElementById('add_manual_rule_modal').showModal()">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-1"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Add Manual Rule
</button>
</h2>
{% if rules and rules.items() %}
<div class="overflow-x-auto -mx-6 sm:-mx-8 pb-24">
<table class="table table-zebra table-sm w-full">
<thead>
<tr>
<th class="p-3 w-auto">Status</th>
<th class="p-3 w-1/4">Hostname</th>
<th class="p-3 w-1/6">Path</th>
<th class="p-3 w-1/5">Service Target</th>
<th class="p-3 w-auto">Identifier</th>
<th class="p-3 w-1/5">Access Policy</th>
<th class="p-3 w-1/4">Manage Policy</th>
<th class="p-3 w-auto">Expires At</th>
<th class="p-3 w-auto">Actions</th>
</tr>
</thead>
<tbody>
{% for hostname, details in rules.items()|sort %}
<tr>
<td class="p-3 whitespace-nowrap">
{% if details.source == 'manual' %}
<span class="badge badge-info badge-sm">Manual</span>
{% else %}
{% set status_badge_color = 'badge-warning' if 'pending' in details.status else 'badge-success' %}
<span class="badge {{ status_badge_color }} badge-sm">
{{ details.status.replace('_',' ') }}
</span>
{% endif %}
</td>
<td class="p-3 whitespace-nowrap">
{% set display_hostname = details.hostname_for_dns if details.hostname_for_dns else hostname.split('|')[0] %}
{% set display_path = details.path if details.path else (hostname.split('|')[1] if '|' in hostname else None) %}
<a href="https://{{ display_hostname }}{% if display_path %}{{ display_path }}{% endif %}" target="_blank" rel="noopener noreferrer" title="Open {{ protocol }}://{{ display_hostname }}{% if display_path %}{{ display_path }}{% endif %}" class="link link-hover link-primary text-sm">
{{ display_hostname }}
</a>
{% if display_hostname and display_hostname.startswith('*.') %}
<span class="badge badge-info badge-xs ml-2">wildcard</span>
{% endif %}
<div class="text-xs mt-1">
{% if details.no_tls_verify %}
<span class="badge badge-warning badge-xs" title="TLS verification disabled for origin">No TLS Verify</span>
{% endif %}
{% if details.origin_server_name %}
<span class="badge badge-info badge-xs ml-1" title="Origin Server Name (SNI): {{ details.origin_server_name }}">SNI: {{ details.origin_server_name | truncate(20, True) }}</span>
{% endif %}
{% if details.http_host_header %}
<span class="badge badge-info badge-xs ml-1" title="HTTP Host Header: {{ details.http_host_header }}">Host: {{ details.http_host_header | truncate(20, True) }}</span>
{% endif %}
</div>
</td>
<td class="p-3 whitespace-nowrap">
{% if display_path and display_path.strip() %}
<code class="text-xs opacity-70">{{ display_path }}</code>
{% else %}
<span class="text-xs opacity-50 italic">(root)</span>
{% endif %}
</td>
<td class="p-3 whitespace-nowrap"><code class="text-xs opacity-80">{{ details.service }}</code></td>
<td class="p-3 whitespace-nowrap">
{% if details.source == 'manual' %}
<em class="text-xs opacity-70 italic">Manual Rule</em>
{% elif details.container_id %}
<code class="text-xs opacity-80" title="{{ details.container_id }}">{{ details.container_id[:12] }}</code>
{% else %}
<span class="text-xs opacity-50">N/A</span>
{% endif %}
</td>
<td class="p-3 whitespace-nowrap text-sm opacity-80" id="access-policy-display-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}">
{% if details.access_policy_type == 'group' %}
<span class="badge badge-primary badge-outline badge-sm">Group: {{ details.access_group_id }}</span>
{% elif details.access_policy_type %}
<span class="capitalize">
{{ details.access_policy_type.replace('_', ' ') if details.access_policy_type != 'pending_label_sync' else 'Pending Label Sync...' }}
</span>
{% elif details.get('status') == 'active' %}
<span class="opacity-60 italic">None (Public)</span>
{% else %}
<span class="text-xs opacity-50">N/A</span>
{% endif %}
{% if details.access_policy_type and details.access_policy_type != 'group' and details.access_policy_type != 'default_tld' and details.access_app_id and CF_ACCOUNT_ID_CONFIGURED and ACCOUNT_ID_FOR_DISPLAY != "Not Configured" %}
<a href="https://one.dash.cloudflare.com/{{ ACCOUNT_ID_FOR_DISPLAY }}/access/apps/self-hosted/{{ details.access_app_id }}/edit?tab=basic-info" target="_blank" rel="noopener noreferrer" title="Edit Access App in Cloudflare" class="link link-hover text-info ml-1 inline-block align-middle">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
{% endif %}
{% if details.access_policy_ui_override %}
<span class="badge badge-warning badge-xs ml-2 animate-pulse" title="This policy is managed by the UI{% if details.source != 'manual' %} and overrides container labels{% endif %}.">UI Override</span>
{% endif %}
</td>
<td class="p-3 whitespace-nowrap text-sm policy-actions-cell" id="policy-actions-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}" style="min-width: 180px;">
<div class="flex items-center space-x-1 sm:space-x-2">
<div class="dropdown dropdown-end dropdown-hover">
<button tabindex="0" role="button" class="btn btn-xs btn-info btn-outline" data-hostname="{{ hostname }}">Edit Policy</button>
<div tabindex="0" class="dropdown-content z-[50] menu p-3 shadow-xl bg-base-100 dark:bg-base-200 rounded-box w-64 sm:w-72">
<form action="{{ url_for('web.ui_update_access_policy', hostname=hostname) }}" method="POST" class="protocol-aware-form space-y-3">
<div>
<label for="access_policy_type-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}" class="block text-xs font-medium opacity-80 mb-1">Policy Type:</label>
<select name="access_policy_type" id="access_policy_type-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}" class="policy-type-select select select-bordered select-xs w-full" data-hostname="{{ hostname }}">
<option value="none" {% if not details.access_policy_type %}selected{% endif %}>None (Public - No App)</option>
<option value="bypass" {% if details.access_policy_type == 'bypass' %}selected{% endif %}>Bypass (Public App)</option>
<option value="authenticate_email" {% if details.access_policy_type == 'authenticate_email' or (details.source != 'manual' and details.access_policy_type == 'authenticate' and not details.access_custom_rules_str and not details.access_allowed_idps_str) %}selected{% endif %}>Authenticate by Email</option>
<option value="default_tld" {% if details.access_policy_type == 'default_tld' %}selected{% endif %}>Use Default *.tld Policy</option>
</select>
</div>
<div class="auth-email-field hidden mt-2" id="auth-email-field-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}">
<label for="auth_email-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}" class="block text-xs font-medium opacity-80 mb-1">Allowed Email:</label>
{% set prefill_email_val = '' %}
<input type="email" name="auth_email" id="auth_email-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}" placeholder="user@example.com" value="{{ prefill_email_val }}" class="input input-bordered input-xs w-full">
</div>
<div class="flex items-center justify-end space-x-2 mt-3">
<button type="submit" class="save-policy-btn btn btn-xs btn-primary">Save</button>
</div>
</form>
</div>
</div>
{% if details.access_policy_ui_override %}
<form action="{{ url_for('web.revert_access_policy_to_labels', hostname=hostname) }}" method="POST"
onsubmit="return confirm('Are you sure you want to revert the Access Policy for {{ details.hostname_for_dns }}{% if details.path %}{{ details.path }}{% endif %}? {% if details.source == "manual" %}The current UI-set policy will be removed, and it will become public or use a TLD policy if one exists.{% else %}The current UI-set policy will be removed, and it will be managed by its container labels.{% endif %}');"
class="protocol-aware-form inline-block">
<button type="submit" class="btn btn-xs btn-warning btn-outline" title="Revert to Label/Default Policy">Revert</button>
</form>
{% endif %}
</div>
</td>
<td class="p-3 whitespace-nowrap text-sm opacity-70">
{% if details.source == 'manual' or (details.source != 'manual' and details.status == 'active') %}
N/A
{% elif details.source != 'manual' and details.status=='pending_deletion' and details.delete_at %}
<div data-delete-at="{{ details.delete_at.isoformat() }}">
<span class="absolute-time-display"></span>
<span class="countdown-timer block text-xs opacity-80"></span>
</div>
{% else %}
N/A
{% endif %}
</td>
<td class="p-3 whitespace-nowrap text-sm" style="min-width: 120px;">
<div class="flex items-center gap-2">
{% if details.source == 'manual' %}
<button class="btn btn-xs btn-info btn-outline edit-manual-rule-btn"
data-rule-key="{{ hostname }}"
data-rule-details="{{ details|tojson|forceescape }}">
Edit
</button>
<form action="{{ url_for('web.ui_delete_manual_rule_route', rule_key_from_url=hostname) }}" method="post" onsubmit="return confirm('Are you sure you want to delete the manual rule for {{ details.hostname_for_dns }}{% if details.path %}{{ details.path }}{% endif %}?');" class="protocol-aware-form">
<button type="submit" class="btn btn-xs btn-error btn-outline">Delete</button>
</form>
{% else %}
<form action="{{ url_for('web.force_delete_rule_route', hostname=hostname) }}" method="post" onsubmit="return confirm('Are you sure you want to force delete the rule and DNS record for {{ details.hostname_for_dns }}{% if details.path %}{{ details.path }}{% endif %} immediately? This bypasses the grace period.');" class="protocol-aware-form">
<button type="submit" class="btn btn-xs btn-error btn-outline" {{ 'disabled' if not docker_available else '' }}>Delete</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center opacity-70 py-8">No ingress rules are currently being managed.</p>
{% endif %}
</div>
</section>
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<h2 class="card-title text-2xl sm:text-3xl border-b border-base-300 pb-3 mb-6">All Cloudflare Tunnels on Account</h2>
{% if CF_ACCOUNT_ID_CONFIGURED %}
<p class="mb-4 text-sm opacity-70">
Displaying tunnels for Account ID: <code class="badge badge-ghost">{{ ACCOUNT_ID_FOR_DISPLAY | e }}</code>.
<br>
<em class="text-xs opacity-60">This list shows all tunnels found on the account, not just the one managed by this DockFlare instance. Click the '+' icon next to a tunnel to view its associated DNS records.</em>
</p>
{% if all_account_tunnels is defined and all_account_tunnels %}
<div class="overflow-x-auto -mx-6 sm:-mx-8">
<table class="table table-sm w-full">
<thead>
<tr>
<th class="p-3 w-auto">Status</th>
<th class="p-3 w-1/4">Hostname</th>
<th class="p-3 w-1/6">Path</th>
<th class="p-3 w-1/5">Service Target</th>
<th class="p-3 w-auto">Identifier</th>
<th class="p-3 w-1/5">Access Policy</th>
<th class="p-3 w-1/4">Manage Policy</th>
<th class="p-3 w-auto">Expires At</th>
<th class="p-3 w-auto">Actions</th>
<th class="w-12">+/-</th>
<th>Tunnel Name</th>
<th>Tunnel ID</th>
<th>Status</th>
<th>Created At</th>
<th>Connections</th>
</tr>
</thead>
<tbody>
{% for hostname, details in rules.items()|sort %}
<tr>
<td class="p-3 whitespace-nowrap">
{% if details.source == 'manual' %}
<span class="badge badge-info badge-sm">Manual</span>
{% else %}
{% set status_badge_color = 'badge-warning' if 'pending' in details.status else 'badge-success' %}
<span class="badge {{ status_badge_color }} badge-sm">
{{ details.status.replace('_',' ') }}
</span>
{% endif %}
{% for tunnel in all_account_tunnels %}
<tr>
<td class="text-center">
<button type="button" class="btn btn-xs btn-ghost btn-circle tunnel-dns-toggle"
data-tunnel-id="{{ tunnel.id | e }}" aria-expanded="false" aria-controls="dns-records-{{ tunnel.id | e }}" title="Toggle DNS records">
<svg class="w-4 h-4 expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
<svg class="w-4 h-4 collapse-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"></path></svg>
</button>
</td>
<td class="p-3 whitespace-nowrap">
{% set display_hostname = details.hostname_for_dns if details.hostname_for_dns else hostname.split('|')[0] %}
{% set display_path = details.path if details.path else (hostname.split('|')[1] if '|' in hostname else None) %}
<a href="https://{{ display_hostname }}{% if display_path %}{{ display_path }}{% endif %}" target="_blank" rel="noopener noreferrer" title="Open {{ protocol }}://{{ display_hostname }}{% if display_path %}{{ display_path }}{% endif %}" class="link link-hover link-primary text-sm">
{{ display_hostname }}
</a>
{% if display_hostname and display_hostname.startswith('*.') %}
<span class="badge badge-info badge-xs ml-2">wildcard</span>
{% endif %}
<div class="text-xs mt-1">
{% if details.no_tls_verify %}
<span class="badge badge-warning badge-xs" title="TLS verification disabled for origin">No TLS Verify</span>
{% endif %}
{% if details.origin_server_name %}
<span class="badge badge-info badge-xs ml-1" title="Origin Server Name (SNI): {{ details.origin_server_name }}">SNI: {{ details.origin_server_name | truncate(20, True) }}</span>
{% endif %}
{% if details.http_host_header %}
<span class="badge badge-info badge-xs ml-1" title="HTTP Host Header: {{ details.http_host_header }}">Host: {{ details.http_host_header | truncate(20, True) }}</span>
{% endif %}
</div>
</td>
<td class="p-3 whitespace-nowrap">
{% if display_path and display_path.strip() %}
<code class="text-xs opacity-70">{{ display_path }}</code>
{% else %}
<span class="text-xs opacity-50 italic">(root)</span>
{% endif %}
</td>
<td class="p-3 whitespace-nowrap"><code class="text-xs opacity-80">{{ details.service }}</code></td>
<td class="p-3 whitespace-nowrap">
{% if details.source == 'manual' %}
<em class="text-xs opacity-70 italic">Manual Rule</em>
{% elif details.container_id %}
<code class="text-xs opacity-80" title="{{ details.container_id }}">{{ details.container_id[:12] }}</code>
{% else %}
<span class="text-xs opacity-50">N/A</span>
{% endif %}
<td class="text-sm font-medium">{{ tunnel.name | e }}</td>
<td><code class="text-xs opacity-80">{{ tunnel.id | e }}</code></td>
<td>
{% set status_color = 'badge-success' if tunnel.status | lower == 'healthy' else ('badge-warning' if tunnel.status | lower == 'degraded' else ('badge-error' if tunnel.status | lower == 'down' else 'badge-ghost')) %}
<span class="badge {{ status_color }} badge-sm">{{ tunnel.status | capitalize | e }}</span>
</td>
<td class="p-3 whitespace-nowrap text-sm opacity-80" id="access-policy-display-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}">
{% if details.access_policy_type %}
<span class="capitalize">
{{ details.access_policy_type.replace('_', ' ') if details.access_policy_type != 'pending_label_sync' else 'Pending Label Sync...' }}
</span>
{% if details.access_policy_type == 'pending_label_sync' %}
<span class="loading loading-spinner loading-xs text-info ml-1 align-middle"></span>
{% elif details.access_app_id and CF_ACCOUNT_ID_CONFIGURED and ACCOUNT_ID_FOR_DISPLAY != "Not Configured" %}
<a href="https://one.dash.cloudflare.com/{{ ACCOUNT_ID_FOR_DISPLAY }}/access/apps/self-hosted/{{ details.access_app_id }}/edit?tab=basic-info" target="_blank" rel="noopener noreferrer" title="Edit Access App in Cloudflare" class="link link-hover text-info ml-1 inline-block align-middle">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
{% elif details.access_policy_type and details.access_policy_type != 'default_tld' and details.access_policy_type != 'None' %}
<span class="badge badge-ghost badge-xs ml-1">(Managed)</span>
{% endif %}
{% if details.access_policy_ui_override %}
<span class="badge badge-warning badge-xs ml-2 animate-pulse" title="This policy is managed by the UI{% if details.source != 'manual' %} and overrides container labels{% endif %}.">UI Override</span>
{% endif %}
{% elif details.get('status') == 'active' %}
<span class="opacity-60 italic">None (Public)</span>
{% else %}
<span class="text-xs opacity-50">N/A</span>
{% endif %}
<td class="text-xs opacity-70">{% if tunnel.created_at %}{{ tunnel.created_at.split('T')[0] | e }}{% else %}N/A{% endif %}</td>
<td class="text-xs opacity-70">
{% if tunnel.connections and tunnel.connections is iterable and not tunnel.connections is string %}
{% for conn in tunnel.connections %}<span class="badge badge-outline badge-xs mr-1 mb-1">{{ conn.colo_name | e }}</span>{% endfor %}
{% if tunnel.connections | length > 0 %}({{ tunnel.connections | length }} total){% else %}(0 total){% endif %}
{% elif tunnel.connections %}{{ tunnel.connections | e }}{% else %}None{% endif %}
</td>
<td class="p-3 whitespace-nowrap text-sm policy-actions-cell" id="policy-actions-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}" style="min-width: 180px;">
<div class="flex items-center space-x-1 sm:space-x-2">
<div class="dropdown dropdown-end dropdown-hover">
<button tabindex="0" role="button" class="btn btn-xs btn-info btn-outline" data-hostname="{{ hostname }}">Edit Policy</button>
<div tabindex="0" class="dropdown-content z-[50] menu p-3 shadow-xl bg-base-100 dark:bg-base-200 rounded-box w-64 sm:w-72">
<form action="{{ url_for('web.ui_update_access_policy', hostname=hostname) }}" method="POST" class="protocol-aware-form space-y-3">
<div>
<label for="access_policy_type-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}" class="block text-xs font-medium opacity-80 mb-1">Policy Type:</label>
<select name="access_policy_type" id="access_policy_type-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}" class="policy-type-select select select-bordered select-xs w-full" data-hostname="{{ hostname }}">
<option value="none" {% if not details.access_policy_type %}selected{% endif %}>None (Public - No App)</option>
<option value="bypass" {% if details.access_policy_type == 'bypass' %}selected{% endif %}>Bypass (Public App)</option>
<option value="authenticate_email" {% if details.access_policy_type == 'authenticate_email' or (details.source != 'manual' and details.access_policy_type == 'authenticate' and not details.access_custom_rules_str and not details.access_allowed_idps_str) %}selected{% endif %}>Authenticate by Email</option>
<option value="default_tld" {% if details.access_policy_type == 'default_tld' %}selected{% endif %}>Use Default *.tld Policy</option>
</select>
</div>
<div class="auth-email-field hidden mt-2" id="auth-email-field-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}">
<label for="auth_email-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}" class="block text-xs font-medium opacity-80 mb-1">Allowed Email:</label>
{% set prefill_email_val = '' %}
{# #}
<input type="email" name="auth_email" id="auth_email-{{ details.source if details.source else 'docker' }}-{{ hostname | replace('.', '-') }}" placeholder="user@example.com" value="{{ prefill_email_val }}" class="input input-bordered input-xs w-full">
</div>
<div class="flex items-center justify-end space-x-2 mt-3">
<button type="submit" class="save-policy-btn btn btn-xs btn-primary">Save</button>
</div>
</form>
</div>
</div>
{% if details.access_policy_ui_override %}
<form action="{{ url_for('web.revert_access_policy_to_labels', hostname=hostname) }}" method="POST"
onsubmit="return confirm('Are you sure you want to revert the Access Policy for {{ details.hostname_for_dns }}{% if details.path %}{{ details.path }}{% endif %}? {% if details.source == "manual" %}The current UI-set policy will be removed, and it will become public or use a TLD policy if one exists.{% else %}The current UI-set policy will be removed, and it will be managed by its container labels.{% endif %}');"
class="protocol-aware-form inline-block">
<button type="submit" class="btn btn-xs btn-warning btn-outline" title="Revert to Label/Default Policy">Revert</button>
</form>
{% endif %}
</div>
</td>
<td class="p-3 whitespace-nowrap text-sm opacity-70">
{% if details.source == 'manual' or (details.source != 'manual' and details.status == 'active') %}
N/A
{% elif details.source != 'manual' and details.status=='pending_deletion' and details.delete_at %}
<div data-delete-at="{{ details.delete_at.isoformat() }}">
<span class="absolute-time-display"></span>
<span class="countdown-timer block text-xs opacity-80"></span>
</div>
{% else %}
N/A
{% endif %}
</td>
<td class="p-3 whitespace-nowrap text-sm" style="min-width: 120px;">
<div class="flex items-center gap-2">
{% if details.source == 'manual' %}
<button class="btn btn-xs btn-info btn-outline edit-manual-rule-btn"
data-rule-key="{{ hostname }}"
data-rule-details="{{ details|tojson|forceescape }}">
Edit
</button>
<form action="{{ url_for('web.ui_delete_manual_rule_route', rule_key_from_url=hostname) }}" method="post" onsubmit="return confirm('Are you sure you want to delete the manual rule for {{ details.hostname_for_dns }}{% if details.path %}{{ details.path }}{% endif %}?');" class="protocol-aware-form">
<button type="submit" class="btn btn-xs btn-error btn-outline">Delete</button>
</form>
{% else %}
<form action="{{ url_for('web.force_delete_rule_route', hostname=hostname) }}" method="post" onsubmit="return confirm('Are you sure you want to force delete the rule and DNS record for {{ details.hostname_for_dns }}{% if details.path %}{{ details.path }}{% endif %} immediately? This bypasses the grace period.');" class="protocol-aware-form">
<button type="submit" class="btn btn-xs btn-error btn-outline" {{ 'disabled' if not docker_available else '' }}>Delete</button>
</form>
{% endif %}
</tr>
<tr class="dns-records-row hidden bg-base-200/30">
<td colspan="6">
<div id="dns-records-{{ tunnel.id | e }}" class="p-4 text-sm space-y-2">
<p class="opacity-60 italic">Click the '+' button to load DNS records for this tunnel.</p>
</div>
</td>
</tr>
@ -356,102 +350,35 @@
</tbody>
</table>
</div>
{% elif all_account_tunnels is defined %}
<p class="text-center opacity-70 py-8">No Cloudflare Tunnels found for Account ID: <code class="badge badge-ghost">{{ ACCOUNT_ID_FOR_DISPLAY | e }}</code>.</p>
<p class="text-center text-xs opacity-60"><em>This could also mean an error occurred fetching them (check DockFlare logs). Ensure your CF_API_TOKEN has 'Account:Cloudflare Tunnel:Read' permission for the account level.</em></p>
{% else %}
<p class="text-center opacity-70 py-8">No ingress rules are currently being managed.</p>
<p class="alert alert-warning text-center py-8">Could not retrieve tunnel information.</p>
{% endif %}
</div>
</section>
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<h2 class="card-title text-2xl sm:text-3xl border-b border-base-300 pb-3 mb-6">All Cloudflare Tunnels on Account</h2>
{% if CF_ACCOUNT_ID_CONFIGURED %}
<p class="mb-4 text-sm opacity-70">
Displaying tunnels for Account ID: <code class="badge badge-ghost">{{ ACCOUNT_ID_FOR_DISPLAY | e }}</code>.
<br>
<em class="text-xs opacity-60">This list shows all tunnels found on the account, not just the one managed by this DockFlare instance. Click the '+' icon next to a tunnel to view its associated DNS records.</em>
</p>
{% if all_account_tunnels is defined and all_account_tunnels %}
<div class="overflow-x-auto -mx-6 sm:-mx-8">
<table class="table table-sm w-full">
<thead>
<tr>
<th class="w-12">+/-</th>
<th>Tunnel Name</th>
<th>Tunnel ID</th>
<th>Status</th>
<th>Created At</th>
<th>Connections</th>
</tr>
</thead>
<tbody>
{% for tunnel in all_account_tunnels %}
<tr>
<td class="text-center">
<button type="button" class="btn btn-xs btn-ghost btn-circle tunnel-dns-toggle"
data-tunnel-id="{{ tunnel.id | e }}" aria-expanded="false" aria-controls="dns-records-{{ tunnel.id | e }}" title="Toggle DNS records">
<svg class="w-4 h-4 expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
<svg class="w-4 h-4 collapse-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"></path></svg>
</button>
</td>
<td class="text-sm font-medium">{{ tunnel.name | e }}</td>
<td><code class="text-xs opacity-80">{{ tunnel.id | e }}</code></td>
<td>
{% set status_color = 'badge-success' if tunnel.status | lower == 'healthy' else ('badge-warning' if tunnel.status | lower == 'degraded' else ('badge-error' if tunnel.status | lower == 'down' else 'badge-ghost')) %}
<span class="badge {{ status_color }} badge-sm">{{ tunnel.status | capitalize | e }}</span>
</td>
<td class="text-xs opacity-70">{% if tunnel.created_at %}{{ tunnel.created_at.split('T')[0] | e }}{% else %}N/A{% endif %}</td>
<td class="text-xs opacity-70">
{% if tunnel.connections and tunnel.connections is iterable and not tunnel.connections is string %}
{% for conn in tunnel.connections %}<span class="badge badge-outline badge-xs mr-1 mb-1">{{ conn.colo_name | e }}</span>{% endfor %}
{% if tunnel.connections | length > 0 %}({{ tunnel.connections | length }} total){% else %}(0 total){% endif %}
{% elif tunnel.connections %}{{ tunnel.connections | e }}{% else %}None{% endif %}
</td>
</tr>
<tr class="dns-records-row hidden bg-base-200/30">
<td colspan="6">
<div id="dns-records-{{ tunnel.id | e }}" class="p-4 text-sm space-y-2">
<p class="opacity-60 italic">Click the '+' button to load DNS records for this tunnel.</p>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif all_account_tunnels is defined %}
<p class="text-center opacity-70 py-8">No Cloudflare Tunnels found for Account ID: <code class="badge badge-ghost">{{ ACCOUNT_ID_FOR_DISPLAY | e }}</code>.</p>
<p class="text-center text-xs opacity-60"><em>This could also mean an error occurred fetching them (check DockFlare logs). Ensure your CF_API_TOKEN has 'Account:Cloudflare Tunnel:Read' permission for the account level.</em></p>
{% else %}
<p class="alert alert-warning text-center py-8">Could not retrieve tunnel information.</p>
{% endif %}
{% else %}
<div role="alert" class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>The <code>CF_ACCOUNT_ID</code> environment variable is not configured. This section cannot be displayed.</span>
</div>
{% endif %}
</div>
</section>
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<h2 class="card-title text-2xl sm:text-3xl border-b border-base-300 pb-3 mb-4">
Real-time Activity Logs
</h2>
<div class="mockup-code text-xs max-h-96 overflow-y-hidden">
<pre id="log-output" aria-live="polite" class="h-full overflow-y-scroll p-4">Connecting to log stream...</pre>
{% else %}
<div role="alert" class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>The <code>CF_ACCOUNT_ID</code> environment variable is not configured. This section cannot be displayed.</span>
</div>
</div>
</section>
</main>
<footer class="text-center py-6 mt-auto text-sm opacity-70 border-t border-base-300">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<p class="mb-1"><a href="https://github.com/ChrispyBacon-dev/DockFlare" target="_blank" rel="noopener noreferrer" class="link link-hover link-primary">DockFlare on GitHub</a></p>
<p class="mb-1"><a href="https://github.com/ChrispyBacon-dev/DockFlare/wiki" target="_blank" rel="noopener noreferrer" class="link link-hover link-primary">DockFlare Documentation</a></p>
<p class="mb-1">Enjoying DockFlare? Consider supporting the project!</p>
<p><a href="https://github.com/sponsors/ChrispyBacon-dev" target="_blank" rel="noopener noreferrer" class="inline-flex items-center link link-hover"><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1 text-pink-500" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" /></svg>Sponsor ChrispyBacon-dev on GitHub</a></p>
<p class="mt-2 text-xs opacity-60">DockFlare v1.9.5</p>
{% endif %}
</div>
</footer>
</section>
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<h2 class="card-title text-2xl sm:text-3xl border-b border-base-300 pb-3 mb-4">
Real-time Activity Logs
</h2>
<div class="mockup-code text-xs max-h-96 overflow-y-hidden">
<pre id="log-output" aria-live="polite" class="h-full overflow-y-scroll p-4">Connecting to log stream...</pre>
</div>
</div>
</section>
{% endblock %}
{% block modals %}
<dialog id="add_manual_rule_modal" class="modal">
<div class="modal-box w-11/12 max-w-5xl bg-base-100/70 dark:bg-neutral/70 backdrop-blur-md shadow-xl">
<form method="dialog">
@ -574,6 +501,7 @@
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<dialog id="edit_manual_rule_modal" class="modal">
<div class="modal-box w-11/12 max-w-5xl bg-base-100/70 dark:bg-neutral/70 backdrop-blur-md shadow-xl">
<form method="dialog">
@ -659,7 +587,7 @@
<label class="label" for="edit_manual_origin_server_name"><span class="label-text">Origin Server Name (SNI)</span></label>
<input type="text" id="edit_manual_origin_server_name" name="manual_origin_server_name" placeholder="e.g., internal.service.local" class="input input-bordered w-full" />
</div>
<div class="form-control">
<div class="form-control">
<label class="label" for="edit_manual_http_host_header">
<span class="label-text">HTTP Host Header (Optional)</span>
</label>
@ -678,6 +606,4 @@
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
</html>
{% endblock %}

View file

@ -166,6 +166,23 @@ def status_page():
CF_ZONE_ID_CONFIGURED=bool(config.CF_ZONE_ID)
)
@bp.route('/access-groups')
def access_groups_page():
groups_for_template = {}
used_group_ids = set()
with state_lock:
used_group_ids = {
rule.get('access_group_id') for rule in managed_rules.values()
if rule.get('source') == 'docker' and rule.get('access_group_id')
}
groups_for_template = copy.deepcopy(access_groups)
return render_template(
'access_groups.html',
access_groups=groups_for_template,
used_group_ids=used_group_ids
)
@bp.route('/ui_update_access_policy/<path:hostname>', methods=['POST'])
def ui_update_access_policy(hostname):
if not docker_client:
@ -889,7 +906,120 @@ def ui_edit_manual_rule_route():
cloudflared_agent_state["last_action_status"] = f"Error: Failed to update Cloudflare tunnel config for manual rule {full_hostname}."
return redirect(url_for('web.status_page'))
def _parse_and_build_policy_from_form(email_str):
if not email_str or not email_str.strip():
return []
emails = []
domains = []
parts = [part.strip() for part in email_str.split(',') if part.strip()]
for part in parts:
if part.startswith('@'):
domains.append({"domain": part[1:]})
else:
emails.append({"email": part})
include_rules = []
if emails:
include_rules.extend(emails)
if domains:
include_rules.extend(domains)
if not include_rules:
return []
return [
{"name": "Allow defined users and domains", "decision": "allow", "include": include_rules},
{"name": "Default Deny", "decision": "deny", "include": [{"everyone": {}}]}
]
@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:
cloudflared_agent_state["last_action_status"] = "Error: Group ID and Display Name are required."
return redirect(url_for('web.access_groups_page'))
with state_lock:
if group_id in access_groups:
cloudflared_agent_state["last_action_status"] = f"Error: Access Group with ID '{group_id}' already exists."
return redirect(url_for('web.access_groups_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', ''))
}
access_groups[group_id] = new_group
save_state()
cloudflared_agent_state["last_action_status"] = f"Success: Access Group '{display_name}' created."
return redirect(url_for('web.access_groups_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:
cloudflared_agent_state["last_action_status"] = f"Error: Access Group with ID '{group_id}' not found."
return redirect(url_for('web.access_groups_page'))
form = request.form
display_name = form.get('display_name', '').strip()
if not display_name:
cloudflared_agent_state["last_action_status"] = "Error: Display Name is required."
return redirect(url_for('web.access_groups_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', ''))
}
access_groups[group_id] = updated_group
save_state()
cloudflared_agent_state["last_action_status"] = f"Success: Access Group '{display_name}' updated. Triggering reconciliation."
reconcile_state_threaded()
return redirect(url_for('web.access_groups_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:
cloudflared_agent_state["last_action_status"] = f"Error: Access Group with ID '{group_id}' not found."
return redirect(url_for('web.access_groups_page'))
is_in_use = any(
rule.get('access_group_id') == group_id
for rule in managed_rules.values()
)
if is_in_use:
cloudflared_agent_state["last_action_status"] = f"Error: Cannot delete Access Group '{access_groups[group_id]['display_name']}' because it is currently in use."
return redirect(url_for('web.access_groups_page'))
display_name = access_groups[group_id]['display_name']
del access_groups[group_id]
save_state()
cloudflared_agent_state["last_action_status"] = f"Success: Access Group '{display_name}' has been deleted."
return redirect(url_for('web.access_groups_page'))
@bp.route('/cloudflare-ping')
def cloudflare_ping_route():
try: