v1.0.0 remote-host-agent: 机器B监控/控制轻量方案(HTTP上报+命令长轮询)
- collector.py: 机器A端 FastAPI 服务(16018),token认证 + strict/open 命令白名单 + SQLite存储 - agent.sh: 机器B端轻量agent(仅bash+curl+base64,零安装),采集CPU/内存/磁盘/负载/开机时间 + 长轮询执行命令回传结果 - hostctl.py: 机器A端 CLI(status/hosts/run/history/commands) - host-agent.service: 机器B端 systemd 服务 - start.sh: collector 启停脚本
This commit is contained in:
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
remote-host-agent 机器A端 CLI(供大模型智能体 / 人调用)
|
||||
用法:
|
||||
hostctl.py status <host> # 查看某台主机实时状态
|
||||
hostctl.py hosts # 列出所有主机
|
||||
hostctl.py run <host> "<命令>" [--timeout N] [--wait] # 下发命令并取回结果
|
||||
hostctl.py history <host> [--limit N] # 历史指标
|
||||
hostctl.py commands [--host H] [--limit N] # 命令记录
|
||||
hostctl.py health # 健康检查
|
||||
hostctl.py config # 查看配置
|
||||
零第三方依赖(标准库 urllib)。
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
TOKEN_FILE = BASE_DIR / "data" / "token.txt"
|
||||
SERVER = os.environ.get("HOST_AGENT_SERVER", "http://127.0.0.1:16018")
|
||||
|
||||
def get_token():
|
||||
if TOKEN_FILE.exists():
|
||||
tok = TOKEN_FILE.read_text().strip()
|
||||
if tok:
|
||||
return tok
|
||||
tok = os.environ.get("HOST_AGENT_TOKEN", "")
|
||||
if not tok:
|
||||
print("[错误] 未找到 token 文件 (data/token.txt)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return tok
|
||||
|
||||
def api(method, path, params=None, payload=None, timeout=70):
|
||||
token = get_token()
|
||||
url = SERVER.rstrip("/") + path
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
data = None
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
detail = json.loads(e.read().decode()).get("detail", str(e))
|
||||
except Exception:
|
||||
detail = str(e)
|
||||
print(f"[错误] HTTP {e.code}: {detail}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"[错误] 无法连接 {SERVER}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# ---------- 输出 ----------
|
||||
def fmt_pct(v):
|
||||
try:
|
||||
f = float(v)
|
||||
return f"{f:.1f}%"
|
||||
except Exception:
|
||||
return str(v) if v is not None else "-"
|
||||
|
||||
def print_status(d):
|
||||
st = "🟢 在线" if d.get("online") else "🔴 离线"
|
||||
print(f"主机: {d.get('host')} ({st})")
|
||||
print(f"最近心跳: {time.strftime('%F %T', time.localtime(d.get('last_seen', 0)))} (距今 {d.get('age_sec')}s)")
|
||||
l = d.get("latest") or {}
|
||||
print(f"CPU: {fmt_pct(l.get('cpu'))}")
|
||||
print(f"内存: {fmt_pct(l.get('mem'))}")
|
||||
print(f"磁盘: {fmt_pct(l.get('disk'))}")
|
||||
print(f"负载: {l.get('load', '-')}")
|
||||
if l.get("uptime") is not None:
|
||||
print(f"开机: {int(l['uptime']) // 86400} 天 {int(l['uptime']) % 86400 // 3600} 小时")
|
||||
lc = d.get("last_command")
|
||||
if lc:
|
||||
print(f"\n最近命令 #{lc.get('id')}: {lc.get('cmd')} [{lc.get('status')}]")
|
||||
if lc.get("result"):
|
||||
print("结果:", lc["result"][:500])
|
||||
|
||||
# ---------- 子命令 ----------
|
||||
def cmd_status(args):
|
||||
print_status(api("GET", "/api/status", {"host": args.host}))
|
||||
|
||||
def cmd_hosts(args):
|
||||
d = api("GET", "/api/hosts")
|
||||
if not d.get("hosts"):
|
||||
print("(暂无主机)")
|
||||
return
|
||||
print(f"{'主机':<20} {'状态':<4} {'CPU':>8} {'内存':>8} {'磁盘':>8} {'最近心跳'}")
|
||||
for h in d["hosts"]:
|
||||
st = "🟢" if h.get("online") else "🔴"
|
||||
l = h.get("latest") or {}
|
||||
ts = time.strftime("%m-%d %H:%M", time.localtime(h.get("last_seen", 0)))
|
||||
print(f"{h.get('host',''):<20} {st:<4} {fmt_pct(l.get('cpu')):>8} {fmt_pct(l.get('mem')):>8} {fmt_pct(l.get('disk')):>8} {ts}")
|
||||
|
||||
def cmd_run(args):
|
||||
r = api("POST", "/api/command", payload={
|
||||
"host": args.host, "cmd": args.cmd, "timeout": args.timeout, "note": args.note,
|
||||
})
|
||||
print(f"命令 #{r['cmd_id']} 已下发 -> {args.host}")
|
||||
if not args.wait:
|
||||
return
|
||||
# 轮询等结果
|
||||
for _ in range(args.timeout + 20):
|
||||
time.sleep(2)
|
||||
d = api("GET", "/api/commands", {"host": args.host, "limit": 20})
|
||||
for c in d["commands"]:
|
||||
if c["id"] == r["cmd_id"] and c["status"] in ("done", "failed"):
|
||||
print("=" * 40)
|
||||
print(c.get("result") or "(无输出)")
|
||||
print("=" * 40)
|
||||
print(f"[退出码: {'非0' if c['status']=='failed' else 0}]")
|
||||
return
|
||||
print("[超时] 等待结果超时,可稍后执行 hostctl.py commands 查看")
|
||||
|
||||
def cmd_history(args):
|
||||
d = api("GET", "/api/history", {"host": args.host, "limit": args.limit})
|
||||
if not d.get("points"):
|
||||
print(f"({args.host} 暂无历史数据)")
|
||||
return
|
||||
print(f"{'时间':<20} {'CPU':>8} {'内存':>8} {'磁盘':>8} 负载")
|
||||
for p in reversed(d["points"]):
|
||||
ts = time.strftime("%m-%d %H:%M:%S", time.localtime(p["ts"]))
|
||||
print(f"{ts:<20} {fmt_pct(p['cpu']):>8} {fmt_pct(p['mem']):>8} {fmt_pct(p['disk']):>8} {p.get('load','-')}")
|
||||
|
||||
def cmd_commands(args):
|
||||
params = {"limit": args.limit}
|
||||
if args.host:
|
||||
params["host"] = args.host
|
||||
d = api("GET", "/api/commands", params)
|
||||
if not d.get("commands"):
|
||||
print("(无命令记录)")
|
||||
return
|
||||
for c in reversed(d["commands"]):
|
||||
ts = time.strftime("%m-%d %H:%M", time.localtime(c["created_at"]))
|
||||
print(f"#{c['id']:<4} [{ts}] {c['host']:<16} {c['status']:<7} {c['cmd']}")
|
||||
if c.get("note"):
|
||||
print(f" 备注: {c['note']}")
|
||||
|
||||
def cmd_health(args):
|
||||
print(api("GET", "/api/health"))
|
||||
|
||||
def cmd_config(args):
|
||||
d = api("GET", "/api/config")
|
||||
for k, v in d.items():
|
||||
print(f"{k}: {v}")
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="remote-host-agent CLI (机器A端)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sp = sub.add_parser("status", help="查看主机状态"); sp.add_argument("host"); sp.set_defaults(fn=cmd_status)
|
||||
sp = sub.add_parser("hosts", help="列出所有主机"); sp.set_defaults(fn=cmd_hosts)
|
||||
sp = sub.add_parser("run", help="下发命令")
|
||||
sp.add_argument("host"); sp.add_argument("cmd")
|
||||
sp.add_argument("--timeout", type=int, default=30)
|
||||
sp.add_argument("--note", default="")
|
||||
sp.add_argument("--wait", action="store_true", help="等待执行结果")
|
||||
sp.set_defaults(fn=cmd_run)
|
||||
sp = sub.add_parser("history", help="历史指标")
|
||||
sp.add_argument("host"); sp.add_argument("--limit", type=int, default=30)
|
||||
sp.set_defaults(fn=cmd_history)
|
||||
sp = sub.add_parser("commands", help="命令记录")
|
||||
sp.add_argument("--host", default=None); sp.add_argument("--limit", type=int, default=30)
|
||||
sp.set_defaults(fn=cmd_commands)
|
||||
sp = sub.add_parser("health", help="健康检查"); sp.set_defaults(fn=cmd_health)
|
||||
sp = sub.add_parser("config", help="查看配置"); sp.set_defaults(fn=cmd_config)
|
||||
|
||||
args = p.parse_args()
|
||||
args.fn(args)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user