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:
2026-08-19 19:08:23 +08:00
commit c877b25ff6
8 changed files with 928 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
data/
logs/
config.sh
__pycache__/
*.pyc
+119
View File
@@ -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
```
### 常用 CLIhostctl.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 + 白名单)
Executable
+128
View File
@@ -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.shSERVER / 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
View File
@@ -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 已写入 %sagent.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")
+19
View File
@@ -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
+28
View File
@@ -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
View File
@@ -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()
Executable
+43
View File
@@ -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