feat: bearer token auth, pairing, wifi config, ssh toggle, remove /cmd

This commit is contained in:
Kayos 2026-03-14 11:47:10 -07:00
parent 37aefb84c8
commit eaf49841f0
3 changed files with 126 additions and 3 deletions

View file

@ -1,6 +1,8 @@
"""Flask app factory.""" """Flask app factory."""
from flask import Flask import subprocess
from flask import Flask, request, jsonify
from . import config, db, forwarder from . import config, db, forwarder
from .auth import get_device_serial, get_api_token, require_auth
from .routes import landmarks, gnss, status, frames from .routes import landmarks, gnss, status, frames
@ -18,6 +20,95 @@ def create_app():
app.register_blueprint(status.bp) app.register_blueprint(status.bp)
app.register_blueprint(frames.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 # Start background forwarder thread
forwarder.start_retry_thread() forwarder.start_retry_thread()

30
adacam_api/auth.py Normal file
View file

@ -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

View file

@ -1,21 +1,23 @@
"""Landmark detection endpoints.""" """Landmark detection endpoints."""
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from .. import db, forwarder from .. import db, forwarder
from ..auth import require_auth
bp = Blueprint("landmarks", __name__, url_prefix="/api/1/landmarks") bp = Blueprint("landmarks", __name__, url_prefix="/api/1/landmarks")
@bp.route("/last/<int:n>", methods=["GET"]) @bp.route("/last/<int:n>", methods=["GET"])
def get_last(n): def get_last(n):
"""Get last N detections.""" """Get last N detections (unauthenticated)."""
n = min(n, 1000) # Cap at 1000 n = min(n, 1000) # Cap at 1000
landmarks = db.get_last_landmarks(n) landmarks = db.get_last_landmarks(n)
return jsonify(landmarks) return jsonify(landmarks)
@bp.route("", methods=["POST"]) @bp.route("", methods=["POST"])
@require_auth
def ingest(): def ingest():
"""Ingest a new detection from camera pipeline.""" """Ingest a new detection from camera pipeline (authenticated)."""
data = request.get_json() data = request.get_json()
if not data or "class_label" not in data: if not data or "class_label" not in data:
return jsonify({"error": "Missing class_label"}), 400 return jsonify({"error": "Missing class_label"}), 400