mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-28 03:39:32 +00:00
new feature reusable access groups policies
This commit is contained in:
parent
bc097e345e
commit
c09f0cd009
10 changed files with 1015 additions and 776 deletions
|
|
@ -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.")
|
||||
|
||||
|
|
|
|||
|
|
@ -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}.")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}.")
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
78
dockflare/app/templates/access_groups.html
Normal file
78
dockflare/app/templates/access_groups.html
Normal 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 %}
|
||||
104
dockflare/app/templates/base.html
Normal file
104
dockflare/app/templates/base.html
Normal 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>
|
||||
62
dockflare/app/templates/modals/_access_group_model.html
Normal file
62
dockflare/app/templates/modals/_access_group_model.html
Normal 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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue