add bee-agent-api for remote shell access

This commit is contained in:
kayos 2026-03-22 10:03:06 -07:00
parent 62bd188894
commit 877f834f59

299
services/bee-agent-api.py Normal file
View file

@ -0,0 +1,299 @@
#!/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)