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 ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ bunny2fmc: Sync BunnyCDN edge IPs to Cisco FMC Dynamic Objects
3
+ """
4
+
5
+ __version__ = "1.0.8"
6
+ __author__ = "Kasper Elsborg -Wingmen"
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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bunny2fmc = bunny2fmc.cli:main
@@ -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