feat: bearer token auth, pairing, wifi config, ssh toggle, remove /cmd
This commit is contained in:
parent
37aefb84c8
commit
eaf49841f0
3 changed files with 126 additions and 3 deletions
|
|
@ -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
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."""
|
"""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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue