Manual rule Access Group Policy feature added

This commit is contained in:
ChrispyBacon-dev 2025-08-01 09:28:51 +02:00
parent c85ecbb57c
commit c1add43747
3 changed files with 324 additions and 302 deletions

View file

@ -1,4 +1,4 @@
// app/static/js/main.js
// app/static/js/main.js - painful .. javascript
const maxLogLines = 250;
let initialConnectMessageCleared = false;
let activeLogSource = null;
@ -101,7 +101,7 @@ function initializeEditManualRuleModal() {
modal.querySelector('#edit_original_rule_key').value = ruleKey;
const hostname = details.hostname_for_dns || '';
const hostname = details.hostname || '';
const parts = hostname.split('.');
if (parts.length > 2 && !hostname.startsWith('*.')) {
modal.querySelector('#edit_manual_subdomain').value = parts.slice(0, -2).join('.');
@ -110,38 +110,39 @@ function initializeEditManualRuleModal() {
modal.querySelector('#edit_manual_subdomain').value = '';
modal.querySelector('#edit_manual_domain_name').value = hostname;
}
const path = details.path || '';
const pathDisplayInput = modal.querySelector('#edit_manual_path_display');
pathDisplayInput.value = path.startsWith('/') ? path.substring(1) : path;
modal.querySelector('#edit_manual_path_display').value = path.startsWith('/') ? path.substring(1) : path;
modal.querySelector('#edit_manual_path').value = path;
const service = details.service || '';
const serviceParts = service.split('://');
let serviceType = '';
let serviceAddress = '';
if (serviceParts.length === 2) {
serviceType = serviceParts[0];
serviceAddress = serviceParts[1];
modal.querySelector('#edit_manual_service_type').value = serviceParts[0];
modal.querySelector('#edit_manual_service_address').value = serviceParts[1];
} else if (service.startsWith('http_status:')) {
serviceType = 'http_status';
serviceAddress = service.split(':')[1];
modal.querySelector('#edit_manual_service_type').value = 'http_status';
modal.querySelector('#edit_manual_service_address').value = service.split(':')[1];
}
modal.querySelector('#edit_manual_service_type').value = serviceType;
modal.querySelector('#edit_manual_service_address').value = serviceAddress;
const policyType = details.access_policy_type || 'none';
const policySelect = modal.querySelector('#edit_manual_access_policy_type');
policySelect.value = policyType;
const accessGroupSelect = modal.querySelector('#edit_manual_access_group');
const manualPolicySelect = modal.querySelector('#edit_manual_access_policy_type');
if (details.access_group_id) {
accessGroupSelect.value = details.access_group_id;
} else {
accessGroupSelect.value = '';
}
policySelect.dispatchEvent(new Event('change'));
manualPolicySelect.value = details.access_policy_type || 'none';
accessGroupSelect.dispatchEvent(new Event('change'));
manualPolicySelect.dispatchEvent(new Event('change'));
modal.querySelector('#edit_manual_auth_email').value = details.auth_email || '';
modal.querySelector('#edit_manual_zone_name_override').value = '';
modal.querySelector('#edit_manual_no_tls_verify').checked = details.no_tls_verify || false;
modal.querySelector('#edit_manual_origin_server_name').value = details.origin_server_name || '';
modal.querySelector('#edit_manual_http_host_header').value = details.http_host_header || '';
modal.showModal();
} catch (e) {
console.error("Error populating edit modal:", e);
@ -445,6 +446,8 @@ function openEditAccessGroupModal(groupId, details) {
policy.include.forEach(rule => {
if (rule.email && rule.email.email) {
emails.push(rule.email.email);
} else if (rule.email_domain && rule.email_domain.domain) {
emails.push(`@${rule.email_domain.domain}`);
}
});
}
@ -521,6 +524,33 @@ document.addEventListener('DOMContentLoaded', function() {
initializeEditManualRuleModal();
}
// Logic for new Access Group dropdown in ADD Manual Rule Modal
const manualAccessGroupSelect = document.getElementById('manual_access_group');
const manualPolicyOptionsWrapper = document.getElementById('manual_policy_options_wrapper');
if (manualAccessGroupSelect && manualPolicyOptionsWrapper) {
manualAccessGroupSelect.addEventListener('change', function() {
const isDisabled = !!this.value;
manualPolicyOptionsWrapper.style.opacity = isDisabled ? '0.5' : '1';
manualPolicyOptionsWrapper.querySelectorAll('select, input').forEach(el => {
el.disabled = isDisabled;
});
});
manualAccessGroupSelect.dispatchEvent(new Event('change'));
}
// Logic for new Access Group dropdown in EDIT Manual Rule Modal
const editManualAccessGroupSelect = document.getElementById('edit_manual_access_group');
const editManualPolicyOptionsWrapper = document.getElementById('edit_manual_policy_options_wrapper');
if (editManualAccessGroupSelect && editManualPolicyOptionsWrapper) {
editManualAccessGroupSelect.addEventListener('change', function() {
const isDisabled = !!this.value;
editManualPolicyOptionsWrapper.style.opacity = isDisabled ? '0.5' : '1';
editManualPolicyOptionsWrapper.querySelectorAll('select, input').forEach(el => {
el.disabled = isDisabled;
});
});
}
// Setup for Access Group Modal (only if on Access Groups Page)
document.querySelectorAll('.edit-access-group-btn').forEach(button => {
button.addEventListener('click', function() {

View file

@ -432,12 +432,33 @@
<div id="manual_service_help" class="text-xs opacity-60 mt-1">e.g., 192.168.1.10:8000 or my-service.local:3000 for HTTP/S/TCP etc.</div>
</div>
</div>
</div>
</div>
<div>
<h4 class="text-md font-semibold mb-2">Access Policy (Optional)</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
<div>
<label class="label" for="manual_access_policy_type"><span class="label-text">Policy Type</span></label>
<h4 class="text-md font-semibold mb-2">Access Policy (Optional)</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2 items-start p-4 rounded-md bg-base-200/50">
<!-- NEW: Access Group Dropdown -->
<div class="form-control md:col-span-2">
<label class="label" for="manual_access_group">
<span class="label-text font-medium">1. Assign an Access Group (Recommended)</span>
</label>
<select name="manual_access_group" id="manual_access_group" class="select select-bordered w-full">
<option value="" selected>-- None (Configure Manually Below) --</option>
{% if access_groups %}
{% for group_id, details in access_groups.items()|sort %}
<option value="{{ group_id }}">{{ details.display_name }} (ID: {{ group_id }})</option>
{% endfor %}
{% endif %}
</select>
<div class="label"><span class="label-text-alt">Using a group overrides the manual policy options below.</span></div>
</div>
<div class="divider md:col-span-2 text-xs opacity-80 my-0">OR</div>
<div id="manual_policy_options_wrapper" class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2">
<div class="form-control">
<label class="label" for="manual_access_policy_type">
<span class="label-text font-medium">2. Configure Manually</span>
</label>
<select name="manual_access_policy_type" id="manual_access_policy_type" class="select select-bordered w-full policy-type-select">
<option value="none" selected>None (Public - No App)</option>
<option value="bypass">Bypass (Public App)</option>
@ -445,57 +466,14 @@
<option value="default_tld">Use Default *.tld Policy</option>
</select>
</div>
<div class="auth-email-field hidden md:col-span-1">
<div class="auth-email-field hidden">
<label class="label" for="manual_auth_email"><span class="label-text">Allowed Email(s) or Domain(s)</span></label>
<input type="text" id="manual_auth_email" name="manual_auth_email" placeholder="user@example.com, @domain.com" class="input input-bordered w-full" />
<div class="text-xs opacity-60 mt-1">Comma-separated. e.g., test@example.com, @another.org</div>
<div class="label"><span class="label-text-alt">Comma-separated. e.g., test@example.com, @another.org</div>
</div>
</div>
</div>
<div class="space-y-4 pt-4 border-t border-base-300 mt-6">
<div>
<label class="label" for="manual_zone_name_override"><span class="label-text">Cloudflare Zone Name (Override/Specific)</span></label>
<input type="text" id="manual_zone_name_override" name="manual_zone_name_override" placeholder="yourdomain.com (if different from Domain Name or CF_ZONE_ID)" class="input input-bordered w-full" />
{% if CF_ZONE_ID_CONFIGURED and CF_ZONE_ID %}
<div class="text-xs opacity-60 mt-1">If blank, DockFlare will use "Domain Name" or default CF_ZONE_ID ({{ CF_ZONE_ID[:12] }}...). Target a different Zone if needed.</div>
{% else %}
<div class="text-xs opacity-60 mt-1">If blank, DockFlare will use "Domain Name". Target a specific Zone if "Domain Name" is ambiguous or CF_ZONE_ID is not set.</div>
{% endif %}
</div>
<div id="manual_no_tls_verify_div" class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="manual_no_tls_verify" id="manual_no_tls_verify" class="checkbox checkbox-sm" />
<span class="label-text">Disable TLS Verification (No TLS Verify)</span>
</label>
<div class="text-xs opacity-60 ml-8">Check if the origin service uses self-signed SSL or is HTTP. (Only applies to HTTP/HTTPS services).</div>
</div>
<div class="form-control" id="manual_origin_server_name_div">
<label class="label" for="manual_origin_server_name">
<span class="label-text">Origin Server Name (SNI for TLS)</span>
</label>
<input type="text" id="manual_origin_server_name" name="manual_origin_server_name"
placeholder="e.g., internal.service.local (optional)"
class="input input-bordered w-full" />
<label class="label">
<span class="label-text-alt">Specify the hostname Cloudflare should use for TLS SNI when connecting to your origin. Leave blank if not needed. (Only applies to HTTP/HTTPS services).</span>
</label>
</div>
<div class="form-control">
<label class="label" for="manual_http_host_header">
<span class="label-text">HTTP Host Header (Optional)</span>
</label>
<input type="text" id="manual_http_host_header" name="manual_http_host_header"
placeholder="e.g., api.example.com"
class="input input-bordered w-full" />
<label class="label">
<span class="label-text-alt">Overrides the <code>Host</code> header sent to your origin server. Useful for origins expecting a different hostname than the public one. (Only applies to HTTP/HTTPS services).</span>
</label>
</div>
<div class="modal-action mt-8">
<button type="submit" class="btn btn-primary">Add Rule</button>
</div>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
@ -551,11 +529,35 @@
</div>
</div>
</div>
<div>
<h4 class="text-md font-semibold mb-2">Access Policy (Optional)</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
<div>
<label class="label" for="edit_manual_access_policy_type"><span class="label-text">Policy Type</span></label>
<div>
<h4 class="text-md font-semibold mb-2">Access Policy (Optional)</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2 items-start p-4 rounded-md bg-base-200/50">
<div class="form-control md:col-span-2">
<label class="label" for="edit_manual_access_group">
<span class="label-text font-medium">1. Assign an Access Group (Recommended)</span>
</label>
<select name="manual_access_group" id="edit_manual_access_group" class="select select-bordered w-full">
<option value="">-- None (Configure Manually Below) --</option>
{% if access_groups %}
{% for group_id, details in access_groups.items()|sort %}
<option value="{{ group_id }}">{{ details.display_name }} (ID: {{ group_id }})</option>
{% endfor %}
{% endif %}
</select>
<div class="label"><span class="label-text-alt">Using a group overrides the manual policy options below.</span></div>
</div>
<div class="divider md:col-span-2 text-xs opacity-80 my-0">OR</div>
<div id="edit_manual_policy_options_wrapper" class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2">
<div class="form-control">
<label class="label" for="edit_manual_access_policy_type">
<span class="label-text font-medium">2. Configure Manually</span>
</label>
<select name="manual_access_policy_type" id="edit_manual_access_policy_type" class="select select-bordered w-full policy-type-select">
<option value="none">None (Public - No App)</option>
<option value="bypass">Bypass (Public App)</option>
@ -563,12 +565,14 @@
<option value="default_tld">Use Default *.tld Policy</option>
</select>
</div>
<div class="auth-email-field hidden md:col-span-1">
<div class="auth-email-field hidden">
<label class="label" for="edit_manual_auth_email"><span class="label-text">Allowed Email(s) or Domain(s)</span></label>
<input type="text" id="edit_manual_auth_email" name="manual_auth_email" placeholder="user@example.com, @domain.com" class="input input-bordered w-full" />
</div>
</div>
</div>
</div>
<div class="space-y-4 pt-4 border-t border-base-300 mt-6">
<div class="form-control">
<label class="label" for="edit_manual_zone_name_override"><span class="label-text">Cloudflare Zone Name (Override/Specific)</span></label>

View file

@ -115,6 +115,7 @@ def status_page():
tld_policy_exists_val = False
account_email_for_tld_val = None
relevant_zone_name_for_tld_policy_val = None
template_access_groups = {}
with state_lock:
for hostname, rule in managed_rules.items():
@ -124,6 +125,7 @@ def status_page():
rules_for_template[hostname] = rule_copy
template_tunnel_state = tunnel_state.copy()
template_agent_state = cloudflared_agent_state.copy()
template_access_groups = copy.deepcopy(access_groups)
initialization_status = {
"complete": template_tunnel_state.get("id") is not None or config.EXTERNAL_TUNNEL_ID,
@ -163,6 +165,7 @@ def status_page():
relevant_zone_name_for_tld_policy=relevant_zone_name_for_tld_policy_val,
tld_policy_exists=tld_policy_exists_val,
account_email_for_tld=account_email_for_tld_val,
access_groups=template_access_groups,
CF_ZONE_ID_CONFIGURED=bool(config.CF_ZONE_ID)
)
@ -469,188 +472,141 @@ def ui_add_manual_rule_route():
origin_server_name_input = request.form.get('manual_origin_server_name', '').strip()
manual_http_host_header = request.form.get('manual_http_host_header', '').strip()
manual_access_group_id = request.form.get('manual_access_group', '').strip()
manual_access_policy_type = request.form.get('manual_access_policy_type', 'none').strip().lower()
manual_auth_email = request.form.get('manual_auth_email', '').strip()
if not domain_name_input or not service_type_input:
cloudflared_agent_state["last_action_status"] = "Error: Domain Name and Service Type are required for manual rule."
return redirect(url_for('web.status_page'))
if service_type_input not in ["http_status", "bastion"] and not service_address_input:
cloudflared_agent_state["last_action_status"] = f"Error: Service Address is required for type '{service_type_input.upper()}'."
return redirect(url_for('web.status_page'))
if manual_access_policy_type == "authenticate_email" and not manual_auth_email:
cloudflared_agent_state["last_action_status"] = "Error: Allowed Email(s) required for 'Authenticate by Email' policy."
return redirect(url_for('web.status_page'))
if subdomain_input:
full_hostname = f"{subdomain_input}.{domain_name_input}"
else:
full_hostname = domain_name_input
full_hostname = f"{subdomain_input}.{domain_name_input}" if subdomain_input else domain_name_input
if not is_valid_hostname(full_hostname):
cloudflared_agent_state["last_action_status"] = f"Error: Constructed hostname '{full_hostname}' is invalid."
return redirect(url_for('web.status_page'))
processed_path = None
if path_input:
processed_path = path_input.strip()
if not processed_path.startswith('/'):
cloudflared_agent_state["last_action_status"] = f"Error: Path '{processed_path}' must start with a '/'."
return redirect(url_for('web.status_page'))
if len(processed_path) > 1 and processed_path.endswith('/'):
processed_path = processed_path.rstrip('/')
key_for_managed_rules = f"{full_hostname}{'|' + processed_path if processed_path else ''}"
processed_path = f"/{path_input.lstrip('/')}" if path_input else None
key_for_managed_rules = get_rule_key(full_hostname, processed_path)
processed_service_for_cf = ""
if service_type_input in ["http", "https"]:
if ":" not in service_address_input and "." not in service_address_input and service_address_input != "localhost":
cloudflared_agent_state["last_action_status"] = f"Error: For HTTP/S, address '{service_address_input}' should be host:port or a resolvable hostname."
return redirect(url_for('web.status_page'))
processed_service_for_cf = f"{service_type_input}://{service_address_input}"
elif service_type_input in ["tcp", "ssh", "rdp"]:
if ":" not in service_address_input:
cloudflared_agent_state["last_action_status"] = f"Error: For {service_type_input.upper()}, address '{service_address_input}' must be in host:port format."
return redirect(url_for('web.status_page'))
processed_service_for_cf = f"{service_type_input}://{service_address_input}"
elif service_type_input == "http_status":
if not service_address_input.isdigit() or not (100 <= int(service_address_input) <= 599):
cloudflared_agent_state["last_action_status"] = f"Error: Invalid HTTP status code '{service_address_input}'. Must be 100-599."
return redirect(url_for('web.status_page'))
processed_service_for_cf = f"http_status:{service_address_input}"
else:
cloudflared_agent_state["last_action_status"] = f"Error: Unsupported service type '{service_type_input}' submitted."
return redirect(url_for('web.status_page'))
if not is_valid_service(processed_service_for_cf):
cloudflared_agent_state["last_action_status"] = f"Error: Constructed service string '{processed_service_for_cf}' is invalid."
return redirect(url_for('web.status_page'))
target_zone_id = None
zone_name_to_lookup = None
if zone_name_override_input:
zone_name_to_lookup = zone_name_override_input
else:
parts = domain_name_input.split('.')
if len(parts) >= 2:
potential_zone = f"{parts[-2]}.{parts[-1]}"
zone_name_to_lookup = potential_zone
else:
zone_name_to_lookup = None
if zone_name_to_lookup:
target_zone_id = get_zone_id_from_name(zone_name_to_lookup)
if not target_zone_id and config.CF_ZONE_ID:
logging.info(f"Could not find zone for '{zone_name_to_lookup}', trying default CF_ZONE_ID.")
target_zone_id = config.CF_ZONE_ID
elif not target_zone_id:
cloudflared_agent_state["last_action_status"] = f"Error: Could not find Zone ID for '{zone_name_to_lookup}' and no default CF_ZONE_ID to fallback."
return redirect(url_for('web.status_page'))
elif config.CF_ZONE_ID:
target_zone_id = config.CF_ZONE_ID
logging.info(f"Using default CF_ZONE_ID as no specific zone name was provided or derivable.")
else:
cloudflared_agent_state["last_action_status"] = "Error: Cloudflare Zone Name/ID is required."
zone_name_to_lookup = zone_name_override_input or '.'.join(domain_name_input.split('.')[-2:])
target_zone_id = get_zone_id_from_name(zone_name_to_lookup) or config.CF_ZONE_ID
if not target_zone_id:
cloudflared_agent_state["last_action_status"] = f"Error: Could not determine Zone ID."
return redirect(url_for('web.status_page'))
access_app_created_or_updated_id = None
access_app_final_config_hash = None
cf_access_policies_for_app = []
custom_rules_for_hash_str = None
desired_session_duration = "24h"
desired_app_launcher_visible = False
desired_allowed_idps_for_api = None
desired_auto_redirect = False
desired_app_name = f"DockFlare-{full_hostname}"
if manual_access_policy_type == "bypass":
cf_access_policies_for_app = [{"name": "UI Manual Public Bypass", "decision": "bypass", "include": [{"everyone": {}}]}]
custom_rules_for_hash_str = json.dumps(cf_access_policies_for_app)
elif manual_access_policy_type == "authenticate_email":
cf_access_policies_for_app = [
{"name": f"UI Allow Access for {manual_auth_email}", "decision": "allow", "include": [{"email": {"email": manual_auth_email}}]},
{"name": "UI Deny Fallback", "decision": "deny", "include": [{"everyone": {}}]}
]
custom_rules_for_hash_str = json.dumps(cf_access_policies_for_app)
if manual_access_policy_type in ["bypass", "authenticate_email"]:
existing_cf_app = find_cloudflare_access_application_by_hostname(full_hostname)
if existing_cf_app and existing_cf_app.get("id"):
logging.info(f"Manual Add: Found existing Access App {existing_cf_app.get('id')} for {full_hostname}. Will attempt to update it.")
access_app_created_or_updated_id = existing_cf_app.get("id")
updated_app = update_cloudflare_access_application(
access_app_created_or_updated_id, full_hostname, desired_app_name,
desired_session_duration, desired_app_launcher_visible,
[full_hostname], cf_access_policies_for_app, desired_allowed_idps_for_api,
desired_auto_redirect
)
if updated_app:
access_app_created_or_updated_id = updated_app.get("id")
access_app_final_config_hash = generate_access_app_config_hash(
manual_access_policy_type, desired_session_duration, desired_app_launcher_visible,
desired_allowed_idps_for_api, desired_auto_redirect, custom_access_rules_str=custom_rules_for_hash_str
)
else:
logging.error(f"Failed to update existing Access App for manual rule {full_hostname}")
access_app_created_or_updated_id = None
else:
created_app = create_cloudflare_access_application(
full_hostname, desired_app_name,
desired_session_duration, desired_app_launcher_visible,
[full_hostname], cf_access_policies_for_app,
desired_allowed_idps_for_api,
desired_auto_redirect
)
if created_app and created_app.get("id"):
access_app_created_or_updated_id = created_app.get("id")
access_app_final_config_hash = generate_access_app_config_hash(
manual_access_policy_type, desired_session_duration, desired_app_launcher_visible,
desired_allowed_idps_for_api, desired_auto_redirect, custom_access_rules_str=custom_rules_for_hash_str
)
else:
logging.error(f"Failed to create Access App for manual rule {full_hostname}")
access_app_id = None
access_policy_type = None
access_app_config_hash = None
access_group_id = None
with state_lock:
existing_rule_details = managed_rules.get(key_for_managed_rules)
if existing_rule_details and existing_rule_details.get("source", "docker") == "docker":
cloudflared_agent_state["last_action_status"] = f"Error: Rule for {full_hostname} (Path: {processed_path or '(root)'}) is Docker-managed."
if manual_access_group_id and manual_access_group_id in access_groups:
group = access_groups[manual_access_group_id]
access_group_id = manual_access_group_id
access_policy_type = "group"
desired_app_name = f"DockFlare-{full_hostname}"
desired_session_duration = group.get("session_duration", "24h")
desired_app_launcher_visible = group.get("app_launcher_visible", False)
desired_allowed_idps = group.get("allowed_idps")
desired_auto_redirect = group.get("auto_redirect_to_identity", False)
cf_access_policies = group.get("policies")
access_app_config_hash = generate_access_app_config_hash(
policy_type="group", session_duration=desired_session_duration,
app_launcher_visible=desired_app_launcher_visible,
allowed_idps_str=json.dumps(desired_allowed_idps, sort_keys=True),
auto_redirect_to_identity=desired_auto_redirect,
custom_access_rules_str=json.dumps(cf_access_policies, sort_keys=True),
group_id=access_group_id
)
existing_app = find_cloudflare_access_application_by_hostname(full_hostname)
if existing_app:
app_result = update_cloudflare_access_application(
existing_app['id'], full_hostname, desired_app_name, desired_session_duration,
desired_app_launcher_visible, [full_hostname], cf_access_policies,
desired_allowed_idps, desired_auto_redirect
)
else:
app_result = create_cloudflare_access_application(
full_hostname, desired_app_name, desired_session_duration,
desired_app_launcher_visible, [full_hostname], cf_access_policies,
desired_allowed_idps, desired_auto_redirect
)
if app_result:
access_app_id = app_result.get('id')
else:
cloudflared_agent_state["last_action_status"] = f"Error: Failed to create/update Access App for group '{access_group_id}'."
elif manual_access_policy_type and manual_access_policy_type != 'none':
cf_access_policies = []
if manual_access_policy_type == "bypass":
cf_access_policies = [{"name": "UI Manual Public Bypass", "decision": "bypass", "include": [{"everyone": {}}]}]
elif manual_access_policy_type == "authenticate_email":
if not manual_auth_email:
cloudflared_agent_state["last_action_status"] = "Error: Email is required for this policy type."
return redirect(url_for('web.status_page'))
cf_access_policies = [
{"name": f"UI Allow Access for {manual_auth_email}", "decision": "allow", "include": [{"email": {"email": manual_auth_email}}]},
{"name": "UI Deny Fallback", "decision": "deny", "include": [{"everyone": {}}]}
]
app_result = create_cloudflare_access_application(
full_hostname, f"DockFlare-{full_hostname}", "24h", False, [full_hostname], cf_access_policies, None, False
)
if app_result:
access_app_id = app_result.get('id')
access_policy_type = manual_access_policy_type
else:
cloudflared_agent_state["last_action_status"] = f"Error: Failed to create Access App for manual policy."
with state_lock:
if key_for_managed_rules in managed_rules and managed_rules[key_for_managed_rules].get("source") == "docker":
cloudflared_agent_state["last_action_status"] = f"Error: Rule for {full_hostname} is Docker-managed."
return redirect(url_for('web.status_page'))
log_action = "Adding new" if not existing_rule_details else "Updating existing"
logging.info(f"{log_action} manual rule for Key: {key_for_managed_rules} (FQDN: {full_hostname}, Path: {processed_path or '(root)'}) with service {processed_service_for_cf}")
managed_rules[key_for_managed_rules] = {
"hostname": full_hostname,
"service": processed_service_for_cf,
"path": processed_path,
"hostname_for_dns": full_hostname,
"container_id": None,
"status": "active",
"delete_at": None,
"service": processed_service_for_cf,
"container_id": None, "status": "active", "delete_at": None,
"zone_id": target_zone_id,
"no_tls_verify": no_tls_verify,
"origin_server_name": origin_server_name_input if origin_server_name_input else None,
"http_host_header": manual_http_host_header if manual_http_host_header else None,
"access_app_id": access_app_created_or_updated_id if manual_access_policy_type in ["bypass", "authenticate_email"] \
else (existing_rule_details.get("access_app_id") if existing_rule_details else None),
"access_policy_type": manual_access_policy_type if manual_access_policy_type != "none" else None,
"access_app_config_hash": access_app_final_config_hash if manual_access_policy_type in ["bypass", "authenticate_email"] \
else (existing_rule_details.get("access_app_config_hash") if existing_rule_details else None),
"auth_email": manual_auth_email if manual_access_policy_type == "authenticate_email" else (existing_rule_details.get("auth_email") if existing_rule_details else None),
"access_policy_ui_override": True if manual_access_policy_type != "none" else (existing_rule_details.get("access_policy_ui_override", False) if existing_rule_details else False),
"source": "manual"
"origin_server_name": origin_server_name_input or None,
"http_host_header": manual_http_host_header or None,
"source": "manual",
"access_app_id": access_app_id,
"access_policy_type": access_policy_type,
"access_app_config_hash": access_app_config_hash,
"access_group_id": access_group_id,
"access_policy_ui_override": bool(access_app_id)
}
save_state()
if update_cloudflare_config():
if create_cloudflare_dns_record(target_zone_id, full_hostname, effective_tunnel_id):
cloudflared_agent_state["last_action_status"] = f"Success: Manual rule for {full_hostname} (Path: {processed_path if processed_path else '(root)'}) added/updated. Policy: {manual_access_policy_type.upper()}."
else:
cloudflared_agent_state["last_action_status"] = f"Warning: Manual rule for {full_hostname} (Path: {processed_path if processed_path else '(root)'}) added/updated. Policy: {manual_access_policy_type.upper()}. DNS creation FAILED."
create_cloudflare_dns_record(target_zone_id, full_hostname, effective_tunnel_id)
cloudflared_agent_state["last_action_status"] = f"Success: Manual rule for {full_hostname} added/updated."
else:
cloudflared_agent_state["last_action_status"] = f"Error: Failed to update Cloudflare tunnel config for manual rule {full_hostname} (Path: {processed_path if processed_path else '(root)'})."
cloudflared_agent_state["last_action_status"] = f"Error: Failed to update Cloudflare tunnel config."
return redirect(url_for('web.status_page'))
@ -768,17 +724,11 @@ def ui_edit_manual_rule_route():
cloudflared_agent_state["last_action_status"] = "Error: Original rule key was missing from the edit request."
return redirect(url_for('web.status_page'))
original_rule_details = None
with state_lock:
original_rule_details = managed_rules.get(original_rule_key)
if not original_rule_details or original_rule_details.get("source") != "manual":
cloudflared_agent_state["last_action_status"] = f"Error: Could not find original manual rule '{original_rule_key}' to edit."
return redirect(url_for('web.status_page'))
old_zone_id = original_rule_details.get("zone_id")
old_access_app_id = original_rule_details.get("access_app_id")
old_hostname_for_dns = original_rule_details.get("hostname_for_dns")
if not original_rule_details or original_rule_details.get("source") != "manual":
cloudflared_agent_state["last_action_status"] = f"Error: Could not find original manual rule '{original_rule_key}' to edit."
return redirect(url_for('web.status_page'))
subdomain_input = request.form.get('manual_subdomain', '').strip()
domain_name_input = request.form.get('manual_domain_name', '').strip()
@ -789,6 +739,7 @@ def ui_edit_manual_rule_route():
no_tls_verify = request.form.get('manual_no_tls_verify') == 'on'
origin_server_name_input = request.form.get('manual_origin_server_name', '').strip()
manual_http_host_header = request.form.get('manual_http_host_header', '').strip()
manual_access_group_id = request.form.get('manual_access_group', '').strip()
manual_access_policy_type = request.form.get('manual_access_policy_type', 'none').strip().lower()
manual_auth_email = request.form.get('manual_auth_email', '').strip()
@ -798,27 +749,23 @@ def ui_edit_manual_rule_route():
if service_type_input not in ["http_status", "bastion"] and not service_address_input:
cloudflared_agent_state["last_action_status"] = f"Error: Service Address is required for type '{service_type_input.upper()}'."
return redirect(url_for('web.status_page'))
if manual_access_policy_type == "authenticate_email" and not manual_auth_email:
cloudflared_agent_state["last_action_status"] = "Error: Allowed Email(s) required for 'Authenticate by Email' policy."
return redirect(url_for('web.status_page'))
full_hostname = f"{subdomain_input}.{domain_name_input}" if subdomain_input else domain_name_input
if not is_valid_hostname(full_hostname):
cloudflared_agent_state["last_action_status"] = f"Error: Constructed hostname '{full_hostname}' is invalid."
return redirect(url_for('web.status_page'))
processed_path = path_input.strip() if path_input else None
if processed_path and not processed_path.startswith('/'):
processed_path = '/' + processed_path
new_rule_key = f"{full_hostname}{'|' + processed_path if processed_path else ''}"
processed_path = f"/{path_input.lstrip('/')}" if path_input else None
new_rule_key = get_rule_key(full_hostname, processed_path)
processed_service_for_cf = ""
if service_type_input in ["http", "https", "tcp", "ssh", "rdp"]:
if service_type_input in ["http", "https"]:
processed_service_for_cf = f"{service_type_input}://{service_address_input}"
elif service_type_input in ["tcp", "ssh", "rdp"]:
processed_service_for_cf = f"{service_type_input}://{service_address_input}"
elif service_type_input == "http_status":
processed_service_for_cf = f"http_status:{service_address_input}"
if not is_valid_service(processed_service_for_cf):
cloudflared_agent_state["last_action_status"] = f"Error: Constructed service string '{processed_service_for_cf}' is invalid."
return redirect(url_for('web.status_page'))
@ -826,84 +773,125 @@ def ui_edit_manual_rule_route():
zone_name_to_lookup = zone_name_override_input or '.'.join(domain_name_input.split('.')[-2:])
target_zone_id = get_zone_id_from_name(zone_name_to_lookup) or config.CF_ZONE_ID
if not target_zone_id:
cloudflared_agent_state["last_action_status"] = f"Error: Could not determine Zone ID for '{zone_name_to_lookup}'."
cloudflared_agent_state["last_action_status"] = f"Error: Could not determine Zone ID."
return redirect(url_for('web.status_page'))
access_app_created_or_updated_id = None
access_app_final_config_hash = None
cf_access_policies_for_app = []
if manual_access_policy_type in ["bypass", "authenticate_email"]:
allowed_idps_str_for_hash = None
if manual_access_policy_type == "bypass":
cf_access_policies_for_app = [{"name": "UI Manual Public Bypass", "decision": "bypass", "include": [{"everyone": {}}]}]
else:
allowed_idps_str_for_hash = "ea94073b-1175-4089-81a2-3498c8c147b3"
policy_include_rules = [
{"email": {"email": manual_auth_email}},
{"login_method": {"id": allowed_idps_str_for_hash}}
]
cf_access_policies_for_app = [
{"name": f"UI Allow Access for {manual_auth_email}", "decision": "allow", "include": policy_include_rules},
{"name": "UI Deny Fallback", "decision": "deny", "include": [{"everyone": {}}]}
]
app_id_to_update = old_access_app_id if old_hostname_for_dns == full_hostname else None
if app_id_to_update:
logging.info(f"Manual Edit: Updating existing Access App {app_id_to_update} for {full_hostname}")
updated_app = update_cloudflare_access_application(app_id_to_update, full_hostname, f"DockFlare-{full_hostname}", "24h", False, [full_hostname], cf_access_policies_for_app, False)
if updated_app: access_app_created_or_updated_id = updated_app.get("id")
else:
logging.info(f"Manual Edit: Creating new Access App for {full_hostname}")
created_app = create_cloudflare_access_application(full_hostname, f"DockFlare-{full_hostname}", "24h", False, [full_hostname], cf_access_policies_for_app, False)
if created_app: access_app_created_or_updated_id = created_app.get("id")
if access_app_created_or_updated_id:
access_app_final_config_hash = generate_access_app_config_hash(manual_access_policy_type, "24h", False, allowed_idps_str_for_hash, False, custom_access_rules_str=json.dumps(cf_access_policies_for_app))
access_app_id = None
access_policy_type = None
access_app_config_hash = None
access_group_id = None
app_to_delete = None
with state_lock:
if new_rule_key != original_rule_key and managed_rules.get(new_rule_key, {}).get("source") == "docker":
cloudflared_agent_state["last_action_status"] = f"Error: New rule for {full_hostname} conflicts with an existing Docker-managed rule."
if manual_access_group_id and manual_access_group_id in access_groups:
group = access_groups[manual_access_group_id]
access_group_id = manual_access_group_id
access_policy_type = "group"
desired_app_name = f"DockFlare-{full_hostname}"
desired_session_duration = group.get("session_duration", "24h")
desired_app_launcher_visible = group.get("app_launcher_visible", False)
desired_allowed_idps = group.get("allowed_idps")
desired_auto_redirect = group.get("auto_redirect_to_identity", False)
cf_access_policies = group.get("policies")
access_app_config_hash = generate_access_app_config_hash(
policy_type="group", session_duration=desired_session_duration,
app_launcher_visible=desired_app_launcher_visible,
allowed_idps_str=json.dumps(desired_allowed_idps, sort_keys=True),
auto_redirect_to_identity=desired_auto_redirect,
custom_access_rules_str=json.dumps(cf_access_policies, sort_keys=True),
group_id=access_group_id
)
existing_app = find_cloudflare_access_application_by_hostname(full_hostname)
app_to_update_id = existing_app['id'] if existing_app else original_rule_details.get('access_app_id')
if app_to_update_id and (original_rule_details.get('hostname') != full_hostname):
app_to_delete = app_to_update_id
app_to_update_id = None
if app_to_update_id:
app_result = update_cloudflare_access_application(
app_to_update_id, full_hostname, desired_app_name, desired_session_duration,
desired_app_launcher_visible, [full_hostname], cf_access_policies,
desired_allowed_idps, desired_auto_redirect
)
else:
app_result = create_cloudflare_access_application(
full_hostname, desired_app_name, desired_session_duration,
desired_app_launcher_visible, [full_hostname], cf_access_policies,
desired_allowed_idps, desired_auto_redirect
)
if app_result: access_app_id = app_result.get('id')
elif manual_access_policy_type and manual_access_policy_type != 'none':
cf_access_policies = []
if manual_access_policy_type == "bypass":
cf_access_policies = [{"name": "UI Manual Public Bypass", "decision": "bypass", "include": [{"everyone": {}}]}]
elif manual_access_policy_type == "authenticate_email":
if not manual_auth_email:
cloudflared_agent_state["last_action_status"] = "Error: Email is required for this policy type."
return redirect(url_for('web.status_page'))
cf_access_policies = [
{"name": f"UI Allow Access for {manual_auth_email}", "decision": "allow", "include": [{"email": {"email": manual_auth_email}}]},
{"name": "UI Deny Fallback", "decision": "deny", "include": [{"everyone": {}}]}
]
app_result = create_cloudflare_access_application(
full_hostname, f"DockFlare-{full_hostname}", "24h", False, [full_hostname], cf_access_policies, None, False
)
if app_result:
access_app_id = app_result.get('id')
access_policy_type = manual_access_policy_type
else: # Case where policy is set to "None"
if original_rule_details.get('access_app_id'):
app_to_delete = original_rule_details.get('access_app_id')
if app_to_delete:
delete_cloudflare_access_application(app_to_delete)
if original_rule_key != new_rule_key:
old_hostname = original_rule_details.get('hostname')
old_zone_id = original_rule_details.get('zone_id')
with state_lock:
is_old_hostname_still_used = any(r.get("hostname") == old_hostname for k, r in managed_rules.items() if k != original_rule_key)
if not is_old_hostname_still_used:
delete_cloudflare_dns_record(old_zone_id, old_hostname, effective_tunnel_id)
with state_lock:
if new_rule_key in managed_rules and new_rule_key != original_rule_key:
cloudflared_agent_state["last_action_status"] = f"Error: A rule for {full_hostname} already exists."
return redirect(url_for('web.status_page'))
if original_rule_key in managed_rules:
del managed_rules[original_rule_key]
managed_rules[new_rule_key] = {
managed_rules[new_rule_key] = {
"hostname": full_hostname,
"service": processed_service_for_cf, "path": processed_path, "hostname_for_dns": full_hostname,
"container_id": None, "status": "active", "delete_at": None, "zone_id": target_zone_id,
"no_tls_verify": no_tls_verify, "origin_server_name": origin_server_name_input or None,
"http_host_header": manual_http_host_header if manual_http_host_header else None,
"access_app_id": access_app_created_or_updated_id, "access_policy_type": manual_access_policy_type if manual_access_policy_type != "none" else None,
"access_app_config_hash": access_app_final_config_hash, "auth_email": manual_auth_email if manual_access_policy_type == "authenticate_email" else None,
"access_policy_ui_override": True if manual_access_policy_type != "none" else False, "source": "manual"
"path": processed_path,
"service": processed_service_for_cf,
"container_id": None, "status": "active", "delete_at": None,
"zone_id": target_zone_id,
"no_tls_verify": no_tls_verify,
"origin_server_name": origin_server_name_input or None,
"http_host_header": manual_http_host_header or None,
"source": "manual",
"access_app_id": access_app_id,
"access_policy_type": access_policy_type,
"access_app_config_hash": access_app_config_hash,
"access_group_id": access_group_id,
"access_policy_ui_override": bool(access_app_id)
}
save_state()
if old_hostname_for_dns != full_hostname:
with state_lock:
is_old_hostname_still_used = any(r.get("hostname_for_dns") == old_hostname_for_dns for r in managed_rules.values())
if not is_old_hostname_still_used:
logging.info(f"Old hostname '{old_hostname_for_dns}' no longer in use. Deleting its DNS record.")
delete_cloudflare_dns_record(old_zone_id, old_hostname_for_dns, effective_tunnel_id)
if old_access_app_id and old_access_app_id != access_app_created_or_updated_id:
with state_lock:
is_old_app_id_still_used = any(r.get("access_app_id") == old_access_app_id for r in managed_rules.values())
if not is_old_app_id_still_used:
logging.info(f"Old Access App ID '{old_access_app_id}' no longer in use. Deleting it.")
delete_cloudflare_access_application(old_access_app_id)
if update_cloudflare_config():
if create_cloudflare_dns_record(target_zone_id, full_hostname, effective_tunnel_id):
cloudflared_agent_state["last_action_status"] = f"Success: Manual rule for {full_hostname} updated."
else:
cloudflared_agent_state["last_action_status"] = f"Warning: Manual rule for {full_hostname} updated, but DNS creation failed."
create_cloudflare_dns_record(target_zone_id, full_hostname, effective_tunnel_id)
cloudflared_agent_state["last_action_status"] = f"Success: Manual rule for {full_hostname} updated."
else:
cloudflared_agent_state["last_action_status"] = f"Error: Failed to update Cloudflare tunnel config for manual rule {full_hostname}."
cloudflared_agent_state["last_action_status"] = f"Error: Failed to update Cloudflare tunnel config."
return redirect(url_for('web.status_page'))