All checks were successful
gitleaks / scan (push) Successful in 35s
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.
142 lines
5.8 KiB
Python
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
|