|
| 1 | +# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. |
| 2 | +# Copyright (C) 2025 ChrispyBacon-Dev <https://github.com/ChrispyBacon-dev/DockFlare> |
| 3 | +# |
| 4 | +# This program is free software: you can redistribute it and/or modify |
| 5 | +# it under the terms of the GNU General Public License as published by |
| 6 | +# the Free Software Foundation, either version 3 of the License, or |
| 7 | +# (at your option) any later version. |
| 8 | +# |
| 9 | +# This program is distributed in the hope that it will be useful, |
| 10 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 11 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 12 | +# GNU General Public License for more details. |
| 13 | +# |
| 14 | +# You should have received a copy of the GNU General Public License |
| 15 | +# along with this program. If not, see <https://www.gnu.org/licenses/>. |
| 16 | +# |
| 17 | +# dockflare/app/cli.py |
| 18 | +""" |
| 19 | +DockFlare CLI utilities for maintenance and troubleshooting. |
| 20 | +""" |
| 21 | +import argparse |
| 22 | +import sys |
| 23 | +import logging |
| 24 | +from collections import defaultdict |
| 25 | +from datetime import datetime |
| 26 | + |
| 27 | +def cleanup_duplicate_policies(dry_run=True): |
| 28 | + """ |
| 29 | + Scan for duplicate reusable policies (same name) and consolidate them. |
| 30 | + Keeps the oldest policy, deletes newer duplicates. |
| 31 | + Updates state.json to reference the correct policy IDs. |
| 32 | +
|
| 33 | + Args: |
| 34 | + dry_run: If True, only show what would be done. If False, execute deletions. |
| 35 | +
|
| 36 | + Returns: |
| 37 | + dict: Summary of actions taken |
| 38 | + """ |
| 39 | + from app import app |
| 40 | + from app.core import reusable_policies |
| 41 | + from app.core.state_manager import access_groups, save_state, state_lock |
| 42 | + |
| 43 | + with app.app_context(): |
| 44 | + logging.info("=" * 60) |
| 45 | + logging.info("DUPLICATE POLICY CLEANUP UTILITY") |
| 46 | + logging.info("=" * 60) |
| 47 | + logging.info(f"Mode: {'DRY RUN (no changes will be made)' if dry_run else 'APPLY (changes will be executed)'}") |
| 48 | + logging.info("") |
| 49 | + |
| 50 | + # Step 1: List all reusable policies |
| 51 | + logging.info("Step 1: Fetching all reusable policies from Cloudflare...") |
| 52 | + policies = reusable_policies.list_reusable_policies() |
| 53 | + |
| 54 | + if not policies: |
| 55 | + logging.info("No reusable policies found.") |
| 56 | + return {"total_policies": 0, "duplicates_found": 0, "policies_deleted": 0} |
| 57 | + |
| 58 | + logging.info(f"Found {len(policies)} total policies") |
| 59 | + logging.info("") |
| 60 | + |
| 61 | + # Step 2: Group policies by name |
| 62 | + logging.info("Step 2: Grouping policies by name...") |
| 63 | + policies_by_name = defaultdict(list) |
| 64 | + for policy in policies: |
| 65 | + policy_name = policy.get("name", "") |
| 66 | + policies_by_name[policy_name].append(policy) |
| 67 | + |
| 68 | + # Step 3: Identify duplicates |
| 69 | + logging.info("Step 3: Identifying duplicates...") |
| 70 | + duplicates = {name: policies_list for name, policies_list in policies_by_name.items() if len(policies_list) > 1} |
| 71 | + |
| 72 | + if not duplicates: |
| 73 | + logging.info("✓ No duplicate policies found. All policies have unique names.") |
| 74 | + return {"total_policies": len(policies), "duplicates_found": 0, "policies_deleted": 0} |
| 75 | + |
| 76 | + logging.info(f"✗ Found {len(duplicates)} policy names with duplicates:") |
| 77 | + logging.info("") |
| 78 | + |
| 79 | + total_to_delete = 0 |
| 80 | + for name, dup_list in duplicates.items(): |
| 81 | + logging.info(f" Policy: '{name}' ({len(dup_list)} instances)") |
| 82 | + total_to_delete += len(dup_list) - 1 |
| 83 | + |
| 84 | + logging.info("") |
| 85 | + logging.info(f"Total policies to delete: {total_to_delete}") |
| 86 | + logging.info("") |
| 87 | + |
| 88 | + # Step 4: Process each duplicate group |
| 89 | + logging.info("Step 4: Processing duplicates...") |
| 90 | + logging.info("") |
| 91 | + |
| 92 | + deleted_count = 0 |
| 93 | + kept_count = 0 |
| 94 | + state_updates = {} |
| 95 | + |
| 96 | + for name, dup_list in duplicates.items(): |
| 97 | + logging.info(f"Processing: '{name}'") |
| 98 | + |
| 99 | + # Sort by created_at (oldest first) |
| 100 | + # Note: Cloudflare API returns created_at in ISO format |
| 101 | + sorted_policies = sorted(dup_list, key=lambda p: p.get("created_at", "")) |
| 102 | + |
| 103 | + # Keep the oldest one |
| 104 | + policy_to_keep = sorted_policies[0] |
| 105 | + policies_to_delete = sorted_policies[1:] |
| 106 | + |
| 107 | + kept_id = policy_to_keep.get("id") |
| 108 | + kept_created = policy_to_keep.get("created_at", "N/A") |
| 109 | + |
| 110 | + logging.info(f" ✓ Keeping: ID={kept_id} (created: {kept_created})") |
| 111 | + kept_count += 1 |
| 112 | + |
| 113 | + # Delete the rest |
| 114 | + for policy in policies_to_delete: |
| 115 | + policy_id = policy.get("id") |
| 116 | + policy_created = policy.get("created_at", "N/A") |
| 117 | + |
| 118 | + if dry_run: |
| 119 | + logging.info(f" ✗ Would delete: ID={policy_id} (created: {policy_created})") |
| 120 | + else: |
| 121 | + logging.info(f" ✗ Deleting: ID={policy_id} (created: {policy_created})") |
| 122 | + success = reusable_policies.delete_reusable_policy(policy_id) |
| 123 | + if success: |
| 124 | + logging.info(f" → Successfully deleted policy {policy_id}") |
| 125 | + deleted_count += 1 |
| 126 | + else: |
| 127 | + logging.error(f" → Failed to delete policy {policy_id}") |
| 128 | + |
| 129 | + # Track which policy ID should be kept for this name |
| 130 | + state_updates[name] = kept_id |
| 131 | + logging.info("") |
| 132 | + |
| 133 | + # Step 5: Update state.json with correct policy IDs |
| 134 | + logging.info("Step 5: Updating state.json with correct policy IDs...") |
| 135 | + |
| 136 | + if dry_run: |
| 137 | + logging.info("DRY RUN: Would update state.json with the following changes:") |
| 138 | + |
| 139 | + state_updated = False |
| 140 | + with state_lock: |
| 141 | + for group_id, group_data in access_groups.items(): |
| 142 | + policy_id = group_data.get("cloudflare_policy_id") |
| 143 | + |
| 144 | + if not policy_id: |
| 145 | + continue |
| 146 | + |
| 147 | + # Check if this group references a policy that was deleted |
| 148 | + # by finding the policy name and checking if we kept a different ID |
| 149 | + current_policy_name = None |
| 150 | + for name, policies_list in policies_by_name.items(): |
| 151 | + for policy in policies_list: |
| 152 | + if policy.get("id") == policy_id: |
| 153 | + current_policy_name = name |
| 154 | + break |
| 155 | + if current_policy_name: |
| 156 | + break |
| 157 | + |
| 158 | + if current_policy_name and current_policy_name in state_updates: |
| 159 | + correct_id = state_updates[current_policy_name] |
| 160 | + if policy_id != correct_id: |
| 161 | + if dry_run: |
| 162 | + logging.info(f" Group '{group_id}': {policy_id} → {correct_id} (policy: {current_policy_name})") |
| 163 | + else: |
| 164 | + logging.info(f" Updating group '{group_id}': {policy_id} → {correct_id}") |
| 165 | + access_groups[group_id]["cloudflare_policy_id"] = correct_id |
| 166 | + state_updated = True |
| 167 | + |
| 168 | + if state_updated and not dry_run: |
| 169 | + save_state() |
| 170 | + logging.info("✓ state.json updated successfully") |
| 171 | + elif not state_updated: |
| 172 | + logging.info("✓ No state.json updates needed") |
| 173 | + |
| 174 | + logging.info("") |
| 175 | + logging.info("=" * 60) |
| 176 | + logging.info("SUMMARY") |
| 177 | + logging.info("=" * 60) |
| 178 | + logging.info(f"Total policies scanned: {len(policies)}") |
| 179 | + logging.info(f"Duplicate policy names found: {len(duplicates)}") |
| 180 | + if dry_run: |
| 181 | + logging.info(f"Policies that would be deleted: {total_to_delete}") |
| 182 | + logging.info(f"Policies that would be kept: {kept_count}") |
| 183 | + else: |
| 184 | + logging.info(f"Policies deleted: {deleted_count}") |
| 185 | + logging.info(f"Policies kept: {kept_count}") |
| 186 | + logging.info(f"State updates applied: {state_updated}") |
| 187 | + logging.info("=" * 60) |
| 188 | + |
| 189 | + return { |
| 190 | + "total_policies": len(policies), |
| 191 | + "duplicates_found": len(duplicates), |
| 192 | + "policies_deleted": deleted_count if not dry_run else 0, |
| 193 | + "policies_kept": kept_count, |
| 194 | + "would_delete": total_to_delete if dry_run else 0 |
| 195 | + } |
| 196 | + |
| 197 | + |
| 198 | +def main(): |
| 199 | + """CLI entry point""" |
| 200 | + parser = argparse.ArgumentParser( |
| 201 | + description="DockFlare CLI utilities", |
| 202 | + formatter_class=argparse.RawDescriptionHelpFormatter, |
| 203 | + epilog=""" |
| 204 | +Examples: |
| 205 | + # Preview what would be cleaned up (dry run) |
| 206 | + python3 -m app.cli cleanup-duplicate-policies --dry-run |
| 207 | +
|
| 208 | + # Actually perform the cleanup |
| 209 | + python3 -m app.cli cleanup-duplicate-policies --apply |
| 210 | +
|
| 211 | + # Run from Docker container |
| 212 | + docker exec dockflare python3 -m app.cli cleanup-duplicate-policies --dry-run |
| 213 | +""" |
| 214 | + ) |
| 215 | + |
| 216 | + parser.add_argument( |
| 217 | + "command", |
| 218 | + choices=["cleanup-duplicate-policies"], |
| 219 | + help="Command to execute" |
| 220 | + ) |
| 221 | + |
| 222 | + parser.add_argument( |
| 223 | + "--dry-run", |
| 224 | + action="store_true", |
| 225 | + help="Preview changes without executing them (default)" |
| 226 | + ) |
| 227 | + |
| 228 | + parser.add_argument( |
| 229 | + "--apply", |
| 230 | + action="store_true", |
| 231 | + help="Execute the cleanup (deletes duplicate policies)" |
| 232 | + ) |
| 233 | + |
| 234 | + args = parser.parse_args() |
| 235 | + |
| 236 | + # Configure logging for CLI |
| 237 | + logging.basicConfig( |
| 238 | + level=logging.INFO, |
| 239 | + format='%(message)s', |
| 240 | + handlers=[logging.StreamHandler(sys.stdout)] |
| 241 | + ) |
| 242 | + |
| 243 | + # Determine mode |
| 244 | + if args.apply: |
| 245 | + dry_run = False |
| 246 | + else: |
| 247 | + dry_run = True # Default to dry run for safety |
| 248 | + |
| 249 | + # Execute command |
| 250 | + if args.command == "cleanup-duplicate-policies": |
| 251 | + try: |
| 252 | + result = cleanup_duplicate_policies(dry_run=dry_run) |
| 253 | + sys.exit(0) |
| 254 | + except Exception as e: |
| 255 | + logging.error(f"Error executing cleanup: {e}", exc_info=True) |
| 256 | + sys.exit(1) |
| 257 | + else: |
| 258 | + logging.error(f"Unknown command: {args.command}") |
| 259 | + sys.exit(1) |
| 260 | + |
| 261 | + |
| 262 | +if __name__ == "__main__": |
| 263 | + main() |
0 commit comments