Add liberate v0.5 — no AP/firewall changes, factory IP preserved, services + keys
This commit is contained in:
parent
ed7ae5ba57
commit
2455241345
1 changed files with 672 additions and 0 deletions
672
liberate-v0.5.sh
Normal file
672
liberate-v0.5.sh
Normal file
|
|
@ -0,0 +1,672 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# liberate.sh — AdaCam Liberation Script v0.5
|
||||||
|
#
|
||||||
|
# Usage: ssh root@192.168.0.10 'bash -s' < liberate.sh
|
||||||
|
#
|
||||||
|
# v0.5 changes from v0.4:
|
||||||
|
# - NO AP/SSID/IP changes (keep factory 192.168.0.10)
|
||||||
|
# - NO firewall rules
|
||||||
|
# - NO reverse tunnel masking
|
||||||
|
# - Adds: SSH key deployment (safe path, no sshd_config changes)
|
||||||
|
# - Adds: adacam services install (capture, forwarder, wigle) embedded inline
|
||||||
|
#
|
||||||
|
# Philosophy: get working first, harden later.
|
||||||
|
#
|
||||||
|
# CRITICAL: Only /data is reliably writable. /etc may be writable. Assume /usr, /var read-only.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ADACAM_DATA="/data/adacam"
|
||||||
|
|
||||||
|
log() { echo "[liberate] $1"; }
|
||||||
|
ok() { echo "[liberate] ✓ $1"; }
|
||||||
|
warn() { echo "[liberate] ⚠ $1"; }
|
||||||
|
die() { echo "[liberate] ✗ FATAL: $1"; exit 1; }
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "╔══════════════════════════════════════════╗"
|
||||||
|
log "║ AdaCam Liberation Script v0.5 ║"
|
||||||
|
log "║ Liberating bees from the hive ║"
|
||||||
|
log "╚══════════════════════════════════════════╝"
|
||||||
|
log ""
|
||||||
|
|
||||||
|
[ "$(id -u)" = "0" ] || die "must run as root"
|
||||||
|
|
||||||
|
if [ -f "$ADACAM_DATA/liberated" ]; then
|
||||||
|
die "device already liberated (marker exists at $ADACAM_DATA/liberated)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── WRITABILITY CHECK ─────────────────────────────────────────────────────────
|
||||||
|
log "=== Checking filesystem writability ==="
|
||||||
|
|
||||||
|
DATA_WRITABLE=false; ETC_WRITABLE=false; OPT_WRITABLE=false
|
||||||
|
|
||||||
|
for d in /data /etc /opt; do
|
||||||
|
if touch "${d}/.adacam_test" 2>/dev/null; then
|
||||||
|
rm -f "${d}/.adacam_test"
|
||||||
|
ok "Writable: $d"
|
||||||
|
case "$d" in
|
||||||
|
/data) DATA_WRITABLE=true ;;
|
||||||
|
/etc) ETC_WRITABLE=true ;;
|
||||||
|
/opt) OPT_WRITABLE=true ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
warn "Read-only or missing: $d"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$DATA_WRITABLE" = "true" ] || die "/data is not writable — cannot proceed"
|
||||||
|
[ -f /opt/odc-api/odc-api-bee.js ] || warn "odc-api not found — may not be a factory device"
|
||||||
|
ok "running on: $(uname -n)"
|
||||||
|
|
||||||
|
# ── DETECT SERIAL ─────────────────────────────────────────────────────────────
|
||||||
|
log ""
|
||||||
|
log "=== Detecting device serial ==="
|
||||||
|
|
||||||
|
SERIAL=""
|
||||||
|
SERIAL=$(cat /proc/device-tree/serial-number 2>/dev/null | tr -d '\0') || true
|
||||||
|
[ -z "$SERIAL" ] && SERIAL=$(ip link show wlp1s0f0 2>/dev/null | grep link/ether | awk '{print $2}' | tr -d ':') || true
|
||||||
|
[ -z "$SERIAL" ] && SERIAL=$(cat /sys/class/net/wlan0/address 2>/dev/null | tr -d ':') || true
|
||||||
|
[ -z "$SERIAL" ] && die "Cannot determine device serial"
|
||||||
|
|
||||||
|
ok "device serial: $SERIAL"
|
||||||
|
API_TOKEN=$(echo -n "adacam-api-${SERIAL}-token" | sha256sum | cut -c1-32)
|
||||||
|
ok "API token: $API_TOKEN"
|
||||||
|
|
||||||
|
# ── SETUP /data/adacam ────────────────────────────────────────────────────────
|
||||||
|
log ""
|
||||||
|
log "=== Setting up /data/adacam ==="
|
||||||
|
|
||||||
|
mkdir -p "$ADACAM_DATA/logs" "$ADACAM_DATA/cache"
|
||||||
|
echo "$SERIAL" > "$ADACAM_DATA/device_serial"
|
||||||
|
echo "$API_TOKEN" > "$ADACAM_DATA/api_token"
|
||||||
|
chmod 600 "$ADACAM_DATA/device_serial" "$ADACAM_DATA/api_token"
|
||||||
|
|
||||||
|
cat > "$ADACAM_DATA/config.json" << CONFIG
|
||||||
|
{
|
||||||
|
"device_id": "$SERIAL",
|
||||||
|
"ap_ip": "192.168.0.10",
|
||||||
|
"adamaps_key": "adamaps-ingest-2026",
|
||||||
|
"adamaps_api": "https://api.adamaps.org",
|
||||||
|
"liberated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||||
|
"liberate_version": "0.5"
|
||||||
|
}
|
||||||
|
CONFIG
|
||||||
|
ok "config written"
|
||||||
|
|
||||||
|
# ── PHASE 1: KILL HIVEMAPPER SERVICES ─────────────────────────────────────────
|
||||||
|
log ""
|
||||||
|
log "=== Phase 1: Stopping Hivemapper services ==="
|
||||||
|
|
||||||
|
# NOTE: usb-updater is NOT touched — recovery path
|
||||||
|
KILL_SERVICES="odc-api mitmproxy mender-client here-plugin model-zoo collectd
|
||||||
|
hivemapper-data-logger hivemapper-folder-purger beekeeper-plugin
|
||||||
|
video-processor cpu-mem-logger vnstat vnstatd rm_vpu_daemon
|
||||||
|
dnf-automatic dnf-automatic-download dnf-automatic-install dnf-makecache"
|
||||||
|
|
||||||
|
for svc in $KILL_SERVICES; do
|
||||||
|
if systemctl list-unit-files "${svc}.service" 2>/dev/null | grep -q "$svc"; then
|
||||||
|
systemctl stop "${svc}.service" 2>/dev/null || true
|
||||||
|
systemctl disable "${svc}.service" 2>/dev/null || true
|
||||||
|
systemctl mask "${svc}.service" 2>/dev/null || true
|
||||||
|
ok "killed: $svc"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── PHASE 2: BLOCK HIVEMAPPER DOMAINS ─────────────────────────────────────────
|
||||||
|
log ""
|
||||||
|
log "=== Phase 2: Blocking Hivemapper endpoints ==="
|
||||||
|
|
||||||
|
if [ "$ETC_WRITABLE" = "true" ]; then
|
||||||
|
BLOCK_HOSTS="hivemapper.com api.hivemapper.com beemaps.com api.trybeekeeper.ai
|
||||||
|
docker.mender.io s3.mender.io direct.data.api.platform.here.com
|
||||||
|
api-lookup.data.api.platform.here.com account.api.here.com
|
||||||
|
edge.hereapi.com olp.here.com
|
||||||
|
dashcam-firmware.s3.us-west-2.amazonaws.com cfapi.cloudflare.com"
|
||||||
|
for host in $BLOCK_HOSTS; do
|
||||||
|
if ! grep -q "$host" /etc/hosts 2>/dev/null; then
|
||||||
|
echo "0.0.0.0 $host" >> /etc/hosts && ok "blocked: $host" || warn "failed: $host"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
warn "/etc not writable — skipping host blocking"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── PHASE 3: SSH KEYS ─────────────────────────────────────────────────────────
|
||||||
|
log ""
|
||||||
|
log "=== Phase 3: Deploying SSH keys ==="
|
||||||
|
|
||||||
|
# Safe path — no sshd_config changes, no overlay touches
|
||||||
|
# Writing to /home/root/.ssh/ which persists (bind-mounted from /data/home at runtime)
|
||||||
|
SSH_DIR="/home/root/.ssh"
|
||||||
|
AUTH_KEYS="$SSH_DIR/authorized_keys"
|
||||||
|
|
||||||
|
mkdir -p "$SSH_DIR"
|
||||||
|
chmod 700 "$SSH_DIR"
|
||||||
|
|
||||||
|
# Write keys — these are the canonical AdaCam authorized keys
|
||||||
|
cat > "$AUTH_KEYS" << 'KEYS'
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK87jxvlXvo60pxwdtyJsXeFsb4KsAiFx4FnyXz81kh7 cobb@adacam
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOQxwJU91TCxds34P18D3xRbu7rxlrgTUoml/H8nxeDK kayos@openclaw
|
||||||
|
KEYS
|
||||||
|
chmod 600 "$AUTH_KEYS"
|
||||||
|
ok "SSH keys written to $AUTH_KEYS"
|
||||||
|
ok "Password auth preserved (no sshd_config changes)"
|
||||||
|
|
||||||
|
# ── PHASE 4: INSTALL ADACAM SERVICES ──────────────────────────────────────────
|
||||||
|
log ""
|
||||||
|
log "=== Phase 4: Installing adacam services ==="
|
||||||
|
|
||||||
|
# Determine install dir — prefer /opt/adacam, fall back to /data/adacam/bin
|
||||||
|
if [ "$OPT_WRITABLE" = "true" ]; then
|
||||||
|
INSTALL_DIR="/opt/adacam"
|
||||||
|
ok "Installing to /opt/adacam"
|
||||||
|
else
|
||||||
|
INSTALL_DIR="/data/adacam/bin"
|
||||||
|
warn "/opt not writable — installing to $INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$INSTALL_DIR/capture"
|
||||||
|
mkdir -p "$INSTALL_DIR/wigle"
|
||||||
|
mkdir -p /tmp/adacam/pics
|
||||||
|
mkdir -p /tmp/recording/pics
|
||||||
|
|
||||||
|
# ── adacam-capture.py ─────────────────────────────────────────────────────────
|
||||||
|
cat > "$INSTALL_DIR/capture/adacam-capture.py" << 'PYEOF'
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""adacam-capture: GStreamer camera capture for AdaCam/Keem Bay."""
|
||||||
|
import json, logging, os, signal, subprocess, sys, time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', stream=sys.stdout)
|
||||||
|
log = logging.getLogger('adacam-capture')
|
||||||
|
|
||||||
|
CONFIG_PATH = Path('/data/adacam/config.json')
|
||||||
|
FRAME_DIR = Path('/tmp/adacam/pics')
|
||||||
|
PIPE_FRAME = Path('/tmp/recording/pics/cam0pipe.jpg')
|
||||||
|
VPU_FWNAME = '/sys/devices/platform/soc/soc:vpusmm/fwname'
|
||||||
|
MAX_FILES, MAX_BYTES = 1300, 500 * 1024 * 1024
|
||||||
|
MODULES = ['kmb_flash', 'kmb_lens', 'kmb_cam', 'pwm_keembay', 'hantro']
|
||||||
|
GST_PIPELINE = '''kmbcamsrc num-frames=-1 name=kmbsrc transform-hub=full awb-mode=daylight
|
||||||
|
stride-align=64 scanline-align=64
|
||||||
|
! capsfilter caps="video/x-raw(memory:DMABuf),format=NV12,width=2028,height=1024,framerate=30/1"
|
||||||
|
! vaapijpegenc ! multifilesink location=/tmp/recording/pics/cam0pipe.jpg'''
|
||||||
|
|
||||||
|
running, redis_client = True, None
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
if CONFIG_PATH.exists():
|
||||||
|
with open(CONFIG_PATH) as f: return json.load(f)
|
||||||
|
return {'device_id': 'unknown', 'adamaps_key': '', 'adamaps_api': ''}
|
||||||
|
|
||||||
|
def get_redis():
|
||||||
|
global redis_client
|
||||||
|
if redis_client:
|
||||||
|
try: redis_client.ping(); return redis_client
|
||||||
|
except: redis_client = None
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
|
||||||
|
redis_client.ping(); log.info('Connected to Redis'); return redis_client
|
||||||
|
except Exception as e: log.warning(f'Redis unavailable: {e}'); return None
|
||||||
|
|
||||||
|
def load_modules():
|
||||||
|
try:
|
||||||
|
with open('/proc/modules') as f: loaded = {l.split()[0] for l in f}
|
||||||
|
except: return
|
||||||
|
for mod in MODULES:
|
||||||
|
if mod not in loaded:
|
||||||
|
try: subprocess.run(['modprobe', mod], check=True, capture_output=True)
|
||||||
|
except: log.warning(f'Failed to load {mod}')
|
||||||
|
|
||||||
|
def load_vpu_firmware():
|
||||||
|
if os.path.exists(VPU_FWNAME):
|
||||||
|
try:
|
||||||
|
with open(VPU_FWNAME, 'w') as f: f.write('luxonis_vpu.bin')
|
||||||
|
log.info('VPU firmware loaded')
|
||||||
|
except Exception as e: log.warning(f'VPU firmware error: {e}')
|
||||||
|
else:
|
||||||
|
try: subprocess.run(['StartVpu', 'luxonis_vpu.bin'], check=True, capture_output=True); log.info('VPU loaded via StartVpu')
|
||||||
|
except: log.warning('StartVpu not available')
|
||||||
|
|
||||||
|
def start_gstreamer():
|
||||||
|
PIPE_FRAME.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
FRAME_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
log.info('Starting GStreamer pipeline')
|
||||||
|
return subprocess.Popen(['gst-launch-1.0', '-e'] + GST_PIPELINE.split(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||||
|
|
||||||
|
def cleanup_frames():
|
||||||
|
try: frames = sorted(FRAME_DIR.glob('*.jpg'), key=lambda p: p.stat().st_mtime)
|
||||||
|
except: return
|
||||||
|
while len(frames) > MAX_FILES:
|
||||||
|
try: frames[0].unlink(); frames.pop(0)
|
||||||
|
except: break
|
||||||
|
total = sum(f.stat().st_size for f in frames if f.exists())
|
||||||
|
while total > MAX_BYTES and frames:
|
||||||
|
try: sz = frames[0].stat().st_size; frames[0].unlink(); frames.pop(0); total -= sz
|
||||||
|
except: break
|
||||||
|
|
||||||
|
def process_frame():
|
||||||
|
if not PIPE_FRAME.exists(): return False
|
||||||
|
try:
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
dest = FRAME_DIR / f'{ts}.jpg'
|
||||||
|
os.rename(PIPE_FRAME, dest)
|
||||||
|
r = get_redis()
|
||||||
|
if r:
|
||||||
|
try: r.publish('adacam:frames', str(dest))
|
||||||
|
except Exception as e: log.warning(f'Redis publish failed: {e}')
|
||||||
|
return True
|
||||||
|
except FileNotFoundError: return False
|
||||||
|
except Exception as e: log.error(f'Frame error: {e}'); return False
|
||||||
|
|
||||||
|
def handle_signal(signum, frame):
|
||||||
|
global running
|
||||||
|
running = False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global running
|
||||||
|
signal.signal(signal.SIGTERM, handle_signal); signal.signal(signal.SIGINT, handle_signal)
|
||||||
|
config = load_config()
|
||||||
|
log.info(f"AdaCam Capture starting (device: {config.get('device_id', 'unknown')})")
|
||||||
|
load_modules(); load_vpu_firmware()
|
||||||
|
gst_proc, last_cleanup = None, 0
|
||||||
|
while running:
|
||||||
|
if gst_proc is None or gst_proc.poll() is not None:
|
||||||
|
if gst_proc: time.sleep(2)
|
||||||
|
gst_proc = start_gstreamer()
|
||||||
|
frame_found = process_frame()
|
||||||
|
now = time.time()
|
||||||
|
if now - last_cleanup > 60: cleanup_frames(); last_cleanup = now
|
||||||
|
time.sleep(0.01 if frame_found else 0.05)
|
||||||
|
if gst_proc and gst_proc.poll() is None:
|
||||||
|
gst_proc.terminate()
|
||||||
|
try: gst_proc.wait(timeout=5)
|
||||||
|
except: gst_proc.kill()
|
||||||
|
|
||||||
|
if __name__ == '__main__': main()
|
||||||
|
PYEOF
|
||||||
|
chmod +x "$INSTALL_DIR/capture/adacam-capture.py"
|
||||||
|
ok "adacam-capture.py installed"
|
||||||
|
|
||||||
|
# ── adacam-forwarder.py ───────────────────────────────────────────────────────
|
||||||
|
cat > "$INSTALL_DIR/capture/adacam-forwarder.py" << 'PYEOF'
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""adacam-forwarder: GPS-tagged frame forwarder for AdaMaps."""
|
||||||
|
import json, logging, signal, sqlite3, sys, threading, time
|
||||||
|
from pathlib import Path
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', stream=sys.stdout)
|
||||||
|
log = logging.getLogger('adacam-forwarder')
|
||||||
|
|
||||||
|
CONFIG_PATH = Path('/data/adacam/config.json')
|
||||||
|
QUEUE_DB = Path('/data/adacam/forward_queue.db')
|
||||||
|
ODC_DB = Path('/data/recording/odc-api.db')
|
||||||
|
GNSS_KEYS = ['GNSSFusion30Hz', 'GnssData', 'GnssPvt', 'GnssPosition']
|
||||||
|
|
||||||
|
config, redis_client = {}, None
|
||||||
|
running = True
|
||||||
|
|
||||||
|
def _handle_signal(signum, frame):
|
||||||
|
global running; running = False
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, _handle_signal); signal.signal(signal.SIGINT, _handle_signal)
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
if CONFIG_PATH.exists():
|
||||||
|
with open(CONFIG_PATH) as f: return json.load(f)
|
||||||
|
return {'device_id': 'unknown', 'adamaps_key': '', 'adamaps_api': 'https://api.adamaps.org'}
|
||||||
|
|
||||||
|
def init_queue_db():
|
||||||
|
QUEUE_DB.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(QUEUE_DB))
|
||||||
|
conn.execute('CREATE TABLE IF NOT EXISTS queue (id INTEGER PRIMARY KEY, payload TEXT, created_at REAL, attempts INTEGER DEFAULT 0)')
|
||||||
|
conn.commit(); return conn
|
||||||
|
|
||||||
|
def get_redis():
|
||||||
|
global redis_client
|
||||||
|
if redis_client:
|
||||||
|
try: redis_client.ping(); return redis_client
|
||||||
|
except: redis_client = None
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
|
||||||
|
redis_client.ping(); return redis_client
|
||||||
|
except Exception as e: log.warning(f'Redis unavailable: {e}'); return None
|
||||||
|
|
||||||
|
def get_latest_gps(r=None):
|
||||||
|
if ODC_DB.exists():
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(ODC_DB))
|
||||||
|
row = conn.execute('SELECT latitude, longitude, altitude, hdop, satellites_used, time FROM framekms WHERE latitude IS NOT NULL AND latitude != 0 ORDER BY time DESC LIMIT 1').fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row: return {'lat': row[0], 'lon': row[1], 'alt': row[2], 'hdop': row[3], 'satellites': row[4], 'ts': row[5]}
|
||||||
|
except Exception as e: log.debug(f'odc-api.db GPS read failed: {e}')
|
||||||
|
if r:
|
||||||
|
for key in GNSS_KEYS:
|
||||||
|
try:
|
||||||
|
for getter in [lambda k: r.zrevrange(k, 0, 0), lambda k: [r.get(k)]]:
|
||||||
|
items = getter(key)
|
||||||
|
if items and items[0]:
|
||||||
|
data = json.loads(items[0])
|
||||||
|
lat = data.get('latitude') or data.get('lat_deg')
|
||||||
|
lon = data.get('longitude') or data.get('lon_deg')
|
||||||
|
if lat and lon:
|
||||||
|
return {'lat': lat, 'lon': lon, 'alt': data.get('altitude') or data.get('alt_m', 0),
|
||||||
|
'hdop': data.get('hdop', 99), 'satellites': data.get('satellites_used') or data.get('num_satellites', 0),
|
||||||
|
'ts': int(data.get('unix_milliseconds', time.time() * 1000))}
|
||||||
|
except: continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def queue_payload(db, payload):
|
||||||
|
try: db.execute('INSERT INTO queue (payload, created_at) VALUES (?, ?)', (json.dumps(payload), time.time())); db.commit()
|
||||||
|
except Exception as e: log.error(f'Queue error: {e}')
|
||||||
|
|
||||||
|
def post_to_adamaps(payload):
|
||||||
|
if not config.get('adamaps_key'): return False
|
||||||
|
url = f"{config.get('adamaps_api', 'https://api.adamaps.org')}/api/ingest"
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, headers={'X-AdaMaps-Key': config['adamaps_key']}, timeout=10)
|
||||||
|
return resp.status_code == 200
|
||||||
|
except: return False
|
||||||
|
|
||||||
|
def process_queue(db):
|
||||||
|
try:
|
||||||
|
for row_id, payload_json, attempts in db.execute('SELECT id, payload, attempts FROM queue ORDER BY created_at LIMIT 50').fetchall():
|
||||||
|
if attempts > 10: db.execute('DELETE FROM queue WHERE id = ?', (row_id,)); continue
|
||||||
|
if post_to_adamaps(json.loads(payload_json)): db.execute('DELETE FROM queue WHERE id = ?', (row_id,))
|
||||||
|
else: db.execute('UPDATE queue SET attempts = ? WHERE id = ?', (attempts + 1, row_id))
|
||||||
|
time.sleep(0.1)
|
||||||
|
db.commit()
|
||||||
|
except Exception as e: log.error(f'Queue process error: {e}')
|
||||||
|
|
||||||
|
def queue_worker(db):
|
||||||
|
while running: time.sleep(60); running and process_queue(db)
|
||||||
|
|
||||||
|
def handle_frame(frame_path, db):
|
||||||
|
path = Path(frame_path)
|
||||||
|
if not path.exists(): return
|
||||||
|
r = get_redis(); gps = get_latest_gps(r)
|
||||||
|
try: frame_size = path.stat().st_size
|
||||||
|
except: frame_size = 0
|
||||||
|
try: ts = int(path.stem)
|
||||||
|
except: ts = int(time.time() * 1000)
|
||||||
|
payload = {'device_id': config.get('device_id', 'unknown'), 'ts': ts,
|
||||||
|
'lat': gps['lat'] if gps else None, 'lon': gps['lon'] if gps else None,
|
||||||
|
'alt': gps['alt'] if gps else None, 'frame_path': str(path), 'frame_size': frame_size}
|
||||||
|
if not post_to_adamaps(payload): queue_payload(db, payload)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global config, running
|
||||||
|
config = load_config()
|
||||||
|
log.info(f"AdaCam Forwarder starting (device: {config.get('device_id', 'unknown')})")
|
||||||
|
db = init_queue_db()
|
||||||
|
threading.Thread(target=queue_worker, args=(db,), daemon=True).start()
|
||||||
|
backoff = 1
|
||||||
|
while running:
|
||||||
|
r = get_redis()
|
||||||
|
if not r: time.sleep(backoff); backoff = min(backoff * 2, 30); continue
|
||||||
|
backoff = 1
|
||||||
|
try:
|
||||||
|
import redis as redis_module
|
||||||
|
pubsub = r.pubsub(); pubsub.subscribe('adacam:frames')
|
||||||
|
for msg in pubsub.listen():
|
||||||
|
if not running: break
|
||||||
|
if msg['type'] == 'message': handle_frame(msg['data'], db)
|
||||||
|
except: time.sleep(1)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try: main()
|
||||||
|
except KeyboardInterrupt: running = False
|
||||||
|
PYEOF
|
||||||
|
chmod +x "$INSTALL_DIR/capture/adacam-forwarder.py"
|
||||||
|
ok "adacam-forwarder.py installed"
|
||||||
|
|
||||||
|
# ── adacam-wigle.py ───────────────────────────────────────────────────────────
|
||||||
|
cat > "$INSTALL_DIR/wigle/adacam-wigle.py" << 'PYEOF'
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AdaCam WiGLE wardriving service — WiFi scanning + WiGLE upload."""
|
||||||
|
import base64, json, logging, os, signal, sqlite3, subprocess, time
|
||||||
|
from datetime import datetime
|
||||||
|
import redis, requests
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||||
|
log = logging.getLogger('adacam-wigle')
|
||||||
|
|
||||||
|
DB_PATH = '/data/adacam/wigle.db'
|
||||||
|
ODC_DB = '/data/recording/odc-api.db'
|
||||||
|
INTERFACE = 'wlp1s0f1'
|
||||||
|
WIGLE_UPLOAD_URL = 'https://api.wigle.net/api/v2/file/upload'
|
||||||
|
WIGLE_CSV_HEADER = 'WigleWifi-1.4,appRelease=adacam-1.0,model=HivemapperBee,release=1.0,device=AdaCam,display=AdaCam,board=KeemBay,brand=AdaMaps\nMAC,SSID,AuthMode,FirstSeen,Channel,RSSI,CurrentLatitude,CurrentLongitude,AltitudeMeters,AccuracyMeters,Type'
|
||||||
|
GNSS_KEYS = ['GNSSFusion30Hz', 'GnssData', 'GnssPvt', 'GnssPosition']
|
||||||
|
running = True
|
||||||
|
|
||||||
|
def _handle_signal(signum, frame):
|
||||||
|
global running; running = False
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, _handle_signal); signal.signal(signal.SIGINT, _handle_signal)
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.execute('''CREATE TABLE IF NOT EXISTS wigle_records (id INTEGER PRIMARY KEY AUTOINCREMENT, bssid TEXT NOT NULL, ssid TEXT, auth_mode TEXT, first_seen TEXT, channel INTEGER, rssi INTEGER, lat REAL, lon REAL, alt REAL, accuracy REAL, uploaded INTEGER DEFAULT 0, created_at INTEGER)''')
|
||||||
|
conn.execute('''CREATE TABLE IF NOT EXISTS wigle_config (key TEXT PRIMARY KEY, value TEXT)''')
|
||||||
|
conn.commit(); return conn
|
||||||
|
|
||||||
|
def get_config(conn):
|
||||||
|
cfg = dict(conn.execute('SELECT key, value FROM wigle_config').fetchall())
|
||||||
|
return {'enabled': cfg.get('enabled', '0') == '1', 'api_name': cfg.get('api_name', ''), 'api_token': cfg.get('api_token', ''),
|
||||||
|
'scan_interval_seconds': int(cfg.get('scan_interval_seconds', '30')), 'upload_interval_seconds': int(cfg.get('upload_interval_seconds', '300'))}
|
||||||
|
|
||||||
|
def get_latest_gps(r=None):
|
||||||
|
if os.path.exists(ODC_DB):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(ODC_DB)
|
||||||
|
row = conn.execute('SELECT latitude, longitude, altitude, hdop, satellites_used FROM framekms WHERE latitude IS NOT NULL AND latitude != 0 ORDER BY time DESC LIMIT 1').fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row: return {'lat': row[0], 'lon': row[1], 'alt': row[2], 'hdop': row[3], 'satellites_used': row[4]}
|
||||||
|
except: pass
|
||||||
|
if r:
|
||||||
|
for key in GNSS_KEYS:
|
||||||
|
try:
|
||||||
|
for val in [r.zrange(key, -1, -1), [r.get(key)]]:
|
||||||
|
item = val[0] if val else None
|
||||||
|
if item:
|
||||||
|
d = json.loads(item)
|
||||||
|
lat = d.get('latitude') or d.get('lat_deg')
|
||||||
|
lon = d.get('longitude') or d.get('lon_deg')
|
||||||
|
if lat and lon: return {'lat': lat, 'lon': lon, 'alt': d.get('altitude') or d.get('alt_m', 0), 'hdop': d.get('hdop', 99), 'satellites_used': d.get('satellites_used') or d.get('num_satellites', 0)}
|
||||||
|
except: continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def freq_to_channel(freq):
|
||||||
|
if 2412 <= freq <= 2484: return (freq - 2407) // 5
|
||||||
|
if 5170 <= freq <= 5825: return (freq - 5000) // 5
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def parse_auth_mode(flags):
|
||||||
|
modes = []
|
||||||
|
if 'WPA3' in flags: modes.append('WPA3-PSK-SAE')
|
||||||
|
elif 'WPA2' in flags: modes.append('WPA2-PSK-CCMP')
|
||||||
|
elif 'WPA' in flags: modes.append('WPA-PSK-TKIP')
|
||||||
|
if 'WPS' in flags: modes.append('WPS')
|
||||||
|
if not modes: modes.append('ESS')
|
||||||
|
return '[' + ']['.join(modes) + ']'
|
||||||
|
|
||||||
|
def get_scan_results():
|
||||||
|
try:
|
||||||
|
subprocess.run(['wpa_cli', '-i', INTERFACE, 'scan'], capture_output=True, timeout=5)
|
||||||
|
time.sleep(4)
|
||||||
|
result = subprocess.run(['wpa_cli', '-i', INTERFACE, 'scan_results'], capture_output=True, text=True, timeout=5)
|
||||||
|
networks = []
|
||||||
|
for line in result.stdout.strip().split('\n')[1:]:
|
||||||
|
parts = line.split('\t')
|
||||||
|
if len(parts) >= 5:
|
||||||
|
networks.append({'bssid': parts[0], 'frequency': int(parts[1]) if parts[1].isdigit() else 0,
|
||||||
|
'signal_dbm': int(parts[2]) if parts[2].lstrip('-').isdigit() else -100,
|
||||||
|
'flags': parts[3], 'ssid': parts[4]})
|
||||||
|
return networks
|
||||||
|
except Exception as e: log.warning(f'Scan failed: {e}'); return []
|
||||||
|
|
||||||
|
def store_records(conn, networks, gps):
|
||||||
|
now, first_seen = int(time.time()), datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
accuracy = gps.get('hdop', 5) * 5
|
||||||
|
for n in networks:
|
||||||
|
conn.execute('INSERT INTO wigle_records (bssid, ssid, auth_mode, first_seen, channel, rssi, lat, lon, alt, accuracy, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)',
|
||||||
|
(n['bssid'], n['ssid'], parse_auth_mode(n['flags']), first_seen, freq_to_channel(n['frequency']), n['signal_dbm'], gps['lat'], gps['lon'], gps.get('alt', 0), accuracy, now))
|
||||||
|
conn.commit(); log.info(f'Stored {len(networks)} networks')
|
||||||
|
|
||||||
|
def upload_to_wigle(csv_content, api_name, api_token):
|
||||||
|
creds = base64.b64encode(f'{api_name}:{api_token}'.encode()).decode()
|
||||||
|
try:
|
||||||
|
resp = requests.post(WIGLE_UPLOAD_URL, headers={'Authorization': f'Basic {creds}'},
|
||||||
|
files={'file': ('adacam-scan.csv', csv_content.encode(), 'text/csv')}, data={'donate': 'false'}, timeout=30)
|
||||||
|
return resp.ok, resp.json() if resp.ok else resp.text
|
||||||
|
except Exception as e: return False, str(e)
|
||||||
|
|
||||||
|
def upload_pending(conn, cfg):
|
||||||
|
if not cfg['api_name'] or not cfg['api_token']: return
|
||||||
|
rows = conn.execute('SELECT id, bssid, ssid, auth_mode, first_seen, channel, rssi, lat, lon, alt, accuracy FROM wigle_records WHERE uploaded=0 LIMIT 10000').fetchall()
|
||||||
|
if not rows: return
|
||||||
|
ids = [r[0] for r in rows]
|
||||||
|
csv_lines = [WIGLE_CSV_HEADER] + [f'{r[1]},{r[2]},{r[3]},{r[4]},{r[5]},{r[6]},{r[7]},{r[8]},{r[9]},{r[10]},WIFI' for r in rows]
|
||||||
|
ok_flag, result = upload_to_wigle('\n'.join(csv_lines), cfg['api_name'], cfg['api_token'])
|
||||||
|
if ok_flag:
|
||||||
|
conn.execute(f'UPDATE wigle_records SET uploaded=1 WHERE id IN ({",".join("?" * len(ids))})', ids)
|
||||||
|
conn.commit(); log.info(f'Uploaded {len(ids)} records to WiGLE')
|
||||||
|
else: log.warning(f'WiGLE upload failed: {result}')
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global running
|
||||||
|
log.info('AdaCam WiGLE service starting')
|
||||||
|
conn, r, last_upload = init_db(), None, 0
|
||||||
|
while running:
|
||||||
|
try:
|
||||||
|
if r is None: r = redis.Redis(host='localhost', port=6379, decode_responses=True)
|
||||||
|
cfg = get_config(conn)
|
||||||
|
if not cfg['enabled']: time.sleep(10); continue
|
||||||
|
gps = get_latest_gps(r)
|
||||||
|
if gps and gps.get('hdop', 99) < 5 and gps.get('satellites_used', 0) >= 4:
|
||||||
|
networks = get_scan_results()
|
||||||
|
if networks: store_records(conn, networks, gps)
|
||||||
|
now = time.time()
|
||||||
|
if now - last_upload >= cfg['upload_interval_seconds']: upload_pending(conn, cfg); last_upload = now
|
||||||
|
time.sleep(cfg['scan_interval_seconds'])
|
||||||
|
except redis.RedisError: r = None; time.sleep(5)
|
||||||
|
except Exception as e: log.error(f'Error: {e}'); time.sleep(10)
|
||||||
|
|
||||||
|
if __name__ == '__main__': main()
|
||||||
|
PYEOF
|
||||||
|
chmod +x "$INSTALL_DIR/wigle/adacam-wigle.py"
|
||||||
|
ok "adacam-wigle.py installed"
|
||||||
|
|
||||||
|
# ── INSTALL PYTHON DEPS ───────────────────────────────────────────────────────
|
||||||
|
log ""
|
||||||
|
log "=== Installing Python dependencies ==="
|
||||||
|
if command -v pip3 &>/dev/null; then
|
||||||
|
pip3 install redis requests --quiet && ok "Python deps installed" || warn "pip3 install failed — may need manual install"
|
||||||
|
else
|
||||||
|
warn "pip3 not found — skipping dep install"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── INSTALL SYSTEMD UNITS ─────────────────────────────────────────────────────
|
||||||
|
log ""
|
||||||
|
log "=== Installing systemd units ==="
|
||||||
|
|
||||||
|
if [ "$ETC_WRITABLE" = "true" ] || [ -d /etc/systemd/system ]; then
|
||||||
|
cat > /etc/systemd/system/adacam-capture.service << SVCEOF
|
||||||
|
[Unit]
|
||||||
|
Description=AdaCam Camera Capture Service
|
||||||
|
After=network.target redis.service
|
||||||
|
Wants=redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=${INSTALL_DIR}/capture/adacam-capture.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
MemoryMax=256M
|
||||||
|
CPUQuota=80%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SVCEOF
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/adacam-forwarder.service << SVCEOF
|
||||||
|
[Unit]
|
||||||
|
Description=AdaCam Frame Forwarder Service
|
||||||
|
After=network.target redis.service adacam-capture.service
|
||||||
|
Wants=redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=${INSTALL_DIR}/capture/adacam-forwarder.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
MemoryMax=128M
|
||||||
|
CPUQuota=20%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SVCEOF
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/adacam-wigle.service << SVCEOF
|
||||||
|
[Unit]
|
||||||
|
Description=AdaCam WiGLE Wardriving Service
|
||||||
|
After=network.target redis.service
|
||||||
|
Wants=redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/python3 ${INSTALL_DIR}/wigle/adacam-wigle.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SVCEOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable adacam-capture adacam-forwarder adacam-wigle
|
||||||
|
ok "systemd units installed and enabled"
|
||||||
|
else
|
||||||
|
warn "/etc/systemd/system not writable — units not installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── MARK LIBERATED ────────────────────────────────────────────────────────────
|
||||||
|
log ""
|
||||||
|
log "=== Marking device as liberated ==="
|
||||||
|
cat > "$ADACAM_DATA/liberated" << MARKER
|
||||||
|
liberated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
serial=$SERIAL
|
||||||
|
version=0.5
|
||||||
|
MARKER
|
||||||
|
ok "liberation marker written"
|
||||||
|
|
||||||
|
# ── SUMMARY ───────────────────────────────────────────────────────────────────
|
||||||
|
log ""
|
||||||
|
echo "════════════════════════════════════════════════════════"
|
||||||
|
echo " AdaCam Liberation Complete (v0.5)"
|
||||||
|
echo "════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
echo " Device Serial: $SERIAL"
|
||||||
|
echo " API Token: $API_TOKEN"
|
||||||
|
echo " AP IP: 192.168.0.10 (UNCHANGED — factory)"
|
||||||
|
echo " SSH: root@192.168.0.10 (password + key auth)"
|
||||||
|
echo ""
|
||||||
|
echo " Services installed:"
|
||||||
|
echo " adacam-capture → camera → /tmp/adacam/pics/"
|
||||||
|
echo " adacam-forwarder → frames + GPS → ADAMaps queue"
|
||||||
|
echo " adacam-wigle → WiFi scan → WiGLE (disabled by default)"
|
||||||
|
echo ""
|
||||||
|
echo " Config: /data/adacam/config.json"
|
||||||
|
echo " Data: /data/adacam/"
|
||||||
|
echo ""
|
||||||
|
echo " REBOOT to start services."
|
||||||
|
echo " Services autostart on next boot."
|
||||||
|
echo ""
|
||||||
|
echo " NO firewall changes. NO AP changes. NO IP changes."
|
||||||
|
echo "════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
log "Run: reboot"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue