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/config.py
CHANGED
|
@@ -6,7 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
log = logging.getLogger("bunny2fmc")
|
|
8
8
|
SERVICE_NAME = "bunny2fmc"
|
|
9
|
-
CONFIG_DIR = Path.home() / "
|
|
9
|
+
CONFIG_DIR = Path.home() / "bunny2fmc"
|
|
10
10
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
11
11
|
LOG_DIR = CONFIG_DIR / "logs"
|
|
12
12
|
|
|
@@ -126,3 +126,154 @@ class ConfigManager:
|
|
|
126
126
|
except Exception as e:
|
|
127
127
|
log.warning("Failed to load config: %s", e)
|
|
128
128
|
return False
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def load_fmc_base_url() -> str:
|
|
132
|
+
"""Load FMC base URL from config file"""
|
|
133
|
+
try:
|
|
134
|
+
if CONFIG_FILE.exists():
|
|
135
|
+
with open(CONFIG_FILE, "r") as f:
|
|
136
|
+
data = json.load(f)
|
|
137
|
+
return data.get("fmc_base_url")
|
|
138
|
+
except Exception as e:
|
|
139
|
+
log.warning("Failed to load config: %s", e)
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def save_fmc_base_url(url: str):
|
|
144
|
+
"""Save FMC base URL to config file"""
|
|
145
|
+
try:
|
|
146
|
+
ConfigManager.ensure_directories()
|
|
147
|
+
data = {}
|
|
148
|
+
if CONFIG_FILE.exists():
|
|
149
|
+
with open(CONFIG_FILE, "r") as f:
|
|
150
|
+
data = json.load(f)
|
|
151
|
+
data["fmc_base_url"] = url
|
|
152
|
+
with open(CONFIG_FILE, "w") as f:
|
|
153
|
+
json.dump(data, f, indent=2)
|
|
154
|
+
log.info("FMC base URL saved")
|
|
155
|
+
except Exception as e:
|
|
156
|
+
log.error("Failed to save config: %s", e)
|
|
157
|
+
raise
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def load_fmc_username() -> str:
|
|
161
|
+
"""Load FMC username from config file"""
|
|
162
|
+
try:
|
|
163
|
+
if CONFIG_FILE.exists():
|
|
164
|
+
with open(CONFIG_FILE, "r") as f:
|
|
165
|
+
data = json.load(f)
|
|
166
|
+
return data.get("fmc_username")
|
|
167
|
+
except Exception as e:
|
|
168
|
+
log.warning("Failed to load config: %s", e)
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def save_fmc_username(username: str):
|
|
173
|
+
"""Save FMC username to config file"""
|
|
174
|
+
try:
|
|
175
|
+
ConfigManager.ensure_directories()
|
|
176
|
+
data = {}
|
|
177
|
+
if CONFIG_FILE.exists():
|
|
178
|
+
with open(CONFIG_FILE, "r") as f:
|
|
179
|
+
data = json.load(f)
|
|
180
|
+
data["fmc_username"] = username
|
|
181
|
+
with open(CONFIG_FILE, "w") as f:
|
|
182
|
+
json.dump(data, f, indent=2)
|
|
183
|
+
log.info("FMC username saved")
|
|
184
|
+
except Exception as e:
|
|
185
|
+
log.error("Failed to save config: %s", e)
|
|
186
|
+
raise
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def load_sync_interval_minutes() -> int:
|
|
190
|
+
"""Load sync interval from config file"""
|
|
191
|
+
try:
|
|
192
|
+
if CONFIG_FILE.exists():
|
|
193
|
+
with open(CONFIG_FILE, "r") as f:
|
|
194
|
+
data = json.load(f)
|
|
195
|
+
interval = data.get("sync_interval_minutes")
|
|
196
|
+
return int(interval) if interval else None
|
|
197
|
+
except Exception as e:
|
|
198
|
+
log.warning("Failed to load config: %s", e)
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def save_sync_interval_minutes(minutes: int):
|
|
203
|
+
"""Save sync interval to config file"""
|
|
204
|
+
try:
|
|
205
|
+
ConfigManager.ensure_directories()
|
|
206
|
+
data = {}
|
|
207
|
+
if CONFIG_FILE.exists():
|
|
208
|
+
with open(CONFIG_FILE, "r") as f:
|
|
209
|
+
data = json.load(f)
|
|
210
|
+
data["sync_interval_minutes"] = minutes
|
|
211
|
+
with open(CONFIG_FILE, "w") as f:
|
|
212
|
+
json.dump(data, f, indent=2)
|
|
213
|
+
log.info("Sync interval saved")
|
|
214
|
+
except Exception as e:
|
|
215
|
+
log.error("Failed to save config: %s", e)
|
|
216
|
+
raise
|
|
217
|
+
|
|
218
|
+
# Default Bunny CDN API URLs
|
|
219
|
+
DEFAULT_BUNNY_IPV4_URL = "https://bunnycdn.com/api/system/edgeserverlist"
|
|
220
|
+
DEFAULT_BUNNY_IPV6_URL = "https://bunnycdn.com/api/system/edgeserverlist/ipv6"
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def load_bunny_ipv4_url() -> str:
|
|
224
|
+
"""Load Bunny IPv4 URL from config file"""
|
|
225
|
+
try:
|
|
226
|
+
if CONFIG_FILE.exists():
|
|
227
|
+
with open(CONFIG_FILE, "r") as f:
|
|
228
|
+
data = json.load(f)
|
|
229
|
+
return data.get("bunny_ipv4_url", ConfigManager.DEFAULT_BUNNY_IPV4_URL)
|
|
230
|
+
except Exception as e:
|
|
231
|
+
log.warning("Failed to load config: %s", e)
|
|
232
|
+
return ConfigManager.DEFAULT_BUNNY_IPV4_URL
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def save_bunny_ipv4_url(url: str):
|
|
236
|
+
"""Save Bunny IPv4 URL to config file"""
|
|
237
|
+
try:
|
|
238
|
+
ConfigManager.ensure_directories()
|
|
239
|
+
data = {}
|
|
240
|
+
if CONFIG_FILE.exists():
|
|
241
|
+
with open(CONFIG_FILE, "r") as f:
|
|
242
|
+
data = json.load(f)
|
|
243
|
+
data["bunny_ipv4_url"] = url
|
|
244
|
+
with open(CONFIG_FILE, "w") as f:
|
|
245
|
+
json.dump(data, f, indent=2)
|
|
246
|
+
log.info("Bunny IPv4 URL saved")
|
|
247
|
+
except Exception as e:
|
|
248
|
+
log.error("Failed to save config: %s", e)
|
|
249
|
+
raise
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def load_bunny_ipv6_url() -> str:
|
|
253
|
+
"""Load Bunny IPv6 URL from config file"""
|
|
254
|
+
try:
|
|
255
|
+
if CONFIG_FILE.exists():
|
|
256
|
+
with open(CONFIG_FILE, "r") as f:
|
|
257
|
+
data = json.load(f)
|
|
258
|
+
return data.get("bunny_ipv6_url", ConfigManager.DEFAULT_BUNNY_IPV6_URL)
|
|
259
|
+
except Exception as e:
|
|
260
|
+
log.warning("Failed to load config: %s", e)
|
|
261
|
+
return ConfigManager.DEFAULT_BUNNY_IPV6_URL
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def save_bunny_ipv6_url(url: str):
|
|
265
|
+
"""Save Bunny IPv6 URL to config file"""
|
|
266
|
+
try:
|
|
267
|
+
ConfigManager.ensure_directories()
|
|
268
|
+
data = {}
|
|
269
|
+
if CONFIG_FILE.exists():
|
|
270
|
+
with open(CONFIG_FILE, "r") as f:
|
|
271
|
+
data = json.load(f)
|
|
272
|
+
data["bunny_ipv6_url"] = url
|
|
273
|
+
with open(CONFIG_FILE, "w") as f:
|
|
274
|
+
json.dump(data, f, indent=2)
|
|
275
|
+
log.info("Bunny IPv6 URL saved")
|
|
276
|
+
except Exception as e:
|
|
277
|
+
log.error("Failed to save config: %s", e)
|
|
278
|
+
raise
|
|
279
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Post-install helper to copy documentation to ~/bunny2fmc/guide/."""
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
def setup_docs():
|
|
6
|
+
"""Copy guide documentation files from package to ~/bunny2fmc/guide/."""
|
|
7
|
+
package_dir = Path(__file__).parent.parent
|
|
8
|
+
guide_dir = Path.home() / "bunny2fmc" / "guide"
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
guide_dir.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
except Exception as e:
|
|
13
|
+
print(f"⚠️ Kunne ikke oprette {guide_dir}: {e}")
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
guide_files = ['guide/PREINSTALL.md', 'guide/INSTALL.md', 'guide/QUICK_START.md', 'guide/README.md']
|
|
17
|
+
|
|
18
|
+
for guide_file in guide_files:
|
|
19
|
+
src = package_dir / guide_file
|
|
20
|
+
dst = guide_dir / Path(guide_file).name
|
|
21
|
+
if src.exists():
|
|
22
|
+
try:
|
|
23
|
+
shutil.copy2(src, dst)
|
|
24
|
+
print(f"✓ {Path(guide_file).name}")
|
|
25
|
+
except Exception as e:
|
|
26
|
+
print(f"⚠️ {Path(guide_file).name}: {e}")
|
|
27
|
+
|
|
28
|
+
print(f"\n📚 Guides: {guide_dir}/")
|
|
29
|
+
|
|
30
|
+
if __name__ == '__main__':
|
|
31
|
+
setup_docs()
|
bunny2fmc/sync_engine.py
CHANGED
|
@@ -1,137 +1,343 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
|
-
"""Core sync engine: Bunny edge IPs to FMC Dynamic Objects
|
|
4
|
-
|
|
3
|
+
"""Core sync engine: Bunny edge IPs to FMC Dynamic Objects
|
|
4
|
+
|
|
5
|
+
Uses wingpy's CiscoFMC client which supports authentication via:
|
|
6
|
+
- Direct parameters: CiscoFMC(base_url=..., username=..., password=...)
|
|
7
|
+
- Environment variables: WINGPY_FMC_BASE_URL, WINGPY_FMC_USERNAME, WINGPY_FMC_PASSWORD
|
|
8
|
+
|
|
9
|
+
See: https://wingpy.automation.wingmen.dk/faq/#environment-variables
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import ipaddress
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
5
15
|
from typing import List, Set, Iterable
|
|
16
|
+
|
|
6
17
|
import requests
|
|
7
18
|
from xml.etree import ElementTree as ET
|
|
8
19
|
from wingpy import CiscoFMC
|
|
9
20
|
|
|
10
21
|
log = logging.getLogger("bunny2fmc")
|
|
22
|
+
|
|
11
23
|
BUNNY_IPV4_URL = "https://bunnycdn.com/api/system/edgeserverlist"
|
|
12
24
|
BUNNY_IPV6_URL = "https://bunnycdn.com/api/system/edgeserverlist/ipv6"
|
|
13
25
|
|
|
26
|
+
|
|
14
27
|
def _parse_possible_formats(body: str) -> List[str]:
|
|
28
|
+
"""Parse Bunny API response (XML, JSON, or plain text)"""
|
|
29
|
+
# Try XML first
|
|
15
30
|
try:
|
|
16
31
|
root = ET.fromstring(body)
|
|
17
32
|
vals = [el.text.strip() for el in root.findall(".//{*}string") if el.text]
|
|
18
|
-
if vals:
|
|
19
|
-
|
|
33
|
+
if vals:
|
|
34
|
+
return vals
|
|
35
|
+
except ET.ParseError:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
# Try JSON
|
|
20
39
|
try:
|
|
21
40
|
data = json.loads(body)
|
|
22
|
-
if isinstance(data, list):
|
|
23
|
-
|
|
41
|
+
if isinstance(data, list):
|
|
42
|
+
return [str(x).strip() for x in data if str(x).strip()]
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
# Fallback to plain text (one IP per line)
|
|
24
47
|
return [ln.strip() for ln in body.splitlines() if ln.strip()]
|
|
25
48
|
|
|
26
|
-
|
|
49
|
+
|
|
50
|
+
def normalize_ip(ip_str: str) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Normalize IP address to plain IP format (no CIDR for single hosts).
|
|
53
|
+
|
|
54
|
+
FMC Dynamic Object mappings store IPs without CIDR notation for /32 and /128.
|
|
55
|
+
- Input: "1.2.3.4/32" or "1.2.3.4" → Output: "1.2.3.4"
|
|
56
|
+
- Input: "2001:db8::1/128" → Output: "2001:db8::1"
|
|
57
|
+
- Input: "10.0.0.0/24" → Output: "10.0.0.0/24" (keep CIDR for subnets)
|
|
58
|
+
"""
|
|
59
|
+
ip_str = ip_str.strip()
|
|
60
|
+
|
|
61
|
+
if "/" in ip_str:
|
|
62
|
+
try:
|
|
63
|
+
net = ipaddress.ip_network(ip_str, strict=False)
|
|
64
|
+
# If it's a single host (/32 or /128), return just the IP
|
|
65
|
+
if (net.version == 4 and net.prefixlen == 32) or \
|
|
66
|
+
(net.version == 6 and net.prefixlen == 128):
|
|
67
|
+
return str(net.network_address)
|
|
68
|
+
# Otherwise keep the CIDR notation
|
|
69
|
+
return str(net)
|
|
70
|
+
except ValueError:
|
|
71
|
+
return ip_str
|
|
72
|
+
else:
|
|
73
|
+
# Already plain IP
|
|
74
|
+
return ip_str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def fetch_bunny_ips(include_ipv6: bool, verify_ssl: bool = False, ipv4_url: str = None, ipv6_url: str = None) -> Set[str]:
|
|
78
|
+
"""Fetch current Bunny CDN edge server IP addresses"""
|
|
27
79
|
sess = requests.Session()
|
|
28
80
|
ips: Set[str] = set()
|
|
29
|
-
|
|
81
|
+
bunny_v4 = ipv4_url or BUNNY_IPV4_URL
|
|
82
|
+
bunny_v6 = ipv6_url or BUNNY_IPV6_URL
|
|
83
|
+
endpoints = [bunny_v4] + ([bunny_v6] if include_ipv6 else [])
|
|
84
|
+
|
|
30
85
|
for url in endpoints:
|
|
31
86
|
log.info("Fetching %s", url)
|
|
32
87
|
try:
|
|
33
|
-
r = sess.get(
|
|
88
|
+
r = sess.get(
|
|
89
|
+
url,
|
|
90
|
+
timeout=60,
|
|
91
|
+
verify=verify_ssl,
|
|
92
|
+
headers={"Accept": "application/xml, application/json, text/plain"},
|
|
93
|
+
)
|
|
34
94
|
r.raise_for_status()
|
|
95
|
+
|
|
35
96
|
for c in _parse_possible_formats(r.text):
|
|
36
97
|
try:
|
|
37
|
-
|
|
98
|
+
# Parse and normalize the IP
|
|
99
|
+
if "/" in c:
|
|
100
|
+
net = ipaddress.ip_network(c, strict=False)
|
|
38
101
|
else:
|
|
39
102
|
ip = ipaddress.ip_address(c)
|
|
40
|
-
net = ipaddress.ip_network(
|
|
41
|
-
|
|
42
|
-
|
|
103
|
+
net = ipaddress.ip_network(
|
|
104
|
+
f"{ip}/{32 if ip.version == 4 else 128}", strict=False
|
|
105
|
+
)
|
|
106
|
+
# Normalize: single hosts without /32 or /128
|
|
107
|
+
ips.add(normalize_ip(str(net)))
|
|
108
|
+
except ValueError:
|
|
109
|
+
log.warning("Ignoring invalid Bunny entry: %r", c)
|
|
43
110
|
except Exception as e:
|
|
44
111
|
log.error("Error fetching %s: %s", url, e)
|
|
45
112
|
raise
|
|
46
|
-
|
|
113
|
+
|
|
114
|
+
if not ips:
|
|
115
|
+
raise RuntimeError("No IPs fetched from Bunny")
|
|
47
116
|
return ips
|
|
48
117
|
|
|
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
118
|
|
|
61
119
|
def chunked(iterable: Iterable[str], size: int) -> Iterable[List[str]]:
|
|
120
|
+
"""Split iterable into chunks of given size"""
|
|
62
121
|
buf = []
|
|
63
122
|
for x in iterable:
|
|
64
123
|
buf.append(x)
|
|
65
|
-
if len(buf) == size:
|
|
66
|
-
|
|
124
|
+
if len(buf) == size:
|
|
125
|
+
yield buf
|
|
126
|
+
buf = []
|
|
127
|
+
if buf:
|
|
128
|
+
yield buf
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def find_or_create_dynamic_object(
|
|
132
|
+
fmc: CiscoFMC, name: str, description: str = "", dry_run: bool = False
|
|
133
|
+
) -> dict:
|
|
134
|
+
"""Find existing Dynamic Object or create a new one"""
|
|
135
|
+
# Use wingpy's get_all for automatic pagination
|
|
136
|
+
items = fmc.get_all(
|
|
137
|
+
"/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects"
|
|
138
|
+
)
|
|
67
139
|
|
|
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
140
|
obj = next((it for it in items if it.get("name") == name), None)
|
|
71
141
|
if obj:
|
|
72
142
|
log.info("Found existing Dynamic Object: %s (id=%s)", name, obj.get("id"))
|
|
73
143
|
return obj
|
|
74
|
-
|
|
144
|
+
|
|
145
|
+
# Create new Dynamic Object
|
|
146
|
+
payload = {
|
|
147
|
+
"type": "DynamicObject",
|
|
148
|
+
"name": name,
|
|
149
|
+
"objectType": "IP",
|
|
150
|
+
"description": description
|
|
151
|
+
or "Managed automatically from BunnyCDN edge server list.",
|
|
152
|
+
}
|
|
153
|
+
|
|
75
154
|
if dry_run:
|
|
76
155
|
log.info("[DRY_RUN] Would create Dynamic Object '%s'.", name)
|
|
77
|
-
return {
|
|
78
|
-
|
|
156
|
+
return {
|
|
157
|
+
"id": "DRYRUN-ID",
|
|
158
|
+
"name": name,
|
|
159
|
+
"type": "DynamicObject",
|
|
160
|
+
"objectType": "IP",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
r = fmc.post(
|
|
164
|
+
"/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects", data=payload
|
|
165
|
+
)
|
|
79
166
|
r.raise_for_status()
|
|
80
167
|
result = r.json()
|
|
81
168
|
log.info("Created Dynamic Object: %s (id=%s)", name, result.get("id"))
|
|
82
169
|
return result
|
|
83
170
|
|
|
171
|
+
|
|
84
172
|
def get_current_mappings(fmc: CiscoFMC, obj_id: str) -> Set[str]:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
173
|
+
"""
|
|
174
|
+
Get current IP mappings for a Dynamic Object.
|
|
175
|
+
|
|
176
|
+
FMC returns paginated results with format:
|
|
177
|
+
{
|
|
178
|
+
"items": [{"mapping": "1.2.3.4"}, {"mapping": "5.6.7.8"}],
|
|
179
|
+
"paging": {"count": 594, "offset": 0, "limit": 25}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
We need to fetch all pages and extract the "mapping" field.
|
|
183
|
+
"""
|
|
184
|
+
mappings: Set[str] = set()
|
|
185
|
+
offset = 0
|
|
186
|
+
limit = 1000 # Max per page
|
|
187
|
+
|
|
188
|
+
while True:
|
|
189
|
+
r = fmc.get(
|
|
190
|
+
"/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects/{objectId}/mappings",
|
|
191
|
+
path_params={"objectId": obj_id},
|
|
192
|
+
params={"offset": offset, "limit": limit},
|
|
193
|
+
)
|
|
194
|
+
r.raise_for_status()
|
|
195
|
+
data = r.json() or {}
|
|
196
|
+
|
|
197
|
+
items = data.get("items", [])
|
|
198
|
+
for item in items:
|
|
199
|
+
if isinstance(item, dict):
|
|
200
|
+
# FMC format: {"mapping": "1.2.3.4"}
|
|
201
|
+
if "mapping" in item:
|
|
202
|
+
mappings.add(normalize_ip(str(item["mapping"])))
|
|
203
|
+
# Alternative format: {"value": "1.2.3.4"}
|
|
204
|
+
elif "value" in item:
|
|
205
|
+
mappings.add(normalize_ip(str(item["value"])))
|
|
206
|
+
elif isinstance(item, str):
|
|
207
|
+
mappings.add(normalize_ip(item))
|
|
208
|
+
|
|
209
|
+
# Check if there are more pages
|
|
210
|
+
paging = data.get("paging", {})
|
|
211
|
+
total_count = paging.get("count", 0)
|
|
212
|
+
|
|
213
|
+
if offset + len(items) >= total_count:
|
|
214
|
+
break
|
|
215
|
+
offset += limit
|
|
216
|
+
|
|
217
|
+
log.debug("Retrieved %d current mappings from FMC", len(mappings))
|
|
218
|
+
return mappings
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def post_mappings_update(
|
|
222
|
+
fmc: CiscoFMC,
|
|
223
|
+
add: List[str],
|
|
224
|
+
remove: List[str],
|
|
225
|
+
obj_id: str,
|
|
226
|
+
chunk_size: int = 500,
|
|
227
|
+
bunny_ipv4_url: str = None,
|
|
228
|
+
bunny_ipv6_url: str = None,
|
|
229
|
+
dry_run: bool = False,
|
|
230
|
+
):
|
|
231
|
+
"""Add/remove IP mappings in chunks"""
|
|
101
232
|
if dry_run:
|
|
102
233
|
log.info("[DRY_RUN] Would ADD %d and REMOVE %d mappings.", len(add), len(remove))
|
|
103
234
|
return
|
|
235
|
+
|
|
104
236
|
endpoint = "/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjectmappings"
|
|
237
|
+
|
|
105
238
|
for batch in chunked(add, chunk_size):
|
|
106
239
|
payload = {"add": [{"mappings": batch, "dynamicObject": {"id": obj_id}}]}
|
|
107
240
|
r = fmc.post(endpoint, data=payload)
|
|
108
241
|
r.raise_for_status()
|
|
109
242
|
log.info("Added %d mappings.", len(batch))
|
|
243
|
+
|
|
110
244
|
for batch in chunked(remove, chunk_size):
|
|
111
245
|
payload = {"remove": [{"mappings": batch, "dynamicObject": {"id": obj_id}}]}
|
|
112
246
|
r = fmc.post(endpoint, data=payload)
|
|
113
247
|
r.raise_for_status()
|
|
114
248
|
log.info("Removed %d mappings.", len(batch))
|
|
115
249
|
|
|
116
|
-
|
|
250
|
+
|
|
251
|
+
def sync(
|
|
252
|
+
fmc_base_url: str,
|
|
253
|
+
fmc_username: str,
|
|
254
|
+
fmc_password: str,
|
|
255
|
+
fmc_dynamic_name: str,
|
|
256
|
+
include_ipv6: bool = False,
|
|
257
|
+
verify_ssl: bool = False,
|
|
258
|
+
dry_run: bool = False,
|
|
259
|
+
chunk_size: int = 500,
|
|
260
|
+
bunny_ipv4_url: str = None,
|
|
261
|
+
bunny_ipv6_url: str = None,
|
|
262
|
+
) -> dict:
|
|
263
|
+
"""
|
|
264
|
+
Main sync function: Fetches Bunny IPs and updates FMC Dynamic Object.
|
|
265
|
+
|
|
266
|
+
Authentication is handled by wingpy's CiscoFMC client:
|
|
267
|
+
- First request: authenticates with username/password to get access token
|
|
268
|
+
- Subsequent requests: uses access token automatically
|
|
269
|
+
- Token refresh: handled automatically by wingpy
|
|
270
|
+
|
|
271
|
+
See: https://wingpy.automation.wingmen.dk/faq/#session-maintenance
|
|
272
|
+
"""
|
|
117
273
|
try:
|
|
118
274
|
log.info("Starting sync for Dynamic Object: %s", fmc_dynamic_name)
|
|
119
|
-
|
|
275
|
+
|
|
276
|
+
# Fetch current Bunny CDN IPs
|
|
277
|
+
bunny_nets = fetch_bunny_ips(include_ipv6, verify_ssl, bunny_ipv4_url, bunny_ipv6_url)
|
|
120
278
|
log.info("Bunny: %d networks", len(bunny_nets))
|
|
121
|
-
|
|
122
|
-
|
|
279
|
+
|
|
280
|
+
# Connect to FMC using wingpy
|
|
281
|
+
# wingpy handles token-based authentication automatically:
|
|
282
|
+
# - First call: POST /api/fmc_platform/v1/auth/generatetoken with Basic Auth
|
|
283
|
+
# - Returns X-auth-access-token (valid 30 min)
|
|
284
|
+
# - All subsequent calls use this token
|
|
285
|
+
# See: https://wingpy.automation.wingmen.dk/user-guide/fmc/
|
|
286
|
+
fmc = CiscoFMC(
|
|
287
|
+
base_url=fmc_base_url,
|
|
288
|
+
username=fmc_username,
|
|
289
|
+
password=fmc_password,
|
|
290
|
+
verify=verify_ssl,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Find or create the Dynamic Object
|
|
294
|
+
dyn = find_or_create_dynamic_object(
|
|
295
|
+
fmc,
|
|
296
|
+
fmc_dynamic_name,
|
|
297
|
+
description="Dynamic Object auto-managed from BunnyCDN edge server list.",
|
|
298
|
+
dry_run=dry_run,
|
|
299
|
+
)
|
|
123
300
|
dyn_id = dyn["id"]
|
|
124
301
|
log.info("Dynamic Object: %s (id=%s)", fmc_dynamic_name, dyn_id)
|
|
302
|
+
|
|
303
|
+
# Get current mappings and calculate diff
|
|
125
304
|
current = get_current_mappings(fmc, dyn_id)
|
|
126
305
|
desired = bunny_nets
|
|
127
|
-
to_add
|
|
128
|
-
|
|
306
|
+
to_add = sorted(desired - current)
|
|
307
|
+
to_remove = sorted(current - desired)
|
|
308
|
+
|
|
309
|
+
log.info(
|
|
310
|
+
"Current: %d, Desired: %d, +Add: %d, -Remove: %d",
|
|
311
|
+
len(current),
|
|
312
|
+
len(desired),
|
|
313
|
+
len(to_add),
|
|
314
|
+
len(to_remove),
|
|
315
|
+
)
|
|
316
|
+
|
|
129
317
|
if not to_add and not to_remove:
|
|
130
318
|
log.info("No changes needed.")
|
|
131
|
-
return {
|
|
319
|
+
return {
|
|
320
|
+
"status": "success",
|
|
321
|
+
"message": "No changes needed",
|
|
322
|
+
"added": 0,
|
|
323
|
+
"removed": 0,
|
|
324
|
+
"total_current": len(current),
|
|
325
|
+
"total_desired": len(desired),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
# Apply changes
|
|
132
329
|
post_mappings_update(fmc, to_add, to_remove, dyn_id, chunk_size, dry_run)
|
|
133
330
|
log.info("Sync completed successfully.")
|
|
134
|
-
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
"status": "success",
|
|
334
|
+
"message": "Sync completed",
|
|
335
|
+
"added": len(to_add),
|
|
336
|
+
"removed": len(to_remove),
|
|
337
|
+
"total_current": len(current),
|
|
338
|
+
"total_desired": len(desired),
|
|
339
|
+
}
|
|
340
|
+
|
|
135
341
|
except Exception as e:
|
|
136
342
|
log.error("Sync failed: %s", str(e), exc_info=True)
|
|
137
343
|
return {"status": "error", "message": str(e), "added": 0, "removed": 0}
|