diff --git a/adacam_api/app.py b/adacam_api/app.py index c5f5049..9b623cd 100644 --- a/adacam_api/app.py +++ b/adacam_api/app.py @@ -1,6 +1,8 @@ """Flask app factory.""" -from flask import Flask +import subprocess +from flask import Flask, request, jsonify from . import config, db, forwarder +from .auth import get_device_serial, get_api_token, require_auth from .routes import landmarks, gnss, status, frames @@ -18,6 +20,95 @@ def create_app(): app.register_blueprint(status.bp) app.register_blueprint(frames.bp) + # ── PAIRING ENDPOINT ───────────────────────────────────────────────────── + @app.route('/pair') + @app.route('/api/1/pair') + def pair(): + """Pairing info for Varroa app.""" + serial = get_device_serial() + return jsonify({ + 'serial': serial, + 'version': '1.0', + 'ap_ip': '10.77.0.1', + 'api_port': 5000 + }) + + # ── DEVICE INFO ────────────────────────────────────────────────────────── + @app.route('/api/1/info') + def device_info(): + """Device identity (unauthenticated).""" + serial = get_device_serial() + return jsonify({ + 'device_id': serial, + 'version': '1.0', + 'firmware': 'adacam', + 'ap_ip': '10.77.0.1' + }) + + # ── WIFI STATUS/CONNECT ────────────────────────────────────────────────── + @app.route('/api/1/wifi/status') + def wifi_status(): + """Get WiFi client interface status (unauthenticated).""" + try: + result = subprocess.run( + ['wpa_cli', '-i', 'wlp1s0f1', 'status'], + capture_output=True, text=True, timeout=5 + ) + lines = dict( + l.split('=', 1) for l in result.stdout.strip().split('\n') if '=' in l + ) + return jsonify({ + 'ssid': lines.get('ssid', ''), + 'ip': lines.get('ip_address', ''), + 'state': lines.get('wpa_state', 'DISCONNECTED'), + 'connected': lines.get('wpa_state') == 'COMPLETED' + }) + except Exception as e: + return jsonify({'error': str(e), 'connected': False}) + + @app.route('/api/1/wifi/connect', methods=['POST']) + @require_auth + def wifi_connect(): + """Connect to a WiFi network (authenticated).""" + data = request.get_json() + ssid = data.get('ssid', '').strip() + password = data.get('password', '').strip() + if not ssid or not password: + return jsonify({'error': 'ssid and password required'}), 400 + try: + result = subprocess.run( + ['/usr/local/bin/adacam-wifi-connect', ssid, password], + capture_output=True, text=True, timeout=15 + ) + if result.returncode == 0: + return jsonify({'ok': True, 'message': f'Connecting to {ssid}'}) + else: + return jsonify({'error': result.stderr or 'Failed'}), 500 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + # ── SSH CONTROL ────────────────────────────────────────────────────────── + @app.route('/api/1/ssh/status') + def ssh_status(): + """Get SSH daemon status (unauthenticated).""" + result = subprocess.run( + ['systemctl', 'is-active', 'sshd'], + capture_output=True, text=True + ) + return jsonify({'active': result.stdout.strip() == 'active'}) + + @app.route('/api/1/ssh/toggle', methods=['POST']) + @require_auth + def ssh_toggle(): + """Enable/disable SSH (authenticated).""" + data = request.get_json() + enable = data.get('enable', True) + subprocess.run( + ['systemctl', 'start' if enable else 'stop', 'sshd'], + timeout=5 + ) + return jsonify({'ok': True, 'ssh_enabled': enable}) + # Start background forwarder thread forwarder.start_retry_thread() diff --git a/adacam_api/auth.py b/adacam_api/auth.py new file mode 100644 index 0000000..9b17a32 --- /dev/null +++ b/adacam_api/auth.py @@ -0,0 +1,30 @@ +"""Authentication helpers for adacam-api.""" +import hashlib +from functools import wraps +from flask import request, jsonify + + +def get_device_serial(): + """Get device serial from liberate.sh-generated file.""" + try: + return open('/data/adacam/device_serial').read().strip() + except: + return 'unknown' + + +def get_api_token(): + """Derive API token from device serial (matches liberate.sh output).""" + serial = get_device_serial() + return hashlib.sha256(f"adacam-api-{serial}-token".encode()).hexdigest()[:32] + + +def require_auth(f): + """Decorator: require valid Bearer token for protected endpoints.""" + @wraps(f) + def decorated(*args, **kwargs): + auth = request.headers.get('Authorization', '') + token = auth.replace('Bearer ', '').strip() + if token != get_api_token(): + return jsonify({'error': 'unauthorized'}), 401 + return f(*args, **kwargs) + return decorated diff --git a/adacam_api/routes/landmarks.py b/adacam_api/routes/landmarks.py index d75a3d2..2368e53 100644 --- a/adacam_api/routes/landmarks.py +++ b/adacam_api/routes/landmarks.py @@ -1,21 +1,23 @@ """Landmark detection endpoints.""" from flask import Blueprint, jsonify, request from .. import db, forwarder +from ..auth import require_auth bp = Blueprint("landmarks", __name__, url_prefix="/api/1/landmarks") @bp.route("/last/", methods=["GET"]) def get_last(n): - """Get last N detections.""" + """Get last N detections (unauthenticated).""" n = min(n, 1000) # Cap at 1000 landmarks = db.get_last_landmarks(n) return jsonify(landmarks) @bp.route("", methods=["POST"]) +@require_auth def ingest(): - """Ingest a new detection from camera pipeline.""" + """Ingest a new detection from camera pipeline (authenticated).""" data = request.get_json() if not data or "class_label" not in data: return jsonify({"error": "Missing class_label"}), 400