feat: bearer token auth, pairing, wifi config, ssh toggle, remove /cmd
This commit is contained in:
parent
0974a8ab98
commit
2dc772e618
3 changed files with 126 additions and 3 deletions
|
|
@ -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
30
adacam_api/auth.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue