f5audit 0.1.0__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.
f5audit/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """f5audit: read-only audit tool for F5 BIG-IP LTM configurations.
2
+
3
+ Collects configuration and statistics via iControl REST (GET only),
4
+ correlates object references, and reports orphaned / inactive objects
5
+ as input for a human-driven, change-controlled cleanup.
6
+ """
7
+
8
+ __version__ = "0.1.0"
f5audit/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
f5audit/analyzer.py ADDED
@@ -0,0 +1,301 @@
1
+ """Verdict rules.
2
+
3
+ Cross-cutting rules (spec section 8):
4
+ - No object touched by dynamic pool-selection logic can ever be ORPHAN;
5
+ the ceiling is MANUAL REVIEW.
6
+ - Traffic-based verdicts are only trusted when the device is ACTIVE;
7
+ on a standby unit they are either skipped (default) or emitted as
8
+ UNRELIABLE (standby) with --allow-standby.
9
+ - Incomplete inventory (denied partitions/endpoints) degrades orphan
10
+ verdicts, because a reference could live in an invisible partition.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+
17
+ from .correlator import Correlation, is_builtin_monitor
18
+ from .parsing import ParsedData
19
+
20
+
21
+ class Verdict:
22
+ ORPHAN = "ORPHAN"
23
+ MANUAL_REVIEW = "MANUAL REVIEW"
24
+ INACTIVE = "INACTIVE"
25
+ UNRELIABLE_STANDBY = "UNRELIABLE (standby)"
26
+ UNRELIABLE_INVENTORY = "UNRELIABLE (incomplete inventory)"
27
+ IN_USE = "IN USE"
28
+
29
+
30
+ @dataclass
31
+ class ObjectVerdict:
32
+ verdict: str
33
+ notes: str = ""
34
+
35
+
36
+ @dataclass
37
+ class ManualReviewItem:
38
+ object_type: str
39
+ full_path: str
40
+ reason: str
41
+ caused_by: str = ""
42
+
43
+
44
+ @dataclass
45
+ class AnalysisResult:
46
+ node_verdicts: dict[str, ObjectVerdict] = field(default_factory=dict)
47
+ pool_verdicts: dict[str, ObjectVerdict] = field(default_factory=dict)
48
+ virtual_verdicts: dict[str, ObjectVerdict] = field(default_factory=dict)
49
+ monitor_verdicts: dict[str, ObjectVerdict] = field(default_factory=dict)
50
+ manual_review: list[ManualReviewItem] = field(default_factory=list)
51
+ warnings: list[str] = field(default_factory=list)
52
+ stats_analysis_skipped: bool = False
53
+
54
+ def verdict_counts(self) -> dict[str, int]:
55
+ counts: dict[str, int] = {}
56
+ for verdicts in (
57
+ self.node_verdicts,
58
+ self.pool_verdicts,
59
+ self.virtual_verdicts,
60
+ self.monitor_verdicts,
61
+ ):
62
+ for object_verdict in verdicts.values():
63
+ counts[object_verdict.verdict] = counts.get(object_verdict.verdict, 0) + 1
64
+ return counts
65
+
66
+
67
+ def _conns_zero(total_conns: int | None) -> bool:
68
+ return total_conns is not None and int(total_conns) == 0
69
+
70
+
71
+ class Analyzer:
72
+ def __init__(
73
+ self, parsed: ParsedData, correlation: Correlation, *, allow_standby: bool = False
74
+ ):
75
+ self.parsed = parsed
76
+ self.correlation = correlation
77
+ self.allow_standby = allow_standby
78
+
79
+ system = parsed.system
80
+ self.is_standby = system.failover_state == "standby"
81
+ self.inventory_complete = not system.partitions_denied and not any(
82
+ "/ltm/node" in ep or "/ltm/pool" in ep or "/ltm/virtual" in ep
83
+ for ep in system.missing_endpoints
84
+ )
85
+ self.irules_available = not any("/ltm/rule" in ep for ep in system.missing_endpoints)
86
+ # Uptime context: traffic counters reset on reboot / stats reset.
87
+ self.traffic_note = (
88
+ "Traffic counters reset on reboot/stats-reset; "
89
+ f"{system.uptime or 'device uptime unknown'}."
90
+ )
91
+
92
+ # ------------------------------------------------------------------
93
+
94
+ def run(self) -> AnalysisResult:
95
+ result = AnalysisResult()
96
+ self._collect_warnings(result)
97
+ self._analyze_nodes(result)
98
+ self._analyze_virtuals(result)
99
+ self._analyze_pools(result)
100
+ self._analyze_monitors(result)
101
+ self._analyze_irules(result)
102
+ return result
103
+
104
+ def _collect_warnings(self, result: AnalysisResult) -> None:
105
+ system = self.parsed.system
106
+ if self.is_standby:
107
+ if self.allow_standby:
108
+ result.warnings.append(
109
+ "Device is STANDBY: traffic statistics are not "
110
+ "representative. Traffic-based verdicts are marked "
111
+ "UNRELIABLE (standby). Re-run against the ACTIVE unit."
112
+ )
113
+ else:
114
+ result.stats_analysis_skipped = True
115
+ result.warnings.append(
116
+ "Device is STANDBY: traffic-based analysis was SKIPPED "
117
+ "(configuration-orphan analysis still ran). Re-run "
118
+ "against the ACTIVE unit, or use --allow-standby to "
119
+ "force traffic verdicts marked as UNRELIABLE."
120
+ )
121
+ if system.partitions_denied:
122
+ result.warnings.append(
123
+ "Access denied to partition(s): "
124
+ + ", ".join(system.partitions_denied)
125
+ + ". Orphan verdicts are degraded to UNRELIABLE (incomplete "
126
+ "inventory) because objects could be referenced from an "
127
+ "invisible partition."
128
+ )
129
+ if not self.irules_available:
130
+ result.warnings.append(
131
+ "ltm/rule was not readable: pool orphan verdicts are capped "
132
+ "at MANUAL REVIEW (an iRule could reference them)."
133
+ )
134
+ for endpoint in system.missing_endpoints:
135
+ if "/ltm/rule" not in endpoint:
136
+ result.warnings.append(f"Endpoint not collected: {endpoint}")
137
+
138
+ # ------------------------------------------------------------------
139
+
140
+ def _analyze_nodes(self, result: AnalysisResult) -> None:
141
+ for path, _node in self.parsed.nodes.items():
142
+ pools = self.correlation.node_to_pools.get(path)
143
+ if pools:
144
+ result.node_verdicts[path] = ObjectVerdict(Verdict.IN_USE)
145
+ elif not self.inventory_complete:
146
+ result.node_verdicts[path] = ObjectVerdict(
147
+ Verdict.UNRELIABLE_INVENTORY,
148
+ "Not a member of any visible pool, but some partitions were not readable.",
149
+ )
150
+ else:
151
+ result.node_verdicts[path] = ObjectVerdict(
152
+ Verdict.ORPHAN, "Not a member of any pool."
153
+ )
154
+
155
+ def _analyze_virtuals(self, result: AnalysisResult) -> None:
156
+ for path, virtual in self.parsed.virtuals.items():
157
+ if virtual.admin_state == "disabled":
158
+ result.virtual_verdicts[path] = ObjectVerdict(
159
+ Verdict.INACTIVE, "Administratively disabled."
160
+ )
161
+ continue
162
+ if not virtual.default_pool and (virtual.irules or virtual.policies):
163
+ result.virtual_verdicts[path] = ObjectVerdict(
164
+ Verdict.MANUAL_REVIEW,
165
+ "No default pool; traffic is steered by iRules/policies.",
166
+ )
167
+ result.manual_review.append(
168
+ ManualReviewItem(
169
+ "virtual_server",
170
+ path,
171
+ "No default pool but has iRules/policies attached",
172
+ ", ".join(virtual.irules + virtual.policies),
173
+ )
174
+ )
175
+ continue
176
+ if _conns_zero(virtual.total_conns):
177
+ self._traffic_verdict(
178
+ result.virtual_verdicts,
179
+ path,
180
+ "Enabled but zero total connections. " + self.traffic_note,
181
+ )
182
+ continue
183
+ result.virtual_verdicts[path] = ObjectVerdict(Verdict.IN_USE)
184
+
185
+ def _traffic_verdict(self, verdicts: dict[str, ObjectVerdict], path: str, note: str) -> None:
186
+ """Emit a traffic-based INACTIVE verdict, degraded on standby."""
187
+ if self.is_standby:
188
+ if self.allow_standby:
189
+ verdicts[path] = ObjectVerdict(
190
+ Verdict.UNRELIABLE_STANDBY,
191
+ "Zero traffic, but this device is standby. " + note,
192
+ )
193
+ else:
194
+ verdicts[path] = ObjectVerdict(
195
+ Verdict.IN_USE,
196
+ "Traffic analysis skipped (standby device).",
197
+ )
198
+ else:
199
+ verdicts[path] = ObjectVerdict(Verdict.INACTIVE, note)
200
+
201
+ def _analyze_pools(self, result: AnalysisResult) -> None:
202
+ for path, _pool in self.parsed.pools.items():
203
+ virtuals = self.correlation.pool_to_virtuals.get(path, set())
204
+ irule_refs = self.correlation.pool_to_irules.get(path, set())
205
+ policy_refs = self.correlation.pool_to_policies.get(path, set())
206
+
207
+ if not virtuals and not irule_refs and not policy_refs:
208
+ self._unreferenced_pool_verdict(result, path)
209
+ continue
210
+
211
+ # Referenced pool: inactive if every attached VS has zero traffic.
212
+ if (
213
+ virtuals
214
+ and all(
215
+ _conns_zero(self.parsed.virtuals[v].total_conns)
216
+ for v in virtuals
217
+ if v in self.parsed.virtuals
218
+ )
219
+ and any(v in self.parsed.virtuals for v in virtuals)
220
+ ):
221
+ self._traffic_verdict(
222
+ result.pool_verdicts,
223
+ path,
224
+ "All attached virtual servers have zero total "
225
+ "connections. " + self.traffic_note,
226
+ )
227
+ continue
228
+ result.pool_verdicts[path] = ObjectVerdict(Verdict.IN_USE)
229
+
230
+ def _unreferenced_pool_verdict(self, result: AnalysisResult, path: str) -> None:
231
+ if not self.inventory_complete:
232
+ result.pool_verdicts[path] = ObjectVerdict(
233
+ Verdict.UNRELIABLE_INVENTORY,
234
+ "No visible references, but some partitions were not readable.",
235
+ )
236
+ elif not self.irules_available:
237
+ result.pool_verdicts[path] = ObjectVerdict(
238
+ Verdict.MANUAL_REVIEW,
239
+ "No VS/policy references; iRules could not be read "
240
+ "(ltm/rule denied), so an iRule reference cannot be ruled out.",
241
+ )
242
+ result.manual_review.append(
243
+ ManualReviewItem(
244
+ "pool",
245
+ path,
246
+ "Unreferenced, but ltm/rule was not readable",
247
+ )
248
+ )
249
+ elif self.correlation.has_attached_dynamic_irules:
250
+ dynamic = ", ".join(self.correlation.attached_dynamic_irules)
251
+ result.pool_verdicts[path] = ObjectVerdict(
252
+ Verdict.MANUAL_REVIEW,
253
+ "No static references, but dynamic pool-selection iRules "
254
+ f"are active ({dynamic}); the pool could be selected at "
255
+ "runtime.",
256
+ )
257
+ result.manual_review.append(
258
+ ManualReviewItem(
259
+ "pool",
260
+ path,
261
+ "No static references but dynamic iRules are active",
262
+ dynamic,
263
+ )
264
+ )
265
+ else:
266
+ result.pool_verdicts[path] = ObjectVerdict(
267
+ Verdict.ORPHAN,
268
+ "Not referenced by any virtual server, iRule or policy.",
269
+ )
270
+
271
+ def _analyze_monitors(self, result: AnalysisResult) -> None:
272
+ for path, _monitor in self.parsed.monitors.items():
273
+ if is_builtin_monitor(path):
274
+ result.monitor_verdicts[path] = ObjectVerdict(
275
+ Verdict.IN_USE, "F5 built-in monitor (excluded from orphan analysis)."
276
+ )
277
+ continue
278
+ users = self.correlation.monitor_users.get(path)
279
+ if users:
280
+ result.monitor_verdicts[path] = ObjectVerdict(Verdict.IN_USE)
281
+ elif not self.inventory_complete:
282
+ result.monitor_verdicts[path] = ObjectVerdict(
283
+ Verdict.UNRELIABLE_INVENTORY,
284
+ "Unused in visible partitions, but some partitions were not readable.",
285
+ )
286
+ else:
287
+ result.monitor_verdicts[path] = ObjectVerdict(
288
+ Verdict.ORPHAN, "Not used by any node or pool."
289
+ )
290
+
291
+ def _analyze_irules(self, result: AnalysisResult) -> None:
292
+ for path, irule in self.parsed.irules.items():
293
+ if irule.has_dynamic_pool_selection:
294
+ result.manual_review.append(
295
+ ManualReviewItem(
296
+ "irule",
297
+ path,
298
+ "Selects pools dynamically ($variable, [command] or "
299
+ "datagroup); static analysis cannot resolve its targets",
300
+ )
301
+ )
f5audit/cli.py ADDED
@@ -0,0 +1,338 @@
1
+ """Command line interface: collect / analyze / validate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import logging
8
+ import os
9
+ import sys
10
+ from datetime import datetime
11
+
12
+ from . import __version__
13
+ from .analyzer import Analyzer
14
+ from .client import F5APIError, F5AuthError, F5ClientError, F5ReadOnlyClient
15
+ from .collector import CollectionData, Collector, RawStore, load_from_raw
16
+ from .correlator import correlate
17
+ from .parsing import parse_collection
18
+ from .report import build_tables, default_report_name, write_csv, write_xlsx
19
+
20
+ EXIT_OK = 0
21
+ EXIT_ERROR = 1
22
+ EXIT_WARNINGS = 2
23
+
24
+ logger = logging.getLogger("f5audit")
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Argument parsing
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ def _add_connection_args(parser: argparse.ArgumentParser) -> None:
33
+ parser.add_argument("--host", help="BIG-IP management address")
34
+ parser.add_argument(
35
+ "--user",
36
+ help="Username (or set the F5_USER environment variable)",
37
+ )
38
+ parser.add_argument(
39
+ "--login-provider",
40
+ default="tmos",
41
+ help="Auth provider for token login (default: tmos; use the remote "
42
+ "provider name for TACACS+/RADIUS setups)",
43
+ )
44
+ parser.add_argument(
45
+ "--insecure",
46
+ action="store_true",
47
+ help="Skip TLS certificate verification (self-signed mgmt certs)",
48
+ )
49
+ parser.add_argument(
50
+ "--delay",
51
+ type=float,
52
+ default=0.1,
53
+ help="Delay in seconds between requests (default: 0.1)",
54
+ )
55
+ parser.add_argument(
56
+ "--top",
57
+ type=int,
58
+ default=100,
59
+ help="Page size for collection pagination (default: 100)",
60
+ )
61
+
62
+
63
+ def build_parser() -> argparse.ArgumentParser:
64
+ parser = argparse.ArgumentParser(
65
+ prog="f5audit",
66
+ description="Read-only audit of an F5 BIG-IP LTM configuration. Never modifies the device.",
67
+ )
68
+ parser.add_argument("--version", action="version", version=__version__)
69
+ parser.add_argument(
70
+ "--verbose", action="store_true", help="Debug logging (never prints passwords/tokens)"
71
+ )
72
+ subparsers = parser.add_subparsers(dest="command", required=True)
73
+
74
+ collect_parser = subparsers.add_parser(
75
+ "collect", help="Collect configuration and stats, save raw JSON"
76
+ )
77
+ _add_connection_args(collect_parser)
78
+ collect_parser.add_argument(
79
+ "--save-raw",
80
+ metavar="DIR",
81
+ help="Directory for raw JSON cache (default: ./f5audit_raw_<host>_<ts>)",
82
+ )
83
+
84
+ analyze_parser = subparsers.add_parser(
85
+ "analyze", help="Analyze (from a live device or a raw cache) and report"
86
+ )
87
+ _add_connection_args(analyze_parser)
88
+ analyze_parser.add_argument(
89
+ "--from-raw", metavar="DIR", help="Analyze a previously saved raw cache"
90
+ )
91
+ analyze_parser.add_argument(
92
+ "--save-raw", metavar="DIR", help="Also save raw JSON while collecting"
93
+ )
94
+ analyze_parser.add_argument("--out", help="Output report path")
95
+ analyze_parser.add_argument("--format", choices=["xlsx", "csv"], default="xlsx")
96
+ analyze_parser.add_argument(
97
+ "--allow-standby",
98
+ action="store_true",
99
+ help="On a standby unit, emit traffic verdicts marked UNRELIABLE "
100
+ "instead of skipping traffic analysis",
101
+ )
102
+
103
+ validate_parser = subparsers.add_parser(
104
+ "validate", help="Probe access: login plus key GET endpoints"
105
+ )
106
+ _add_connection_args(validate_parser)
107
+
108
+ return parser
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Helpers
113
+ # ---------------------------------------------------------------------------
114
+
115
+
116
+ def _resolve_credentials(args) -> tuple:
117
+ username = args.user or os.environ.get("F5_USER")
118
+ if not username:
119
+ print(
120
+ "Error: provide a username with --user or the F5_USER environment variable.",
121
+ file=sys.stderr,
122
+ )
123
+ sys.exit(EXIT_ERROR)
124
+ # Password never travels as a CLI argument (visible in process lists).
125
+ password = os.environ.get("F5_PASS")
126
+ if not password:
127
+ password = getpass.getpass(f"Password for {username}@{args.host}: ")
128
+ return username, password
129
+
130
+
131
+ def _build_client(args) -> F5ReadOnlyClient:
132
+ if not args.host:
133
+ print("Error: --host is required for this command.", file=sys.stderr)
134
+ sys.exit(EXIT_ERROR)
135
+ username, password = _resolve_credentials(args)
136
+ if args.insecure:
137
+ print(
138
+ "WARNING: TLS certificate verification is DISABLED "
139
+ "(--insecure). Only use this on a trusted management network.",
140
+ file=sys.stderr,
141
+ )
142
+ try:
143
+ import urllib3
144
+
145
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
146
+ except ImportError:
147
+ pass
148
+ return F5ReadOnlyClient(
149
+ args.host,
150
+ username,
151
+ password,
152
+ verify_tls=not args.insecure,
153
+ login_provider=args.login_provider,
154
+ delay=args.delay,
155
+ page_size=args.top,
156
+ )
157
+
158
+
159
+ def _collect(args, save_raw_dir: str | None) -> CollectionData:
160
+ client = _build_client(args)
161
+ raw_store = RawStore(save_raw_dir) if save_raw_dir else None
162
+ collector = Collector(client, raw_store=raw_store)
163
+ data = collector.collect()
164
+ if save_raw_dir:
165
+ print(f"Raw JSON saved to: {save_raw_dir}")
166
+ if data.meta.get("aborted"):
167
+ print(f"Collection aborted early: {data.meta['aborted']}", file=sys.stderr)
168
+ print(f"Partial data ({len(data.datasets)} datasets) was kept.", file=sys.stderr)
169
+ return data
170
+
171
+
172
+ def _default_raw_dir(host: str) -> str:
173
+ stamp = datetime.now().strftime("%Y%m%d-%H%M")
174
+ return f"./f5audit_raw_{host}_{stamp}"
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Subcommands
179
+ # ---------------------------------------------------------------------------
180
+
181
+
182
+ def cmd_collect(args) -> int:
183
+ save_raw_dir = args.save_raw or _default_raw_dir(args.host or "unknown")
184
+ data = _collect(args, save_raw_dir)
185
+ if data.meta.get("aborted"):
186
+ return EXIT_ERROR
187
+ print(
188
+ f"Collected {len(data.datasets)} datasets. "
189
+ f"Analyze offline with: f5audit analyze --from-raw {save_raw_dir}"
190
+ )
191
+ return EXIT_OK
192
+
193
+
194
+ def cmd_analyze(args) -> int:
195
+ if args.from_raw:
196
+ data = load_from_raw(args.from_raw)
197
+ print(
198
+ f"Loaded raw cache from {args.from_raw} "
199
+ f"(collected at {data.meta.get('collected_at', 'unknown')})."
200
+ )
201
+ else:
202
+ data = _collect(args, args.save_raw)
203
+ if data.meta.get("aborted"):
204
+ return EXIT_ERROR
205
+
206
+ parsed = parse_collection(data)
207
+ correlation = correlate(parsed)
208
+ analysis = Analyzer(parsed, correlation, allow_standby=args.allow_standby).run()
209
+
210
+ # Prominent standby warning happens before writing anything.
211
+ for warning in analysis.warnings:
212
+ print(f"\nWARNING: {warning}", file=sys.stderr)
213
+
214
+ tables = build_tables(parsed, correlation, analysis)
215
+ out = args.out or default_report_name(parsed.system.hostname, args.format)
216
+ if args.format == "csv":
217
+ written = write_csv(tables, out)
218
+ print(f"\nCSV report written: {len(written)} files in {out}/")
219
+ else:
220
+ write_xlsx(tables, out)
221
+ print(f"\nExcel report written: {out}")
222
+
223
+ counts = analysis.verdict_counts()
224
+ print(
225
+ "Verdict counts: "
226
+ + ", ".join(f"{verdict}={count}" for verdict, count in sorted(counts.items()))
227
+ )
228
+ return EXIT_WARNINGS if analysis.warnings else EXIT_OK
229
+
230
+
231
+ VALIDATE_PROBES = [
232
+ ("sys/version", "/mgmt/tm/sys/version", None),
233
+ ("auth/partition", "/mgmt/tm/auth/partition", {"$top": 1}),
234
+ ("ltm/pool", "/mgmt/tm/ltm/pool", {"$top": 1}),
235
+ ("ltm/virtual", "/mgmt/tm/ltm/virtual", {"$top": 1}),
236
+ ("ltm/node", "/mgmt/tm/ltm/node", {"$top": 1}),
237
+ ("ltm/rule", "/mgmt/tm/ltm/rule", {"$top": 1}),
238
+ ("ltm/virtual/stats", "/mgmt/tm/ltm/virtual/stats", None),
239
+ ("sys/failover", "/mgmt/tm/sys/failover", None),
240
+ ]
241
+
242
+
243
+ def _diagnose(endpoint: str, status: object) -> str:
244
+ if status == 200:
245
+ return "OK"
246
+ if status == 401:
247
+ return "Unauthorized: check credentials / REST access for this account"
248
+ if status == 403:
249
+ if endpoint == "ltm/rule":
250
+ return "Forbidden: pool orphan verdicts will be capped at MANUAL REVIEW"
251
+ return "Forbidden: this account cannot read this endpoint"
252
+ if status == 404:
253
+ return "Not found: endpoint missing on this BIG-IP version"
254
+ if status == "timeout":
255
+ return "Timeout: check network/firewall path to the management interface"
256
+ return f"Unexpected response ({status})"
257
+
258
+
259
+ def cmd_validate(args) -> int:
260
+ client = _build_client(args)
261
+ print(f"Validating read access to {args.host}...\n")
262
+ try:
263
+ client.login()
264
+ except F5AuthError as exc:
265
+ print(f"LOGIN FAILED: {exc}", file=sys.stderr)
266
+ return EXIT_ERROR
267
+ except F5ClientError as exc:
268
+ print(f"CONNECTION FAILED: {exc}", file=sys.stderr)
269
+ return EXIT_ERROR
270
+ auth_mode = getattr(client, "_auth_mode", "token")
271
+ print(
272
+ f"Login: OK (auth mode: {auth_mode}"
273
+ + (", old BIG-IP without token auth" if auth_mode == "basic" else "")
274
+ + ")\n"
275
+ )
276
+
277
+ print(f"{'Endpoint':<22} {'Status':<8} Diagnosis")
278
+ print("-" * 78)
279
+ failures = 0
280
+ for endpoint, path, params in VALIDATE_PROBES:
281
+ try:
282
+ client.get(path, params=params)
283
+ status: object = 200
284
+ except F5APIError as exc:
285
+ status = exc.status_code
286
+ except F5ClientError:
287
+ status = "timeout"
288
+ if status != 200:
289
+ failures += 1
290
+ print(f"{endpoint:<22} {str(status):<8} {_diagnose(endpoint, status)}")
291
+
292
+ if failures:
293
+ print(f"\n{failures} probe(s) failed. Collection may be degraded.")
294
+ return EXIT_WARNINGS
295
+ print("\nAll probes passed. The account is ready for 'f5audit collect'.")
296
+ return EXIT_OK
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+
301
+
302
+ def main(argv: list | None = None) -> int:
303
+ args = build_parser().parse_args(argv)
304
+ logging.basicConfig(
305
+ level=logging.DEBUG if args.verbose else logging.INFO,
306
+ format="%(levelname)s %(name)s: %(message)s",
307
+ )
308
+ handlers = {
309
+ "collect": cmd_collect,
310
+ "analyze": cmd_analyze,
311
+ "validate": cmd_validate,
312
+ }
313
+ try:
314
+ return handlers[args.command](args)
315
+ except F5AuthError as exc:
316
+ print(f"Authentication error: {exc}", file=sys.stderr)
317
+ return EXIT_ERROR
318
+ except F5ClientError as exc:
319
+ if args.verbose:
320
+ logger.exception("Client error")
321
+ print(f"Error: {exc}", file=sys.stderr)
322
+ return EXIT_ERROR
323
+ except KeyboardInterrupt:
324
+ print("\nInterrupted.", file=sys.stderr)
325
+ return EXIT_ERROR
326
+ except Exception as exc: # noqa: BLE001 - no raw tracebacks for end users
327
+ if args.verbose:
328
+ logger.exception("Unexpected error")
329
+ print(
330
+ f"Unexpected error: {exc.__class__.__name__}: {exc}. "
331
+ "Re-run with --verbose for details.",
332
+ file=sys.stderr,
333
+ )
334
+ return EXIT_ERROR
335
+
336
+
337
+ if __name__ == "__main__":
338
+ sys.exit(main())