packet-tracer-skill 0.2.2 → 0.2.3

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.
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import copy
4
+ import hashlib
4
5
  import json
5
6
  import re
6
7
  from pathlib import Path
@@ -189,6 +190,88 @@ def inventory_iot(root: ET.Element) -> dict[str, dict[str, object]]:
189
190
  return result
190
191
 
191
192
 
193
+ def _script_language(app_name: str, file_name: str, content: str) -> str:
194
+ lowered = " ".join([app_name.lower(), file_name.lower(), content[:500].lower()])
195
+ if file_name.lower().endswith(".py") or "python" in lowered:
196
+ return "python"
197
+ if file_name.lower().endswith(".js") or "javascript" in lowered:
198
+ return "javascript"
199
+ if file_name.lower().endswith(".visual") or "<xml" in lowered or "blockly" in lowered:
200
+ return "visual"
201
+ return "unknown"
202
+
203
+
204
+ def _script_feature_tags(app_name: str, file_name: str, content: str) -> list[str]:
205
+ lowered = " ".join([app_name.lower(), file_name.lower(), content.lower()])
206
+ tags: set[str] = set()
207
+ if "mqtt" in lowered:
208
+ tags.add("mqtt")
209
+ if "realhttp" in lowered or "real http" in lowered:
210
+ tags.add("real_http")
211
+ if "realws" in lowered or "websocket" in lowered:
212
+ tags.add("real_websocket")
213
+ language = _script_language(app_name, file_name, content)
214
+ if language == "python":
215
+ tags.add("python_programming")
216
+ if language == "javascript":
217
+ tags.add("javascript_programming")
218
+ if language == "visual":
219
+ tags.add("blockly_programming")
220
+ if "tcp" in lowered or "udp" in lowered:
221
+ tags.add("tcp_udp_app")
222
+ return sorted(tags)
223
+
224
+
225
+ def _file_text(file_node: ET.Element) -> str:
226
+ return file_node.findtext("./FILE_CONTENT/TEXT", default="")
227
+
228
+
229
+ def inventory_programming(root: ET.Element) -> dict[str, dict[str, object]]:
230
+ result: dict[str, dict[str, object]] = {}
231
+ for device in root.findall(".//DEVICES/DEVICE"):
232
+ device_name = device.findtext("./ENGINE/NAME", default="")
233
+ apps: list[dict[str, object]] = []
234
+ for directory in device.findall(".//FILE[@class='CDirectory']"):
235
+ app_name = directory.findtext("NAME", default="").strip()
236
+ if not app_name:
237
+ continue
238
+ files: list[dict[str, object]] = []
239
+ app_tags: set[str] = set()
240
+ for file_node in directory.findall(".//FILE[@class='CFile']"):
241
+ file_name = file_node.findtext("NAME", default="").strip()
242
+ content = _file_text(file_node)
243
+ if not file_name or not content:
244
+ continue
245
+ language = _script_language(app_name, file_name, content)
246
+ feature_tags = _script_feature_tags(app_name, file_name, content)
247
+ app_tags.update(feature_tags)
248
+ files.append(
249
+ {
250
+ "name": file_name,
251
+ "language": language,
252
+ "content_length": len(content),
253
+ "content_sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
254
+ "feature_tags": feature_tags,
255
+ }
256
+ )
257
+ if files:
258
+ apps.append(
259
+ {
260
+ "app_name": app_name,
261
+ "file_count": len(files),
262
+ "feature_tags": sorted(app_tags),
263
+ "files": files,
264
+ }
265
+ )
266
+ if apps:
267
+ result[device_name] = {
268
+ "app_count": len(apps),
269
+ "feature_tags": sorted({tag for app in apps for tag in list(app.get("feature_tags", []))}),
270
+ "apps": apps,
271
+ }
272
+ return result
273
+
274
+
192
275
  def inventory_vlans(root: ET.Element) -> dict[str, list[dict[str, str]]]:
193
276
  result: dict[str, list[dict[str, str]]] = {}
194
277
  for device in root.findall(".//DEVICES/DEVICE"):
@@ -297,6 +380,60 @@ def inventory_routing(root: ET.Element) -> dict[str, dict[str, object]]:
297
380
  capabilities.add("ripng")
298
381
  if re.search(r"(?mi)^\s*standby\s+\d+\s+ipv6\b", running):
299
382
  capabilities.add("hsrp")
383
+ if re.search(r"(?mi)^\s*interface\s+Tunnel\d+\b", running) or re.search(r"(?mi)^\s*tunnel\s+(?:source|destination|mode\s+gre)\b", running):
384
+ capabilities.add("gre")
385
+ if re.search(r"(?mi)^\s*encapsulation\s+ppp\b", running):
386
+ capabilities.add("ppp")
387
+ if re.search(r"(?mi)^\s*crypto\s+ipsec\s+transform-set\b", running) or re.search(r"(?mi)^\s*crypto\s+map\b", running):
388
+ capabilities.add("ipsec")
389
+ if re.search(r"(?mi)^\s*crypto\s+map\b", running):
390
+ capabilities.add("vpn")
391
+ if re.search(r"(?mi)^\s*ip\s+inspect\s+name\b", running):
392
+ capabilities.add("cbac")
393
+ if (
394
+ re.search(r"(?mi)^zone\s+security\b", running)
395
+ or re.search(r"(?mi)^zone-pair\s+security\b", running)
396
+ or re.search(r"(?mi)^class-map\s+type\s+inspect\b", running)
397
+ or re.search(r"(?mi)^policy-map\s+type\s+inspect\b", running)
398
+ ):
399
+ capabilities.add("zfw")
400
+ if capabilities:
401
+ result[name] = {"capabilities": sorted(capabilities)}
402
+ return result
403
+
404
+
405
+ def inventory_ipv4_routing_management(root: ET.Element) -> dict[str, dict[str, object]]:
406
+ result: dict[str, dict[str, object]] = {}
407
+ for device in root.findall(".//DEVICES/DEVICE"):
408
+ name = device.findtext("./ENGINE/NAME", default="")
409
+ running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
410
+ if not running:
411
+ continue
412
+ capabilities: set[str] = set()
413
+ if re.search(r"(?mi)^\s*router\s+ospf\s+\d+\s*$", running):
414
+ capabilities.add("ospfv2")
415
+ if re.search(r"(?mi)^\s*router\s+eigrp\s+\d+\s*$", running):
416
+ capabilities.add("eigrp_ipv4")
417
+ if re.search(r"(?mi)^\s*router\s+rip\s*$", running):
418
+ capabilities.add("ripv2")
419
+ if re.search(r"(?mi)^\s*ip\s+route\s+\S+\s+\S+\s+\S+", running):
420
+ capabilities.add("static_route")
421
+ if re.search(r"(?mi)^\s*ip\s+route\s+0\.0\.0\.0\s+0\.0\.0\.0\s+\S+", running):
422
+ capabilities.add("default_route")
423
+ if re.search(r"(?mi)^\s*ip\s+helper-address\s+\d+\.\d+\.\d+\.\d+\s*$", running):
424
+ capabilities.add("dhcp_relay")
425
+ if re.search(r"(?mi)^\s*ip\s+nat\s+inside\s+source\s+static\b", running):
426
+ capabilities.update({"nat_static", "nat"})
427
+ if re.search(r"(?mi)^\s*ip\s+nat\s+inside\s+source\s+(?:list|route-map)\b", running):
428
+ capabilities.update({"nat_dynamic", "nat"})
429
+ if re.search(r"(?mi)^\s*ip\s+nat\s+inside\s+source\s+list\s+\S+\s+interface\s+\S+\s+overload\b", running):
430
+ capabilities.update({"pat", "nat"})
431
+ if re.search(r"(?mi)^\s*ip\s+ssh\b", running) or re.search(r"(?mi)^\s*crypto\s+key\s+generate\s+rsa\b", running):
432
+ capabilities.add("ssh_ios")
433
+ if re.search(r"(?mi)^\s*ntp\s+server\s+\d+\.\d+\.\d+\.\d+\s*$", running):
434
+ capabilities.add("ntp_ios")
435
+ if re.search(r"(?mi)^\s*logging\s+host\s+\d+\.\d+\.\d+\.\d+\s*$", running):
436
+ capabilities.add("syslog_ios")
300
437
  if capabilities:
301
438
  result[name] = {"capabilities": sorted(capabilities)}
302
439
  return result
@@ -335,6 +472,72 @@ def inventory_l2_security_monitoring(root: ET.Element) -> dict[str, dict[str, ob
335
472
  return result
336
473
 
337
474
 
475
+ def inventory_l2_resiliency_routing(root: ET.Element) -> dict[str, dict[str, object]]:
476
+ result: dict[str, dict[str, object]] = {}
477
+ for device in root.findall(".//DEVICES/DEVICE"):
478
+ name = device.findtext("./ENGINE/NAME", default="")
479
+ running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
480
+ if not running:
481
+ continue
482
+ capabilities: set[str] = set()
483
+ if re.search(r"(?mi)^\s*router\s+bgp\s+\d+\s*$", running) or re.search(r"(?mi)^\s*neighbor\s+\S+\s+remote-as\s+\d+\s*$", running):
484
+ capabilities.add("bgp")
485
+ if re.search(r"(?mi)^\s*spanning-tree\b", running):
486
+ capabilities.add("stp")
487
+ if re.search(r"(?mi)^\s*spanning-tree\s+mode\s+(?:rapid-pvst|rstp)\b", running) or re.search(r"(?mi)\brapid-pvst\b", running):
488
+ capabilities.add("rstp")
489
+ if re.search(r"(?mi)^\s*interface\s+Port-channel\d+\b", running) or re.search(r"(?mi)^\s*channel-group\s+\d+\s+mode\b", running):
490
+ capabilities.add("etherchannel")
491
+ if re.search(r"(?mi)^\s*channel-group\s+\d+\s+mode\s+(?:active|passive)\b", running):
492
+ capabilities.add("lacp")
493
+ if re.search(r"(?mi)^\s*channel-group\s+\d+\s+mode\s+(?:desirable|auto)\b", running):
494
+ capabilities.add("pagp")
495
+ if re.search(r"(?mi)^\s*vtp\s+(?:domain|mode|version)\b", running):
496
+ capabilities.add("vtp")
497
+ if re.search(r"(?mi)^\s*switchport\s+mode\s+dynamic\s+(?:desirable|auto)\b", running) or re.search(r"(?mi)^\s*switchport\s+nonegotiate\b", running):
498
+ capabilities.add("dtp")
499
+ if capabilities:
500
+ result[name] = {"capabilities": sorted(capabilities)}
501
+ return result
502
+
503
+
504
+ def inventory_voice(root: ET.Element) -> dict[str, dict[str, object]]:
505
+ result: dict[str, dict[str, object]] = {}
506
+ for device in root.findall(".//DEVICES/DEVICE"):
507
+ name = device.findtext("./ENGINE/NAME", default="")
508
+ device_type = _device_type(device)
509
+ model = device.find("./ENGINE/TYPE").get("model", "") if device.find("./ENGINE/TYPE") is not None else ""
510
+ running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
511
+ capabilities: set[str] = set()
512
+ details: dict[str, object] = {"device_type": device_type, "model": model}
513
+ if device_type in {"IpPhone", "HomeVoip", "AnalogPhone"}:
514
+ capabilities.add("voip")
515
+ if device_type == "IpPhone":
516
+ capabilities.add("ip_phone")
517
+ if running:
518
+ extensions = sorted(dict.fromkeys(re.findall(r"(?mi)^\s*number\s+([A-Za-z0-9*+#.-]+)\s*$", running)))
519
+ ephones = sorted(dict.fromkeys(re.findall(r"(?mi)^ephone\s+(\d+)\s*$", running)), key=int)
520
+ dial_peers = sorted(dict.fromkeys(re.findall(r"(?mi)^dial-peer\s+voice\s+(\d+)\b", running)), key=int)
521
+ source_match = re.search(r"(?mi)^\s*ip\s+source-address\s+(\d+\.\d+\.\d+\.\d+)\s+port\s+(\d+)\s*$", running)
522
+ if re.search(r"(?mi)^telephony-service\s*$", running) or extensions or ephones:
523
+ capabilities.update({"voip", "call_manager", "ip_phone"})
524
+ if dial_peers:
525
+ capabilities.add("voip")
526
+ if extensions:
527
+ details["extensions"] = extensions
528
+ if ephones:
529
+ details["ephones"] = ephones
530
+ if dial_peers:
531
+ details["dial_peers"] = dial_peers
532
+ if source_match:
533
+ details["source_address"] = source_match.group(1)
534
+ details["source_port"] = int(source_match.group(2))
535
+ if capabilities:
536
+ details["capabilities"] = sorted(capabilities)
537
+ result[name] = details
538
+ return result
539
+
540
+
338
541
  def inventory_topology_summary(root: ET.Element) -> dict[str, object]:
339
542
  devices = inventory_devices(root)
340
543
  counts: dict[str, int] = {}
@@ -362,7 +565,11 @@ def inventory_root(root: ET.Element) -> dict[str, object]:
362
565
  "acl_names": inventory_acl_names(root),
363
566
  "management": inventory_management(root),
364
567
  "routing": inventory_routing(root),
568
+ "ipv4_routing_management": inventory_ipv4_routing_management(root),
365
569
  "l2_security_monitoring": inventory_l2_security_monitoring(root),
570
+ "l2_resiliency_routing": inventory_l2_resiliency_routing(root),
571
+ "voice": inventory_voice(root),
572
+ "programming": inventory_programming(root),
366
573
  "topology_summary": inventory_topology_summary(root),
367
574
  }
368
575
 
@@ -801,6 +1008,66 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
801
1008
  ],
802
1009
  )
803
1010
  return
1011
+ elif operation["op"] == "set_dot1x":
1012
+ global_lines = ["aaa new-model", "dot1x system-auth-control"]
1013
+ if operation.get("radius_host") and operation.get("radius_key"):
1014
+ global_lines.append(f"radius-server host {operation['radius_host']} key {operation['radius_key']}")
1015
+ mode = str(operation.get("mode") or "auto")
1016
+ for target in _config_targets(device):
1017
+ _append_unique_config_lines(target, global_lines)
1018
+ _append_config_block(
1019
+ target,
1020
+ f"interface {operation['interface']}",
1021
+ [f" authentication port-control {mode}", " dot1x pae authenticator"],
1022
+ )
1023
+ return
1024
+ elif operation["op"] == "set_qos_policy":
1025
+ action = str(operation.get("action") or "priority")
1026
+ for target in _config_targets(device):
1027
+ _append_unique_config_lines(target, ["mls qos"])
1028
+ _append_config_block(
1029
+ target,
1030
+ f"class-map match-any {operation['class_map']}",
1031
+ [f" match {operation['match']}"],
1032
+ )
1033
+ _append_config_block(
1034
+ target,
1035
+ f"policy-map {operation['policy_map']}",
1036
+ [f" class {operation['class_map']}", f" {action}"],
1037
+ )
1038
+ _append_config_block(
1039
+ target,
1040
+ f"interface {operation['interface']}",
1041
+ [f" service-policy {operation['direction']} {operation['policy_map']}"],
1042
+ )
1043
+ return
1044
+ elif operation["op"] == "set_stp":
1045
+ lines = [f"spanning-tree mode {operation['mode']}"]
1046
+ if operation.get("vlan") and operation.get("root"):
1047
+ lines.append(f"spanning-tree vlan {operation['vlan']} root {operation['root']}")
1048
+ for target in _config_targets(device):
1049
+ _append_unique_config_lines(target, lines)
1050
+ return
1051
+ elif operation["op"] == "set_etherchannel":
1052
+ channel = int(operation["channel"])
1053
+ mode = str(operation.get("mode") or "active")
1054
+ interfaces = [str(interface) for interface in operation.get("interfaces", []) if str(interface).strip()]
1055
+ for target in _config_targets(device):
1056
+ for interface_name in interfaces:
1057
+ _append_config_block(target, f"interface {interface_name}", [f" channel-group {channel} mode {mode}"])
1058
+ _append_config_block(target, f"interface Port-channel{channel}", [" no shutdown"])
1059
+ return
1060
+ elif operation["op"] == "set_vtp":
1061
+ lines = [f"vtp domain {operation['domain']}", f"vtp mode {operation['mode']}"]
1062
+ if operation.get("version"):
1063
+ lines.append(f"vtp version {operation['version']}")
1064
+ for target in _config_targets(device):
1065
+ _append_unique_config_lines(target, lines)
1066
+ return
1067
+ elif operation["op"] == "set_dtp":
1068
+ for target in _config_targets(device):
1069
+ _append_config_block(target, f"interface {operation['interface']}", [f" switchport mode {operation['mode']}"])
1070
+ return
804
1071
  else:
805
1072
  return
806
1073
 
@@ -902,6 +1169,74 @@ def _apply_router_op(device: ET.Element, operation: dict[str, object]) -> None:
902
1169
  _append_unique_config_lines(target, ["ipv6 unicast-routing"])
903
1170
  _append_config_block(target, f"interface {operation['interface']}", body)
904
1171
  return
1172
+ elif operation["op"] == "set_ospfv2_network":
1173
+ for target in _config_targets(device):
1174
+ _append_config_block(
1175
+ target,
1176
+ f"router ospf {operation['process_id']}",
1177
+ [f" network {operation['network']} {operation['wildcard']} area {operation['area']}"],
1178
+ )
1179
+ return
1180
+ elif operation["op"] == "set_eigrp_ipv4_network":
1181
+ body = [f" network {operation['network']} {operation['wildcard']}"]
1182
+ if operation.get("no_auto_summary"):
1183
+ body.append(" no auto-summary")
1184
+ for target in _config_targets(device):
1185
+ _append_config_block(target, f"router eigrp {operation['asn']}", body)
1186
+ return
1187
+ elif operation["op"] == "set_ripv2_network":
1188
+ body = [" version 2", f" network {operation['network']}"]
1189
+ if operation.get("no_auto_summary"):
1190
+ body.append(" no auto-summary")
1191
+ for target in _config_targets(device):
1192
+ _append_config_block(target, "router rip", body)
1193
+ return
1194
+ elif operation["op"] == "set_static_route":
1195
+ for target in _config_targets(device):
1196
+ _append_unique_config_lines(target, [f"ip route {operation['network']} {_prefix_to_mask(int(operation['prefix']))} {operation['next_hop']}"])
1197
+ return
1198
+ elif operation["op"] == "set_dhcp_relay":
1199
+ for target in _config_targets(device):
1200
+ _append_config_block(target, f"interface {operation['interface']}", [f" ip helper-address {operation['helper']}"])
1201
+ return
1202
+ elif operation["op"] == "set_nat_interface":
1203
+ for target in _config_targets(device):
1204
+ _append_config_block(target, f"interface {operation['interface']}", [f" ip nat {operation['role']}"])
1205
+ return
1206
+ elif operation["op"] == "set_nat_static":
1207
+ for target in _config_targets(device):
1208
+ _append_unique_config_lines(target, [f"ip nat inside source static {operation['inside_local']} {operation['inside_global']}"])
1209
+ return
1210
+ elif operation["op"] == "set_pat_overload":
1211
+ suffix = " overload" if operation.get("overload") else ""
1212
+ for target in _config_targets(device):
1213
+ _append_unique_config_lines(target, [f"ip nat inside source list {operation['acl']} interface {operation['interface']}{suffix}"])
1214
+ return
1215
+ elif operation["op"] == "set_ssh_ios":
1216
+ lines = [
1217
+ f"ip domain-name {operation['domain']}",
1218
+ f"username {operation['username']} password {operation['password']}",
1219
+ f"crypto key generate rsa modulus {operation['modulus']}",
1220
+ "ip ssh version 2",
1221
+ ]
1222
+ for target in _config_targets(device):
1223
+ _append_unique_config_lines(target, lines)
1224
+ return
1225
+ elif operation["op"] == "set_ntp_server":
1226
+ for target in _config_targets(device):
1227
+ _append_unique_config_lines(target, [f"ntp server {operation['server']}"])
1228
+ return
1229
+ elif operation["op"] == "set_syslog_server":
1230
+ for target in _config_targets(device):
1231
+ _append_unique_config_lines(target, [f"logging host {operation['server']}"])
1232
+ return
1233
+ elif operation["op"] == "set_bgp_neighbor":
1234
+ body = [f" neighbor {operation['neighbor']} remote-as {operation['remote_as']}"]
1235
+ if operation.get("network") and operation.get("mask"):
1236
+ body.append(f" network {operation['network']} mask {operation['mask']}")
1237
+ for target in _config_targets(device):
1238
+ _append_config_block(target, f"router bgp {operation['asn']}", body)
1239
+ return
905
1240
  elif operation["op"] == "set_snmp_community":
906
1241
  for target in _config_targets(device):
907
1242
  _append_unique_config_lines(target, [f"snmp-server community {operation['community']} {operation['mode']}"])
@@ -916,6 +1251,114 @@ def _apply_router_op(device: ET.Element, operation: dict[str, object]) -> None:
916
1251
  if operation.get("interface") and operation.get("direction"):
917
1252
  _append_config_block(target, f"interface {operation['interface']}", [f" ip flow {operation['direction']}"])
918
1253
  return
1254
+ elif operation["op"] == "set_gre_tunnel":
1255
+ body = []
1256
+ if operation.get("ip") and operation.get("prefix"):
1257
+ body.append(f" ip address {operation['ip']} {_prefix_to_mask(int(operation['prefix']))}")
1258
+ body.extend(
1259
+ [
1260
+ f" tunnel source {operation['source']}",
1261
+ f" tunnel destination {operation['destination']}",
1262
+ " tunnel mode gre ip",
1263
+ " no shutdown",
1264
+ ]
1265
+ )
1266
+ for target in _config_targets(device):
1267
+ _append_config_block(target, f"interface {operation['interface']}", body)
1268
+ return
1269
+ elif operation["op"] == "set_ppp_interface":
1270
+ body = [" encapsulation ppp"]
1271
+ if operation.get("authentication"):
1272
+ body.append(f" ppp authentication {operation['authentication']}")
1273
+ body.append(" no shutdown")
1274
+ for target in _config_targets(device):
1275
+ _append_config_block(target, f"interface {operation['interface']}", body)
1276
+ return
1277
+ elif operation["op"] == "set_ipsec_transform_set":
1278
+ for target in _config_targets(device):
1279
+ _append_unique_config_lines(
1280
+ target,
1281
+ [f"crypto ipsec transform-set {operation['name']} {operation['encryption']} {operation['integrity']}"],
1282
+ )
1283
+ return
1284
+ elif operation["op"] == "set_crypto_map":
1285
+ for target in _config_targets(device):
1286
+ _append_config_block(
1287
+ target,
1288
+ f"crypto map {operation['map_name']} {operation['sequence']} ipsec-isakmp",
1289
+ [
1290
+ f" set peer {operation['peer']}",
1291
+ f" set transform-set {operation['transform_set']}",
1292
+ f" match address {operation['acl_name']}",
1293
+ ],
1294
+ )
1295
+ if operation.get("interface"):
1296
+ _append_config_block(target, f"interface {operation['interface']}", [f" crypto map {operation['map_name']}"])
1297
+ return
1298
+ elif operation["op"] == "set_cbac_inspect":
1299
+ for target in _config_targets(device):
1300
+ _append_unique_config_lines(target, [f"ip inspect name {operation['name']} {operation['protocol']}"])
1301
+ _append_config_block(target, f"interface {operation['interface']}", [f" ip inspect {operation['name']} {operation['direction']}"])
1302
+ return
1303
+ elif operation["op"] == "set_zfw_zone_interface":
1304
+ for target in _config_targets(device):
1305
+ _append_config_block(target, f"zone security {operation['zone']}", [])
1306
+ _append_config_block(target, f"interface {operation['interface']}", [f" zone-member security {operation['zone']}"])
1307
+ return
1308
+ elif operation["op"] == "set_zfw_zone_pair":
1309
+ for target in _config_targets(device):
1310
+ _append_config_block(
1311
+ target,
1312
+ f"zone-pair security {operation['pair_name']} source {operation['source']} destination {operation['destination']}",
1313
+ [f" service-policy type inspect {operation['policy']}"],
1314
+ )
1315
+ return
1316
+ elif operation["op"] == "set_zfw_policy":
1317
+ for target in _config_targets(device):
1318
+ _append_config_block(
1319
+ target,
1320
+ f"class-map type inspect match-any {operation['class_map']}",
1321
+ [f" match protocol {operation['protocol']}"],
1322
+ )
1323
+ _append_config_block(
1324
+ target,
1325
+ f"policy-map type inspect {operation['policy_map']}",
1326
+ [f" class type inspect {operation['class_map']}", f" {operation['action']}"],
1327
+ )
1328
+ return
1329
+ elif operation["op"] == "set_telephony_service":
1330
+ body = [" no auto-reg-ephone"]
1331
+ if operation.get("max_ephones"):
1332
+ body.append(f" max-ephones {operation['max_ephones']}")
1333
+ if operation.get("max_dn"):
1334
+ body.append(f" max-dn {operation['max_dn']}")
1335
+ body.append(f" ip source-address {operation['source_address']} port {operation['port']}")
1336
+ for target in _config_targets(device):
1337
+ _append_config_block(target, "telephony-service", body)
1338
+ return
1339
+ elif operation["op"] == "set_ephone_dn":
1340
+ for target in _config_targets(device):
1341
+ _append_config_block(target, f"ephone-dn {operation['dn_id']}", [f" number {operation['number']}"])
1342
+ return
1343
+ elif operation["op"] == "set_ephone":
1344
+ for target in _config_targets(device):
1345
+ _append_config_block(
1346
+ target,
1347
+ f"ephone {operation['ephone_id']}",
1348
+ [f" mac-address {operation['mac']}", f" button {operation['button']}"],
1349
+ )
1350
+ return
1351
+ elif operation["op"] == "set_dial_peer_voice":
1352
+ for target in _config_targets(device):
1353
+ _append_config_block(
1354
+ target,
1355
+ f"dial-peer voice {operation['peer_id']} voip",
1356
+ [
1357
+ f" destination-pattern {operation['destination_pattern']}",
1358
+ f" session target ipv4:{operation['session_target']}",
1359
+ ],
1360
+ )
1361
+ return
919
1362
  else:
920
1363
  return
921
1364
  for target in _config_targets(device):
@@ -1151,6 +1594,39 @@ def _apply_iot_op(device: ET.Element, operation: dict[str, object]) -> None:
1151
1594
  return
1152
1595
 
1153
1596
 
1597
+ def _find_script_file(device: ET.Element, app_name: str, file_name: str) -> ET.Element:
1598
+ app_matches = [
1599
+ directory
1600
+ for directory in device.findall(".//FILE[@class='CDirectory']")
1601
+ if directory.findtext("NAME", default="").strip() == app_name
1602
+ ]
1603
+ if not app_matches:
1604
+ device_name = device.findtext("./ENGINE/NAME", default="")
1605
+ raise ValueError(f"Script app {app_name!r} was not found on device {device_name!r}.")
1606
+ file_matches: list[ET.Element] = []
1607
+ for directory in app_matches:
1608
+ file_matches.extend(
1609
+ file_node
1610
+ for file_node in directory.findall(".//FILE[@class='CFile']")
1611
+ if file_node.findtext("NAME", default="").strip() == file_name
1612
+ )
1613
+ if not file_matches:
1614
+ raise ValueError(f"Script file {file_name!r} was not found in app {app_name!r}.")
1615
+ if len(file_matches) > 1:
1616
+ raise ValueError(f"Script file {file_name!r} in app {app_name!r} is ambiguous.")
1617
+ return file_matches[0]
1618
+
1619
+
1620
+ def _apply_programming_op(device: ET.Element, operation: dict[str, object]) -> None:
1621
+ if operation["op"] != "set_script_file_content":
1622
+ return
1623
+ file_node = _find_script_file(device, str(operation["app_name"]), str(operation["file_name"]))
1624
+ content_node = file_node.find("FILE_CONTENT")
1625
+ if content_node is None:
1626
+ content_node = ET.SubElement(file_node, "FILE_CONTENT", {"class": "CTextFileContent"})
1627
+ _ensure_text(content_node, "TEXT", str(operation["content"]))
1628
+
1629
+
1154
1630
  def apply_plan_operations(root: ET.Element, plan: IntentPlan) -> ET.Element:
1155
1631
  updated = copy.deepcopy(root)
1156
1632
  port_mem_map = _link_port_mem_map(updated)
@@ -1199,6 +1675,7 @@ def apply_plan_operations(root: ET.Element, plan: IntentPlan) -> ET.Element:
1199
1675
  (plan.end_device_ops, _apply_end_device_op),
1200
1676
  (plan.management_ops, _apply_management_op),
1201
1677
  (plan.iot_ops, _apply_iot_op),
1678
+ (plan.programming_ops, _apply_programming_op),
1202
1679
  ]:
1203
1680
  for operation in bucket:
1204
1681
  device = _find_device(updated, str(operation["device"]))