bunny2fmc 1.0.8__py3-none-any.whl → 1.3.21__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bunny2fmc/cli.py CHANGED
@@ -6,67 +6,134 @@ bunny2fmc CLI: Main entry point with argparse, credential management, and loggin
6
6
  """
7
7
 
8
8
  import argparse
9
+ import getpass
9
10
  import logging
10
11
  import sys
12
+ import os
13
+ import subprocess
14
+ import shutil
11
15
  from logging.handlers import RotatingFileHandler
12
16
 
13
17
  import bunny2fmc
14
18
  from bunny2fmc.config import CredentialManager, ConfigManager
15
19
  from bunny2fmc.sync_engine import sync
16
-
17
-
18
20
  # Global epilog text for help output
19
21
  _EPILOG = """
20
22
  Examples:
21
- bunny2fmc --setup # First time setup with interactive prompts
22
- bunny2fmc # Run sync (default if no arguments)
23
- bunny2fmc --run # Explicit run command
24
- bunny2fmc --show-config # View current settings
25
- bunny2fmc --clear-config # Reset all credentials and configuration
26
-
27
- Interactive Setup Prompts:
28
- FMC IP Address (e.g., 192.168.3.122) - automatically prepends https://
29
- FMC Username (account with API access)
30
- FMC Password (stored securely in OS Keyring)
31
- • Dynamic Object Name (create or select in FMC)
32
- Include IPv6? (yes/no - include IPv6 endpoint ranges)
33
- • Sync Interval (minutes - for cron job scheduling)
34
-
35
- Cron Scheduling Examples (after setup):
36
- */5 * * * * bunny2fmc # Every 5 minutes
37
- */15 * * * * bunny2fmc # Every 15 minutes
38
- 0 3 * * * bunny2fmc # Daily at 3 AM
39
-
40
- Documentation & Support:
41
- GitHub: https://github.com/IronKeyVault/Bunny_Sync_FMC
42
- PyPI: https://pypi.org/project/bunny2fmc/
43
- Logs: ~/.local/share/bunny2fmc/logs/bunny2fmc.log
44
- Config: ~/.local/share/bunny2fmc/config.json
45
-
46
- Credentials Security:
47
- All credentials stored securely in OS Keyring (never plain text):
48
- Linux: Secret Service (D-Bus)
49
- macOS: Keychain
50
- Windows: Credential Manager
23
+
24
+ bunny2fmc --setup # First time setup (interactive)
25
+ bunny2fmc --run # Run sync once
26
+ bunny2fmc --config # Show current configuration
27
+ bunny2fmc --logs # View last 20 lines of log
28
+ bunny2fmc --logs follow # Follow log in realtime
29
+ bunny2fmc --start # Start scheduled syncs
30
+ bunny2fmc --stop # Stop scheduled syncs
31
+ bunny2fmc --init # Copy docs (QUICK_START.md etc.) to current dir
32
+ bunny2fmc --clear # Clear all configuration and credentials
33
+
34
+ Customer Installation - bunny2fmc
35
+
36
+ Prerequisites:
37
+
38
+ Linux server with cron (Ubuntu/Debian recommended)
39
+ Python 3.8+ (already installed on modern Linux)
40
+ Network access to both Bunny CDN API and customer's FMC
41
+
42
+ Installation (2 commands):
43
+
44
+ sudo apt install pipx
45
+ pipx install bunny2fmc
46
+
47
+ FMC API User Setup:
48
+
49
+ In Cisco FMC, create dedicated API user:
50
+ Username: bunny2fmc_sync
51
+ Password: [choose strong password]
52
+ Role: Network Admin (or Maintenance User)
53
+
54
+ Configuration (interactive wizard):
55
+
56
+ bunny2fmc --setup
57
+
58
+ Wizard will ask for:
59
+ • FMC hostname (e.g. fmc.kunde.dk)
60
+ • FMC username: bunny2fmc_sync
61
+ • FMC password: [chosen password]
62
+ • Dynamic Object name: Bunny-CDN-IPs
63
+ • Sync interval: daily (default)
64
+
65
+ Test & Start:
66
+
67
+ bunny2fmc --run # Test sync now
68
+ bunny2fmc --start # Start scheduled syncs
69
+
70
+ Verification:
71
+
72
+ bunny2fmc --config # See configuration
73
+ bunny2fmc --logs # See logs
74
+
75
+ Done! Syncs now automatically daily via cron.
76
+
77
+ Upgrade:
78
+
79
+ pipx upgrade bunny2fmc
80
+
81
+ Log file: ~/.local/share/bunny2fmc/logs/bunny2fmc.log
82
+
83
+ About:
84
+
85
+ bunny2fmc is a tool that automatically syncs the latest BunnyCDN IP address
86
+ ranges to a Dynamic Object in Cisco FMC. It can run on-demand or on a schedule
87
+ via cron, ensuring your security policies always have the latest CDN IPs.
88
+
89
+ Features:
90
+ - Automatic IP range synchronization from BunnyCDN
91
+ - Secure credential storage in OS keyring
92
+ - Scheduled execution via cron jobs
93
+ - Real-time log monitoring
94
+ - Easy on/off toggle for scheduled syncs
95
+
96
+ API User Setup (Recommended):
97
+
98
+ To avoid being logged out of FMC during syncs, create a dedicated API user:
99
+
100
+ 1. In Cisco FMC, create a new user:
101
+ Username: bunny2fmc_sync
102
+ Authentication: Local (password)
103
+
104
+ 2. Assign required roles:
105
+ ☑ Network Admin (or)
106
+ ☑ Maintenance User
107
+
108
+ 3. This user needs permissions for:
109
+ - Object Management → Dynamic Objects: Read, Create, Modify
110
+
111
+ 4. Use this user in bunny2fmc --setup (not your admin account)
51
112
  """
52
113
 
53
114
 
54
- def setup_logging(log_file):
55
- """Configure logging with file rotation (10 MB per file, max 5 backups)"""
56
- log_format = "%(asctime)s %(levelname)s %(name)s %(message)s"
57
-
115
+
116
+ def setup_logging():
117
+ """Configure logging with rotating file handler"""
58
118
  logger = logging.getLogger("bunny2fmc")
59
119
  logger.setLevel(logging.INFO)
60
120
 
121
+ config_dir = ConfigManager.get_config_dir()
122
+ log_file = ConfigManager.get_log_file()
123
+
124
+ log_format = "[%(asctime)s] %(levelname)-8s %(name)s %(message)s"
125
+
126
+ # Rotating file handler (10 MB per file)
61
127
  file_handler = RotatingFileHandler(
62
128
  log_file,
63
- maxBytes=10 * 1024 * 1024,
129
+ maxBytes=10 * 1024 * 1024, # 10 MB
64
130
  backupCount=5,
65
131
  )
66
132
  file_handler.setLevel(logging.INFO)
67
133
  file_handler.setFormatter(logging.Formatter(log_format))
68
134
  logger.addHandler(file_handler)
69
135
 
136
+ # Console handler (for interactive mode)
70
137
  console_handler = logging.StreamHandler()
71
138
  console_handler.setLevel(logging.INFO)
72
139
  console_handler.setFormatter(logging.Formatter(log_format))
@@ -75,43 +142,234 @@ def setup_logging(log_file):
75
142
  return logger
76
143
 
77
144
 
145
+ def _masked_input(prompt):
146
+ """
147
+ Read password input with asterisks masking (works on most terminals).
148
+ Falls back to getpass if terminal doesn't support it.
149
+ """
150
+ import sys
151
+ import tty
152
+ import termios
153
+
154
+ try:
155
+ # Try to use terminal control for masking
156
+ sys.stdout.write(prompt)
157
+ sys.stdout.flush()
158
+
159
+ fd = sys.stdin.fileno()
160
+ old_settings = termios.tcgetattr(fd)
161
+
162
+ password = []
163
+ try:
164
+ tty.setraw(fd)
165
+ while True:
166
+ char = sys.stdin.read(1)
167
+ if char == '\r' or char == '\n':
168
+ sys.stdout.write('\n')
169
+ break
170
+ elif char == '\x7f' or char == '\x08': # Backspace
171
+ if password:
172
+ password.pop()
173
+ sys.stdout.write('\b \b')
174
+ elif char == '\x1b': # Escape sequence (arrows, delete, etc.)
175
+ # Read and discard the rest of the escape sequence
176
+ next_char = sys.stdin.read(1)
177
+ if next_char == '[':
178
+ # Read until we get a letter or ~ (end of sequence)
179
+ while True:
180
+ seq_char = sys.stdin.read(1)
181
+ if seq_char.isalpha() or seq_char == '~':
182
+ break
183
+ # Ignore the entire escape sequence
184
+ elif char == '\x03': # Ctrl+C
185
+ sys.stdout.write('\n')
186
+ raise KeyboardInterrupt
187
+ elif ord(char) < 32: # Other control characters
188
+ pass # Ignore
189
+ else:
190
+ password.append(char)
191
+ sys.stdout.write('*')
192
+ sys.stdout.flush()
193
+ finally:
194
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
195
+
196
+ return ''.join(password)
197
+ except (ImportError, OSError, termios.error):
198
+ # Fallback to getpass if terminal control fails
199
+ return getpass.getpass(prompt)
200
+
201
+
202
+ def _get_password(prompt):
203
+ """
204
+ Get password input. Uses getpass in interactive mode (TTY), uses input() in piped mode.
205
+ This ensures compatibility with both terminal and non-interactive (piped/heredoc) input.
206
+ """
207
+ if sys.stdin.isatty():
208
+ # Interactive terminal mode: use getpass for masking
209
+ return getpass.getpass(prompt)
210
+ else:
211
+ # Piped/heredoc mode: use input() to consume from stdin sequentially
212
+ sys.stdout.write(prompt)
213
+ sys.stdout.flush()
214
+ return sys.stdin.readline().rstrip("\n")
215
+
216
+
217
+
78
218
  def interactive_setup():
79
- """Interactive setup: prompt for credentials and interval."""
219
+ """
220
+ Interactive setup: prompt for credentials and interval.
221
+ Loads previous values as defaults. Password is hidden.
222
+ Returns dict with configuration.
223
+ """
80
224
  print("\n" + "=" * 60)
81
- print("bunny2fmc - Initial Configuration")
225
+ print("bunny2fmc - Configuration")
82
226
  print("=" * 60)
83
227
  print("Enter your FMC and Bunny configuration.\n")
84
228
 
229
+ # Try to load previous configuration
230
+ try:
231
+ previous_url = ConfigManager.load_fmc_base_url()
232
+ previous_username = ConfigManager.load_fmc_username()
233
+ previous_dynamic = ConfigManager.load_dynamic_object_name()
234
+ previous_ipv6 = ConfigManager.load_include_ipv6()
235
+ previous_interval = ConfigManager.load_sync_interval_minutes()
236
+ # Check if password is already stored in keyring
237
+ has_stored_password = CredentialManager.get_credentials() is not None
238
+ except Exception:
239
+ previous_url = None
240
+ previous_username = None
241
+ previous_dynamic = None
242
+ previous_ipv6 = False
243
+ previous_interval = None
244
+ has_stored_password = False
245
+
246
+ # Extract IP from URL (remove https://)
247
+ previous_ip = None
248
+ if previous_url:
249
+ previous_ip = previous_url.replace("https://", "").replace("http://", "")
250
+
251
+ # FMC IP Address
85
252
  while True:
86
- fmc_ip = input("Enter FMC IP Address (e.g., 192.168.3.122): ").strip()
253
+ prompt = f"Enter FMC IP Address"
254
+ if previous_ip:
255
+ prompt += f" [{previous_ip}]"
256
+ else:
257
+ prompt += " (e.g., 192.168.3.122)"
258
+ prompt += ": "
259
+
260
+ fmc_ip = input(prompt).strip()
261
+
262
+ # Use previous IP if user just presses Enter
263
+ if not fmc_ip and previous_ip:
264
+ fmc_ip = previous_ip
265
+
87
266
  if fmc_ip:
267
+ # Automatically prepend https://
88
268
  fmc_base_url = f"https://{fmc_ip}"
89
269
  break
90
270
  print("FMC IP Address cannot be empty.")
91
271
 
272
+ # FMC Username
92
273
  while True:
93
- fmc_username = input("Enter FMC Username: ").strip()
274
+ prompt = f"Enter FMC Username"
275
+ if previous_username:
276
+ prompt += f" [{previous_username}]"
277
+ prompt += ": "
278
+
279
+ fmc_username = input(prompt).strip()
280
+
281
+ # Use previous username if user just presses Enter
282
+ if not fmc_username and previous_username:
283
+ fmc_username = previous_username
284
+
94
285
  if fmc_username:
95
286
  break
96
287
  print("FMC Username cannot be empty.")
97
288
 
289
+ # FMC Password (hidden input)
98
290
  while True:
99
- fmc_password = input("Enter FMC Password: ").strip()
291
+ if has_stored_password:
292
+ password_prompt = "Enter FMC Password (or press Enter to use stored password): "
293
+ else:
294
+ password_prompt = "Enter FMC Password: "
295
+
296
+ fmc_password = _get_password(password_prompt)
297
+
298
+ # If user pressed Enter and we have a stored password, retrieve it
299
+ if not fmc_password and has_stored_password:
300
+ creds = CredentialManager.get_credentials()
301
+ if creds:
302
+ fmc_password = creds.get("fmc_password")
303
+
100
304
  if fmc_password:
101
305
  break
102
- print("FMC Password cannot be empty.")
103
306
 
307
+ if has_stored_password:
308
+ print("Please enter a password or press Enter to use the stored one.")
309
+ else:
310
+ print("FMC Password cannot be empty.")
311
+
312
+ # Dynamic Object Name
104
313
  while True:
105
- fmc_dynamic_name = input("Enter Dynamic Object Name (e.g., BunnyCDN_Dynamic): ").strip()
314
+ prompt = f"Enter Dynamic Object Name"
315
+ if previous_dynamic:
316
+ prompt += f" [{previous_dynamic}]"
317
+ else:
318
+ prompt += " (e.g., BunnyCDN_Dynamic)"
319
+ prompt += ": "
320
+
321
+ fmc_dynamic_name = input(prompt).strip()
322
+
323
+ # Use previous dynamic name if user just presses Enter
324
+ if not fmc_dynamic_name and previous_dynamic:
325
+ fmc_dynamic_name = previous_dynamic
326
+
106
327
  if fmc_dynamic_name:
107
328
  break
108
329
  print("Dynamic Object Name cannot be empty.")
109
330
 
110
- include_ipv6_str = input("Include IPv6 endpoints? (y/n, default: n): ").strip().lower()
111
- include_ipv6 = include_ipv6_str in ("y", "yes", "1", "true")
331
+ # Include IPv6
332
+ default_ipv6_str = "y" if previous_ipv6 else "n"
333
+ include_ipv6_str = input(
334
+ f"Include IPv6 endpoints? (y/n, default: {default_ipv6_str}): "
335
+ ).strip().lower()
336
+
337
+ if not include_ipv6_str:
338
+ include_ipv6 = previous_ipv6
339
+ else:
340
+ include_ipv6 = include_ipv6_str in ("y", "yes", "1", "true")
341
+
112
342
 
343
+ # Bunny CDN API URLs (with defaults)
344
+ print("\n--- Bunny CDN API URLs (press Enter for defaults) ---")
345
+
346
+ previous_ipv4_url = ConfigManager.load_bunny_ipv4_url()
347
+ prompt = f"Bunny IPv4 URL [{previous_ipv4_url}]: "
348
+ bunny_ipv4_url = input(prompt).strip()
349
+ if not bunny_ipv4_url:
350
+ bunny_ipv4_url = previous_ipv4_url
351
+
352
+ previous_ipv6_url = ConfigManager.load_bunny_ipv6_url()
353
+ prompt = f"Bunny IPv6 URL [{previous_ipv6_url}]: "
354
+ bunny_ipv6_url = input(prompt).strip()
355
+ if not bunny_ipv6_url:
356
+ bunny_ipv6_url = previous_ipv6_url
357
+
358
+ # Sync interval in minutes
113
359
  while True:
114
- interval_str = input("Sync interval in minutes (e.g., 15): ").strip()
360
+ prompt = f"Sync interval in minutes"
361
+ if previous_interval:
362
+ prompt += f" [{previous_interval}]"
363
+ else:
364
+ prompt += " (e.g., 15)"
365
+ prompt += ": "
366
+
367
+ interval_str = input(prompt).strip()
368
+
369
+ # Use previous interval if user just presses Enter
370
+ if not interval_str and previous_interval:
371
+ interval_str = str(previous_interval)
372
+
115
373
  try:
116
374
  sync_interval_minutes = int(interval_str)
117
375
  if sync_interval_minutes > 0:
@@ -120,6 +378,7 @@ def interactive_setup():
120
378
  except ValueError:
121
379
  print("Interval must be a valid integer.")
122
380
 
381
+ # Confirm
123
382
  print("\n" + "-" * 60)
124
383
  print("Configuration Summary:")
125
384
  print(f" FMC URL: {fmc_base_url}")
@@ -127,6 +386,8 @@ def interactive_setup():
127
386
  print(f" Dynamic Object Name: {fmc_dynamic_name}")
128
387
  print(f" Include IPv6: {include_ipv6}")
129
388
  print(f" Sync Interval: {sync_interval_minutes} minute(s)")
389
+ print(f" Bunny IPv4 URL: {bunny_ipv4_url}")
390
+ print(f" Bunny IPv6 URL: {bunny_ipv6_url}")
130
391
  print("-" * 60)
131
392
 
132
393
  confirm = input("\nSave this configuration? (y/n): ").strip().lower()
@@ -140,10 +401,95 @@ def interactive_setup():
140
401
  "fmc_password": fmc_password,
141
402
  "fmc_dynamic_name": fmc_dynamic_name,
142
403
  "include_ipv6": include_ipv6,
404
+ "bunny_ipv4_url": bunny_ipv4_url,
405
+ "bunny_ipv6_url": bunny_ipv6_url,
143
406
  "sync_interval_minutes": sync_interval_minutes,
144
407
  }
145
408
 
146
409
 
410
+
411
+
412
+ def setup_cron_job(interval_minutes):
413
+ """
414
+ Set up a cron job to run bunny2fmc at the specified interval.
415
+ Returns True if successful, False otherwise.
416
+ """
417
+ # Find bunny2fmc executable path
418
+ bunny2fmc_path = shutil.which("bunny2fmc")
419
+ if not bunny2fmc_path:
420
+ print("Warning: Could not find bunny2fmc in PATH. Cron job not created.")
421
+ return False
422
+
423
+ # Create the cron entry
424
+ cron_comment = "# bunny2fmc automatic sync"
425
+ cron_job = f"*/{interval_minutes} * * * * {bunny2fmc_path} --run"
426
+
427
+ try:
428
+ # Get current crontab
429
+ result = subprocess.run(
430
+ ["crontab", "-l"],
431
+ capture_output=True,
432
+ text=True
433
+ )
434
+
435
+ if result.returncode == 0:
436
+ current_crontab = result.stdout
437
+ else:
438
+ # No existing crontab
439
+ current_crontab = ""
440
+
441
+ # Remove any existing bunny2fmc entries
442
+ lines = current_crontab.strip().split("\n") if current_crontab.strip() else []
443
+ new_lines = [line for line in lines if "bunny2fmc" not in line]
444
+
445
+ # Add new cron job
446
+ new_lines.append(cron_comment)
447
+ new_lines.append(cron_job)
448
+
449
+ new_crontab = "\n".join(new_lines) + "\n"
450
+
451
+ # Install new crontab
452
+ process = subprocess.run(
453
+ ["crontab", "-"],
454
+ input=new_crontab,
455
+ capture_output=True,
456
+ text=True
457
+ )
458
+
459
+ if process.returncode == 0:
460
+ return True
461
+ else:
462
+ print(f"Warning: Failed to install cron job: {process.stderr}")
463
+ return False
464
+
465
+ except FileNotFoundError:
466
+ print("Warning: crontab command not found. Please set up scheduling manually.")
467
+ return False
468
+ except Exception as e:
469
+ print(f"Warning: Could not set up cron job: {e}")
470
+ return False
471
+
472
+
473
+ def remove_cron_job():
474
+ """Remove bunny2fmc cron job if it exists."""
475
+ try:
476
+ result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
477
+ if result.returncode != 0:
478
+ return True # No crontab exists
479
+
480
+ lines = result.stdout.strip().split("\n")
481
+ new_lines = [line for line in lines if "bunny2fmc" not in line]
482
+
483
+ if new_lines:
484
+ new_crontab = "\n".join(new_lines) + "\n"
485
+ subprocess.run(["crontab", "-"], input=new_crontab, capture_output=True, text=True)
486
+ else:
487
+ subprocess.run(["crontab", "-r"], capture_output=True, text=True)
488
+
489
+ return True
490
+ except Exception:
491
+ return False
492
+
147
493
  def cmd_setup(args):
148
494
  """Handle --setup flag"""
149
495
  config = interactive_setup()
@@ -151,137 +497,431 @@ def cmd_setup(args):
151
497
  sys.exit(1)
152
498
 
153
499
  try:
500
+ # Store credentials in keyring
154
501
  CredentialManager.set_credentials(
155
502
  config["fmc_base_url"],
156
503
  config["fmc_username"],
157
504
  config["fmc_password"],
158
505
  config["sync_interval_minutes"],
159
506
  )
507
+
508
+ # Store config values in config.json (URL, username, dynamic name, IPv6, interval)
509
+ ConfigManager.save_fmc_base_url(config["fmc_base_url"])
510
+ ConfigManager.save_fmc_username(config["fmc_username"])
160
511
  ConfigManager.save_dynamic_object_name(config["fmc_dynamic_name"])
161
512
  ConfigManager.save_include_ipv6(config["include_ipv6"])
513
+ ConfigManager.save_bunny_ipv4_url(config["bunny_ipv4_url"])
514
+ ConfigManager.save_bunny_ipv6_url(config["bunny_ipv6_url"])
515
+ ConfigManager.save_sync_interval_minutes(config["sync_interval_minutes"])
162
516
 
163
517
  print("\n✓ Configuration saved securely!")
164
- print(f"\nYou can now run: bunny2fmc")
518
+
519
+ # Ask about automatic scheduling
520
+ print(f"\nWould you like to enable automatic sync every {config['sync_interval_minutes']} minute(s)?")
521
+ enable_cron = input("Enable scheduled sync? (y/n, default: y): ").strip().lower()
522
+
523
+ if enable_cron != "n":
524
+ if setup_cron_job(config["sync_interval_minutes"]):
525
+ print(f"✓ Cron job installed: runs every {config['sync_interval_minutes']} minute(s)")
526
+ else:
527
+ print(f"\nTo manually schedule with cron:")
528
+ print(f" */{config['sync_interval_minutes']} * * * * bunny2fmc --run")
529
+ else:
530
+ print(f"\nTo manually schedule with cron later:")
531
+ print(f" */{config['sync_interval_minutes']} * * * * bunny2fmc --run")
532
+
165
533
  print(f"\nTo run the sync immediately:")
166
534
  print(f" bunny2fmc --run")
167
- print(f"\nTo schedule with cron (for {config['sync_interval_minutes']} minute interval):")
168
- print(f" */{config['sync_interval_minutes']} * * * * bunny2fmc --run")
169
535
  sys.exit(0)
170
536
  except Exception as e:
171
537
  print(f"\n✗ Failed to save configuration: {e}")
172
538
  sys.exit(1)
173
539
 
174
540
 
541
+ def cmd_show_config(args):
542
+ """Handle --config flag: show current configuration"""
543
+ try:
544
+ # Load config
545
+ fmc_url = ConfigManager.load_fmc_base_url()
546
+ fmc_username = ConfigManager.load_fmc_username()
547
+ fmc_dynamic = ConfigManager.load_dynamic_object_name()
548
+ include_ipv6 = ConfigManager.load_include_ipv6()
549
+ sync_interval = ConfigManager.load_sync_interval_minutes()
550
+
551
+ if not all([fmc_url, fmc_username, fmc_dynamic, sync_interval is not None]):
552
+ print("No configuration found. Run: bunny2fmc --setup")
553
+ sys.exit(1)
554
+
555
+ print("\nCurrent Configuration:")
556
+ print("-" * 60)
557
+ print(f" FMC URL: {fmc_url}")
558
+ print(f" FMC Username: {fmc_username}")
559
+ print(f" Dynamic Object Name: {fmc_dynamic}")
560
+ print(f" Include IPv6: {include_ipv6}")
561
+ print(f" Bunny IPv4 URL: {ConfigManager.load_bunny_ipv4_url()}")
562
+ print(f" Bunny IPv6 URL: {ConfigManager.load_bunny_ipv6_url()}")
563
+ print(f" Sync Interval: {sync_interval} minute(s)")
564
+ print("-" * 60)
565
+ print("\n✓ Credentials are stored securely in your OS keyring.")
566
+ print(f"\nLog file: {ConfigManager.get_log_file()}")
567
+ sys.exit(0)
568
+ except Exception as e:
569
+ print(f"Error: {e}")
570
+ sys.exit(1)
571
+
572
+
573
+ def cmd_clear_config(args):
574
+ """Handle --clear flag: clear all stored configuration"""
575
+ confirm = input(
576
+ "\nWarning: This will clear all stored credentials and configuration.\n"
577
+ "Continue? (y/n): "
578
+ ).strip().lower()
579
+ if confirm not in ("y", "yes"):
580
+ print("Cancelled.")
581
+ sys.exit(0)
582
+
583
+ try:
584
+ CredentialManager.clear_credentials()
585
+ config_file = ConfigManager.get_config_dir() / "config.json"
586
+ if config_file.exists():
587
+ config_file.unlink()
588
+ print("✓ Configuration cleared.")
589
+ sys.exit(0)
590
+ except Exception as e:
591
+ print(f"Error: {e}")
592
+ sys.exit(1)
593
+
594
+
175
595
  def cmd_run(args, logger):
176
596
  """Handle --run flag or normal execution"""
597
+ # Load credentials from keyring
177
598
  creds = CredentialManager.get_credentials()
178
599
  if not creds:
179
600
  logger.error("No stored credentials found. Run: bunny2fmc --setup")
180
601
  print("Error: No stored credentials found.\nPlease run: bunny2fmc --setup")
181
602
  sys.exit(1)
182
603
 
604
+ # Load additional config
183
605
  fmc_dynamic_name = ConfigManager.load_dynamic_object_name()
606
+ include_ipv6 = ConfigManager.load_include_ipv6()
607
+ bunny_ipv4_url = ConfigManager.load_bunny_ipv4_url()
608
+ bunny_ipv6_url = ConfigManager.load_bunny_ipv6_url()
609
+
184
610
  if not fmc_dynamic_name:
185
- logger.error("No Dynamic Object name configured. Run: bunny2fmc --setup")
186
- print("Error: No Dynamic Object name configured.\nPlease run: bunny2fmc --setup")
611
+ logger.error("Dynamic Object name not configured. Run: bunny2fmc --setup")
612
+ print(
613
+ "Error: Dynamic Object name not configured.\n"
614
+ "Please run: bunny2fmc --setup"
615
+ )
187
616
  sys.exit(1)
188
617
 
189
- include_ipv6 = ConfigManager.load_include_ipv6()
618
+ # Run the sync
619
+ try:
620
+ logger.info(
621
+ f"Starting sync (IPv6: {include_ipv6}, Dynamic Object: {fmc_dynamic_name})"
622
+ )
623
+ sync(
624
+ fmc_base_url=creds["fmc_base_url"],
625
+ fmc_username=creds["fmc_username"],
626
+ fmc_password=creds["fmc_password"],
627
+ fmc_dynamic_name=fmc_dynamic_name,
628
+ include_ipv6=include_ipv6,
629
+ bunny_ipv4_url=bunny_ipv4_url,
630
+ bunny_ipv6_url=bunny_ipv6_url,
631
+ )
632
+ logger.info("Sync completed successfully")
633
+ print("✓ Sync completed successfully")
634
+ sys.exit(0)
635
+ except Exception as e:
636
+ logger.error(f"Sync failed: {e}")
637
+ print(f"Error: {e}")
638
+ sys.exit(1)
190
639
 
191
- logger.info("Starting bunny2fmc sync")
192
- logger.info("Dynamic Object: %s", fmc_dynamic_name)
193
-
194
- result = sync(
195
- fmc_base_url=creds["fmc_base_url"],
196
- fmc_username=creds["fmc_username"],
197
- fmc_password=creds["fmc_password"],
198
- dynamic_object_name=fmc_dynamic_name,
199
- include_ipv6=include_ipv6,
200
- verify_ssl=True,
201
- dry_run=False,
202
- chunk_size=500,
203
- )
204
640
 
205
- if result["status"] == "success":
206
- logger.info("Sync completed: +%d -%d (total: %d)", result["added"], result["removed"], result["total_desired"])
207
- print(f"\n✓ Sync completed successfully!\n Added: {result['added']}\n Removed: {result['removed']}\n Total: {result['total_desired']}")
208
- sys.exit(0)
641
+ def cmd_logs(args):
642
+ """Handle --logs flag - view or follow log file"""
643
+ import subprocess
644
+
645
+ log_file = ConfigManager.get_log_file()
646
+
647
+ if not log_file.exists():
648
+ print(f"Error: Log file not found at {log_file}")
649
+ print("No syncs have been run yet")
650
+ sys.exit(1)
651
+
652
+ # Check if follow mode requested
653
+ follow = getattr(args, 'logs_follow', False)
654
+
655
+ if follow:
656
+ # Follow log in realtime
657
+ try:
658
+ subprocess.run(['tail', '-f', str(log_file)])
659
+ except KeyboardInterrupt:
660
+ print("\n✓ Log following stopped")
661
+ sys.exit(0)
209
662
  else:
210
- logger.error("Sync failed: %s", result["message"])
211
- print(f"\n✗ Sync failed: {result['message']}")
663
+ # Show last 20 lines
664
+ try:
665
+ result = subprocess.run(['tail', '-20', str(log_file)], capture_output=True, text=True)
666
+ print(result.stdout)
667
+ sys.exit(0)
668
+ except Exception as e:
669
+ print(f"Error reading log file: {e}")
670
+ sys.exit(1)
671
+
672
+
673
+ def cmd_start(args):
674
+ """Handle --start flag - re-enables scheduled syncs with existing config"""
675
+ try:
676
+ # Check if config exists
677
+ fmc_base_url = ConfigManager.load_fmc_base_url()
678
+ fmc_username = ConfigManager.load_fmc_username()
679
+ fmc_dynamic_name = ConfigManager.load_dynamic_object_name()
680
+
681
+ if not all([fmc_base_url, fmc_username, fmc_dynamic_name]):
682
+ print("Error: No configuration found. Please run: bunny2fmc --setup")
683
+ sys.exit(1)
684
+
685
+ # Display current config
686
+ print("\nCurrent configuration:")
687
+ print(f" FMC URL: {fmc_base_url}")
688
+ print(f" FMC Username: {fmc_username}")
689
+ print(f" Dynamic Object: {fmc_dynamic_name}")
690
+
691
+ # Ask for sync interval
692
+ print("\nEnter sync interval (in minutes):")
693
+ while True:
694
+ interval_input = input(" Enter interval [1-1440]: ").strip()
695
+ try:
696
+ interval = int(interval_input)
697
+ if 1 <= interval <= 1440:
698
+ break
699
+ print(" Invalid: Please enter a value between 1 and 1440")
700
+ except ValueError:
701
+ print(" Invalid: Please enter a number")
702
+
703
+ # Save interval and setup cron
704
+ ConfigManager.save_sync_interval_minutes(interval)
705
+ setup_cron_job(interval)
706
+
707
+ print(f"\n✓ Scheduled syncs re-enabled")
708
+ print(f"✓ Cron job configured for every {interval} minute(s)")
709
+ print(f"✓ Next sync will run in {interval} minute(s)")
710
+ sys.exit(0)
711
+ except Exception as e:
712
+ print(f"Error: {e}")
212
713
  sys.exit(1)
213
714
 
214
715
 
215
- def cmd_show_config(args, logger):
216
- """Handle --show-config flag"""
217
- creds = CredentialManager.get_credentials()
218
- if not creds:
219
- print("No configuration found. Run: bunny2fmc --setup")
220
- return
716
+ def cmd_init(args):
717
+ """Copy documentation files to current directory"""
718
+ from pathlib import Path
719
+
720
+ # Find the package installation directory
721
+ package_dir = Path(__file__).parent.parent
722
+
723
+ doc_files = ['QUICK_START.md', 'LINUX_INSTALL.md', 'README.md', 'INSTALL.md', 'install.sh']
724
+ copied = []
725
+ skipped = []
726
+
727
+ for doc_file in doc_files:
728
+ src = package_dir / doc_file
729
+ dst = Path.cwd() / doc_file
730
+
731
+ if not src.exists():
732
+ continue
733
+
734
+ if dst.exists():
735
+ skipped.append(doc_file)
736
+ continue
737
+
738
+ try:
739
+ shutil.copy2(src, dst)
740
+ copied.append(doc_file)
741
+ except Exception as e:
742
+ print(f"Warning: Could not copy {doc_file}: {e}")
743
+
744
+ if copied:
745
+ print("✓ Documentation files copied to current directory:")
746
+ for f in copied:
747
+ print(f" - {f}")
748
+
749
+ if skipped:
750
+ print("\nSkipped (already exist):")
751
+ for f in skipped:
752
+ print(f" - {f}")
753
+
754
+ if not copied and not skipped:
755
+ print("No documentation files found in package.")
756
+ sys.exit(1)
757
+
758
+ print("\n→ Start with: cat QUICK_START.md")
759
+ sys.exit(0)
221
760
 
222
- fmc_dynamic_name = ConfigManager.load_dynamic_object_name()
223
- include_ipv6 = ConfigManager.load_include_ipv6()
224
761
 
225
- print("\n" + "=" * 60)
226
- print("Current Configuration:")
227
- print("=" * 60)
228
- print(f"FMC Base URL: {creds['fmc_base_url']}")
229
- print(f"FMC Username: {creds['fmc_username']}")
230
- print(f"Dynamic Object Name: {fmc_dynamic_name}")
231
- print(f"Include IPv6: {include_ipv6}")
232
- print(f"Sync Interval: {creds['sync_interval_minutes']} minute(s)")
233
- print("=" * 60 + "\n")
762
+ def cmd_stop(args):
763
+ """Handle --stop flag - removes cron job and stops scheduled syncs"""
764
+ if remove_cron_job():
765
+ print(" Cron job removed successfully")
766
+ print("Scheduled syncs have been stopped")
767
+ sys.exit(0)
768
+ else:
769
+ print("Error: Failed to remove cron job")
770
+ sys.exit(1)
234
771
 
235
772
 
236
- def cmd_clear_config(args, logger):
237
- """Handle --clear-config flag"""
238
- confirm = input("Are you sure you want to clear all stored configuration? (y/n): ").strip().lower()
239
- if confirm not in ("y", "yes"):
240
- print("Cancelled.")
241
- return
242
773
 
774
+ def cmd_status(args):
775
+ """Handle --status flag - show if cron job is running and last sync time"""
776
+ import subprocess
777
+ from pathlib import Path
778
+
779
+ # Check if cron job exists
780
+ cron_running = False
243
781
  try:
244
- CredentialManager.clear_credentials()
245
- print(" Configuration cleared.")
246
- except Exception as e:
247
- logger.error("Failed to clear configuration: %s", e)
248
- print(f"✗ Failed to clear configuration: {e}")
782
+ result = subprocess.run(
783
+ ["crontab", "-l"],
784
+ capture_output=True,
785
+ text=True,
786
+ timeout=5
787
+ )
788
+ if result.returncode == 0 and "bunny2fmc" in result.stdout:
789
+ cron_running = True
790
+ except Exception:
791
+ pass
792
+
793
+ # Get last sync time from log
794
+ log_file = Path.home() / "bunny2fmc" / "logs" / "bunny2fmc.log"
795
+ last_sync = "Never"
796
+
797
+ if log_file.exists():
798
+ try:
799
+ with open(log_file, "r") as f:
800
+ lines = f.readlines()
801
+ for line in reversed(lines):
802
+ if "Sync completed successfully" in line:
803
+ parts = line.split("]")
804
+ if len(parts) >= 1:
805
+ timestamp = parts[0].strip("[")
806
+ last_sync = timestamp
807
+ break
808
+ except Exception:
809
+ pass
810
+
811
+ # Display status
812
+ print()
813
+ print("=" * 60)
814
+ print("bunny2fmc Status")
815
+ print("=" * 60)
816
+ status_str = "✓ Running" if cron_running else "✗ Stopped"
817
+ print(f"Cron job: {status_str}")
818
+ print(f"Last sync: {last_sync}")
819
+ print("=" * 60)
820
+ print()
249
821
 
250
822
 
251
823
  def main():
252
- """Main entry point with comprehensive help"""
824
+ """Main entry point with argparse"""
253
825
  parser = argparse.ArgumentParser(
254
826
  prog="bunny2fmc",
255
- description="Sync BunnyCDN edge IPs to Cisco FMC Dynamic Objects with secure credential management",
827
+ description="bunny2fmc - Sync BunnyCDN IP ranges to Cisco FMC Dynamic Objects",
256
828
  epilog=_EPILOG,
257
829
  formatter_class=argparse.RawDescriptionHelpFormatter,
258
830
  )
259
831
 
260
- parser.add_argument("--version", action="version", version=f"%(prog)s {bunny2fmc.__version__}")
261
- parser.add_argument("--setup", action="store_true", help="Interactive setup: Configure FMC credentials, Dynamic Object name, IPv6 option, and sync interval")
262
- parser.add_argument("--run", action="store_true", help="Execute sync immediately using stored credentials")
263
- parser.add_argument("--show-config", action="store_true", help="Display current configuration (FMC, Dynamic Object name, interval, etc.)")
264
- parser.add_argument("--clear-config", action="store_true", help="Clear all credentials and configuration")
832
+ parser.add_argument(
833
+ "--version",
834
+ action="version",
835
+ version=f"%(prog)s {bunny2fmc.__version__}",
836
+ help="Show version and exit",
837
+ )
265
838
 
266
- args = parser.parse_args()
839
+ parser.add_argument(
840
+ "--setup",
841
+ action="store_true",
842
+ help="Run interactive setup wizard",
843
+ )
267
844
 
268
- ConfigManager.ensure_directories()
269
- log_file = ConfigManager.get_log_file()
270
- logger = setup_logging(log_file)
845
+ parser.add_argument(
846
+ "--config",
847
+ action="store_true",
848
+ help="Show current configuration",
849
+ )
271
850
 
272
- logger.info("bunny2fmc started with args: %s", sys.argv[1:])
851
+ parser.add_argument(
852
+ "--clear",
853
+ action="store_true",
854
+ help="Clear all stored configuration and credentials",
855
+ )
856
+
857
+ parser.add_argument(
858
+ "--run",
859
+ action="store_true",
860
+ help="Run sync immediately (can also just run 'bunny2fmc')",
861
+ )
273
862
 
863
+ parser.add_argument(
864
+ "--logs",
865
+ nargs='?',
866
+ const='view',
867
+ metavar='MODE',
868
+ help="View or follow log file (MODE: 'view' or 'follow', default: view)",
869
+ )
870
+
871
+ parser.add_argument(
872
+ "--start",
873
+ action="store_true",
874
+ help="Start scheduled syncs (re-enable with existing config)",
875
+ )
876
+
877
+ parser.add_argument(
878
+ "--stop",
879
+ action="store_true",
880
+ help="Stop scheduled syncs (remove cron job)",
881
+ )
882
+ parser.add_argument(
883
+ "--status",
884
+ action="store_true",
885
+ help="Show cron job status and last sync time",
886
+ )
887
+
888
+ parser.add_argument(
889
+ "--init",
890
+ action="store_true",
891
+ help="Copy documentation files (QUICK_START.md, etc.) to current directory",
892
+ )
893
+
894
+ args = parser.parse_args()
895
+
896
+ # Setup logging
897
+ logger = setup_logging()
898
+ logger.info(f"bunny2fmc started with args: {sys.argv[1:]}")
899
+
900
+ # Handle commands
274
901
  if args.setup:
275
902
  cmd_setup(args)
276
- elif args.show_config:
277
- cmd_show_config(args, logger)
278
- elif args.clear_config:
279
- cmd_clear_config(args, logger)
280
- elif args.run or len(sys.argv) == 1:
281
- cmd_run(args, logger)
903
+ elif args.config:
904
+ cmd_show_config(args)
905
+ elif args.clear:
906
+ cmd_clear_config(args)
907
+ elif args.logs is not None:
908
+ # Parse logs mode: 'view' (default) or 'follow'
909
+ if args.logs == 'follow':
910
+ args.logs_follow = True
911
+ else:
912
+ args.logs_follow = False
913
+ cmd_logs(args)
914
+ elif args.start:
915
+ cmd_start(args)
916
+ elif args.stop:
917
+ cmd_stop(args)
918
+ elif args.init:
919
+ cmd_init(args)
920
+ elif args.status:
921
+ cmd_status(args)
282
922
  else:
283
- parser.print_help()
284
- sys.exit(1)
923
+ # Default: run sync
924
+ cmd_run(args, logger)
285
925
 
286
926
 
287
927
  if __name__ == "__main__":