Skip to content

Commit 0d0b455

Browse files
policy drift/CLI tool for troubleshooting
1 parent 39bc950 commit 0d0b455

File tree

3 files changed

+419
-27
lines changed

3 files changed

+419
-27
lines changed

CLI_USAGE.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# DockFlare CLI Utilities
2+
3+
## Cleanup Duplicate Policies
4+
5+
DockFlare now includes a CLI utility to detect and remove duplicate reusable policies in your Cloudflare account.
6+
7+
### Problem
8+
9+
When running multiple DockFlare instances (local + deployed) or experiencing state.json drift between instances, duplicate policies with the same name can be created in Cloudflare. This utility consolidates them by keeping the oldest policy and deleting newer duplicates.
10+
11+
### Usage
12+
13+
#### Preview (Dry Run) - Recommended First Step
14+
15+
```bash
16+
docker exec dockflare python -m app.cli cleanup-duplicate-policies --dry-run
17+
```
18+
19+
This will:
20+
- Scan all reusable policies in your Cloudflare account
21+
- Identify policies with duplicate names
22+
- Show which policies would be deleted (newer ones)
23+
- Show which policy ID would be kept (oldest one)
24+
- Show state.json updates that would be made
25+
- **Make NO actual changes**
26+
27+
#### Execute Cleanup
28+
29+
```bash
30+
docker exec dockflare python -m app.cli cleanup-duplicate-policies --apply
31+
```
32+
33+
This will:
34+
- Delete all duplicate policies (keeping the oldest)
35+
- Update state.json to reference the correct policy IDs
36+
- **Actually make changes to your Cloudflare account**
37+
38+
### What It Does
39+
40+
1. **Fetches all reusable policies** from your Cloudflare account
41+
2. **Groups policies by name** to identify duplicates
42+
3. **Sorts by creation date** - keeps the oldest policy for each name
43+
4. **Deletes newer duplicates** (only when using `--apply`)
44+
5. **Updates state.json** - ensures all access groups reference the correct (kept) policy ID
45+
46+
### Example Output
47+
48+
```
49+
============================================================
50+
DUPLICATE POLICY CLEANUP UTILITY
51+
============================================================
52+
Mode: DRY RUN (no changes will be made)
53+
54+
Step 1: Fetching all reusable policies from Cloudflare...
55+
Found 15 total policies
56+
57+
Step 2: Grouping policies by name...
58+
59+
Step 3: Identifying duplicates...
60+
✗ Found 2 policy names with duplicates:
61+
62+
Policy: 'DockFlare-Default-Public-Access-Bypass' (3 instances)
63+
Policy: 'DockFlare-AccessGroup-idp-blocker' (3 instances)
64+
65+
Total policies to delete: 4
66+
67+
Step 4: Processing duplicates...
68+
69+
Processing: 'DockFlare-Default-Public-Access-Bypass'
70+
✓ Keeping: ID=abc123 (created: 2025-01-01T10:00:00Z)
71+
✗ Would delete: ID=def456 (created: 2025-01-02T11:00:00Z)
72+
✗ Would delete: ID=ghi789 (created: 2025-01-03T12:00:00Z)
73+
74+
Processing: 'DockFlare-AccessGroup-idp-blocker'
75+
✓ Keeping: ID=jkl012 (created: 2025-01-01T09:00:00Z)
76+
✗ Would delete: ID=mno345 (created: 2025-01-02T10:00:00Z)
77+
✗ Would delete: ID=pqr678 (created: 2025-01-03T11:00:00Z)
78+
79+
Step 5: Updating state.json with correct policy IDs...
80+
DRY RUN: Would update state.json with the following changes:
81+
Group 'public-default-bypass': def456 → abc123 (policy: DockFlare-Default-Public-Access-Bypass)
82+
Group 'idp-blocker': mno345 → jkl012 (policy: DockFlare-AccessGroup-idp-blocker)
83+
84+
============================================================
85+
SUMMARY
86+
============================================================
87+
Total policies scanned: 15
88+
Duplicate policy names found: 2
89+
Policies that would be deleted: 4
90+
Policies that would be kept: 2
91+
============================================================
92+
```
93+
94+
### Safety Features
95+
96+
- **Dry run by default** - You must explicitly use `--apply` to make changes
97+
- **Keeps oldest policy** - Ensures you don't lose the original policy
98+
- **Updates state.json** - Automatically fixes references to deleted policies
99+
- **Detailed logging** - Shows exactly what will be (or was) done
100+
101+
### When to Use
102+
103+
- After discovering duplicate system policies (DockFlare-Default-*)
104+
- After running multiple DockFlare instances that created duplicate user policies
105+
- Before major version upgrades to clean up your Cloudflare account
106+
- When troubleshooting policy-related issues
107+
108+
### Notes
109+
110+
- The utility requires DockFlare to be configured with valid Cloudflare credentials
111+
- It operates on **all reusable policies** in your account, not just DockFlare-managed ones
112+
- Always run with `--dry-run` first to preview changes
113+
- Deletion is permanent and cannot be undone (except by recreating policies manually)

dockflare/app/cli.py

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
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

Comments
 (0)