bunny2fmc 1.0.8__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/__init__.py +6 -0
- bunny2fmc/cli.py +288 -0
- bunny2fmc/config.py +128 -0
- bunny2fmc/sync_engine.py +137 -0
- bunny2fmc-1.0.8.dist-info/METADATA +176 -0
- bunny2fmc-1.0.8.dist-info/RECORD +10 -0
- bunny2fmc-1.0.8.dist-info/WHEEL +5 -0
- bunny2fmc-1.0.8.dist-info/entry_points.txt +2 -0
- bunny2fmc-1.0.8.dist-info/licenses/LICENSE +21 -0
- bunny2fmc-1.0.8.dist-info/top_level.txt +1 -0
bunny2fmc/__init__.py
ADDED
bunny2fmc/cli.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
bunny2fmc CLI: Main entry point with argparse, credential management, and logging
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
from logging.handlers import RotatingFileHandler
|
|
12
|
+
|
|
13
|
+
import bunny2fmc
|
|
14
|
+
from bunny2fmc.config import CredentialManager, ConfigManager
|
|
15
|
+
from bunny2fmc.sync_engine import sync
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Global epilog text for help output
|
|
19
|
+
_EPILOG = """
|
|
20
|
+
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
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
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
|
+
|
|
58
|
+
logger = logging.getLogger("bunny2fmc")
|
|
59
|
+
logger.setLevel(logging.INFO)
|
|
60
|
+
|
|
61
|
+
file_handler = RotatingFileHandler(
|
|
62
|
+
log_file,
|
|
63
|
+
maxBytes=10 * 1024 * 1024,
|
|
64
|
+
backupCount=5,
|
|
65
|
+
)
|
|
66
|
+
file_handler.setLevel(logging.INFO)
|
|
67
|
+
file_handler.setFormatter(logging.Formatter(log_format))
|
|
68
|
+
logger.addHandler(file_handler)
|
|
69
|
+
|
|
70
|
+
console_handler = logging.StreamHandler()
|
|
71
|
+
console_handler.setLevel(logging.INFO)
|
|
72
|
+
console_handler.setFormatter(logging.Formatter(log_format))
|
|
73
|
+
logger.addHandler(console_handler)
|
|
74
|
+
|
|
75
|
+
return logger
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def interactive_setup():
|
|
79
|
+
"""Interactive setup: prompt for credentials and interval."""
|
|
80
|
+
print("\n" + "=" * 60)
|
|
81
|
+
print("bunny2fmc - Initial Configuration")
|
|
82
|
+
print("=" * 60)
|
|
83
|
+
print("Enter your FMC and Bunny configuration.\n")
|
|
84
|
+
|
|
85
|
+
while True:
|
|
86
|
+
fmc_ip = input("Enter FMC IP Address (e.g., 192.168.3.122): ").strip()
|
|
87
|
+
if fmc_ip:
|
|
88
|
+
fmc_base_url = f"https://{fmc_ip}"
|
|
89
|
+
break
|
|
90
|
+
print("FMC IP Address cannot be empty.")
|
|
91
|
+
|
|
92
|
+
while True:
|
|
93
|
+
fmc_username = input("Enter FMC Username: ").strip()
|
|
94
|
+
if fmc_username:
|
|
95
|
+
break
|
|
96
|
+
print("FMC Username cannot be empty.")
|
|
97
|
+
|
|
98
|
+
while True:
|
|
99
|
+
fmc_password = input("Enter FMC Password: ").strip()
|
|
100
|
+
if fmc_password:
|
|
101
|
+
break
|
|
102
|
+
print("FMC Password cannot be empty.")
|
|
103
|
+
|
|
104
|
+
while True:
|
|
105
|
+
fmc_dynamic_name = input("Enter Dynamic Object Name (e.g., BunnyCDN_Dynamic): ").strip()
|
|
106
|
+
if fmc_dynamic_name:
|
|
107
|
+
break
|
|
108
|
+
print("Dynamic Object Name cannot be empty.")
|
|
109
|
+
|
|
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")
|
|
112
|
+
|
|
113
|
+
while True:
|
|
114
|
+
interval_str = input("Sync interval in minutes (e.g., 15): ").strip()
|
|
115
|
+
try:
|
|
116
|
+
sync_interval_minutes = int(interval_str)
|
|
117
|
+
if sync_interval_minutes > 0:
|
|
118
|
+
break
|
|
119
|
+
print("Interval must be a positive number.")
|
|
120
|
+
except ValueError:
|
|
121
|
+
print("Interval must be a valid integer.")
|
|
122
|
+
|
|
123
|
+
print("\n" + "-" * 60)
|
|
124
|
+
print("Configuration Summary:")
|
|
125
|
+
print(f" FMC URL: {fmc_base_url}")
|
|
126
|
+
print(f" FMC Username: {fmc_username}")
|
|
127
|
+
print(f" Dynamic Object Name: {fmc_dynamic_name}")
|
|
128
|
+
print(f" Include IPv6: {include_ipv6}")
|
|
129
|
+
print(f" Sync Interval: {sync_interval_minutes} minute(s)")
|
|
130
|
+
print("-" * 60)
|
|
131
|
+
|
|
132
|
+
confirm = input("\nSave this configuration? (y/n): ").strip().lower()
|
|
133
|
+
if confirm not in ("y", "yes"):
|
|
134
|
+
print("Setup cancelled.")
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"fmc_base_url": fmc_base_url,
|
|
139
|
+
"fmc_username": fmc_username,
|
|
140
|
+
"fmc_password": fmc_password,
|
|
141
|
+
"fmc_dynamic_name": fmc_dynamic_name,
|
|
142
|
+
"include_ipv6": include_ipv6,
|
|
143
|
+
"sync_interval_minutes": sync_interval_minutes,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_setup(args):
|
|
148
|
+
"""Handle --setup flag"""
|
|
149
|
+
config = interactive_setup()
|
|
150
|
+
if not config:
|
|
151
|
+
sys.exit(1)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
CredentialManager.set_credentials(
|
|
155
|
+
config["fmc_base_url"],
|
|
156
|
+
config["fmc_username"],
|
|
157
|
+
config["fmc_password"],
|
|
158
|
+
config["sync_interval_minutes"],
|
|
159
|
+
)
|
|
160
|
+
ConfigManager.save_dynamic_object_name(config["fmc_dynamic_name"])
|
|
161
|
+
ConfigManager.save_include_ipv6(config["include_ipv6"])
|
|
162
|
+
|
|
163
|
+
print("\n✓ Configuration saved securely!")
|
|
164
|
+
print(f"\nYou can now run: bunny2fmc")
|
|
165
|
+
print(f"\nTo run the sync immediately:")
|
|
166
|
+
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
|
+
sys.exit(0)
|
|
170
|
+
except Exception as e:
|
|
171
|
+
print(f"\n✗ Failed to save configuration: {e}")
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cmd_run(args, logger):
|
|
176
|
+
"""Handle --run flag or normal execution"""
|
|
177
|
+
creds = CredentialManager.get_credentials()
|
|
178
|
+
if not creds:
|
|
179
|
+
logger.error("No stored credentials found. Run: bunny2fmc --setup")
|
|
180
|
+
print("Error: No stored credentials found.\nPlease run: bunny2fmc --setup")
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
|
|
183
|
+
fmc_dynamic_name = ConfigManager.load_dynamic_object_name()
|
|
184
|
+
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")
|
|
187
|
+
sys.exit(1)
|
|
188
|
+
|
|
189
|
+
include_ipv6 = ConfigManager.load_include_ipv6()
|
|
190
|
+
|
|
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
|
+
|
|
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)
|
|
209
|
+
else:
|
|
210
|
+
logger.error("Sync failed: %s", result["message"])
|
|
211
|
+
print(f"\n✗ Sync failed: {result['message']}")
|
|
212
|
+
sys.exit(1)
|
|
213
|
+
|
|
214
|
+
|
|
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
|
|
221
|
+
|
|
222
|
+
fmc_dynamic_name = ConfigManager.load_dynamic_object_name()
|
|
223
|
+
include_ipv6 = ConfigManager.load_include_ipv6()
|
|
224
|
+
|
|
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")
|
|
234
|
+
|
|
235
|
+
|
|
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
|
+
|
|
243
|
+
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}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def main():
|
|
252
|
+
"""Main entry point with comprehensive help"""
|
|
253
|
+
parser = argparse.ArgumentParser(
|
|
254
|
+
prog="bunny2fmc",
|
|
255
|
+
description="Sync BunnyCDN edge IPs to Cisco FMC Dynamic Objects with secure credential management",
|
|
256
|
+
epilog=_EPILOG,
|
|
257
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
258
|
+
)
|
|
259
|
+
|
|
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")
|
|
265
|
+
|
|
266
|
+
args = parser.parse_args()
|
|
267
|
+
|
|
268
|
+
ConfigManager.ensure_directories()
|
|
269
|
+
log_file = ConfigManager.get_log_file()
|
|
270
|
+
logger = setup_logging(log_file)
|
|
271
|
+
|
|
272
|
+
logger.info("bunny2fmc started with args: %s", sys.argv[1:])
|
|
273
|
+
|
|
274
|
+
if args.setup:
|
|
275
|
+
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)
|
|
282
|
+
else:
|
|
283
|
+
parser.print_help()
|
|
284
|
+
sys.exit(1)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
main()
|
bunny2fmc/config.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Secure credential and configuration management using OS keyring"""
|
|
4
|
+
import json, os, logging, keyring
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
log = logging.getLogger("bunny2fmc")
|
|
8
|
+
SERVICE_NAME = "bunny2fmc"
|
|
9
|
+
CONFIG_DIR = Path.home() / ".local" / "share" / "bunny2fmc"
|
|
10
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
11
|
+
LOG_DIR = CONFIG_DIR / "logs"
|
|
12
|
+
|
|
13
|
+
class CredentialManager:
|
|
14
|
+
@staticmethod
|
|
15
|
+
def set_credentials(fmc_base_url: str, fmc_username: str, fmc_password: str, sync_interval_minutes: int):
|
|
16
|
+
try:
|
|
17
|
+
keyring.set_password(SERVICE_NAME, "fmc_base_url", fmc_base_url)
|
|
18
|
+
keyring.set_password(SERVICE_NAME, "fmc_username", fmc_username)
|
|
19
|
+
keyring.set_password(SERVICE_NAME, "fmc_password", fmc_password)
|
|
20
|
+
keyring.set_password(SERVICE_NAME, "sync_interval_minutes", str(sync_interval_minutes))
|
|
21
|
+
log.info("Credentials and interval stored in OS keyring")
|
|
22
|
+
return True
|
|
23
|
+
except Exception as e:
|
|
24
|
+
log.error("Failed to store credentials: %s", e)
|
|
25
|
+
raise
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def get_credentials() -> dict:
|
|
29
|
+
try:
|
|
30
|
+
fmc_base_url = keyring.get_password(SERVICE_NAME, "fmc_base_url")
|
|
31
|
+
fmc_username = keyring.get_password(SERVICE_NAME, "fmc_username")
|
|
32
|
+
fmc_password = keyring.get_password(SERVICE_NAME, "fmc_password")
|
|
33
|
+
sync_interval_str = keyring.get_password(SERVICE_NAME, "sync_interval_minutes")
|
|
34
|
+
if not all([fmc_base_url, fmc_username, fmc_password, sync_interval_str]):
|
|
35
|
+
log.info("Credentials not found in keyring")
|
|
36
|
+
return None
|
|
37
|
+
return {"fmc_base_url": fmc_base_url, "fmc_username": fmc_username, "fmc_password": fmc_password, "sync_interval_minutes": int(sync_interval_str)}
|
|
38
|
+
except Exception as e:
|
|
39
|
+
log.error("Failed to retrieve credentials: %s", e)
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def clear_credentials():
|
|
44
|
+
try:
|
|
45
|
+
for key in ["fmc_base_url", "fmc_username", "fmc_password", "sync_interval_minutes"]:
|
|
46
|
+
try:
|
|
47
|
+
keyring.delete_password(SERVICE_NAME, key)
|
|
48
|
+
except keyring.errors.PasswordDeleteError:
|
|
49
|
+
pass
|
|
50
|
+
log.info("Credentials cleared from keyring")
|
|
51
|
+
return True
|
|
52
|
+
except Exception as e:
|
|
53
|
+
log.error("Failed to clear credentials: %s", e)
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
class ConfigManager:
|
|
57
|
+
@staticmethod
|
|
58
|
+
def ensure_directories():
|
|
59
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def get_config_dir() -> Path:
|
|
64
|
+
ConfigManager.ensure_directories()
|
|
65
|
+
return CONFIG_DIR
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def get_log_dir() -> Path:
|
|
69
|
+
ConfigManager.ensure_directories()
|
|
70
|
+
return LOG_DIR
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def get_log_file() -> Path:
|
|
74
|
+
return ConfigManager.get_log_dir() / "bunny2fmc.log"
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def load_dynamic_object_name() -> str:
|
|
78
|
+
try:
|
|
79
|
+
if CONFIG_FILE.exists():
|
|
80
|
+
with open(CONFIG_FILE, "r") as f:
|
|
81
|
+
data = json.load(f)
|
|
82
|
+
return data.get("fmc_dynamic_name")
|
|
83
|
+
except Exception as e:
|
|
84
|
+
log.warning("Failed to load config: %s", e)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def save_dynamic_object_name(name: str):
|
|
89
|
+
try:
|
|
90
|
+
ConfigManager.ensure_directories()
|
|
91
|
+
data = {}
|
|
92
|
+
if CONFIG_FILE.exists():
|
|
93
|
+
with open(CONFIG_FILE, "r") as f:
|
|
94
|
+
data = json.load(f)
|
|
95
|
+
data["fmc_dynamic_name"] = name
|
|
96
|
+
with open(CONFIG_FILE, "w") as f:
|
|
97
|
+
json.dump(data, f, indent=2)
|
|
98
|
+
log.info("Dynamic Object name saved")
|
|
99
|
+
except Exception as e:
|
|
100
|
+
log.error("Failed to save config: %s", e)
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def save_include_ipv6(include: bool):
|
|
105
|
+
try:
|
|
106
|
+
ConfigManager.ensure_directories()
|
|
107
|
+
data = {}
|
|
108
|
+
if CONFIG_FILE.exists():
|
|
109
|
+
with open(CONFIG_FILE, "r") as f:
|
|
110
|
+
data = json.load(f)
|
|
111
|
+
data["include_ipv6"] = include
|
|
112
|
+
with open(CONFIG_FILE, "w") as f:
|
|
113
|
+
json.dump(data, f, indent=2)
|
|
114
|
+
log.info("IPv6 preference saved")
|
|
115
|
+
except Exception as e:
|
|
116
|
+
log.error("Failed to save config: %s", e)
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def load_include_ipv6() -> bool:
|
|
121
|
+
try:
|
|
122
|
+
if CONFIG_FILE.exists():
|
|
123
|
+
with open(CONFIG_FILE, "r") as f:
|
|
124
|
+
data = json.load(f)
|
|
125
|
+
return data.get("include_ipv6", False)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
log.warning("Failed to load config: %s", e)
|
|
128
|
+
return False
|
bunny2fmc/sync_engine.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Core sync engine: Bunny edge IPs to FMC Dynamic Objects"""
|
|
4
|
+
import json, ipaddress, logging
|
|
5
|
+
from typing import List, Set, Iterable
|
|
6
|
+
import requests
|
|
7
|
+
from xml.etree import ElementTree as ET
|
|
8
|
+
from wingpy import CiscoFMC
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger("bunny2fmc")
|
|
11
|
+
BUNNY_IPV4_URL = "https://bunnycdn.com/api/system/edgeserverlist"
|
|
12
|
+
BUNNY_IPV6_URL = "https://bunnycdn.com/api/system/edgeserverlist/ipv6"
|
|
13
|
+
|
|
14
|
+
def _parse_possible_formats(body: str) -> List[str]:
|
|
15
|
+
try:
|
|
16
|
+
root = ET.fromstring(body)
|
|
17
|
+
vals = [el.text.strip() for el in root.findall(".//{*}string") if el.text]
|
|
18
|
+
if vals: return vals
|
|
19
|
+
except ET.ParseError: pass
|
|
20
|
+
try:
|
|
21
|
+
data = json.loads(body)
|
|
22
|
+
if isinstance(data, list): return [str(x).strip() for x in data if str(x).strip()]
|
|
23
|
+
except json.JSONDecodeError: pass
|
|
24
|
+
return [ln.strip() for ln in body.splitlines() if ln.strip()]
|
|
25
|
+
|
|
26
|
+
def fetch_bunny_ips(include_ipv6: bool, verify_ssl: bool = False) -> Set[str]:
|
|
27
|
+
sess = requests.Session()
|
|
28
|
+
ips: Set[str] = set()
|
|
29
|
+
endpoints = [BUNNY_IPV4_URL] + ([BUNNY_IPV6_URL] if include_ipv6 else [])
|
|
30
|
+
for url in endpoints:
|
|
31
|
+
log.info("Fetching %s", url)
|
|
32
|
+
try:
|
|
33
|
+
r = sess.get(url, timeout=60, verify=verify_ssl, headers={"Accept": "application/xml, application/json, text/plain"})
|
|
34
|
+
r.raise_for_status()
|
|
35
|
+
for c in _parse_possible_formats(r.text):
|
|
36
|
+
try:
|
|
37
|
+
if "/" in c: net = ipaddress.ip_network(c, strict=False)
|
|
38
|
+
else:
|
|
39
|
+
ip = ipaddress.ip_address(c)
|
|
40
|
+
net = ipaddress.ip_network(f"{ip}/{32 if ip.version == 4 else 128}", strict=False)
|
|
41
|
+
ips.add(str(net))
|
|
42
|
+
except ValueError: log.warning("Ignoring invalid Bunny entry: %r", c)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
log.error("Error fetching %s: %s", url, e)
|
|
45
|
+
raise
|
|
46
|
+
if not ips: raise RuntimeError("No IPs fetched from Bunny")
|
|
47
|
+
return ips
|
|
48
|
+
|
|
49
|
+
def get_all(fmc: CiscoFMC, path: str) -> List[dict]:
|
|
50
|
+
items: List[dict] = []
|
|
51
|
+
offset = 0
|
|
52
|
+
while True:
|
|
53
|
+
r = fmc.get(path + f"?offset={offset}&limit=1000&expanded=false")
|
|
54
|
+
r.raise_for_status()
|
|
55
|
+
batch = (r.json() or {}).get("items", [])
|
|
56
|
+
items.extend(batch)
|
|
57
|
+
if len(batch) < 1000: break
|
|
58
|
+
offset += 1000
|
|
59
|
+
return items
|
|
60
|
+
|
|
61
|
+
def chunked(iterable: Iterable[str], size: int) -> Iterable[List[str]]:
|
|
62
|
+
buf = []
|
|
63
|
+
for x in iterable:
|
|
64
|
+
buf.append(x)
|
|
65
|
+
if len(buf) == size: yield buf; buf = []
|
|
66
|
+
if buf: yield buf
|
|
67
|
+
|
|
68
|
+
def find_or_create_dynamic_object(fmc: CiscoFMC, name: str, description: str = "", dry_run: bool = False) -> dict:
|
|
69
|
+
items = get_all(fmc, "/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects")
|
|
70
|
+
obj = next((it for it in items if it.get("name") == name), None)
|
|
71
|
+
if obj:
|
|
72
|
+
log.info("Found existing Dynamic Object: %s (id=%s)", name, obj.get("id"))
|
|
73
|
+
return obj
|
|
74
|
+
payload = {"type": "DynamicObject", "name": name, "objectType": "IP", "description": description or "Managed automatically from BunnyCDN edge server list."}
|
|
75
|
+
if dry_run:
|
|
76
|
+
log.info("[DRY_RUN] Would create Dynamic Object '%s'.", name)
|
|
77
|
+
return {"id": "DRYRUN-ID", "name": name, "type": "DynamicObject", "objectType": "IP"}
|
|
78
|
+
r = fmc.post("/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects", data=payload)
|
|
79
|
+
r.raise_for_status()
|
|
80
|
+
result = r.json()
|
|
81
|
+
log.info("Created Dynamic Object: %s (id=%s)", name, result.get("id"))
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
def get_current_mappings(fmc: CiscoFMC, obj_id: str) -> Set[str]:
|
|
85
|
+
r = fmc.get("/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects/{objectId}/mappings", path_params={"objectId": obj_id})
|
|
86
|
+
r.raise_for_status()
|
|
87
|
+
data = r.json() or {}
|
|
88
|
+
if isinstance(data, dict):
|
|
89
|
+
if "mappings" in data and isinstance(data["mappings"], list):
|
|
90
|
+
return set(str(x).strip() for x in data["mappings"] if str(x).strip())
|
|
91
|
+
if "items" in data and isinstance(data["items"], list):
|
|
92
|
+
out = set()
|
|
93
|
+
for it in data["items"]:
|
|
94
|
+
if isinstance(it, str): out.add(it.strip())
|
|
95
|
+
elif isinstance(it, dict) and "value" in it: out.add(str(it["value"]).strip())
|
|
96
|
+
return out
|
|
97
|
+
if isinstance(data, list): return set(str(x).strip() for x in data if str(x).strip())
|
|
98
|
+
return set()
|
|
99
|
+
|
|
100
|
+
def post_mappings_update(fmc: CiscoFMC, add: List[str], remove: List[str], obj_id: str, chunk_size: int = 500, dry_run: bool = False):
|
|
101
|
+
if dry_run:
|
|
102
|
+
log.info("[DRY_RUN] Would ADD %d and REMOVE %d mappings.", len(add), len(remove))
|
|
103
|
+
return
|
|
104
|
+
endpoint = "/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjectmappings"
|
|
105
|
+
for batch in chunked(add, chunk_size):
|
|
106
|
+
payload = {"add": [{"mappings": batch, "dynamicObject": {"id": obj_id}}]}
|
|
107
|
+
r = fmc.post(endpoint, data=payload)
|
|
108
|
+
r.raise_for_status()
|
|
109
|
+
log.info("Added %d mappings.", len(batch))
|
|
110
|
+
for batch in chunked(remove, chunk_size):
|
|
111
|
+
payload = {"remove": [{"mappings": batch, "dynamicObject": {"id": obj_id}}]}
|
|
112
|
+
r = fmc.post(endpoint, data=payload)
|
|
113
|
+
r.raise_for_status()
|
|
114
|
+
log.info("Removed %d mappings.", len(batch))
|
|
115
|
+
|
|
116
|
+
def sync(fmc_base_url: str, fmc_username: str, fmc_password: str, fmc_dynamic_name: str, include_ipv6: bool = False, verify_ssl: bool = False, dry_run: bool = False, chunk_size: int = 500) -> dict:
|
|
117
|
+
try:
|
|
118
|
+
log.info("Starting sync for Dynamic Object: %s", fmc_dynamic_name)
|
|
119
|
+
bunny_nets = fetch_bunny_ips(include_ipv6, verify_ssl)
|
|
120
|
+
log.info("Bunny: %d networks", len(bunny_nets))
|
|
121
|
+
fmc = CiscoFMC(base_url=fmc_base_url, username=fmc_username, password=fmc_password, verify=verify_ssl)
|
|
122
|
+
dyn = find_or_create_dynamic_object(fmc, fmc_dynamic_name, description="Dynamic Object auto-managed from BunnyCDN edge server list.", dry_run=dry_run)
|
|
123
|
+
dyn_id = dyn["id"]
|
|
124
|
+
log.info("Dynamic Object: %s (id=%s)", fmc_dynamic_name, dyn_id)
|
|
125
|
+
current = get_current_mappings(fmc, dyn_id)
|
|
126
|
+
desired = bunny_nets
|
|
127
|
+
to_add, to_remove = sorted(desired - current), sorted(current - desired)
|
|
128
|
+
log.info("Current: %d, Desired: %d, +Add: %d, -Remove: %d", len(current), len(desired), len(to_add), len(to_remove))
|
|
129
|
+
if not to_add and not to_remove:
|
|
130
|
+
log.info("No changes needed.")
|
|
131
|
+
return {"status": "success", "message": "No changes needed", "added": 0, "removed": 0, "total_current": len(current), "total_desired": len(desired)}
|
|
132
|
+
post_mappings_update(fmc, to_add, to_remove, dyn_id, chunk_size, dry_run)
|
|
133
|
+
log.info("Sync completed successfully.")
|
|
134
|
+
return {"status": "success", "message": "Sync completed", "added": len(to_add), "removed": len(to_remove), "total_current": len(current), "total_desired": len(desired)}
|
|
135
|
+
except Exception as e:
|
|
136
|
+
log.error("Sync failed: %s", str(e), exc_info=True)
|
|
137
|
+
return {"status": "error", "message": str(e), "added": 0, "removed": 0}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bunny2fmc
|
|
3
|
+
Version: 1.0.8
|
|
4
|
+
Summary: Sync BunnyCDN edge IPs to Cisco FMC Dynamic Objects with secure credential management and scheduling
|
|
5
|
+
Author: Kasper Elsborg -Wingmen
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/wingmen/bunny-sync-fmc
|
|
8
|
+
Project-URL: Repository, https://github.com/wingmen/bunny-sync-fmc
|
|
9
|
+
Keywords: bunny,fmc,cisco,edge-server,dynamic-object
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Topic :: System :: Networking :: Firewalls
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: wingpy>=0.3.0
|
|
24
|
+
Requires-Dist: requests>=2.25.0
|
|
25
|
+
Requires-Dist: python-dotenv>=0.19.0
|
|
26
|
+
Requires-Dist: keyring>=23.0.0
|
|
27
|
+
Requires-Dist: keyrings.alt>=5.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
30
|
+
Requires-Dist: black>=22.0; extra == "dev"
|
|
31
|
+
Requires-Dist: flake8>=4.0; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# 🐰 bunny2fmc
|
|
35
|
+
|
|
36
|
+
Synkroniserer BunnyCDN edge server IP-adresser til en Cisco FMC Dynamic Object med sikker credential-gempling og automatisk scheduling.
|
|
37
|
+
|
|
38
|
+
## Hvad gør scriptet?
|
|
39
|
+
|
|
40
|
+
1. Henter aktuelle IPv4 (og evt. IPv6) adresser fra BunnyCDN's API
|
|
41
|
+
2. Opretter/finder Dynamic Object på FMC
|
|
42
|
+
3. Sammenligner nuværende mappings med Bunny's liste
|
|
43
|
+
4. Tilføjer nye og fjerner forældede IP'er
|
|
44
|
+
5. Ingen deploy nødvendig - Dynamic Objects opdateres on-the-fly
|
|
45
|
+
6. **Sikker gempling**: Credentials gemmesto i OS Keyring (Windows Credential Manager / Linux Secret Service)
|
|
46
|
+
7. **Automatisk scheduling**: Konfigurer interval ved første setup, køres via cron
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
### Option 1: Fra PyPI (Anbefalet)
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Opret virtual environment
|
|
56
|
+
python3 -m venv venv
|
|
57
|
+
|
|
58
|
+
# Aktivér venv (VIGTIG - skal gøres før hver brug!)
|
|
59
|
+
source venv/bin/activate # Linux/macOS
|
|
60
|
+
# eller
|
|
61
|
+
venv\Scripts\activate # Windows
|
|
62
|
+
|
|
63
|
+
# Installer bunny2fmc
|
|
64
|
+
pip install bunny2fmc
|
|
65
|
+
|
|
66
|
+
# Verificer installation
|
|
67
|
+
bunny2fmc --version
|
|
68
|
+
bunny2fmc --help
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Option 2: Fra source (Local development)
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Clone repository
|
|
75
|
+
cd /path/to/Bunny_Sync_FMC
|
|
76
|
+
|
|
77
|
+
# Opret virtual environment
|
|
78
|
+
python3 -m venv venv
|
|
79
|
+
|
|
80
|
+
# Aktivér venv (VIGTIG!)
|
|
81
|
+
source venv/bin/activate # Linux/macOS
|
|
82
|
+
# eller
|
|
83
|
+
venv\Scripts\activate # Windows
|
|
84
|
+
|
|
85
|
+
# Installer som development package
|
|
86
|
+
pip install -e .
|
|
87
|
+
|
|
88
|
+
# Verificer installation
|
|
89
|
+
bunny2fmc --version
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### ⚠️ VIGTIG: Aktivér venv før brug
|
|
93
|
+
|
|
94
|
+
**Du SKAL aktivere venv før du kan bruge bunny2fmc!**
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# Linux/macOS
|
|
98
|
+
source venv/bin/activate
|
|
99
|
+
|
|
100
|
+
# Windows
|
|
101
|
+
venv\Scripts\activate
|
|
102
|
+
|
|
103
|
+
# Nu kan du bruge kommandoen:
|
|
104
|
+
bunny2fmc --help
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Når venv er aktiveret, vises `(venv)` i din prompt
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Distribution Strategy
|
|
111
|
+
|
|
112
|
+
### Repository Struktur
|
|
113
|
+
|
|
114
|
+
- **Privat GitHub** (`https://github.com/IronKeyVault/Bunny_Sync_FMC`):
|
|
115
|
+
- Source of truth for alt udvikling
|
|
116
|
+
- Indeholder hele kodebase (gamle scripts, notes, configs)
|
|
117
|
+
- Kun for interne team medlemmer
|
|
118
|
+
|
|
119
|
+
- **PyPI** (`https://pypi.org/project/bunny2fmc`):
|
|
120
|
+
- Public distribution punkt
|
|
121
|
+
- Alle brugere kan installere med: `pip install bunny2fmc`
|
|
122
|
+
- Synkroniseret fra privat repo
|
|
123
|
+
|
|
124
|
+
### Release Workflow
|
|
125
|
+
|
|
126
|
+
Når du er klar til at udgive en ny version:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
./release.sh
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Scriptet vil:
|
|
133
|
+
1. Spørge for nyt versionsnummer (f.eks. 1.0.2)
|
|
134
|
+
2. Opdatere `pyproject.toml` og `bunny2fmc/__init__.py`
|
|
135
|
+
3. Committe og pushe til GitHub
|
|
136
|
+
4. Bygge pakken
|
|
137
|
+
5. Uploade til PyPI
|
|
138
|
+
|
|
139
|
+
**Eksempel:**
|
|
140
|
+
```
|
|
141
|
+
./release.sh
|
|
142
|
+
Enter new version (e.g., 1.0.2): 1.0.2
|
|
143
|
+
✓ Release v1.0.2 complete
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Hvornår kan jeg slette den gamle public repo?
|
|
149
|
+
|
|
150
|
+
Du kan slette `https://github.com/IronKeyVault/bunny2fmc` når:
|
|
151
|
+
|
|
152
|
+
1. ✅ **PyPI har den seneste version**
|
|
153
|
+
- Verificer på https://pypi.org/project/bunny2fmc/
|
|
154
|
+
- Alle nye releases går direkte til PyPI via `release.sh`
|
|
155
|
+
|
|
156
|
+
2. ✅ **Ingen users refererer til det**
|
|
157
|
+
- Hvis andre har installeringer fra Git URL, skal de updateres til:
|
|
158
|
+
- `pip install bunny2fmc` (fra PyPI i stedet)
|
|
159
|
+
|
|
160
|
+
3. ✅ **Du er sikker på at PyPI er det eneste public distribution point**
|
|
161
|
+
- Alt udvikling fortsætter i privat repo
|
|
162
|
+
- Releases håndteres via `./release.sh`
|
|
163
|
+
|
|
164
|
+
### Sletning
|
|
165
|
+
|
|
166
|
+
Når du er 100% sikker, kan du slette repoet:
|
|
167
|
+
|
|
168
|
+
1. Gå til https://github.com/IronKeyVault/bunny2fmc
|
|
169
|
+
2. Settings → Danger Zone → Delete Repository
|
|
170
|
+
3. Bekræft ved at skrive repo-navnet
|
|
171
|
+
|
|
172
|
+
**Efter sletning:**
|
|
173
|
+
- Alt udvikling/history ligger stadig på privat repo
|
|
174
|
+
- Alle brugere bruger PyPI (bedst praksis)
|
|
175
|
+
- Enklere repository-management
|
|
176
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
bunny2fmc/__init__.py,sha256=fVsHsYtI5haE_NeyaMKlYiaAROUctiIYibLRdvIyPTg,133
|
|
2
|
+
bunny2fmc/cli.py,sha256=p8Mhve3L9_6xEtu2712LLp7S3nC8hfyXJBc-wmC7UtY,10111
|
|
3
|
+
bunny2fmc/config.py,sha256=rvpkAGZv2NaRChXyO9u4dnQl5wnvExToMvlCIJZMlpQ,4803
|
|
4
|
+
bunny2fmc/sync_engine.py,sha256=zCAJ0LrKgHQ6zOWHVOfTvvlHgNkILYyFq0ftdPC3Pt4,7024
|
|
5
|
+
bunny2fmc-1.0.8.dist-info/licenses/LICENSE,sha256=2_vaDeZJ15RDU_6C1r5IJ7urPUt4hHeMrUd92xwWcwQ,1080
|
|
6
|
+
bunny2fmc-1.0.8.dist-info/METADATA,sha256=EQXJepPA3AAa1Q38W-o_DPQx7fcTVUH0vC4_BpB05d8,4766
|
|
7
|
+
bunny2fmc-1.0.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
bunny2fmc-1.0.8.dist-info/entry_points.txt,sha256=ZjZFEAAugvnna7HHbYcfivJGJway3qgM-S-B2lf9akI,49
|
|
9
|
+
bunny2fmc-1.0.8.dist-info/top_level.txt,sha256=7a2i3_fKlFFNKUmSEDFbwXI_CXPEcUsrIisHNLEAXtM,10
|
|
10
|
+
bunny2fmc-1.0.8.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kasper Elsborg -Wingmen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bunny2fmc
|