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 0974a8ab98
commit 2dc772e618
3 changed files with 126 additions and 3 deletions

View file

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

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."""
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/<int:n>", 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