299 lines
9.3 KiB
Python
299 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
bee-agent-api: Local agent API for remote Bee management.
|
|
|
|
Provides HTTP endpoints for shell access, status, and data retrieval.
|
|
Runs on the Bee, accessed via tunnel or direct AP connection.
|
|
|
|
Install: /opt/adacam/bee-agent-api.py
|
|
Config: /data/adacam/agent-config.json
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import time
|
|
from functools import wraps
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from flask import Flask, request, jsonify
|
|
except ImportError:
|
|
# Fallback: try http.server if Flask unavailable
|
|
print("Flask not found. Install with: pip3 install flask")
|
|
raise SystemExit(1)
|
|
|
|
app = Flask(__name__)
|
|
|
|
# ── Config ───────────────────────────────────────────────────────────────────
|
|
CONFIG_PATH = Path('/data/adacam/agent-config.json')
|
|
DEFAULT_CONFIG = {
|
|
'agent_key': 'bee-agent-2026-CHANGE-ME',
|
|
'allowed_commands': [], # empty = allow all (careful!)
|
|
'max_output_bytes': 1024 * 1024, # 1MB max response
|
|
'shell_timeout': 30,
|
|
}
|
|
|
|
def load_config():
|
|
cfg = dict(DEFAULT_CONFIG)
|
|
if CONFIG_PATH.exists():
|
|
try:
|
|
with open(CONFIG_PATH) as f:
|
|
cfg.update(json.load(f))
|
|
except Exception as e:
|
|
print(f'Config error: {e}')
|
|
return cfg
|
|
|
|
CONFIG = load_config()
|
|
|
|
|
|
# ── Auth ─────────────────────────────────────────────────────────────────────
|
|
def require_auth(f):
|
|
@wraps(f)
|
|
def decorated(*args, **kwargs):
|
|
key = request.headers.get('X-Agent-Key', '')
|
|
if key != CONFIG['agent_key']:
|
|
return jsonify({'error': 'unauthorized'}), 401
|
|
return f(*args, **kwargs)
|
|
return decorated
|
|
|
|
|
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/status', methods=['GET'])
|
|
@require_auth
|
|
def status():
|
|
"""Health check and basic system info."""
|
|
try:
|
|
with open('/proc/uptime') as f:
|
|
uptime_secs = float(f.read().split()[0])
|
|
except:
|
|
uptime_secs = 0
|
|
|
|
try:
|
|
with open('/proc/loadavg') as f:
|
|
load = f.read().split()[:3]
|
|
except:
|
|
load = ['?', '?', '?']
|
|
|
|
try:
|
|
with open('/proc/meminfo') as f:
|
|
mem = {}
|
|
for line in f:
|
|
parts = line.split()
|
|
if parts[0] in ('MemTotal:', 'MemAvailable:', 'MemFree:'):
|
|
mem[parts[0].rstrip(':')] = int(parts[1]) * 1024
|
|
except:
|
|
mem = {}
|
|
|
|
# Check key services
|
|
services = {}
|
|
for svc in ['map-ai', 'depthai_gate', 'odc-api', 'redis', 'bee-agent-api']:
|
|
try:
|
|
r = subprocess.run(['systemctl', 'is-active', svc],
|
|
capture_output=True, timeout=5)
|
|
services[svc] = r.stdout.decode().strip()
|
|
except:
|
|
services[svc] = 'unknown'
|
|
|
|
return jsonify({
|
|
'ok': True,
|
|
'uptime_secs': uptime_secs,
|
|
'load': load,
|
|
'memory': mem,
|
|
'services': services,
|
|
'time': time.time(),
|
|
})
|
|
|
|
|
|
@app.route('/shell', methods=['POST'])
|
|
@require_auth
|
|
def shell():
|
|
"""Execute a shell command and return output."""
|
|
data = request.get_json(force=True, silent=True) or {}
|
|
cmd = data.get('cmd', '')
|
|
|
|
if not cmd:
|
|
return jsonify({'error': 'missing cmd'}), 400
|
|
|
|
# Optional: restrict commands
|
|
allowed = CONFIG.get('allowed_commands', [])
|
|
if allowed and not any(cmd.startswith(a) for a in allowed):
|
|
return jsonify({'error': 'command not allowed'}), 403
|
|
|
|
timeout = min(data.get('timeout', CONFIG['shell_timeout']), 60)
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
shell=True,
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
cwd=data.get('cwd', '/'),
|
|
)
|
|
stdout = result.stdout[:CONFIG['max_output_bytes']].decode('utf-8', errors='replace')
|
|
stderr = result.stderr[:CONFIG['max_output_bytes']].decode('utf-8', errors='replace')
|
|
|
|
return jsonify({
|
|
'ok': True,
|
|
'exit': result.returncode,
|
|
'stdout': stdout,
|
|
'stderr': stderr,
|
|
})
|
|
except subprocess.TimeoutExpired:
|
|
return jsonify({'error': 'timeout', 'timeout': timeout}), 504
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@app.route('/landmarks', methods=['GET'])
|
|
@require_auth
|
|
def landmarks():
|
|
"""Get landmark detection files."""
|
|
landmark_dir = Path('/data/recording/landmarks')
|
|
since_id = request.args.get('since', type=int, default=0)
|
|
limit = min(request.args.get('limit', type=int, default=100), 500)
|
|
|
|
if not landmark_dir.exists():
|
|
return jsonify({'landmarks': [], 'count': 0})
|
|
|
|
files = sorted(landmark_dir.iterdir(), key=lambda p: p.name)
|
|
|
|
results = []
|
|
for f in files[-limit:]:
|
|
try:
|
|
# Try to extract ID from filename or use modification order
|
|
file_id = int(f.stem) if f.stem.isdigit() else hash(f.name) & 0x7FFFFFFF
|
|
if file_id <= since_id:
|
|
continue
|
|
|
|
content = f.read_text()
|
|
# Try JSON parse, fallback to raw
|
|
try:
|
|
data = json.loads(content)
|
|
except:
|
|
data = {'raw': content[:1000]}
|
|
|
|
results.append({
|
|
'id': file_id,
|
|
'file': f.name,
|
|
'data': data,
|
|
})
|
|
except Exception as e:
|
|
continue
|
|
|
|
return jsonify({
|
|
'landmarks': results,
|
|
'count': len(results),
|
|
'dir': str(landmark_dir),
|
|
})
|
|
|
|
|
|
@app.route('/files', methods=['GET'])
|
|
@require_auth
|
|
def list_files():
|
|
"""List files in a directory."""
|
|
path = request.args.get('path', '/data/recording')
|
|
try:
|
|
p = Path(path)
|
|
if not p.exists():
|
|
return jsonify({'error': 'path not found'}), 404
|
|
|
|
files = []
|
|
for f in sorted(p.iterdir())[:100]:
|
|
try:
|
|
stat = f.stat()
|
|
files.append({
|
|
'name': f.name,
|
|
'type': 'dir' if f.is_dir() else 'file',
|
|
'size': stat.st_size,
|
|
'mtime': stat.st_mtime,
|
|
})
|
|
except:
|
|
continue
|
|
|
|
return jsonify({'path': str(p), 'files': files})
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@app.route('/file', methods=['GET'])
|
|
@require_auth
|
|
def read_file():
|
|
"""Read a file's contents."""
|
|
path = request.args.get('path', '')
|
|
if not path:
|
|
return jsonify({'error': 'missing path'}), 400
|
|
|
|
try:
|
|
p = Path(path)
|
|
if not p.exists():
|
|
return jsonify({'error': 'not found'}), 404
|
|
if p.is_dir():
|
|
return jsonify({'error': 'is directory'}), 400
|
|
|
|
size = p.stat().st_size
|
|
max_size = CONFIG['max_output_bytes']
|
|
|
|
if size > max_size:
|
|
content = p.read_bytes()[:max_size].decode('utf-8', errors='replace')
|
|
truncated = True
|
|
else:
|
|
content = p.read_text()
|
|
truncated = False
|
|
|
|
return jsonify({
|
|
'path': str(p),
|
|
'size': size,
|
|
'truncated': truncated,
|
|
'content': content,
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@app.route('/redis', methods=['GET'])
|
|
@require_auth
|
|
def redis_keys():
|
|
"""Get Redis keys and values."""
|
|
try:
|
|
import redis
|
|
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
|
|
|
|
keys = r.keys('*')
|
|
result = {}
|
|
for k in keys[:50]: # limit
|
|
try:
|
|
t = r.type(k)
|
|
if t == 'string':
|
|
result[k] = {'type': t, 'value': r.get(k)}
|
|
elif t == 'zset':
|
|
result[k] = {'type': t, 'latest': r.zrevrange(k, 0, 0)}
|
|
elif t == 'list':
|
|
result[k] = {'type': t, 'length': r.llen(k)}
|
|
else:
|
|
result[k] = {'type': t}
|
|
except:
|
|
result[k] = {'type': 'error'}
|
|
|
|
return jsonify({'keys': result})
|
|
except ImportError:
|
|
return jsonify({'error': 'redis module not available'}), 500
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
|
if __name__ == '__main__':
|
|
import argparse
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('-p', '--port', type=int, default=8080)
|
|
parser.add_argument('-H', '--host', default='127.0.0.1')
|
|
args = parser.parse_args()
|
|
|
|
print(f'Bee Agent API starting on {args.host}:{args.port}')
|
|
print(f'Config: {CONFIG_PATH}')
|
|
print(f'Agent key: {CONFIG["agent_key"][:10]}...')
|
|
|
|
# Don't use debug in production
|
|
app.run(host=args.host, port=args.port, threaded=True)
|