Initial release: airndb

This commit is contained in:
admin
2026-05-18 11:45:05 +08:00
commit d5078999a5
9 changed files with 1383 additions and 0 deletions

441
scripts/airndb_capture.py Normal file
View File

@@ -0,0 +1,441 @@
#!/usr/bin/env python3
"""Run safe bounded tcpdump/WinDump capture helpers for AirNDB."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import platform
import shlex
import shutil
import subprocess
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
DEFAULT_COUNT = 200
DEFAULT_TIMEOUT = 30
MAX_COUNT = 500000
WINDUMP_DOWNLOAD_PAGE = "https://www.winpcap.org/windump/install/"
WINDUMP_DOWNLOAD_URL = "https://www.winpcap.org/windump/install/bin/windump_3_9_5/WinDump.exe"
WINDUMP_SHA1 = "d59bc54721951dec855cbb4bbc000f9a71ea4d95"
def now_stamp() -> str:
return datetime.now().strftime("%Y%m%d-%H%M%S")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def is_windows() -> bool:
return platform.system().lower().startswith("win")
def parse_env_file(path: Path) -> Dict[str, str]:
if not path.exists():
return {}
values: Dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"')
return values
def project_tool_env(project_root: Optional[Path]) -> Dict[str, str]:
if not project_root:
return {}
return parse_env_file(project_root / "AirPlan" / "state" / "airndb" / "tool.env")
def tool_dir(project_root: Optional[Path] = None) -> Path:
configured = os.environ.get("AIRNDB_TOOL_DIR", "").strip()
if configured:
return Path(configured).expanduser().resolve()
if project_root:
return (project_root / "AirPlan" / "state" / "airndb" / "tools").resolve()
return Path.home() / ".airndb" / "tools"
def detect_tool(explicit: str = "", project_root: Optional[Path] = None) -> Optional[str]:
candidates: List[str] = []
if explicit:
candidates.append(explicit)
project_env = project_tool_env(project_root)
project_tool = project_env.get("AIRNDB_TCPDUMP", "")
if project_tool:
candidates.append(project_tool)
env_tool = os.environ.get("AIRNDB_TCPDUMP", "")
if env_tool:
candidates.append(env_tool)
bundled = tool_dir(project_root) / "WinDump.exe"
if bundled.exists():
candidates.append(str(bundled))
candidates.extend(["windump", "WinDump.exe", "tcpdump"] if is_windows() else ["tcpdump", "windump", "WinDump.exe"])
for candidate in candidates:
if Path(candidate).exists():
return str(Path(candidate).resolve())
found = shutil.which(candidate)
if found:
return found
return None
def sha1_file(path: Path) -> str:
digest = hashlib.sha1()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_project_tool_env(project_root: Path, tool_path: Path) -> Path:
airndb_dir = project_root / "AirPlan" / "state" / "airndb"
airndb_dir.mkdir(parents=True, exist_ok=True)
env_path = airndb_dir / "tool.env"
env_path.write_text(f"AIRNDB_TCPDUMP={tool_path}\n", encoding="utf-8", newline="\n")
gitignore = airndb_dir / ".gitignore"
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
if "tool.env" not in existing.splitlines():
gitignore.write_text((existing.rstrip() + "\ntool.env\n").lstrip(), encoding="utf-8", newline="\n")
return env_path
def download_windump(target: Path, timeout: int = 60) -> Dict[str, Any]:
target.parent.mkdir(parents=True, exist_ok=True)
temp = target.with_suffix(".download")
try:
with urllib.request.urlopen(WINDUMP_DOWNLOAD_URL, timeout=timeout) as response:
data = response.read()
temp.write_bytes(data)
actual_sha1 = sha1_file(temp)
if actual_sha1.lower() != WINDUMP_SHA1.lower():
temp.unlink(missing_ok=True)
return {
"status": "failed",
"reason": "sha1_mismatch",
"expectedSha1": WINDUMP_SHA1,
"actualSha1": actual_sha1,
"downloadPage": WINDUMP_DOWNLOAD_PAGE,
"downloadUrl": WINDUMP_DOWNLOAD_URL,
}
if target.exists():
target.unlink()
temp.replace(target)
return {
"status": "downloaded",
"path": str(target),
"sha1": actual_sha1,
"downloadPage": WINDUMP_DOWNLOAD_PAGE,
"downloadUrl": WINDUMP_DOWNLOAD_URL,
}
except (urllib.error.URLError, TimeoutError, OSError) as exc:
temp.unlink(missing_ok=True)
return {
"status": "failed",
"reason": type(exc).__name__,
"message": str(exc),
"downloadPage": WINDUMP_DOWNLOAD_PAGE,
"downloadUrl": WINDUMP_DOWNLOAD_URL,
}
def ensure_capture_tool(project_root: Path, explicit: str = "", auto_install: bool = False) -> Dict[str, Any]:
existing = detect_tool(explicit, project_root)
if existing:
env_path = write_project_tool_env(project_root, Path(existing)) if Path(existing).exists() else None
return {
"status": "ok",
"tool": existing,
"source": "existing",
"projectEnv": str(env_path) if env_path else "",
}
if not is_windows():
return {
"status": "missing",
"tool": "",
"reason": "tcpdump_or_windump_not_found",
"hint": "Install tcpdump or pass --tool <path>.",
}
if not auto_install:
return {
"status": "missing",
"tool": "",
"reason": "tcpdump_or_windump_not_found",
"hint": "Run /airndb enter or pass --tool <path>.",
"downloadPage": WINDUMP_DOWNLOAD_PAGE,
}
target = tool_dir(project_root) / "WinDump.exe"
download = download_windump(target)
if download.get("status") != "downloaded":
return {
"status": "missing",
"tool": "",
"reason": "windump_download_failed",
"download": download,
"hint": "Install tcpdump/WinDump manually or set AIRNDB_TCPDUMP.",
}
env_path = write_project_tool_env(project_root, target)
return {
"status": "ok",
"tool": str(target),
"source": "downloaded",
"projectEnv": str(env_path),
"download": download,
"driverHint": "WinDump still requires a packet capture driver such as Npcap or WinPcap; install one manually if interface listing fails.",
}
def split_filter(filter_expr: str) -> List[str]:
if not filter_expr.strip():
return []
try:
return shlex.split(filter_expr, posix=not is_windows())
except ValueError:
return filter_expr.split()
def display_command(command: Sequence[str]) -> str:
if is_windows():
return subprocess.list2cmdline(list(command))
return shlex.join(list(command))
def tail(text: str, limit: int = 8000) -> str:
if len(text) <= limit:
return text
return text[-limit:]
def bounded_count(value: int) -> int:
if value < 1:
raise SystemExit("--count must be >= 1")
if value > MAX_COUNT:
raise SystemExit(f"--count must be <= {MAX_COUNT}")
return value
def capture_dir(project_root: Path, output_dir: str = "") -> Path:
path = Path(output_dir).expanduser().resolve() if output_dir else project_root / "AirPlan" / "docs" / "network" / "airndb-captures"
path.mkdir(parents=True, exist_ok=True)
return path
def base_capture_command(tool: str, iface: str, output: Path, count: int, filter_expr: str) -> List[str]:
if not iface:
raise SystemExit("--iface is required for capture or command")
command = [tool, "-i", iface, "-nn", "-s", "0", "-w", str(output), "-c", str(bounded_count(count))]
command.extend(split_filter(filter_expr))
return command
def read_command(tool: str, read_file: Path, filter_expr: str) -> List[str]:
if not read_file.exists():
raise SystemExit(f"read file not found: {read_file}")
command = [tool, "-nn", "-tttt", "-r", str(read_file)]
command.extend(split_filter(filter_expr))
return command
def run_command(command: Sequence[str], timeout: int) -> Dict[str, Any]:
started = now_iso()
try:
completed = subprocess.run(
list(command),
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
return {
"startedAt": started,
"finishedAt": now_iso(),
"timedOut": False,
"returnCode": completed.returncode,
"stdout": tail(completed.stdout),
"stderr": tail(completed.stderr),
}
except subprocess.TimeoutExpired as exc:
return {
"startedAt": started,
"finishedAt": now_iso(),
"timedOut": True,
"returnCode": None,
"stdout": tail((exc.stdout or "") if isinstance(exc.stdout, str) else ""),
"stderr": tail((exc.stderr or "") if isinstance(exc.stderr, str) else ""),
}
def write_report(output_dir: Path, action: str, payload: Dict[str, Any]) -> Path:
report_path = output_dir / f"{now_stamp()}-airndb-{action}.json"
report_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return report_path
def append_log(project_root: Path, payload: Dict[str, Any]) -> None:
log_path = project_root / "AirPlan" / "docs" / "network" / "airndb-log.md"
log_path.parent.mkdir(parents=True, exist_ok=True)
lines = [
"",
f"## {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: AirNDB {payload['action']}",
"",
f"- Tool: `{payload.get('tool', 'missing')}`",
f"- Interface: `{payload.get('interface', '')}`",
f"- Filter: `{payload.get('filter', '')}`",
f"- Command: `{payload.get('command', '')}`",
f"- Artifacts: `{payload.get('artifact', '')}`",
f"- Report: `{payload.get('report', '')}`",
f"- Result: `{payload.get('result', '')}`",
"- AirDbg handoff: TODO",
"- Residual risk: pcap files may contain sensitive packet contents; review before sharing.",
]
with log_path.open("a", encoding="utf-8", newline="\n") as handle:
handle.write("\n".join(lines) + "\n")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="AirNDB tcpdump/WinDump helper.")
parser.add_argument("--project", default=".")
parser.add_argument("--action", choices=["interfaces", "command", "capture", "read"], default="interfaces")
parser.add_argument("--tool", default="", help="Explicit tcpdump/WinDump path.")
parser.add_argument("--iface", default="", help="Interface name or WinDump interface index.")
parser.add_argument("--filter", default="", help="BPF filter expression.")
parser.add_argument("--count", type=int, default=DEFAULT_COUNT)
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
parser.add_argument("--output-dir", default="")
parser.add_argument("--output", default="")
parser.add_argument("--read-file", default="")
return parser.parse_args()
def main() -> None:
args = parse_args()
project_root = Path(args.project).expanduser().resolve()
output_dir = capture_dir(project_root, args.output_dir)
tool = detect_tool(args.tool, project_root)
if not tool:
print("airndb_status=blocked")
print("reason=tcpdump_or_windump_not_found")
print("hint=Install tcpdump on Unix-like systems or WinDump/Npcap on Windows, or pass --tool <path>.")
raise SystemExit(2)
if args.action == "interfaces":
command = [tool, "-D"]
result = run_command(command, max(5, args.timeout))
payload = {
"action": "interfaces",
"tool": tool,
"command": display_command(command),
"result": result,
}
report = write_report(output_dir, "interfaces", payload)
print("airndb_status=ok" if result["returnCode"] == 0 else "airndb_status=failed")
print(f"tool={tool}")
print(f"command={display_command(command)}")
print(f"report={report}")
print(result.get("stdout", ""))
print(result.get("stderr", ""))
append_log(project_root, {
"action": "interfaces",
"tool": tool,
"interface": "",
"filter": "",
"command": display_command(command),
"artifact": "",
"report": str(report),
"result": "ok" if result["returnCode"] == 0 else "failed",
})
return
if args.action == "command":
output = Path(args.output).expanduser().resolve() if args.output else output_dir / f"{now_stamp()}-airndb.pcap"
command = base_capture_command(tool, args.iface, output, args.count, args.filter)
print("airndb_status=ok")
print(f"capture_command={display_command(command)}")
print(f"output={output}")
return
if args.action == "capture":
output = Path(args.output).expanduser().resolve() if args.output else output_dir / f"{now_stamp()}-airndb.pcap"
command = base_capture_command(tool, args.iface, output, args.count, args.filter)
result = run_command(command, max(1, args.timeout))
payload = {
"action": "capture",
"tool": tool,
"interface": args.iface,
"filter": args.filter,
"count": bounded_count(args.count),
"timeoutSeconds": args.timeout,
"pcap": str(output),
"command": display_command(command),
"result": result,
}
report = write_report(output_dir, "capture", payload)
status = "timeout" if result["timedOut"] else ("ok" if result["returnCode"] == 0 else "failed")
print(f"airndb_status={status}")
print(f"pcap={output}")
print(f"report={report}")
print(f"command={display_command(command)}")
append_log(project_root, {
"action": "capture",
"tool": tool,
"interface": args.iface,
"filter": args.filter,
"command": display_command(command),
"artifact": str(output),
"report": str(report),
"result": status,
})
return
if args.action == "read":
if not args.read_file:
raise SystemExit("--read-file is required for read")
read_file = Path(args.read_file).expanduser()
if not read_file.is_absolute():
read_file = project_root / read_file
read_file = read_file.resolve()
command = read_command(tool, read_file, args.filter)
result = run_command(command, max(1, args.timeout))
summary_path = output_dir / f"{now_stamp()}-airndb-read.txt"
summary_path.write_text((result.get("stdout") or "") + (result.get("stderr") or ""), encoding="utf-8", newline="\n")
payload = {
"action": "read",
"tool": tool,
"readFile": str(read_file),
"filter": args.filter,
"summary": str(summary_path),
"command": display_command(command),
"result": result,
}
report = write_report(output_dir, "read", payload)
status = "ok" if result["returnCode"] == 0 else "failed"
print(f"airndb_status={status}")
print(f"summary={summary_path}")
print(f"report={report}")
print(f"command={display_command(command)}")
append_log(project_root, {
"action": "read",
"tool": tool,
"interface": "",
"filter": args.filter,
"command": display_command(command),
"artifact": str(summary_path),
"report": str(report),
"result": status,
})
if __name__ == "__main__":
main()

271
scripts/airndb_mode.py Normal file
View File

@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""Bootstrap AirNDB network debugging context files."""
from __future__ import annotations
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Tuple
from airndb_capture import ensure_capture_tool
MARKER_BEGIN = "<!-- AIRNDB:BEGIN -->"
MARKER_END = "<!-- AIRNDB:END -->"
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def airndb_agents_block() -> str:
return f"""{MARKER_BEGIN}
## AirNDB Network Debug Workflow
1. Use AirNDB for `/airndb` sessions that need tcpdump/WinDump packet capture, pcap reading, BPF filters, or network evidence.
2. On first entry, detect tcpdump/WinDump; on Windows, if none is available, download official `WinDump.exe` from https://www.winpcap.org/windump/install/, verify SHA1, and write `AirPlan/state/airndb/tool.env`.
3. Only capture authorized traffic; prefer short bounded captures with `-nn`, `-s 0`, `-c <count>`, and narrow BPF filters.
4. Before capture, load:
- `AirPlan/AGENTS.md`
- `AirPlan/docs/architecture/adr/`
- `AirPlan/docs/architecture/c4/module.md`
- `AirPlan/docs/network/airndb-log.md`
5. Store pcap, text summaries, and JSON reports under `AirPlan/docs/network/airndb-captures/`.
6. Record exact command, interface, filter, capture window, artifact paths, key observations, and residual risk in `AirPlan/docs/network/airndb-log.md`.
7. AirDbg may call AirNDB when debugging needs DNS/TCP/UDP/TLS/HTTP, ports, proxy, firewall, packet-loss, retransmit, reset, or pcap evidence.
8. Update ADR/C4 when network boundaries, capture tooling, observability, ports, protocols, DNS, proxy, TLS, or runtime topology become durable architecture context.
{MARKER_END}
"""
def c4_module_template() -> str:
return """# C4 Module
## System Context
- TODO: Describe the system, users, and important external systems.
## Containers
- TODO: Describe runtime/deployable units and network boundaries.
## Modules
| Module | Responsibility | Network Interfaces | Dependencies | Data Ownership | Network Debug Notes |
| --- | --- | --- | --- | --- | --- |
| TODO | TODO | TODO | TODO | TODO | TODO |
## Network / Observability Boundaries
- tcpdump/WinDump capture points, interfaces, container/WSL/VM/host boundaries: TODO
- Ports, protocols, DNS, proxy, TLS, firewall, NAT, or gateway notes: TODO
## Change Log
- TODO: Record network-boundary or observability changes discovered by AirNDB.
"""
def adr_template() -> str:
return """# ADR-0001: AirNDB Packet Capture Governance
- Status: Accepted
- Date: TODO
## Context
Network debugging needs durable, AI-readable packet capture context and bounded evidence collection.
## Decision
Use AirNDB to build safe tcpdump/WinDump commands, capture or read pcap artifacts, and maintain `AirPlan/docs/network/airndb-log.md`, C4 module docs, and ADR records when network boundaries or diagnostics change.
## Consequences
- Packet evidence can be reused by future AirDbg or AirNDB sessions.
- Captures must stay bounded and authorized.
- pcap artifacts may contain sensitive data and need careful handling.
## Alternatives
- Chat-only packet notes: rejected because commands, filters, and pcap paths are easy to lose.
"""
def network_log_template() -> str:
return """# AirNDB Network Debug Log
Append entries for AirNDB packet capture sessions.
## Entry Template
### YYYY-MM-DD: short network issue title
- Scope / authorization: TODO
- Symptom: TODO
- Interface: TODO
- Filter: TODO
- Capture window: TODO
- Command: TODO
- Artifacts: TODO
- Key observations: TODO
- AirDbg handoff: TODO
- ADR/C4 updates: TODO
- Residual risk: TODO
"""
def captures_gitignore_template() -> str:
return """*.pcap
*.pcapng
*.cap
*.txt
*.json
!.gitignore
"""
def airndb_gitignore_template() -> str:
return """tool.env
"""
def write_if_missing(path: Path, content: str) -> bool:
if path.exists():
return False
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8", newline="\n")
return True
def upsert_agents_md(path: Path) -> str:
block = airndb_agents_block().rstrip() + "\n"
if path.exists():
original = path.read_text(encoding="utf-8")
existed = True
else:
original = "# AGENTS.md\n\n"
existed = False
begin = original.find(MARKER_BEGIN)
end = original.find(MARKER_END)
if begin >= 0 and end > begin:
end += len(MARKER_END)
updated = original[:begin].rstrip() + "\n\n" + block + original[end:].lstrip()
status = "updated"
else:
updated = original.rstrip() + "\n\n" + block
status = "updated" if existed else "created"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(updated, encoding="utf-8", newline="\n")
return status
def artifact_map(project_root: Path) -> Dict[str, Path]:
return {
"AGENTS.md": project_root / "AirPlan" / "AGENTS.md",
"c4_module": project_root / "AirPlan" / "docs" / "architecture" / "c4" / "module.md",
"adr_dir": project_root / "AirPlan" / "docs" / "architecture" / "adr",
"adr_0001": project_root / "AirPlan" / "docs" / "architecture" / "adr" / "ADR-0001-airndb-packet-capture-governance.md",
"network_log": project_root / "AirPlan" / "docs" / "network" / "airndb-log.md",
"captures_dir": project_root / "AirPlan" / "docs" / "network" / "airndb-captures",
"captures_gitignore": project_root / "AirPlan" / "docs" / "network" / "airndb-captures" / ".gitignore",
"tool_env": project_root / "AirPlan" / "state" / "airndb" / "tool.env",
"airndb_gitignore": project_root / "AirPlan" / "state" / "airndb" / ".gitignore",
"state": project_root / "AirPlan" / "state" / "airndb" / "state.json",
}
def write_state(path: Path, enabled: bool, project_root: Path) -> None:
artifacts = artifact_map(project_root)
health = {
name: artifacts[name].exists()
for name in [
"AGENTS.md",
"c4_module",
"adr_dir",
"adr_0001",
"network_log",
"captures_dir",
"captures_gitignore",
"tool_env",
"airndb_gitignore",
]
}
payload = {
"enabled": enabled,
"updatedAt": now_iso(),
"projectRoot": str(project_root),
"artifactHealth": health,
"captureTool": ensure_capture_tool(project_root, auto_install=False),
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
def enter_mode(project_root: Path) -> Tuple[str, Dict[str, str]]:
artifacts = artifact_map(project_root)
results: Dict[str, str] = {}
results["AGENTS.md"] = upsert_agents_md(artifacts["AGENTS.md"])
results["c4_module"] = "created" if write_if_missing(artifacts["c4_module"], c4_module_template()) else "exists"
artifacts["adr_dir"].mkdir(parents=True, exist_ok=True)
results["adr_dir"] = "exists"
results["adr_0001"] = "created" if write_if_missing(artifacts["adr_0001"], adr_template()) else "exists"
results["network_log"] = "created" if write_if_missing(artifacts["network_log"], network_log_template()) else "exists"
artifacts["captures_dir"].mkdir(parents=True, exist_ok=True)
results["captures_dir"] = "exists"
results["captures_gitignore"] = "created" if write_if_missing(artifacts["captures_gitignore"], captures_gitignore_template()) else "exists"
results["airndb_gitignore"] = "created" if write_if_missing(artifacts["airndb_gitignore"], airndb_gitignore_template()) else "exists"
tool_status = ensure_capture_tool(project_root, auto_install=True)
results["capture_tool"] = tool_status.get("status", "unknown")
results["capture_tool_path"] = tool_status.get("tool", "")
results["capture_tool_source"] = tool_status.get("source", "")
results["capture_tool_env"] = tool_status.get("projectEnv", "")
if tool_status.get("driverHint"):
results["capture_driver_hint"] = tool_status["driverHint"]
write_state(artifacts["state"], True, project_root)
return "enabled", results
def exit_mode(project_root: Path) -> Tuple[str, Dict[str, str]]:
artifacts = artifact_map(project_root)
write_state(artifacts["state"], False, project_root)
return "disabled", {}
def status_mode(project_root: Path) -> Tuple[str, Dict[str, str]]:
artifacts = artifact_map(project_root)
state_file = artifacts["state"]
enabled = False
if state_file.exists():
try:
payload = json.loads(state_file.read_text(encoding="utf-8"))
enabled = bool(payload.get("enabled"))
except json.JSONDecodeError:
enabled = False
results = {
name: ("ok" if path.exists() else "missing")
for name, path in artifacts.items()
if name != "state"
}
return ("enabled" if enabled else "disabled"), results
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Manage AirNDB network debug artifacts.")
parser.add_argument("--mode", choices=["enter", "setup", "exit", "status"], default="enter")
parser.add_argument("--project", default=".")
return parser.parse_args()
def main() -> None:
args = parse_args()
project_root = Path(args.project).expanduser().resolve()
if args.mode in {"enter", "setup"}:
mode_state, result = enter_mode(project_root)
elif args.mode == "exit":
mode_state, result = exit_mode(project_root)
else:
mode_state, result = status_mode(project_root)
print(f"airndb_mode={mode_state}")
print(f"project_root={project_root}")
for key, value in result.items():
print(f"{key}={value}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""AirNDB remote packet capture helper over SSH."""
from __future__ import annotations
import argparse, base64, json, os, shlex, shutil, subprocess
from datetime import datetime, timezone
from pathlib import Path
MAX_COUNT = 500000
def stamp(): return datetime.now().strftime('%Y%m%d-%H%M%S')
def iso(): return datetime.now(timezone.utc).isoformat()
def q(s): return shlex.quote(str(s))
def split(s): return shlex.split(s, posix=True) if s.strip() else []
def read_env(path):
vals = {}
if path.exists():
for raw in path.read_text(encoding='utf-8-sig').splitlines():
line = raw.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1); vals[k.strip()] = v.strip().strip('"').strip("'")
for k in ['AIRNDB_REMOTE_SSH_TARGET','AIRNDB_REMOTE_SSH_PORT','AIRNDB_REMOTE_SSH_OPTIONS','AIRNDB_REMOTE_WORKDIR','AIRNDB_REMOTE_TCPDUMP','AIRNDB_REMOTE_CAPTURE_PREFIX']:
if os.environ.get(k): vals[k] = os.environ[k]
vals.setdefault('AIRNDB_REMOTE_SSH_PORT','22'); vals.setdefault('AIRNDB_REMOTE_TCPDUMP','auto'); vals.setdefault('AIRNDB_REMOTE_CAPTURE_PREFIX','sudo -n')
return vals
def write_env(path, updates):
path.parent.mkdir(parents=True, exist_ok=True); old = path.read_text(encoding='utf-8') if path.exists() else ''; keys = set(updates); lines = []
for raw in old.splitlines():
key = raw.split('=',1)[0].strip() if '=' in raw and not raw.strip().startswith('#') else None
if key not in keys: lines.append(raw)
if lines and lines[-1].strip(): lines.append('')
for k, v in updates.items():
if v: lines.append(f'{k}={v}')
path.write_text('\n'.join(lines).rstrip()+'\n', encoding='utf-8', newline='\n')
def ensure_files(project):
d = project/'AirPlan'/'state'/'airndb'; d.mkdir(parents=True, exist_ok=True); ex = d/'remote-device.env.example'
if not ex.exists():
ex.write_text('# AirNDB remote packet-capture device configuration\nAIRNDB_REMOTE_SSH_TARGET=user@host\nAIRNDB_REMOTE_SSH_PORT=22\nAIRNDB_REMOTE_SSH_OPTIONS=\nAIRNDB_REMOTE_WORKDIR=\nAIRNDB_REMOTE_TCPDUMP=auto\nAIRNDB_REMOTE_CAPTURE_PREFIX=sudo -n\n', encoding='utf-8', newline='\n')
gi = d/'.gitignore'; old = gi.read_text(encoding='utf-8') if gi.exists() else ''
for item in ['remote-device.env','tool.env']:
if item not in old.splitlines(): old = (old.rstrip()+f'\n{item}\n').lstrip()
gi.write_text(old, encoding='utf-8', newline='\n')
def ssh_args(env, cmd):
target = env.get('AIRNDB_REMOTE_SSH_TARGET','')
if not target: raise SystemExit('AIRNDB_REMOTE_SSH_TARGET is required')
args = [shutil.which('ssh') or 'ssh']; port = env.get('AIRNDB_REMOTE_SSH_PORT','22')
if port: args += ['-p', port]
args += split(env.get('AIRNDB_REMOTE_SSH_OPTIONS','')); return args + [target, cmd]
def run(env, cmd, timeout=60):
return subprocess.run(ssh_args(env, cmd), capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout)
def remote_home(env):
r = run(env, 'printf %s "$HOME"', 15); return r.stdout.strip() if r.returncode == 0 else ''
def workdir(env):
if env.get('AIRNDB_REMOTE_WORKDIR'): return env['AIRNDB_REMOTE_WORKDIR']
home = remote_home(env); return (home.rstrip('/')+'/.airndb') if home else '.airndb'
def detect_tool(env):
tool = env.get('AIRNDB_REMOTE_TCPDUMP','auto').strip()
if tool and tool != 'auto': return tool
r = run(env, 'for t in tcpdump dumpcap windump WinDump.exe; do command -v "$t" >/dev/null 2>&1 && { command -v "$t"; exit 0; }; done', 15)
return r.stdout.strip().splitlines()[-1] if r.returncode == 0 and r.stdout.strip() else ''
def install_tool(env):
script = """set -e
if command -v tcpdump >/dev/null 2>&1; then exit 0; fi
if [ "$(id -u 2>/dev/null || echo 1)" = "0" ]; then SUDO=""; else SUDO="sudo -n"; fi
if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update && $SUDO apt-get install -y tcpdump;
elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y tcpdump;
elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y tcpdump;
elif command -v apk >/dev/null 2>&1; then $SUDO apk add tcpdump;
elif command -v pacman >/dev/null 2>&1; then $SUDO pacman -Sy --noconfirm tcpdump;
else exit 42; fi"""
return run(env, script, 300)
def setup(project, env, auto=True):
if not env.get('AIRNDB_REMOTE_SSH_TARGET'):
return {'status':'blocked','reason':'missing_remote_target','example':str(project/'AirPlan'/'state'/'airndb'/'remote-device.env.example')}
if not shutil.which('ssh'): return {'status':'blocked','reason':'ssh_not_found'}
probe = run(env, 'printf ok', 20)
if probe.returncode: return {'status':'blocked','reason':'ssh_probe_failed','stderr':probe.stderr.strip()}
wd = workdir(env); mk = run(env, f'mkdir -p {q(wd)}', 20)
if mk.returncode: return {'status':'blocked','reason':'remote_workdir_failed','stderr':mk.stderr.strip()}
tool = detect_tool(env); install = 'skipped'
if not tool and auto:
inst = install_tool(env); install = 'ok' if inst.returncode == 0 else f'failed:{inst.returncode}'; tool = detect_tool(env)
upd = {'AIRNDB_REMOTE_WORKDIR':wd}
if tool: upd['AIRNDB_REMOTE_TCPDUMP'] = tool
write_env(project/'AirPlan'/'state'/'airndb'/'remote-device.env', upd)
return {'status':'ok' if tool else 'blocked','target':env.get('AIRNDB_REMOTE_SSH_TARGET',''),'workdir':wd,'tool':tool,'capturePrefix':env.get('AIRNDB_REMOTE_CAPTURE_PREFIX',''),'autoConfigure':install,'hint':'' if tool else 'Install tcpdump/dumpcap or set AIRNDB_REMOTE_TCPDUMP.'}
def bounded(n):
if n < 1 or n > MAX_COUNT: raise SystemExit(f'--count must be 1..{MAX_COUNT}')
return n
def filt(expr):
if not expr.strip(): return []
try: return split(expr)
except ValueError: return expr.split()
def cap_cmd(env, tool, iface, out, count, filter_expr):
if not iface: raise SystemExit('--iface is required')
parts = split(env.get('AIRNDB_REMOTE_CAPTURE_PREFIX','')) + [tool,'-i',iface,'-nn','-s','0','-w',out,'-c',str(bounded(count))] + filt(filter_expr)
return shlex.join(parts)
def outdir(project, configured):
p = Path(configured).expanduser().resolve() if configured else project/'AirPlan'/'docs'/'network'/'airndb-captures'; p.mkdir(parents=True, exist_ok=True); return p
def emit(prefix, data):
print(f'{prefix}_status={data.get("status","unknown")}')
for k, v in data.items():
if k != 'status': print(f'{k}={json.dumps(v, ensure_ascii=False) if isinstance(v,(dict,list)) else v}')
def log(project, data):
lp = project/'AirPlan'/'docs'/'network'/'airndb-log.md'; lp.parent.mkdir(parents=True, exist_ok=True)
lp.open('a', encoding='utf-8', newline='\n').write(f"\n## {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: AirNDB remote {data.get('action','capture')}\n\n- Remote target: `{data.get('target','')}`\n- Tool: `{data.get('tool','')}`\n- Interface: `{data.get('interface','')}`\n- Filter: `{data.get('filter','')}`\n- Command: `{data.get('command','')}`\n- Artifacts: `{data.get('artifact','')}`\n- Report: `{data.get('report','')}`\n- Result: `{data.get('status','')}`\n- AirDbg handoff: TODO\n- Residual risk: remote pcap files may contain sensitive packet contents.\n")
def main():
ap = argparse.ArgumentParser(); ap.add_argument('--project', default='.'); ap.add_argument('--action', choices=['setup','status','interfaces','command','capture'], default='setup'); ap.add_argument('--iface', default=''); ap.add_argument('--filter', default=''); ap.add_argument('--count', type=int, default=200); ap.add_argument('--timeout', type=int, default=30); ap.add_argument('--output-dir', default=''); ap.add_argument('--output', default=''); ap.add_argument('--no-auto-configure', action='store_true'); a = ap.parse_args()
project = Path(a.project).expanduser().resolve(); ensure_files(project); env = read_env(project/'AirPlan'/'state'/'airndb'/'remote-device.env')
info = setup(project, env, auto=(a.action!='status' and not a.no_auto_configure))
if a.action in ['setup','status'] or info.get('status') != 'ok': emit('airndb_remote', info); raise SystemExit(0 if info.get('status')=='ok' else 2)
od = outdir(project, a.output_dir); tool = info['tool']
if a.action == 'interfaces':
icmd = shlex.join(split(env.get('AIRNDB_REMOTE_CAPTURE_PREFIX','')) + [tool,'-D']); r = run(env, icmd, max(5,a.timeout)); data = {'action':'interfaces','status':'ok' if r.returncode==0 else 'failed','target':info['target'],'tool':tool,'command':shlex.join(ssh_args(env, icmd)),'stdout':r.stdout.strip(),'stderr':r.stderr.strip()}; report = od/(stamp()+'-airndb-remote-interfaces.json'); report.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8'); data['report']=str(report); log(project,data); emit('airndb_remote',data); print(r.stdout); print(r.stderr); raise SystemExit(0 if data['status']=='ok' else 1)
remote = a.output or info['workdir'].rstrip('/') + '/' + stamp() + '-airndb-remote.pcap'; cmd = cap_cmd(env, tool, a.iface, remote, a.count, a.filter)
if a.action == 'command': emit('airndb_remote', {'status':'ok','target':info['target'],'remotePcap':remote,'command':shlex.join(ssh_args(env, cmd))}); return
r = run(env, cmd, max(1,a.timeout)); data = {'action':'capture','status':'ok' if r.returncode==0 else 'failed','target':info['target'],'tool':tool,'interface':a.iface,'filter':a.filter,'count':bounded(a.count),'timeoutSeconds':a.timeout,'remotePcap':remote,'command':shlex.join(ssh_args(env, cmd)),'stdout':r.stdout.strip(),'stderr':r.stderr.strip(),'capturedAt':iso()}
if r.returncode == 0:
b64 = run(env, f'base64 < {q(remote)}', a.timeout); local = od/(stamp()+'-airndb-remote.pcap'); local.write_bytes(base64.b64decode(''.join(b64.stdout.split()))); data['artifact'] = str(local)
report = od/(stamp()+'-airndb-remote-capture.json'); report.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8'); data['report'] = str(report); log(project, data); emit('airndb_remote', data); raise SystemExit(0 if data['status']=='ok' else 1)
if __name__ == '__main__': main()

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Install the AirNDB plugin into the current user's home-local plugin directory."""
from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
from typing import Any, Dict
PLUGIN_NAME = "airndb"
def copy_plugin(source: Path, target: Path) -> None:
if source.resolve() == target.resolve():
return
if target.exists():
shutil.rmtree(target)
ignore = shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", ".git")
shutil.copytree(source, target, ignore=ignore)
def marketplace_payload() -> Dict[str, Any]:
return {
"name": "local-airarc",
"interface": {"displayName": "Local AirArc Plugins"},
"plugins": [],
}
def update_marketplace(path: Path) -> None:
if path.exists():
payload = json.loads(path.read_text(encoding="utf-8"))
else:
payload = marketplace_payload()
payload.setdefault("name", "local-airarc")
payload.setdefault("interface", {}).setdefault("displayName", "Local AirArc Plugins")
plugins = payload.setdefault("plugins", [])
entry = {
"name": PLUGIN_NAME,
"source": {
"source": "local",
"path": f"./plugins/{PLUGIN_NAME}",
},
"policy": {
"installation": "INSTALLED_BY_DEFAULT",
"authentication": "ON_INSTALL",
},
"category": "Productivity",
}
for index, existing in enumerate(plugins):
if existing.get("name") == PLUGIN_NAME:
plugins[index] = entry
break
else:
plugins.append(entry)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Install AirNDB as a home-local Codex plugin.")
parser.add_argument("--source", default=str(Path(__file__).resolve().parents[1]))
parser.add_argument("--home", default=str(Path.home()))
return parser.parse_args()
def main() -> None:
args = parse_args()
source = Path(args.source).expanduser().resolve()
home = Path(args.home).expanduser().resolve()
target = home / "plugins" / PLUGIN_NAME
marketplace = home / ".agents" / "plugins" / "marketplace.json"
copy_plugin(source, target)
update_marketplace(marketplace)
print(f"installed_plugin={target}")
print(f"marketplace={marketplace}")
if __name__ == "__main__":
main()