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.
- README.md +119 -0
- bunny2fmc/__init__.py +1 -1
- bunny2fmc/cli.py +760 -120
- bunny2fmc/config.py +152 -1
- bunny2fmc/setup_helper.py +31 -0
- bunny2fmc/sync_engine.py +262 -56
- bunny2fmc-1.3.21.dist-info/METADATA +152 -0
- bunny2fmc-1.3.21.dist-info/RECORD +15 -0
- {bunny2fmc-1.0.8.dist-info → bunny2fmc-1.3.21.dist-info}/WHEEL +1 -1
- guide/INSTALL.md +208 -0
- install.sh +54 -0
- requirements.txt +3 -0
- bunny2fmc-1.0.8.dist-info/METADATA +0 -176
- bunny2fmc-1.0.8.dist-info/RECORD +0 -10
- {bunny2fmc-1.0.8.dist-info → bunny2fmc-1.3.21.dist-info}/entry_points.txt +0 -0
- {bunny2fmc-1.0.8.dist-info → bunny2fmc-1.3.21.dist-info}/licenses/LICENSE +0 -0
- {bunny2fmc-1.0.8.dist-info → bunny2fmc-1.3.21.dist-info}/top_level.txt +0 -0
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
|
-
|
|
22
|
-
bunny2fmc
|
|
23
|
-
bunny2fmc --run
|
|
24
|
-
bunny2fmc --
|
|
25
|
-
bunny2fmc --
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
•
|
|
49
|
-
•
|
|
50
|
-
•
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
"""
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
186
|
-
print(
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
216
|
-
"""
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
824
|
+
"""Main entry point with argparse"""
|
|
253
825
|
parser = argparse.ArgumentParser(
|
|
254
826
|
prog="bunny2fmc",
|
|
255
|
-
description="Sync BunnyCDN
|
|
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(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
839
|
+
parser.add_argument(
|
|
840
|
+
"--setup",
|
|
841
|
+
action="store_true",
|
|
842
|
+
help="Run interactive setup wizard",
|
|
843
|
+
)
|
|
267
844
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
845
|
+
parser.add_argument(
|
|
846
|
+
"--config",
|
|
847
|
+
action="store_true",
|
|
848
|
+
help="Show current configuration",
|
|
849
|
+
)
|
|
271
850
|
|
|
272
|
-
|
|
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.
|
|
277
|
-
cmd_show_config(args
|
|
278
|
-
elif args.
|
|
279
|
-
cmd_clear_config(args
|
|
280
|
-
elif args.
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
923
|
+
# Default: run sync
|
|
924
|
+
cmd_run(args, logger)
|
|
285
925
|
|
|
286
926
|
|
|
287
927
|
if __name__ == "__main__":
|