Initial release: airndb
This commit is contained in:
136
scripts/airndb_remote_device.py
Normal file
136
scripts/airndb_remote_device.py
Normal 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()
|
||||
Reference in New Issue
Block a user