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:
@@ -0,0 +1,5 @@
|
||||
data/
|
||||
logs/
|
||||
config.sh
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,119 @@
|
||||
# remote-host-agent
|
||||
|
||||
大模型智能体(机器A,有固定 IP)查看/控制机器B(无固定 IP)状态的轻量方案。
|
||||
|
||||
**方案A:HTTP 上报 + 命令队列长轮询**。B 端只需 bash + curl(系统自带,零安装)。
|
||||
|
||||
```
|
||||
机器B (无固定IP) 机器A (121.40.164.32:16018)
|
||||
┌───────────────────┐ HTTPS ┌──────────────────────────┐
|
||||
│ agent.sh (轻量) │ ──────────→ │ collector.py (FastAPI) │ ←── hostctl.py / 智能体
|
||||
│ · 采集CPU/内存/磁盘 │ ←───────── │ · /api/report 收指标 │
|
||||
│ · 长轮询命令执行 │ 命令+结果 │ · /api/poll 下发命令 │
|
||||
└───────────────────┘ │ · /api/status 查状态 │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
remote-host-agent/
|
||||
├── collector.py 机器A 端服务(FastAPI, 端口 16018)
|
||||
├── start.sh 机器A 服务启停(PID 管理)
|
||||
├── hostctl.py 机器A 端 CLI(智能体/人调用)
|
||||
├── agent.sh 机器B 端轻量 agent(核心)
|
||||
├── config.sh.example 机器B 配置模板(复制为 config.sh)
|
||||
├── host-agent.service 机器B 端 systemd 服务
|
||||
└── data/ token.txt + host_agent.db(自动生成,勿提交)
|
||||
```
|
||||
|
||||
## 一、机器A 部署(本机)
|
||||
|
||||
```bash
|
||||
cd works/remote-host-agent
|
||||
./start.sh # 启动(端口 16018,首次自动生成 data/token.txt)
|
||||
./start.sh stop # 停止
|
||||
```
|
||||
|
||||
启动后查看 token:
|
||||
|
||||
```bash
|
||||
cat data/token.txt
|
||||
```
|
||||
|
||||
### 常用 CLI(hostctl.py)
|
||||
|
||||
```bash
|
||||
python3 hostctl.py hosts # 列出所有主机+在线状态
|
||||
python3 hostctl.py status <host> # 查看单台主机实时状态
|
||||
python3 hostctl.py run <host> "df -h" --wait # 下发命令并等待结果
|
||||
python3 hostctl.py run <host> "cat /proc/cpuinfo | head -20" --wait
|
||||
python3 hostctl.py history <host> --limit 50 # 历史指标
|
||||
python3 hostctl.py commands --host <host> # 命令执行记录
|
||||
```
|
||||
|
||||
### API 一览
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/report | B 端上报指标(host/cpu/mem/disk/load/uptime) |
|
||||
| GET | /api/poll | B 端长轮询取命令(阻塞 ≤65s) |
|
||||
| POST | /api/result | B 端回传执行结果(base64) |
|
||||
| GET | /api/status?host=X | 实时状态 |
|
||||
| GET | /api/hosts | 主机列表 |
|
||||
| GET | /api/history?host=X | 历史指标 |
|
||||
| POST | /api/command | 下发命令 {host, cmd, timeout, note} |
|
||||
| GET | /api/commands | 命令记录 |
|
||||
| GET | /api/health / /api/config | 健康/配置 |
|
||||
|
||||
所有 API 需 `Authorization: Bearer <token>`。
|
||||
|
||||
## 二、机器B 部署(零安装,就 2 个文件)
|
||||
|
||||
1. 将 `agent.sh` 和 `config.sh`(由 `config.sh.example` 复制)放到 B 端任意目录(如 `/opt/host-agent/`)
|
||||
|
||||
2. 配置 `config.sh`:
|
||||
|
||||
```bash
|
||||
SERVER="http://121.40.164.32:16018"
|
||||
TOKEN="<机器A data/token.txt 里的值>"
|
||||
HOST_NAME="web-server-01" # 自定义主机标识
|
||||
INTERVAL=10
|
||||
```
|
||||
|
||||
3. 方式一(前台测试):
|
||||
|
||||
```bash
|
||||
chmod +x agent.sh
|
||||
./agent.sh
|
||||
```
|
||||
|
||||
4. 方式二(systemd 常驻,推荐):
|
||||
|
||||
```bash
|
||||
cp host-agent.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now host-agent
|
||||
journalctl -u host-agent -f # 看日志
|
||||
```
|
||||
|
||||
## 三、大模型智能体集成
|
||||
|
||||
机器A 上智能体(OpenClaw)直接调用 CLI 即可,无需改 agent 代码:
|
||||
|
||||
```
|
||||
"查机器B的CPU和内存" → exec: python3 hostctl.py status web-server-01
|
||||
"在机器B上跑一下 df -h" → exec: python3 hostctl.py run web-server-01 "df -h" --wait
|
||||
```
|
||||
|
||||
## 四、安全设计
|
||||
|
||||
- **Token 认证**:所有 API 必须带 `Authorization: Bearer <token>`,token 在 A 端首次启动自动生成(32字节随机)
|
||||
- **命令白名单**:默认 `HOST_AGENT_CMD_MODE=strict`,只允许只读命令(df/free/top/ps/uptime/cat/ls/netstat 等),可用 `HOST_AGENT_CMD_MODE=open` 放开全部
|
||||
- **超时保护**:B 端 `timeout` 强制命令超时(默认 30s),防挂死
|
||||
- **离线检测**:A 端 3 分钟无心跳标记离线
|
||||
- **审计**:所有命令记录落库(hostctl.py commands 可查)
|
||||
|
||||
## 五、版本
|
||||
|
||||
- v1.0.0:方案A 落地(监控上报 + 命令控制 + CLI + systemd + 白名单)
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# remote-host-agent 机器B端 agent.sh(轻量,仅需 bash+curl+base64)
|
||||
# 功能:定时上报 CPU/内存/磁盘 等指标 + 长轮询执行机器A下发的命令
|
||||
# 用法:
|
||||
# 前台运行 ./agent.sh
|
||||
# 后台常驻 nohup ./agent.sh > /var/log/host-agent.log 2>&1 &
|
||||
# 系统服务 cp host-agent.service /etc/systemd/system/ && systemctl daemon-reload && systemctl enable --now host-agent
|
||||
# 配置:同目录 config.sh(SERVER / TOKEN / HOST_NAME / INTERVAL)
|
||||
# ============================================================
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# ---------- 加载配置 ----------
|
||||
if [ -f "$SCRIPT_DIR/config.sh" ]; then
|
||||
. "$SCRIPT_DIR/config.sh"
|
||||
fi
|
||||
|
||||
SERVER="${SERVER:-http://121.40.164.32:16018}"
|
||||
TOKEN="${TOKEN:-}"
|
||||
HOST_NAME="${HOST_NAME:-$(hostname)}"
|
||||
INTERVAL="${INTERVAL:-10}" # 上报间隔(秒)
|
||||
POLL_TIMEOUT="${POLL_TIMEOUT:-65}" # 命令长轮询超时(秒),略大于服务端阻塞时间
|
||||
CMD_TIMEOUT="${CMD_TIMEOUT:-30}" # 单条命令执行超时(秒)
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "[错误] 未配置 TOKEN,请在 config.sh 中填写机器A生成的 token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UA="host-agent/1.0"
|
||||
|
||||
# ---------- 通用请求封装(保留备用;上报/结果用 curl -G 保证参数编码) ----------
|
||||
req() { # req <method> <path> [--data ...]
|
||||
local method="$1" path="$2"; shift 2
|
||||
curl -s -m 15 -A "$UA" -X "$method" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
"$@" \
|
||||
"http://${SERVER#http://}${path}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ---------- 采集指标(全部系统自带命令) ----------
|
||||
collect_cpu() {
|
||||
# 取两次采样差值,避免 top 首次采样不准(awk 用 %d 强制整数,防科学计数法)
|
||||
local c1 c2 id1 id2
|
||||
c1=$(grep '^cpu ' /proc/stat | awk '{printf "%d", $2+$3+$4+$5+$6+$7+$8}')
|
||||
id1=$(grep '^cpu ' /proc/stat | awk '{printf "%d", $5}')
|
||||
sleep 1
|
||||
c2=$(grep '^cpu ' /proc/stat | awk '{printf "%d", $2+$3+$4+$5+$6+$7+$8}')
|
||||
id2=$(grep '^cpu ' /proc/stat | awk '{printf "%d", $5}')
|
||||
local total=$((c2 - c1 + id2 - id1))
|
||||
local idle=$((id2 - id1))
|
||||
if [ "$total" -le 0 ]; then echo 0; else
|
||||
awk -v t="$total" -v i="$idle" 'BEGIN{printf "%.1f", 100*(t-i)/t}'
|
||||
fi
|
||||
}
|
||||
|
||||
collect_metrics() {
|
||||
# CPU 使用率 %
|
||||
CPU="$(collect_cpu)"
|
||||
# 内存使用率 %(用行号取数据行,兼容中英文 locale)
|
||||
local mt mu
|
||||
read mt mu < <(free -m 2>/dev/null | awk 'NR==2{print $2, $3}')
|
||||
if [ -n "$mt" ] && [ "$mt" -gt 0 ]; then
|
||||
MEM=$(( mu * 100 / mt ))
|
||||
else
|
||||
MEM=0
|
||||
fi
|
||||
# 根分区磁盘使用率 %
|
||||
DISK="$(df -h / 2>/dev/null | awk 'NR==2{gsub("%","",$5); print $5}')"
|
||||
[ -z "$DISK" ] && DISK=0
|
||||
# 负载
|
||||
LOAD="$(cat /proc/loadavg 2>/dev/null | awk '{print $1" "$2" "$3}')"
|
||||
# 开机秒数
|
||||
UPTIME="$(awk '{print int($1)}' /proc/uptime 2>/dev/null)"
|
||||
}
|
||||
|
||||
# ---------- 上报 ----------
|
||||
report() {
|
||||
collect_metrics
|
||||
curl -s -m 15 -A "$UA" -X POST -G \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
--data-urlencode "host=${HOST_NAME}" \
|
||||
--data-urlencode "cpu=${CPU}" \
|
||||
--data-urlencode "mem=${MEM}" \
|
||||
--data-urlencode "disk=${DISK}" \
|
||||
--data-urlencode "load=${LOAD}" \
|
||||
--data-urlencode "uptime=${UPTIME}" \
|
||||
"http://${SERVER#http://}/api/report" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# ---------- 长轮询取命令并执行 ----------
|
||||
poll_and_run() {
|
||||
local resp
|
||||
resp=$(curl -s -m "$POLL_TIMEOUT" -A "$UA" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
"http://${SERVER#http://}/api/poll?host=${HOST_NAME}" 2>/dev/null) || return 0
|
||||
# 解析 cmd_id / cmd(用 grep 轻量解析,避免依赖 jq)
|
||||
local cmd_id cmd
|
||||
cmd_id=$(echo "$resp" | grep -o '"cmd_id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
cmd=$(echo "$resp" | sed -n 's/.*"cmd":"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$cmd_id" ] && [ -n "$cmd" ]; then
|
||||
# 还原 JSON 转义
|
||||
cmd=$(printf '%b' "$cmd" | sed 's/\\n/\n/g; s/\\"/"/g; s/\\\\/\\/g')
|
||||
local out exitcode
|
||||
out=$(timeout "$CMD_TIMEOUT" bash -c "$cmd" 2>&1)
|
||||
exitcode=$?
|
||||
local b64
|
||||
b64=$(printf '%s' "$out" | base64 -w0 2>/dev/null || printf '%s' "$out" | base64)
|
||||
# 用 curl -G + --data-urlencode,避免 base64 中的 + / = 被 URL 解码破坏
|
||||
curl -s -m 15 -A "$UA" -X POST -G \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
--data-urlencode "host=${HOST_NAME}" \
|
||||
--data-urlencode "cmd_id=${cmd_id}" \
|
||||
--data-urlencode "output=${b64}" \
|
||||
--data-urlencode "exit_code=${exitcode}" \
|
||||
"http://${SERVER#http://}/api/result" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------- 主循环(常驻,永不退出) ----------
|
||||
echo "[$(date '+%F %T')] host-agent 启动: host=${HOST_NAME} server=${SERVER} interval=${INTERVAL}s"
|
||||
while true; do
|
||||
report
|
||||
poll_and_run
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
remote-host-agent - 机器B状态监控/控制 Collector 服务(运行在机器A,固定IP端)
|
||||
方案A:HTTP 上报 + 命令队列长轮询
|
||||
- POST /api/report B 端定时上报 CPU/内存/磁盘等指标
|
||||
- GET /api/poll B 端长轮询取命令(阻塞最多 65s)
|
||||
- POST /api/result B 端回传命令执行结果(base64)
|
||||
- GET /api/status 查询某台主机实时状态
|
||||
- GET /api/hosts 列出所有已注册主机
|
||||
- GET /api/history 查询历史指标
|
||||
- POST /api/command 下发命令到某台主机的队列
|
||||
- GET /api/health 健康检查
|
||||
- GET /api/config 查看当前配置(不含 token)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from contextlib import contextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, HTTPException, Depends, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
|
||||
# ---------- 基础配置 ----------
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
LOG_DIR = BASE_DIR / "logs"
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
|
||||
DB_PATH = DATA_DIR / "host_agent.db"
|
||||
TOKEN_FILE = DATA_DIR / "token.txt"
|
||||
|
||||
HOST = os.environ.get("HOST_AGENT_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("HOST_AGENT_PORT", "16018"))
|
||||
|
||||
# 命令控制模式:strict = 白名单(默认,安全);open = 允许任意命令(仅审计)
|
||||
CMD_MODE = os.environ.get("HOST_AGENT_CMD_MODE", "strict").lower()
|
||||
|
||||
# B 端上报心跳超时(秒),超过则标记离线
|
||||
OFFLINE_AFTER = int(os.environ.get("HOST_AGENT_OFFLINE_AFTER", "180"))
|
||||
|
||||
# 长轮询最长阻塞时间(秒)
|
||||
POLL_WAIT = 65
|
||||
|
||||
# ---------- 命令白名单(strict 模式) ----------
|
||||
# 只允许以这些命令名开头的命令(按空格分隔的第一个 token 精确匹配)
|
||||
CMD_WHITELIST = {
|
||||
"df", "free", "top", "ps", "uptime", "uname", "hostname", "whoami",
|
||||
"cat", "ls", "find", "du", "netstat", "ss", "sar", "vmstat", "iostat",
|
||||
"mpstat", "pgrep", "lsof", "date", "echo", "ip", "ifconfig", "mount",
|
||||
"sysctl", "lscpu", "lsblk", "nproc", "getconf", "id", "dmesg", "lspci",
|
||||
"pwd", "head", "tail", "grep", "wc", "file", "stat", "readlink",
|
||||
"hostnamectl", "systemctl", "journalctl", "nvidia-smi", "psutil",
|
||||
}
|
||||
|
||||
# ---------- 日志 ----------
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(),
|
||||
logging.FileHandler(LOG_DIR / "collector.log", encoding="utf-8"),
|
||||
],
|
||||
)
|
||||
log = logging.getLogger("collector")
|
||||
|
||||
# ---------- Token ----------
|
||||
def load_or_create_token():
|
||||
if TOKEN_FILE.exists():
|
||||
token = TOKEN_FILE.read_text().strip()
|
||||
if token:
|
||||
return token
|
||||
token = os.environ.get("HOST_AGENT_TOKEN") or secrets.token_urlsafe(32)
|
||||
TOKEN_FILE.write_text(token)
|
||||
log.info("已%s token 文件: %s", "更新" if os.environ.get("HOST_AGENT_TOKEN") else "生成", TOKEN_FILE)
|
||||
return token
|
||||
|
||||
TOKEN = load_or_create_token()
|
||||
|
||||
# ---------- 数据库 ----------
|
||||
def get_conn():
|
||||
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
with get_conn() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS hosts (
|
||||
host TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
first_seen REAL,
|
||||
last_seen REAL,
|
||||
online INTEGER DEFAULT 0,
|
||||
latest TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host TEXT,
|
||||
ts REAL,
|
||||
cpu REAL,
|
||||
mem REAL,
|
||||
disk REAL,
|
||||
load TEXT,
|
||||
uptime INTEGER,
|
||||
extra TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_host_ts ON metrics(host, ts);
|
||||
CREATE TABLE IF NOT EXISTS commands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host TEXT,
|
||||
cmd TEXT,
|
||||
timeout INTEGER DEFAULT 30,
|
||||
status TEXT DEFAULT 'pending', -- pending/sent/done/failed
|
||||
note TEXT,
|
||||
created_at REAL,
|
||||
sent_at REAL,
|
||||
result TEXT,
|
||||
result_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_commands_host_status ON commands(host, status);
|
||||
""")
|
||||
|
||||
# ---------- 命令队列唤醒(长轮询用) ----------
|
||||
_host_events = {}
|
||||
_host_events_lock = threading.Lock()
|
||||
|
||||
def event_for(host):
|
||||
with _host_events_lock:
|
||||
if host not in _host_events:
|
||||
_host_events[host] = threading.Event()
|
||||
return _host_events[host]
|
||||
|
||||
def notify_host(host):
|
||||
event_for(host).set()
|
||||
|
||||
def reset_host_event(host):
|
||||
event_for(host).clear()
|
||||
|
||||
# ---------- 认证 ----------
|
||||
def check_auth(authorization: str):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="缺少 Bearer token")
|
||||
if authorization[len("Bearer "):].strip() != TOKEN:
|
||||
raise HTTPException(status_code=401, detail="token 无效")
|
||||
|
||||
# ---------- 命令校验 ----------
|
||||
def validate_command(cmd: str) -> str:
|
||||
cmd = cmd.strip()
|
||||
if not cmd:
|
||||
raise HTTPException(status_code=400, detail="命令不能为空")
|
||||
if len(cmd) > 2048:
|
||||
raise HTTPException(status_code=400, detail="命令过长")
|
||||
if CMD_MODE == "strict":
|
||||
first = cmd.split()[0].lstrip("$").strip()
|
||||
if first not in CMD_WHITELIST:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"strict 模式下命令被白名单拦截: {first}。可用 HOST_AGENT_CMD_MODE=open 放开",
|
||||
)
|
||||
return cmd
|
||||
|
||||
# ---------- FastAPI ----------
|
||||
app = FastAPI(title="Remote Host Agent", version="1.0.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
log.info("Collector 启动: %s:%s CMD_MODE=%s", HOST, PORT, CMD_MODE)
|
||||
log.info("Token 已写入 %s(agent.sh / hostctl 需使用)", TOKEN_FILE)
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok", "time": time.time()}
|
||||
|
||||
@app.get("/api/config")
|
||||
def config():
|
||||
return {
|
||||
"port": PORT,
|
||||
"cmd_mode": CMD_MODE,
|
||||
"offline_after": OFFLINE_AFTER,
|
||||
"whitelist_count": len(CMD_WHITELIST) if CMD_MODE == "strict" else None,
|
||||
}
|
||||
|
||||
# ---------- B 端上报 ----------
|
||||
@app.post("/api/report")
|
||||
def report(
|
||||
request: Request,
|
||||
host: str = Query(..., description="主机标识"),
|
||||
cpu: float = Query(None),
|
||||
mem: float = Query(None),
|
||||
disk: float = Query(None),
|
||||
load: str = Query(None),
|
||||
uptime: int = Query(None),
|
||||
):
|
||||
check_auth(request.headers.get("authorization", ""))
|
||||
host = host.strip()[:64]
|
||||
now = time.time()
|
||||
extra = {}
|
||||
for k, v in request.query_params.items():
|
||||
if k.startswith("x_"):
|
||||
extra[k[2:]] = v
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO metrics(host,ts,cpu,mem,disk,load,uptime,extra) VALUES(?,?,?,?,?,?,?,?)",
|
||||
(host, now, cpu, mem, disk, load, uptime, json.dumps(extra, ensure_ascii=False)),
|
||||
)
|
||||
conn.execute("""
|
||||
INSERT INTO hosts(host,name,first_seen,last_seen,online,latest)
|
||||
VALUES(?,?,?,?,1,?)
|
||||
ON CONFLICT(host) DO UPDATE SET
|
||||
last_seen=excluded.last_seen, online=1, latest=excluded.latest
|
||||
""", (host, host, now, now, json.dumps({
|
||||
"cpu": cpu, "mem": mem, "disk": disk, "load": load,
|
||||
"uptime": uptime, "extra": extra, "ts": now,
|
||||
}, ensure_ascii=False)))
|
||||
return {"ok": True, "time": now}
|
||||
|
||||
# ---------- B 端长轮询取命令 ----------
|
||||
@app.get("/api/poll")
|
||||
def poll(request: Request, host: str = Query(...)):
|
||||
check_auth(request.headers.get("authorization", ""))
|
||||
host = host.strip()[:64]
|
||||
# 先标记在线
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE hosts SET last_seen=?, online=1 WHERE host=?", (time.time(), host))
|
||||
deadline = time.time() + POLL_WAIT
|
||||
ev = event_for(host)
|
||||
while time.time() < deadline:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id,host,cmd,timeout FROM commands WHERE host=? AND status='pending' ORDER BY id LIMIT 1",
|
||||
(host,),
|
||||
).fetchone()
|
||||
if row:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE commands SET status='sent', sent_at=? WHERE id=?",
|
||||
(time.time(), row["id"]),
|
||||
)
|
||||
reset_host_event(host)
|
||||
return {
|
||||
"cmd_id": row["id"],
|
||||
"cmd": row["cmd"],
|
||||
"timeout": row["timeout"],
|
||||
}
|
||||
# 等待新的命令(被 POST /api/command 唤醒)
|
||||
ev.wait(timeout=min(2.0, deadline - time.time()))
|
||||
reset_host_event(host)
|
||||
return {"cmd_id": None, "cmd": None}
|
||||
|
||||
# ---------- B 端回传结果 ----------
|
||||
@app.post("/api/result")
|
||||
def result(
|
||||
request: Request,
|
||||
host: str = Query(...),
|
||||
cmd_id: int = Query(...),
|
||||
output: str = Query(..., description="命令输出(base64 编码)"),
|
||||
exit_code: int = Query(0),
|
||||
):
|
||||
check_auth(request.headers.get("authorization", ""))
|
||||
host = host.strip()[:64]
|
||||
try:
|
||||
out_text = base64.b64decode(output).decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
out_text = "[解码失败]"
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE commands SET status=?, result=?, result_at=? WHERE id=? AND host=?",
|
||||
("done" if exit_code == 0 else "failed", out_text, time.time(), cmd_id, host),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return {"ok": False, "detail": "未找到对应命令"}
|
||||
log.info("命令 #%s (%s) 完成 exit=%s", cmd_id, host, exit_code)
|
||||
return {"ok": True}
|
||||
|
||||
# ---------- 查询 ----------
|
||||
@app.get("/api/hosts")
|
||||
def hosts(request: Request):
|
||||
check_auth(request.headers.get("authorization", ""))
|
||||
now = time.time()
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM hosts").fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
latest = json.loads(r["latest"]) if r["latest"] else {}
|
||||
online = (now - r["last_seen"]) < OFFLINE_AFTER
|
||||
result.append({
|
||||
"host": r["host"],
|
||||
"name": r["name"],
|
||||
"online": online,
|
||||
"last_seen": r["last_seen"],
|
||||
"first_seen": r["first_seen"],
|
||||
"latest": latest,
|
||||
})
|
||||
# 后台清理离线标记(延迟处理,不影响返回)
|
||||
if result:
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE hosts SET online=0 WHERE ? - last_seen > ?", (now, OFFLINE_AFTER))
|
||||
return {"hosts": result}
|
||||
|
||||
@app.get("/api/status")
|
||||
def status(request: Request, host: str = Query(...)):
|
||||
check_auth(request.headers.get("authorization", ""))
|
||||
now = time.time()
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM hosts WHERE host=?", (host,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"主机 {host} 未注册")
|
||||
latest = json.loads(row["latest"]) if row["latest"] else {}
|
||||
online = (now - row["last_seen"]) < OFFLINE_AFTER
|
||||
last_cmd = conn.execute(
|
||||
"SELECT id,cmd,status,result,created_at,result_at FROM commands "
|
||||
"WHERE host=? AND status IN ('done','failed') ORDER BY id DESC LIMIT 1",
|
||||
(host,),
|
||||
).fetchone()
|
||||
data = {
|
||||
"host": row["host"],
|
||||
"name": row["name"],
|
||||
"online": online,
|
||||
"last_seen": row["last_seen"],
|
||||
"age_sec": round(now - row["last_seen"], 1),
|
||||
"latest": latest,
|
||||
}
|
||||
if last_cmd:
|
||||
data["last_command"] = dict(last_cmd)
|
||||
return data
|
||||
|
||||
@app.get("/api/history")
|
||||
def history(
|
||||
request: Request,
|
||||
host: str = Query(...),
|
||||
limit: int = Query(30, ge=1, le=1000),
|
||||
):
|
||||
check_auth(request.headers.get("authorization", ""))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT ts,cpu,mem,disk,load,uptime FROM metrics WHERE host=? ORDER BY ts DESC LIMIT ?",
|
||||
(host, limit),
|
||||
).fetchall()
|
||||
return {"host": host, "count": len(rows), "points": [dict(r) for r in rows]}
|
||||
|
||||
@app.get("/api/commands")
|
||||
def list_commands(
|
||||
request: Request,
|
||||
host: str = Query(None),
|
||||
limit: int = Query(30, ge=1, le=200),
|
||||
):
|
||||
check_auth(request.headers.get("authorization", ""))
|
||||
with get_conn() as conn:
|
||||
if host:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM commands WHERE host=? ORDER BY id DESC LIMIT ?", (host, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM commands ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
||||
return {"commands": [dict(r) for r in rows]}
|
||||
|
||||
# ---------- 下发命令 ----------
|
||||
@app.post("/api/command")
|
||||
def command(
|
||||
request: Request,
|
||||
payload: dict,
|
||||
):
|
||||
check_auth(request.headers.get("authorization", ""))
|
||||
host = str(payload.get("host", "")).strip()[:64]
|
||||
cmd = str(payload.get("cmd", "")).strip()
|
||||
timeout = int(payload.get("timeout", 30))
|
||||
note = str(payload.get("note", ""))[:200]
|
||||
if not host:
|
||||
raise HTTPException(status_code=400, detail="缺少 host")
|
||||
cmd = validate_command(cmd)
|
||||
timeout = min(max(timeout, 1), 300)
|
||||
now = time.time()
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO commands(host,cmd,timeout,status,note,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(host, cmd, timeout, "pending", note, now),
|
||||
)
|
||||
cmd_id = cur.lastrowid
|
||||
notify_host(host)
|
||||
log.info("下发命令 #%s -> %s: %s", cmd_id, host, cmd)
|
||||
return {"ok": True, "cmd_id": cmd_id}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host=HOST, port=PORT, log_level="info")
|
||||
@@ -0,0 +1,19 @@
|
||||
# ============================================================
|
||||
# remote-host-agent 机器B端配置
|
||||
# 复制为 config.sh 并填写后,与 agent.sh 放在同一目录
|
||||
# ============================================================
|
||||
|
||||
# 机器A 的 collector 服务地址(A 有固定 IP/域名)
|
||||
SERVER="http://121.40.164.32:16018"
|
||||
|
||||
# 认证 token(在机器A 执行: cat data/token.txt 获取)
|
||||
TOKEN=""
|
||||
|
||||
# 本机标识(默认取 hostname,建议设为有意义的名称如 web-server-01)
|
||||
HOST_NAME="$(hostname)"
|
||||
|
||||
# 上报间隔(秒)
|
||||
INTERVAL=10
|
||||
|
||||
# 命令执行超时(秒)
|
||||
CMD_TIMEOUT=30
|
||||
@@ -0,0 +1,28 @@
|
||||
# ============================================================
|
||||
# remote-host-agent 机器B端 systemd 服务
|
||||
# 部署步骤(B 端 root):
|
||||
# mkdir -p /opt/host-agent
|
||||
# 将 agent.sh / config.sh 放入 /opt/host-agent/
|
||||
# cp host-agent.service /etc/systemd/system/
|
||||
# systemctl daemon-reload
|
||||
# systemctl enable --now host-agent
|
||||
# 常用:
|
||||
# systemctl status host-agent
|
||||
# journalctl -u host-agent -f
|
||||
# ============================================================
|
||||
[Unit]
|
||||
Description=Remote Host Agent (machine B -> machine A)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/host-agent
|
||||
ExecStart=/bin/bash /opt/host-agent/agent.sh
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=append:/var/log/host-agent.log
|
||||
StandardError=append:/var/log/host-agent.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
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()
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
# remote-host-agent collector 启停脚本(机器A端)
|
||||
# ./start.sh 启动 | ./start.sh stop 停止 | ./start.sh restart 重启
|
||||
cd "$(dirname "$0")"
|
||||
PY=/home/hz1/miniconda3/envs/openclaw/bin/python3
|
||||
PORT=${HOST_AGENT_PORT:-16018}
|
||||
PID_FILE="logs/collector.pid"
|
||||
mkdir -p logs
|
||||
|
||||
case "${1:-start}" in
|
||||
stop)
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
kill "$(cat "$PID_FILE")" 2>/dev/null && echo "已停止 collector (PID $(cat "$PID_FILE"))" || echo "进程不存在"
|
||||
rm -f "$PID_FILE"
|
||||
else
|
||||
echo "未找到 PID 文件"
|
||||
fi
|
||||
;;
|
||||
restart)
|
||||
"$0" stop; sleep 1; "$0" start
|
||||
;;
|
||||
start)
|
||||
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
echo "collector 已在运行 (PID $(cat "$PID_FILE"), 端口 $PORT)"
|
||||
exit 0
|
||||
fi
|
||||
nohup "$PY" collector.py > logs/collector.out 2>&1 &
|
||||
echo $! > "$PID_FILE"
|
||||
sleep 2
|
||||
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
echo "collector 启动成功 (PID $(cat "$PID_FILE"), 端口 $PORT)"
|
||||
echo "Token 文件: $(pwd)/data/token.txt"
|
||||
if [ -f data/token.txt ]; then
|
||||
echo "Token: $(cat data/token.txt)"
|
||||
fi
|
||||
else
|
||||
echo "启动失败,查看 logs/collector.out"; exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 [start|stop|restart]"; exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user