bind9-aiops 0.2.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.
- bind9_aiops/__init__.py +8 -0
- bind9_aiops/cli/__init__.py +9 -0
- bind9_aiops/cli/_common.py +118 -0
- bind9_aiops/cli/_root.py +249 -0
- bind9_aiops/cli/undo.py +66 -0
- bind9_aiops/config.py +58 -0
- bind9_aiops/connection.py +119 -0
- bind9_aiops/governance/__init__.py +50 -0
- bind9_aiops/governance/audit.py +526 -0
- bind9_aiops/governance/budget.py +222 -0
- bind9_aiops/governance/decorators.py +599 -0
- bind9_aiops/governance/outcome.py +102 -0
- bind9_aiops/governance/paths.py +23 -0
- bind9_aiops/governance/patterns.py +396 -0
- bind9_aiops/governance/policy.py +67 -0
- bind9_aiops/governance/sanitize.py +79 -0
- bind9_aiops/governance/undo.py +259 -0
- bind9_aiops/mcp_server/__init__.py +1 -0
- bind9_aiops/mcp_server/_annotations.py +66 -0
- bind9_aiops/mcp_server/_shared.py +67 -0
- bind9_aiops/mcp_server/server.py +14 -0
- bind9_aiops/mcp_server/tools/__init__.py +1 -0
- bind9_aiops/mcp_server/tools/dns.py +224 -0
- bind9_aiops/mcp_server/tools/undo.py +204 -0
- bind9_aiops/ops/__init__.py +1 -0
- bind9_aiops/ops/dns.py +116 -0
- bind9_aiops-0.2.0.dist-info/METADATA +51 -0
- bind9_aiops-0.2.0.dist-info/RECORD +31 -0
- bind9_aiops-0.2.0.dist-info/WHEEL +4 -0
- bind9_aiops-0.2.0.dist-info/entry_points.txt +3 -0
- bind9_aiops-0.2.0.dist-info/licenses/LICENSE +21 -0
bind9_aiops/__init__.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Shared helpers for bind9-aiops CLI sub-modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
# ─── Shared Option types ───────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
TargetOption = Annotated[str | None, typer.Option("--target", "-t", help="Target name from config")]
|
|
18
|
+
DryRunOption = Annotated[
|
|
19
|
+
bool, typer.Option("--dry-run", help="Print the API call without executing")
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cli_error_types() -> tuple[type[BaseException], ...]:
|
|
24
|
+
"""Exceptions translated to a one-line teaching error instead of a traceback.
|
|
25
|
+
|
|
26
|
+
``BudgetExceeded`` (and the retained ``PolicyDenied`` type) are raised by
|
|
27
|
+
``@governed_tool`` OUTSIDE the tool body, so ``tool_errors`` never sees them
|
|
28
|
+
and they never arrive as an ``{"error": ...}`` dict. Their message is the
|
|
29
|
+
teaching text (e.g. which budget was hit) — without them here such a refusal
|
|
30
|
+
reaches the CLI as a traceback instead.
|
|
31
|
+
"""
|
|
32
|
+
from bind9_aiops.connection import BindCommandError
|
|
33
|
+
from bind9_aiops.governance import BudgetExceeded, PolicyDenied
|
|
34
|
+
|
|
35
|
+
return (BindCommandError, PolicyDenied, BudgetExceeded, KeyError, OSError, ValueError)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cli_errors(fn: Callable) -> Callable:
|
|
39
|
+
"""Translate known exceptions into one red line + exit code 1."""
|
|
40
|
+
|
|
41
|
+
@functools.wraps(fn)
|
|
42
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
43
|
+
try:
|
|
44
|
+
return fn(*args, **kwargs)
|
|
45
|
+
except (typer.Exit, typer.Abort):
|
|
46
|
+
raise
|
|
47
|
+
except _cli_error_types() as e:
|
|
48
|
+
message = str(e)
|
|
49
|
+
if isinstance(e, KeyError):
|
|
50
|
+
message = f"Missing required key or environment variable: {message}"
|
|
51
|
+
# Upstream text can contain "[/bold]"; unescaped, rich raises on it and the
|
|
52
|
+
# real error is replaced by a MarkupError traceback.
|
|
53
|
+
console.print(f"[red]Error: {escape(message)}[/]")
|
|
54
|
+
raise typer.Exit(1) from e
|
|
55
|
+
|
|
56
|
+
return wrapper
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
#: Exit status for a write whose outcome could not be determined — kept distinct
|
|
60
|
+
#: from 0 (confirmed) and 1 (failed) so a script can tell all three apart.
|
|
61
|
+
EXIT_UNDETERMINED = 2
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def governed(result: Any) -> dict:
|
|
65
|
+
"""Return a governed tool's result, or print its error and exit 1.
|
|
66
|
+
|
|
67
|
+
The ``bind9_aiops.mcp_server.tools`` twins never raise: ``@tool_errors`` flattens every
|
|
68
|
+
failure — an unreachable service, a refused login, a bad argument — into
|
|
69
|
+
``{"error": ...}``. A CLI command that drops that on the floor prints an empty
|
|
70
|
+
table and exits 0 as if the service had nothing to report. Route every governed
|
|
71
|
+
call through here.
|
|
72
|
+
"""
|
|
73
|
+
# ``outcomeUnknown`` is judged BEFORE ``error``, matching the harness: a
|
|
74
|
+
# write whose response was lost carries BOTH keys, and it is audited
|
|
75
|
+
# `unknown` precisely because it may have taken effect. Reporting that as a
|
|
76
|
+
# plain failure would tell a script the change did not happen and invite the
|
|
77
|
+
# double-apply the payload's own note warns about.
|
|
78
|
+
if isinstance(result, dict) and result.get("outcomeUnknown"):
|
|
79
|
+
console.print(f"[yellow]Outcome undetermined: {escape(str(result.get('note') or ''))}[/]")
|
|
80
|
+
raise typer.Exit(EXIT_UNDETERMINED)
|
|
81
|
+
if isinstance(result, dict) and result.get("error"):
|
|
82
|
+
console.print(f"[red]Error: {escape(str(result['error']))}[/]")
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
return result if isinstance(result, dict) else {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = None) -> None:
|
|
88
|
+
"""Print a dry-run preview of the API call that would be made."""
|
|
89
|
+
console.print("\n[bold magenta][DRY-RUN] No changes will be made.[/]")
|
|
90
|
+
console.print(f"[magenta] Operation: {operation}[/]")
|
|
91
|
+
console.print(f"[magenta] API Call: {api_call}[/]")
|
|
92
|
+
for k, v in (parameters or {}).items():
|
|
93
|
+
console.print(f"[magenta] Param: {k} = {v}[/]")
|
|
94
|
+
console.print("[magenta] Run without --dry-run to execute.[/]\n")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def double_confirm(action: str, resource: str) -> None:
|
|
98
|
+
"""Require two confirmations for a destructive operation."""
|
|
99
|
+
console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
|
|
100
|
+
typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
|
|
101
|
+
typer.confirm(
|
|
102
|
+
f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
|
|
103
|
+
abort=True,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def print_truncation_note(result: dict, noun: str) -> None:
|
|
108
|
+
"""Print a visible warning when a listing envelope was capped.
|
|
109
|
+
|
|
110
|
+
The JSON already carries ``truncated``, but a human scrolling a long dump
|
|
111
|
+
will not spot it — and neither will a model summarising the terminal. Say
|
|
112
|
+
it in words, on the last line, where it cannot be missed.
|
|
113
|
+
"""
|
|
114
|
+
if isinstance(result, dict) and result.get("truncated"):
|
|
115
|
+
console.print(
|
|
116
|
+
f"[yellow]… showing {result.get('returned')} of more {noun} — "
|
|
117
|
+
f"truncated, re-run with a higher --limit[/]"
|
|
118
|
+
)
|
bind9_aiops/cli/_root.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""BIND 9 AIops CLI; each server operation calls its governed MCP twin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from bind9_aiops.cli._common import cli_errors, console, double_confirm, governed
|
|
13
|
+
from bind9_aiops.cli.undo import undo_app
|
|
14
|
+
from bind9_aiops.governance.paths import ops_home
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="bind9-aiops", no_args_is_help=True, help="Audited BIND 9 DNS diagnosis.")
|
|
17
|
+
app.add_typer(undo_app, name="undo")
|
|
18
|
+
Target = Annotated[str | None, typer.Option("--target", "-t", help="Configured BIND 9 target")]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print(result: dict) -> None:
|
|
22
|
+
console.print_json(json.dumps(governed(result)))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("fetch-limits")
|
|
26
|
+
@cli_errors
|
|
27
|
+
def fetch_limits(target: Target = None) -> None:
|
|
28
|
+
"""Show recursive fetch limit hits."""
|
|
29
|
+
from bind9_aiops.mcp_server.tools.dns import bind_fetch_limits
|
|
30
|
+
_print(bind_fetch_limits(target=target))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command("security-roots")
|
|
34
|
+
@cli_errors
|
|
35
|
+
def security_roots(target: Target = None) -> None:
|
|
36
|
+
"""Show DNSSEC trust anchors through rndc, without a dump file."""
|
|
37
|
+
from bind9_aiops.mcp_server.tools.dns import bind_security_roots
|
|
38
|
+
_print(bind_security_roots(target=target))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command("tcp-timeouts")
|
|
42
|
+
@cli_errors
|
|
43
|
+
def tcp_timeouts(target: Target = None) -> None:
|
|
44
|
+
"""Show current DNS TCP timeouts."""
|
|
45
|
+
from bind9_aiops.mcp_server.tools.dns import bind_tcp_timeouts
|
|
46
|
+
_print(bind_tcp_timeouts(target=target))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command("signing-status")
|
|
50
|
+
@cli_errors
|
|
51
|
+
def signing_status(zone: str, target: Target = None) -> None:
|
|
52
|
+
"""Show DNSSEC signing state for a zone."""
|
|
53
|
+
from bind9_aiops.mcp_server.tools.dns import bind_signing_status
|
|
54
|
+
_print(bind_signing_status(zone=zone, target=target))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("dnssec-zone")
|
|
58
|
+
@cli_errors
|
|
59
|
+
def dnssec_zone(zone: str, target: Target = None) -> None:
|
|
60
|
+
"""Show DNSSEC signing status for a zone."""
|
|
61
|
+
from bind9_aiops.mcp_server.tools.dns import bind_dnssec_zone_status
|
|
62
|
+
_print(bind_dnssec_zone_status(zone=zone, target=target))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command("managed-keys")
|
|
66
|
+
@cli_errors
|
|
67
|
+
def managed_keys(target: Target = None) -> None:
|
|
68
|
+
"""Show managed DNSSEC trust-key status."""
|
|
69
|
+
from bind9_aiops.mcp_server.tools.dns import bind_managed_keys_status
|
|
70
|
+
_print(bind_managed_keys_status(target=target))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command("serve-stale")
|
|
74
|
+
@cli_errors
|
|
75
|
+
def serve_stale(target: Target = None) -> None:
|
|
76
|
+
"""Show serve-stale mode."""
|
|
77
|
+
from bind9_aiops.mcp_server.tools.dns import bind_serve_stale_status
|
|
78
|
+
_print(bind_serve_stale_status(target=target))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command("show-zone")
|
|
82
|
+
@cli_errors
|
|
83
|
+
def show_zone(zone: str, target: Target = None) -> None:
|
|
84
|
+
"""Show running zone configuration when enabled."""
|
|
85
|
+
from bind9_aiops.mcp_server.tools.dns import bind_show_zone
|
|
86
|
+
_print(bind_show_zone(zone=zone, target=target))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command("query-server")
|
|
90
|
+
@cli_errors
|
|
91
|
+
def query_server(name: str, server: str, rrtype: str = "A", target: Target = None) -> None:
|
|
92
|
+
"""Query a specified DNS server."""
|
|
93
|
+
from bind9_aiops.mcp_server.tools.dns import bind_query_server
|
|
94
|
+
_print(bind_query_server(name=name, server=server, rrtype=rrtype, target=target))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command("reload-zone")
|
|
98
|
+
@cli_errors
|
|
99
|
+
def reload_zone(zone: str, dry_run: bool = False, target: Target = None) -> None:
|
|
100
|
+
"""Check config, then queue a zone reload."""
|
|
101
|
+
from bind9_aiops.mcp_server.tools.dns import bind_reload_zone
|
|
102
|
+
if not dry_run:
|
|
103
|
+
double_confirm("reload zone", zone)
|
|
104
|
+
_print(bind_reload_zone(zone=zone, dry_run=dry_run, target=target))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command("refresh-zone")
|
|
108
|
+
@cli_errors
|
|
109
|
+
def refresh_zone(zone: str, dry_run: bool = False, target: Target = None) -> None:
|
|
110
|
+
"""Request a secondary zone refresh."""
|
|
111
|
+
from bind9_aiops.mcp_server.tools.dns import bind_refresh_zone
|
|
112
|
+
if not dry_run:
|
|
113
|
+
double_confirm("refresh zone", zone)
|
|
114
|
+
_print(bind_refresh_zone(zone=zone, dry_run=dry_run, target=target))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command("flush-name")
|
|
118
|
+
@cli_errors
|
|
119
|
+
def flush_name(name: str, dry_run: bool = False, target: Target = None) -> None:
|
|
120
|
+
"""Flush one name from the DNS cache."""
|
|
121
|
+
from bind9_aiops.mcp_server.tools.dns import bind_flush_name
|
|
122
|
+
if not dry_run:
|
|
123
|
+
double_confirm("flush cached name", name)
|
|
124
|
+
_print(bind_flush_name(name=name, dry_run=dry_run, target=target))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command("retransfer-zone")
|
|
128
|
+
@cli_errors
|
|
129
|
+
def retransfer_zone(zone: str, dry_run: bool = False, target: Target = None) -> None:
|
|
130
|
+
"""Request a fresh transfer of one secondary zone."""
|
|
131
|
+
from bind9_aiops.mcp_server.tools.dns import bind_retransfer_zone
|
|
132
|
+
if not dry_run:
|
|
133
|
+
double_confirm("retransfer secondary zone", zone)
|
|
134
|
+
_print(bind_retransfer_zone(zone=zone, dry_run=dry_run, target=target))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command("notify-zone")
|
|
138
|
+
@cli_errors
|
|
139
|
+
def notify_zone(zone: str, dry_run: bool = False, target: Target = None) -> None:
|
|
140
|
+
"""Resend NOTIFY for one zone."""
|
|
141
|
+
from bind9_aiops.mcp_server.tools.dns import bind_notify_zone
|
|
142
|
+
if not dry_run:
|
|
143
|
+
double_confirm("resend zone NOTIFY", zone)
|
|
144
|
+
_print(bind_notify_zone(zone=zone, dry_run=dry_run, target=target))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command("init")
|
|
148
|
+
@cli_errors
|
|
149
|
+
def init() -> None:
|
|
150
|
+
"""Configure a local BIND 9 service target."""
|
|
151
|
+
name = typer.prompt("Target name", default="local")
|
|
152
|
+
server = typer.prompt("DNS server address", default="127.0.0.1")
|
|
153
|
+
from bind9_aiops.config import TargetConfig
|
|
154
|
+
|
|
155
|
+
chosen = TargetConfig(name=name, server=server)
|
|
156
|
+
path = ops_home() / "config.yaml"
|
|
157
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
if path.exists():
|
|
159
|
+
raise ValueError(f"Config already exists at {path}; edit it to add a target.")
|
|
160
|
+
path.write_text(yaml.safe_dump({"targets": [{"name": chosen.name, "server": chosen.server}]}))
|
|
161
|
+
path.chmod(0o600)
|
|
162
|
+
console.print(f"Configured {name} at {path}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command("status")
|
|
166
|
+
@cli_errors
|
|
167
|
+
def status(target: Target = None) -> None:
|
|
168
|
+
"""Show named status."""
|
|
169
|
+
from bind9_aiops.mcp_server.tools.dns import bind_status
|
|
170
|
+
|
|
171
|
+
_print(bind_status(target=target))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@app.command("config-check")
|
|
175
|
+
@cli_errors
|
|
176
|
+
def config_check(target: Target = None) -> None:
|
|
177
|
+
"""Validate config and zone files."""
|
|
178
|
+
from bind9_aiops.mcp_server.tools.dns import bind_config_check
|
|
179
|
+
|
|
180
|
+
_print(bind_config_check(target=target))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command("zone-status")
|
|
184
|
+
@cli_errors
|
|
185
|
+
def zone_status(zone: str, target: Target = None) -> None:
|
|
186
|
+
"""Show live zone status."""
|
|
187
|
+
from bind9_aiops.mcp_server.tools.dns import bind_zone_status
|
|
188
|
+
|
|
189
|
+
_print(bind_zone_status(zone=zone, target=target))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command("dnssec")
|
|
193
|
+
@cli_errors
|
|
194
|
+
def dnssec(target: Target = None) -> None:
|
|
195
|
+
"""Show DNSSEC validation status."""
|
|
196
|
+
from bind9_aiops.mcp_server.tools.dns import bind_dnssec_validation
|
|
197
|
+
|
|
198
|
+
_print(bind_dnssec_validation(target=target))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.command("query")
|
|
202
|
+
@cli_errors
|
|
203
|
+
def query(name: str, rrtype: str = "A", target: Target = None) -> None:
|
|
204
|
+
"""Query a DNS record."""
|
|
205
|
+
from bind9_aiops.mcp_server.tools.dns import bind_query
|
|
206
|
+
|
|
207
|
+
_print(bind_query(name=name, rrtype=rrtype, target=target))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command("compare-soa")
|
|
211
|
+
@cli_errors
|
|
212
|
+
def compare_soa(zone: str, secondary: str, target: Target = None) -> None:
|
|
213
|
+
"""Compare primary and secondary SOA serials."""
|
|
214
|
+
from bind9_aiops.mcp_server.tools.dns import bind_compare_soa
|
|
215
|
+
|
|
216
|
+
_print(bind_compare_soa(zone=zone, secondary=secondary, target=target))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.command("diagnose")
|
|
220
|
+
@cli_errors
|
|
221
|
+
def diagnose(zone: str, target: Target = None) -> None:
|
|
222
|
+
"""Diagnose a BIND 9 zone."""
|
|
223
|
+
from bind9_aiops.mcp_server.tools.dns import bind_diagnose_zone
|
|
224
|
+
|
|
225
|
+
_print(bind_diagnose_zone(zone=zone, target=target))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@app.command("doctor")
|
|
229
|
+
@cli_errors
|
|
230
|
+
def doctor(target: Target = None) -> None:
|
|
231
|
+
"""Test access to rndc status."""
|
|
232
|
+
from bind9_aiops.mcp_server.tools.dns import bind_status
|
|
233
|
+
|
|
234
|
+
_print(bind_status(target=target))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@app.command("mcp")
|
|
238
|
+
@cli_errors
|
|
239
|
+
def mcp_cmd() -> None:
|
|
240
|
+
"""Start stdio MCP server."""
|
|
241
|
+
if sys.version_info < (3, 11):
|
|
242
|
+
raise typer.Exit(2)
|
|
243
|
+
from bind9_aiops.mcp_server.server import main
|
|
244
|
+
|
|
245
|
+
main()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
app()
|
bind9_aiops/cli/undo.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""``bind9-aiops undo`` — list recorded undo tokens and apply them.
|
|
2
|
+
|
|
3
|
+
Real execution is delegated to the ``@governed_tool``-wrapped functions in
|
|
4
|
+
``bind9_aiops.mcp_server.tools.undo`` so an applied undo is audited on the SAME governance
|
|
5
|
+
path as any other write (the inverse tool it dispatches is itself governed).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from bind9_aiops.cli._common import (
|
|
16
|
+
DryRunOption,
|
|
17
|
+
TargetOption,
|
|
18
|
+
cli_errors,
|
|
19
|
+
console,
|
|
20
|
+
double_confirm,
|
|
21
|
+
dry_run_print,
|
|
22
|
+
governed,
|
|
23
|
+
print_truncation_note,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
undo_app = typer.Typer(
|
|
27
|
+
name="undo",
|
|
28
|
+
help="List recorded undo tokens and apply their inverse operations.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
UndoIdArg = Annotated[str, typer.Argument(help="Undo id from 'undo list'")]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@undo_app.command("list")
|
|
36
|
+
@cli_errors
|
|
37
|
+
def undo_list_cmd(
|
|
38
|
+
limit: Annotated[int, typer.Option("--limit", help="Max tokens to show")] = 50,
|
|
39
|
+
target: TargetOption = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""List recorded, not-yet-applied undo tokens."""
|
|
42
|
+
from bind9_aiops.mcp_server.tools import undo as gov
|
|
43
|
+
|
|
44
|
+
result = governed(gov.undo_list(limit=limit, target=target))
|
|
45
|
+
console.print_json(json.dumps(result))
|
|
46
|
+
print_truncation_note(result, "undo tokens")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@undo_app.command("apply")
|
|
50
|
+
@cli_errors
|
|
51
|
+
def undo_apply_cmd(
|
|
52
|
+
undo_id: UndoIdArg, target: TargetOption = None, dry_run: DryRunOption = False
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Apply a recorded undo (dispatches its inverse tool; dry-run + confirm)."""
|
|
55
|
+
from bind9_aiops.mcp_server.tools import undo as gov
|
|
56
|
+
|
|
57
|
+
if dry_run:
|
|
58
|
+
preview = governed(gov.undo_apply(undo_id=undo_id, dry_run=True, target=target))
|
|
59
|
+
dry_run_print(
|
|
60
|
+
operation="undo_apply",
|
|
61
|
+
api_call=f"inverse: {preview.get('wouldApply', {}).get('tool', '?')}",
|
|
62
|
+
parameters=preview.get("wouldApply", {}).get("params", {}),
|
|
63
|
+
)
|
|
64
|
+
return
|
|
65
|
+
double_confirm("apply undo", undo_id)
|
|
66
|
+
console.print_json(json.dumps(governed(gov.undo_apply(undo_id=undo_id, target=target))))
|
bind9_aiops/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Local BIND 9 target configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from bind9_aiops.governance.paths import ops_home
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class TargetConfig:
|
|
16
|
+
name: str
|
|
17
|
+
server: str = "127.0.0.1"
|
|
18
|
+
dns_port: int = 53
|
|
19
|
+
rndc_config: str | None = None
|
|
20
|
+
named_config: str | None = None
|
|
21
|
+
timeout: float = 10.0
|
|
22
|
+
rndc_bin: str = "rndc"
|
|
23
|
+
checkconf_bin: str = "named-checkconf"
|
|
24
|
+
dig_bin: str = "dig"
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if not self.name or not self.server or self.server.startswith("-"):
|
|
28
|
+
raise ValueError("name and server are required; server cannot start with '-'.")
|
|
29
|
+
if not 1 <= self.dns_port <= 65535 or self.timeout <= 0:
|
|
30
|
+
raise ValueError("dns_port must be 1–65535 and timeout must be positive.")
|
|
31
|
+
for binary in (self.rndc_bin, self.checkconf_bin, self.dig_bin):
|
|
32
|
+
if not binary or binary.startswith("-"):
|
|
33
|
+
raise ValueError("Command binaries must be configured by the operator.")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class AppConfig:
|
|
38
|
+
targets: tuple[TargetConfig, ...] = ()
|
|
39
|
+
|
|
40
|
+
def get_target(self, name: str | None = None) -> TargetConfig:
|
|
41
|
+
if name is None:
|
|
42
|
+
if not self.targets:
|
|
43
|
+
raise ValueError("No BIND 9 target configured. Run 'bind9-aiops init'.")
|
|
44
|
+
return self.targets[0]
|
|
45
|
+
for target in self.targets:
|
|
46
|
+
if target.name == name:
|
|
47
|
+
return target
|
|
48
|
+
raise KeyError(f"Unknown BIND 9 target '{name}'.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_config(path: Path | None = None) -> AppConfig:
|
|
52
|
+
path = path or Path(os.environ.get("BIND9_AIOPS_CONFIG", ops_home() / "config.yaml"))
|
|
53
|
+
if not path.is_file():
|
|
54
|
+
raise FileNotFoundError(f"No BIND 9 config at {path}. Run 'bind9-aiops init'.")
|
|
55
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
56
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("targets"), list):
|
|
57
|
+
raise ValueError("BIND 9 config must have a targets list.")
|
|
58
|
+
return AppConfig(tuple(TargetConfig(**entry) for entry in raw["targets"]))
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Bounded execution of official BIND 9 administrative commands; no shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from bind9_aiops.config import AppConfig, TargetConfig, load_config
|
|
10
|
+
|
|
11
|
+
_DOMAIN = re.compile(r"^(?:[A-Za-z0-9_*-]{1,63}\.)*[A-Za-z0-9_*-]{1,63}\.?$")
|
|
12
|
+
_RRTYPES = {"A", "AAAA", "CNAME", "DNSKEY", "DS", "MX", "NS", "RRSIG", "SOA", "TXT"}
|
|
13
|
+
_OUTPUT_MAX = 12000
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BindCommandError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BindWriteUncertainError(BindCommandError):
|
|
21
|
+
"""The rndc write may have reached named before the response was lost."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def domain(value: str) -> str:
|
|
25
|
+
if not _DOMAIN.fullmatch(value) or value.startswith("-") or ".." in value:
|
|
26
|
+
raise ValueError("Invalid DNS name; use a domain or host name without command options.")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BindConnection:
|
|
31
|
+
def __init__(self, target: TargetConfig, runner: Any = subprocess.run) -> None:
|
|
32
|
+
self.target = target
|
|
33
|
+
self._run = runner
|
|
34
|
+
|
|
35
|
+
def _command(self, argv: list[str], *, write: bool = False) -> dict:
|
|
36
|
+
try:
|
|
37
|
+
result = self._run(
|
|
38
|
+
argv, capture_output=True, text=True, timeout=self.target.timeout, check=False
|
|
39
|
+
)
|
|
40
|
+
except FileNotFoundError as exc:
|
|
41
|
+
raise BindCommandError(
|
|
42
|
+
f"Missing BIND command '{argv[0]}'; install bind9 utilities or configure its path."
|
|
43
|
+
) from exc
|
|
44
|
+
except subprocess.TimeoutExpired as exc:
|
|
45
|
+
error_type = BindWriteUncertainError if write else BindCommandError
|
|
46
|
+
raise error_type(
|
|
47
|
+
f"BIND command '{argv[0]}' timed out after {self.target.timeout:g}s."
|
|
48
|
+
) from exc
|
|
49
|
+
if result.returncode != 0:
|
|
50
|
+
detail = (result.stderr or result.stdout or "unknown error")[:800]
|
|
51
|
+
raise BindCommandError(
|
|
52
|
+
f"BIND command '{argv[0]}' failed (exit {result.returncode}): {detail}"
|
|
53
|
+
)
|
|
54
|
+
output = result.stdout or ""
|
|
55
|
+
return {
|
|
56
|
+
"output": output[:_OUTPUT_MAX] + ("…" if len(output) > _OUTPUT_MAX else ""),
|
|
57
|
+
"truncated": len(output) > _OUTPUT_MAX,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def rndc(self, *arguments: str) -> dict:
|
|
61
|
+
argv = [self.target.rndc_bin]
|
|
62
|
+
if self.target.rndc_config:
|
|
63
|
+
argv.extend(["-c", self.target.rndc_config])
|
|
64
|
+
argv.extend(arguments)
|
|
65
|
+
return self._command(argv)
|
|
66
|
+
|
|
67
|
+
def rndc_write(self, *arguments: str) -> dict:
|
|
68
|
+
argv = [self.target.rndc_bin]
|
|
69
|
+
if self.target.rndc_config:
|
|
70
|
+
argv.extend(["-c", self.target.rndc_config])
|
|
71
|
+
argv.extend(arguments)
|
|
72
|
+
return self._command(argv, write=True)
|
|
73
|
+
|
|
74
|
+
def check_config(self) -> dict:
|
|
75
|
+
argv = [self.target.checkconf_bin, "-z"]
|
|
76
|
+
if self.target.named_config:
|
|
77
|
+
argv.append(self.target.named_config)
|
|
78
|
+
return self._command(argv)
|
|
79
|
+
|
|
80
|
+
def query(self, name: str, rrtype: str = "SOA", server: str | None = None) -> dict:
|
|
81
|
+
name = domain(name)
|
|
82
|
+
rrtype = rrtype.upper()
|
|
83
|
+
if rrtype not in _RRTYPES:
|
|
84
|
+
raise ValueError(f"Unsupported RR type '{rrtype}'.")
|
|
85
|
+
server = server or self.target.server
|
|
86
|
+
if not server or server.startswith("-") or any(c.isspace() for c in server):
|
|
87
|
+
raise ValueError("Invalid DNS server address.")
|
|
88
|
+
argv = [
|
|
89
|
+
self.target.dig_bin,
|
|
90
|
+
f"@{server}",
|
|
91
|
+
"-p",
|
|
92
|
+
str(self.target.dns_port),
|
|
93
|
+
name,
|
|
94
|
+
rrtype,
|
|
95
|
+
"+tries=1",
|
|
96
|
+
"+time=2",
|
|
97
|
+
"+noall",
|
|
98
|
+
"+comments",
|
|
99
|
+
"+answer",
|
|
100
|
+
]
|
|
101
|
+
result = self._command(argv)
|
|
102
|
+
match = re.search(r"\bstatus:\s*([A-Z]+)", result["output"])
|
|
103
|
+
result.update(
|
|
104
|
+
{
|
|
105
|
+
"name": name,
|
|
106
|
+
"rrtype": rrtype,
|
|
107
|
+
"server": server,
|
|
108
|
+
"rcode": match.group(1) if match else None,
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ConnectionManager:
|
|
115
|
+
def __init__(self, config: AppConfig | None = None) -> None:
|
|
116
|
+
self.config = config or load_config()
|
|
117
|
+
|
|
118
|
+
def connect(self, target: str | None = None) -> BindConnection:
|
|
119
|
+
return BindConnection(self.config.get_target(target))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Governance harness for bind9-aiops — audit, policy, budget, undo, sanitize.
|
|
2
|
+
|
|
3
|
+
A self-contained, vendored governance layer. bind9-aiops has NO dependency on
|
|
4
|
+
any external skill family — this package is its own copy of the harness:
|
|
5
|
+
|
|
6
|
+
- ``@governed_tool`` — mandatory decorator on every MCP tool: policy pre-check,
|
|
7
|
+
token/runaway budget guard, graduated-autonomy risk-tier gate, audit logging,
|
|
8
|
+
and undo-token recording.
|
|
9
|
+
- unified SQLite audit log under ``~/.bind9-aiops/`` (override with
|
|
10
|
+
``BIND9_AIOPS_HOME``).
|
|
11
|
+
- ``sanitize`` — output hygiene (encoding-level defense) for API-returned text.
|
|
12
|
+
|
|
13
|
+
State lives under ``ops_home()`` (default ``~/.bind9-aiops``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from bind9_aiops.governance.audit import AuditEngine, get_engine
|
|
17
|
+
from bind9_aiops.governance.budget import BudgetExceeded, BudgetTracker, get_budget
|
|
18
|
+
from bind9_aiops.governance.decorators import PolicyDenied, governed_tool
|
|
19
|
+
from bind9_aiops.governance.outcome import (
|
|
20
|
+
UNKNOWN_FIELD,
|
|
21
|
+
capture_prior_state,
|
|
22
|
+
is_unknown,
|
|
23
|
+
mark_unknown,
|
|
24
|
+
)
|
|
25
|
+
from bind9_aiops.governance.patterns import Pattern, PatternMatch, get_pattern_engine
|
|
26
|
+
from bind9_aiops.governance.policy import get_policy_engine
|
|
27
|
+
from bind9_aiops.governance.sanitize import opt_str, sanitize
|
|
28
|
+
from bind9_aiops.governance.undo import UndoStore, get_undo_store
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"governed_tool",
|
|
32
|
+
"sanitize",
|
|
33
|
+
"opt_str",
|
|
34
|
+
"capture_prior_state",
|
|
35
|
+
"mark_unknown",
|
|
36
|
+
"is_unknown",
|
|
37
|
+
"UNKNOWN_FIELD",
|
|
38
|
+
"PolicyDenied",
|
|
39
|
+
"get_engine",
|
|
40
|
+
"AuditEngine",
|
|
41
|
+
"get_policy_engine",
|
|
42
|
+
"get_budget",
|
|
43
|
+
"BudgetTracker",
|
|
44
|
+
"BudgetExceeded",
|
|
45
|
+
"get_undo_store",
|
|
46
|
+
"UndoStore",
|
|
47
|
+
"Pattern",
|
|
48
|
+
"PatternMatch",
|
|
49
|
+
"get_pattern_engine",
|
|
50
|
+
]
|