add bee-agent-api for remote shell access
This commit is contained in:
parent
62bd188894
commit
877f834f59
1 changed files with 299 additions and 0 deletions
299
services/bee-agent-api.py
Normal file
299
services/bee-agent-api.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue