adacam-api/adacam_api/app.py
Cobb 22fba16c0c
All checks were successful
gitleaks / scan (push) Successful in 35s
security: random per-device API token + one-shot pairing window (CRIT auth-bypass fix)
The bearer token was sha256(serial)[:32] and the serial is served unauthenticated, so anyone reaching :5000 could compute it and take the device over. Now: token is a random secrets.token_urlsafe(32) at /data/adacam/api_token (never derived from serial); /pair only returns it during a one-shot pairing window (/data/adacam/pairing_open, opened by adacam-pair or install.sh, closes after one pair); require_auth uses hmac.compare_digest. NEEDS ON-DEVICE PAIRING TEST before merge to main — see SECURITY-PAIRING.md.
2026-06-13 09:48:29 -07:00

142 lines
5.8 KiB
Python

"""Flask app factory."""
import subprocess
from flask import Flask, request, jsonify
from . import config, db, forwarder
from .auth import get_device_serial, get_api_token, require_auth, pairing_open, close_pairing
from .routes import landmarks, gnss, status, frames, wigle
def create_app():
"""Create and configure the Flask app."""
app = Flask(__name__)
# Load config and init DB
config.load()
db.init()
# Register blueprints
app.register_blueprint(landmarks.bp)
app.register_blueprint(gnss.bp)
app.register_blueprint(status.bp)
app.register_blueprint(frames.bp)
app.register_blueprint(wigle.bp)
# ── PAIRING ENDPOINT ─────────────────────────────────────────────────────
@app.route('/pair')
@app.route('/api/1/pair')
def pair():
"""One-shot pairing. Returns the device's RANDOM API token, but only
while the pairing window is open (opened on the device via `adacam-pair`
or install.sh on first provision). The window closes the instant a pair
succeeds, so the token can't be harvested by an unprivileged caller the
way the old serial-derived token could.
"""
if not pairing_open():
return jsonify({
'error': 'pairing window closed',
'hint': 'run `adacam-pair` on the device (or re-run install.sh) to open a one-shot window'
}), 403
serial = get_device_serial()
token = get_api_token()
close_pairing() # one-shot — must be re-opened on the device for the next pair
return jsonify({
'serial': serial,
'token': token,
'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})
# ── DEBUG ENDPOINTS ────────────────────────────────────────────────────
@app.route('/api/1/debug/redis-keys')
@require_auth
def redis_keys():
"""Debug endpoint — list Redis keys for GPS/IMU troubleshooting."""
try:
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
keys = r.keys('*')
return jsonify({'keys': sorted(keys)})
except Exception as e:
return jsonify({'error': str(e)}), 503
# Start background forwarder thread
forwarder.start_retry_thread()
return app