refactor: separate fleet manager from local monitoring

This commit is contained in:
way
2026-09-27 22:28:03 +08:00
parent ced0140450
commit c8c12b7b70
8 changed files with 90 additions and 361 deletions
+24 -39
View File
@@ -1,65 +1,50 @@
# PIGWay Pi Control v3.7.0 # PIGWay Pi Control v3.7.0
独立的 Raspberry Pi / Linux 系统监控与多机 Web 管理平台。提供本机状态、告警、事件日志和多机配置管理,可按需接入独立插件。 极轻的 Raspberry Pi / Linux 本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。无 Web 界面;无需硬件插件,也无需管理中心。
## 功能
- CPU、温度、内存和磁盘容量/用量;主网络接口、IPv4/IPv6、Wi-Fi 信号百分比。
- systemd 服务及进程监控;暂停监控与启停服务是不同操作。
- Raspberry Pi 当前欠压、降频、频率受限、温度限制和根分区只读监控。
- 当前故障统一管理;历史电源位仅记录日志,不生成当前告警。
- 事件日志按日期、严重程度、事件类型和机器查询、排序。
- 多机状态、机器标识、配置与独立的批量配置同步。
- 中英文、跟随系统/深色/浅色主题。
- 独立硬件插件发现、手动接入、暂停联动、断开、能力配置。
## 安装 ## 安装
```bash ```bash
sudo ./install.sh sudo ./install.sh
# 仅安装文件,不改变服务运行状态: # 显式启用 API:
sudo ./install.sh --no-start sudo ./install.sh --enable-api
# 不启用 API:
sudo ./install.sh --disable-api
``` ```
仅需要 Python 3。插件独立安装与运行,主程序不会自动安装或启动插件。 交互安装询问是否启用远程 API,默认否;非交互安装未指定时也关闭。`--no-start` 仅更新文件和配置,不改变现有服务运行状态,接口开关在下次启动生效。
配置 `/etc/pigway-pi-control.conf`;Web 默认端口 6001,可配置。
API 写入令牌在首次安装时生成,保存为 root 可读文件;安装器会显示令牌查询方法,不直接显示令牌内容: 配置文件:`/etc/pigway-pi-control.conf`。修改监控配置后执行:
```bash ```bash
sudo systemctl restart pigway-pi-control.service
```
## 可选 API
默认 `[api] enabled = false`,没有网络监听。需要集中管理时改为 `true`,设置 bind/port,然后:
```bash
sudo systemctl enable --now pigway-pi-control-api.service
sudo cat /etc/pigway-pi-control-api.token sudo cat /etc/pigway-pi-control-api.token
``` ```
服务分别是 `pigway-pi-control.service` 和 `pigway-pi-control-api.service`。 默认端口6001。读取及写入都需要 `Authorization: Bearer <token>`。令牌只用于该设备;安装器显示查询方法,不直接显示令牌内容。关闭 API:配置改为 false,执行 `sudo systemctl disable --now pigway-pi-control-api.service`。
Web 重启不会自动启动已停止的监控。监控与硬件插件的运行状态彼此独立。
## 已接入插件与扩展 API 提供 `/api/v1/health`、`/status`、`/logs`、`/config`、`/services`、`/plugins` 和 `/plugin/config`(均以 `/api/v1` 开头)。不提供网页、多机登记或远程转发。
[Yahboom 散热板插件](https://tea.pigway.com/way/pigway-cooling-hat) 是独立项目、独立安装包及 systemd 服务。 ## 独立管理中心与插件
先在目标机器安装并运行插件,再进入 Web → 系统配置 → 硬件插件 → 发现并接入。
仅发现插件不会接管插件的设备能力。接入后发送当前监控状态,无需开放新的网络端口。
运行状态卡片仅为已接入插件显示摘要。插件配置根据能力生成:只有风扇就只有风扇设置,有 OLED/RGB 才有相应控件。保存插件配置不重启监控。暂停告警联动或断开接入不会停止插件的本地功能。 - [PIGWay Web Manager](https://tea.pigway.com/way/pigway-web-manager):可部署在 NAS 或远端,登记设备地址与令牌,统一看板、配置和批量同步。
- [PIGWay Cooling HAT](https://tea.pigway.com/way/pigway-cooling-hat):独立本地采集、OLED、风扇与 RGB。
默认 Unix socket:`/run/pigway-plugins/<id>/api.sock`。协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。插件声明自身能力,Web 据此展示状态和配置。其他设备可通过独立插件实现此协议。 远程 API 与本地插件联动相互独立。插件启用接入后,可在 `[plugins]` 显式登记 `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` 并重启监控,或通过管理中心接入。发现不会自动绑定;暂停/断开联动不停止插件本地功能。
监控和插件互不依赖:插件离线不影响监控、日志和 Web;监控离线不停止插件的本地功能。通信恢复后同步最新状态,过期告警不会继续显示。
当前可用插件:
| 插件 | 独立功能 | 仓库 |
| --- | --- | --- |
| PIGWay Cooling HAT | 本机状态采集、OLED 展示、风扇温控、RGB 灯效 | [pigway-cooling-hat](https://tea.pigway.com/way/pigway-cooling-hat) |
## 运维 ## 运维
```bash ```bash
sudo systemctl status pigway-pi-control.service pigway-pi-control-api.service
sudo journalctl -u pigway-pi-control.service -f
sudo /usr/local/sbin/pigway-pi-control --diagnose sudo /usr/local/sbin/pigway-pi-control --diagnose
sudo journalctl -u pigway-pi-control.service -f
``` ```
`--diagnose` 使用同一套监控配置和检查函数,仅通过只读 API 发现插件,不访问硬件总线。 日志依照系统 journal 的保留策略,不额外建库。卸载任何一个项目不会卸载另外两个项目。插件协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。
卸载监控不会停止或删除独立插件;卸载插件也不会影响监控。建议先在 Web 断开关联,避免保留离线插件条目。
静态检查:Python 编译、`bash -n install.sh`、`git diff --check`、`sha256sum -c SHA256SUMS`。测试工具不随安装包发布。
+6 -7
View File
@@ -1,11 +1,10 @@
e83c52906953a723fc79208aecf08372a875e0bc2f74b4283329a475744f8984 README.md 70eb073f87f2e75830c545d809f28a188afc3cd2c2c6379418b9ad665f6339df README.md
8372d65296273c034cac7d3a6c702ceb1d8168dac2bf81586fecf2fe57219059 install.sh 5223c7b71c8d18b3dd9b946a24f398b80afd0bf115e1273bac0f01df8e2ce46d install.sh
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh 9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
0f1ebcb0e9fa63744fcf0c0045fe4db3fa6eedcf22c333b46d6079d9105fad5c app/pigway_pi_control.py 0f1ebcb0e9fa63744fcf0c0045fe4db3fa6eedcf22c333b46d6079d9105fad5c app/pigway_pi_control.py
339f28978641242436e141add0cc923ed5ebb855bea9f6c9fe949b8eb8579527 app/pigway_pi_control_api.py f91af58e7dd752fa97c168ae419e14e6ee5099ea58d8252568f7ce72affd219f app/pigway_pi_control_api.py
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py 3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
3da8b5d94e2206bffc19e1c4fa493b31880c9a90fd6a02546461fe54a7255dbd config/pigway-pi-control.conf 60665659712a0c7311e7bfa8fd09b300b0ea164cf53746feda91c7cc2a28145d config/pigway-pi-control.conf
20c584762cae3a50a498df25e11272c3c191a62c9ce823e63f8224d14637b1d4 systemd/pigway-pi-control-api.service 18a62bbe2b2a187a9184d7637f36cd22146f432236c2f95f4588ef6162de97dc systemd/pigway-pi-control-api.service
35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service 35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service
217474cf1fea5aebe54dea8b4c3434a7e3560d4fac8ad51d7d8faa9c7819a4f2 web/index.html e3bf3e7070ea62c0526791bed922688c3fb49fee36ca9c375d080119968feb72 docs/HARDWARE_PLUGIN_API.md
73b7922be93bf57169589755137dc5ec7ec7c8059c85c2bf2ee53a344e534d7e docs/HARDWARE_PLUGIN_API.md
+15 -164
View File
@@ -14,8 +14,7 @@ APP_VERSION="3.7.0"
CFG=Path("/etc/pigway-pi-control.conf") CFG=Path("/etc/pigway-pi-control.conf")
STATUS=Path("/run/pigway-pi-control/status.json") STATUS=Path("/run/pigway-pi-control/status.json")
TOKEN=Path("/etc/pigway-pi-control-api.token") TOKEN=Path("/etc/pigway-pi-control-api.token")
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html") ALLOWED_SECTIONS={"device","services","processes","alerts","timing","dark_mode","api"}
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","timing","dark_mode","api"}
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$") NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$") UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$") RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
@@ -65,60 +64,6 @@ def machine_identity():
if name.lower()=="auto": name=identifier if name.lower()=="auto": name=identifier
return {"id":identifier,"name":name,"hostname":socket.gethostname(),"local":True,"url":""} return {"id":identifier,"name":name,"hostname":socket.gethostname(),"local":True,"url":""}
def normalize_url(value):
parsed=urlparse(str(value).strip())
if parsed.scheme not in {"http","https"} or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("host URL must be an http(s) URL without credentials")
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
def host_registry():
local=machine_identity(); result=[local]
cfg=load_cfg()
if cfg.has_section("hosts"):
for host_id,url in cfg.items("hosts"):
try: base=normalize_url(url)
except ValueError: continue
if host_id==local["id"]: continue
result.append({"id":host_id,"name":host_id,"hostname":"","local":False,"url":base})
return result
def find_host(host_id):
for host in host_registry():
if host["id"]==host_id:return host
raise ValueError("unknown host")
def remote_json(base,path,method="GET",payload=None,token=""):
data=None if payload is None else json.dumps(payload,separators=(",",":")).encode()
headers={"Accept":"application/json"}
if data is not None: headers["Content-Type"]="application/json"
if token: headers["Authorization"]=f"Bearer {token}"
request=Request(base+path,data=data,headers=headers,method=method)
with urlopen(request,timeout=5) as response:
return json.loads(response.read())
def fleet_status():
hosts=host_registry(); results=[]
try:
status=json.loads(STATUS.read_text())
status.setdefault("machine",machine_identity())
results.append({"host":hosts[0],"online":True,"status":status})
except Exception as exc:
results.append({"host":hosts[0],"online":False,"error":str(exc)})
def fetch(host):
try:
status=remote_json(host["url"],"/api/v1/status")
machine=status.get("machine",{})
merged={**host,"name":machine.get("name") or host["name"],"hostname":machine.get("hostname","")}
return {"host":merged,"online":True,"status":status}
except Exception as exc:return {"host":host,"online":False,"error":str(exc)}
peers=hosts[1:]
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
futures=[pool.submit(fetch,h) for h in peers]
for future in as_completed(futures): results.append(future.result())
order={h["id"]:i for i,h in enumerate(hosts)}
results.sort(key=lambda x:order.get(x["host"]["id"],999))
return {"hosts":results,"timestamp":int(time.time())}
def load_cfg(): def load_cfg():
cfg=configparser.ConfigParser(); cfg.read(CFG) cfg=configparser.ConfigParser(); cfg.read(CFG)
return cfg return cfg
@@ -132,8 +77,11 @@ def config_json():
return {section:{k:v for k,v in cfg.items(section) if not (section=="timing" and k in {"oled_refresh_interval","page_interval"})} for section in cfg.sections() if section in ALLOWED_SECTIONS} return {section:{k:v for k,v in cfg.items(section) if not (section=="timing" and k in {"oled_refresh_interval","page_interval"})} for section in cfg.sections() if section in ALLOWED_SECTIONS}
def validate_value(section,key,value,existing): def validate_value(section,key,value,existing):
if section=="api" and key=="enabled":
if value.lower() not in {"true","false"}:raise ValueError("enabled must be true or false")
return
if section=="timing" and key in {"oled_refresh_interval","page_interval"}:raise ValueError("display timing is managed by the hardware plugin") if section=="timing" and key in {"oled_refresh_interval","page_interval"}:raise ValueError("display timing is managed by the hardware plugin")
if section not in {"services","processes","hosts"} and key not in existing.get(section,set()): if section not in {"services","processes"} and key not in existing.get(section,set()):
raise ValueError(f"unsupported option: {section}.{key}") raise ValueError(f"unsupported option: {section}.{key}")
if section=="hardware" and key=="cooling_hat_enabled": if section=="hardware" and key=="cooling_hat_enabled":
if value.lower() not in ("true","false"): raise ValueError("cooling_hat_enabled must be true or false") if value.lower() not in ("true","false"): raise ValueError("cooling_hat_enabled must be true or false")
@@ -163,8 +111,7 @@ def validate_value(section,key,value,existing):
ipaddress.ip_address(value) ipaddress.ip_address(value)
elif section=="device" and key=="identifier" and value.lower()!="auto" and not NAME_RE.fullmatch(value): elif section=="device" and key=="identifier" and value.lower()!="auto" and not NAME_RE.fullmatch(value):
raise ValueError("device.identifier must be auto or 1..64 letters, numbers, dot, underscore or dash") raise ValueError("device.identifier must be auto or 1..64 letters, numbers, dot, underscore or dash")
elif section=="hosts":
normalize_url(value)
def replace_ini_value(section,key,value): def replace_ini_value(section,key,value):
text=CFG.read_text() text=CFG.read_text()
@@ -172,7 +119,7 @@ def replace_ini_value(section,key,value):
if not section_match: if not section_match:
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n" if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
return text return text
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():]) next_section=re.search(r"(?m)^\[[^\]\r\n]+\]",text[section_match.end():])
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end()) end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
chunk=text[section_match.end():end] chunk=text[section_match.end():end]
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk) key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
@@ -189,15 +136,6 @@ def save_ini_text(text,prefix="api"):
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG) temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
return str(backup) return str(backup)
def edit_host(operation,host_id,url=""):
if operation not in {"save","delete"}: raise ValueError("invalid host operation")
if not NAME_RE.fullmatch(host_id): raise ValueError("invalid host identifier")
local=machine_identity()["id"]
if host_id==local: raise ValueError("local host cannot be changed in the remote host list")
if operation=="save": url=normalize_url(url)
text=replace_ini_value("hosts",host_id,url if operation=="save" else None)
return save_ini_text(text,"hosts")
def update_ini(updates): def update_ini(updates):
if not isinstance(updates,dict): raise ValueError("updates must be an object") if not isinstance(updates,dict): raise ValueError("updates must be an object")
text=CFG.read_text() text=CFG.read_text()
@@ -217,7 +155,7 @@ def update_ini(updates):
if not section_match: if not section_match:
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n" text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
continue continue
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():]) next_section=re.search(r"(?m)^\[[^\]\r\n]+\]",text[section_match.end():])
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end()) end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
chunk=text[section_match.end():end] chunk=text[section_match.end():end]
key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk) key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk)
@@ -268,50 +206,6 @@ def journal_query(since=None,until=None,severity="",event="",search="",sort="tim
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit, return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
"event_types":event_types,"retention":"systemd-journal"} "event_types":event_types,"retention":"systemd-journal"}
def fleet_journal_query(since=None,until=None,severity="",event="",search="",machine="",
sort="timestamp",order="desc",limit=100,offset=0):
hosts=host_registry(); rows=[]; event_types=set(); machines=[]
def decorate(payload,host):
machine_info=payload.get("machine") or {"id":host["id"],"name":host["name"]}
host_id=machine_info.get("id") or host["id"]
host_name=machine_info.get("name") or host["name"]
decorated=[]
for row in payload.get("logs",[]):
decorated.append({**row,"machine_id":host_id,"machine_name":host_name})
return decorated,set(payload.get("event_types",[])),{"id":host_id,"name":host_name}
local_payload=journal_query(since,until,severity,event,search,"timestamp","desc",50000,0)
local_payload["machine"]=machine_identity()
local_rows,local_events,local_machine=decorate(local_payload,hosts[0])
rows.extend(local_rows); event_types.update(local_events); machines.append(local_machine)
query={"limit":50000,"offset":0,"sort":"timestamp","order":"desc"}
for key,value in (("since",since),("until",until),("severity",severity),("event",event),("search",search)):
if value not in (None,""): query[key]=value
def fetch(host):
payload=remote_json(host["url"],"/api/v1/logs?"+urlencode(query))
try:
status=remote_json(host["url"],"/api/v1/status")
payload["machine"]=status.get("machine",{})
except Exception: pass
return decorate(payload,host)
peers=hosts[1:]
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
futures=[pool.submit(fetch,h) for h in peers]
for future in as_completed(futures):
try:
remote_rows,remote_events,remote_machine=future.result()
rows.extend(remote_rows); event_types.update(remote_events); machines.append(remote_machine)
except Exception: pass
if machine: rows=[row for row in rows if row["machine_id"]==machine]
severity_rank={"EMERGENCY":0,"ALERT":1,"CRITICAL":2,"ERROR":3,"WARN":4,"NOTICE":5,"INFO":6,"DEBUG":7}
keys={"timestamp":lambda x:x["timestamp"],"severity":lambda x:severity_rank.get(x["severity"],99),
"event":lambda x:x["event"],"message":lambda x:x["message"],
"machine":lambda x:(x["machine_name"],x["machine_id"])}
rows.sort(key=keys[sort],reverse=order=="desc")
total=len(rows)
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
"event_types":sorted(event_types),"machines":sorted(machines,key=lambda x:(x["name"],x["id"])),
"retention":"systemd-journal"}
def service_inventory(): def service_inventory():
cfg=load_cfg() cfg=load_cfg()
monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {} monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {}
@@ -356,7 +250,7 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
if not section_match: if not section_match:
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n" if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
return return
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():]) next_section=re.search(r"(?m)^\[[^\]\r\n]+\]",text[section_match.end():])
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end()) end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
chunk=text[section_match.end():end] chunk=text[section_match.end():end]
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk) key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
@@ -422,19 +316,13 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
parsed=urlparse(self.path) parsed=urlparse(self.path)
try: try:
if parsed.path=="/": if not self.authorized():self.send_json(401,{"error":"bearer token required"});return
data=WEB.read_bytes(); self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8")
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data); return
if parsed.path=="/api/v1/health": if parsed.path=="/api/v1/health":
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()}); return self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()});return
if parsed.path=="/api/v1/hosts":
self.send_json(200,{"hosts":host_registry()}); return
if parsed.path=="/api/v1/fleet/status":
self.send_json(200,fleet_status()); return
if parsed.path=="/api/v1/status": if parsed.path=="/api/v1/status":
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
self.send_json(200,json.loads(STATUS.read_text())); return self.send_json(200,json.loads(STATUS.read_text())); return
if parsed.path in {"/api/v1/logs","/api/v1/fleet/logs"}: if parsed.path=="/api/v1/logs":
query=parse_qs(parsed.query) query=parse_qs(parsed.query)
value=lambda key,default="":query.get(key,[default])[0] value=lambda key,default="":query.get(key,[default])[0]
limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),50000) limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),50000)
@@ -444,33 +332,23 @@ class Handler(BaseHTTPRequestHandler):
if since is not None and until is not None and since>until: raise ValueError("since must not be after until") if since is not None and until is not None and since>until: raise ValueError("since must not be after until")
sort=value("sort","timestamp"); order=value("order","desc") sort=value("sort","timestamp"); order=value("order","desc")
allowed_sort={"timestamp","severity","event","message"} allowed_sort={"timestamp","severity","event","message"}
if parsed.path=="/api/v1/fleet/logs": allowed_sort.add("machine")
if sort not in allowed_sort: raise ValueError("invalid log sort") if sort not in allowed_sort: raise ValueError("invalid log sort")
if order not in {"asc","desc"}: raise ValueError("invalid log order") if order not in {"asc","desc"}: raise ValueError("invalid log order")
severity=value("severity").upper(); event=value("event"); search=value("search") severity=value("severity").upper(); event=value("event"); search=value("search")
if len(event)>80 or len(search)>120: raise ValueError("log filter is too long") if len(event)>80 or len(search)>120: raise ValueError("log filter is too long")
if parsed.path=="/api/v1/fleet/logs":
self.send_json(200,fleet_journal_query(since,until,severity,event,search,value("machine"),sort,order,limit,offset)); return
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return
if parsed.path in {"/api/v1/plugins","/api/v1/plugin/config"}: if parsed.path in {"/api/v1/plugins","/api/v1/plugin/config"}:
query=parse_qs(parsed.query);host_id=query.get("host",[""])[0] query=parse_qs(parsed.query);host_id=query.get("host",[""])[0]
plugin_id=query.get("id",[""])[0] plugin_id=query.get("id",[""])[0]
if host_id and host_id!=machine_identity()["id"]:
host=find_host(host_id)
self.send_json(200,remote_json(host["url"],parsed.path+"?"+urlencode({"id":plugin_id})));return
if parsed.path.endswith("/plugins"): if parsed.path.endswith("/plugins"):
self.send_json(200,{"plugins":plugin_inventory()});return self.send_json(200,{"plugins":plugin_inventory()});return
item=find_plugin(plugin_id) item=find_plugin(plugin_id)
self.send_json(200,plugin_request(item["socket"],"GET","/v1/config"));return self.send_json(200,plugin_request(item["socket"],"GET","/v1/config"));return
if parsed.path=="/api/v1/config": if parsed.path=="/api/v1/config":
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0] query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
if host_id and host_id!=machine_identity()["id"]:
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/config")); return
self.send_json(200,{"config":config_json(),"write_requires_token":True,"machine":machine_identity()}); return self.send_json(200,{"config":config_json(),"write_requires_token":True,"machine":machine_identity()}); return
if parsed.path=="/api/v1/services": if parsed.path=="/api/v1/services":
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0] query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
if host_id and host_id!=machine_identity()["id"]:
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/services")); return
self.send_json(200,{"services":service_inventory(),"machine":machine_identity()}); return self.send_json(200,{"services":service_inventory(),"machine":machine_identity()}); return
self.send_json(404,{"error":"not found"}) self.send_json(404,{"error":"not found"})
except ValueError as e: self.send_json(400,{"error":str(e)}) except ValueError as e: self.send_json(400,{"error":str(e)})
@@ -478,44 +356,15 @@ class Handler(BaseHTTPRequestHandler):
def do_PUT(self): def do_PUT(self):
path=urlparse(self.path).path path=urlparse(self.path).path
if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/config"}: self.send_json(404,{"error":"not found"}); return if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control"}: self.send_json(404,{"error":"not found"}); return
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
try: try:
body=self.read_json() body=self.read_json()
if path in {"/api/v1/plugins","/api/v1/plugin/config"}: if path in {"/api/v1/plugins","/api/v1/plugin/config"}:
host_id=str(body.get("host","")) host_id=str(body.get("host",""))
if host_id and host_id!=machine_identity()["id"]:
host=find_host(host_id);token=str(body.get("target_token",""))
if not token:raise ValueError("remote bearer token required")
payload={k:v for k,v in body.items() if k not in {"host","target_token"}}
self.send_json(200,remote_json(host["url"],path,"PUT",payload,token));return
if path.endswith("/plugins"):self.send_json(200,plugin_operation(body));return if path.endswith("/plugins"):self.send_json(200,plugin_operation(body));return
item=find_plugin(str(body.get("id",""))) item=find_plugin(str(body.get("id","")))
self.send_json(200,plugin_request(item["socket"],"PUT","/v1/config",{"updates":body.get("updates")}));return self.send_json(200,plugin_request(item["socket"],"PUT","/v1/config",{"updates":body.get("updates")}));return
if path=="/api/v1/hosts":
backup=edit_host(str(body.get("operation","")),str(body.get("id","")),str(body.get("url","")))
self.send_json(200,{"ok":True,"backup":backup,"hosts":host_registry()}); return
if path=="/api/v1/fleet/config":
updates=body.get("updates"); targets=body.get("targets",[]); tokens=body.get("tokens",{})
if not isinstance(targets,list) or not targets or len(targets)>32: raise ValueError("targets must contain 1..32 host identifiers")
if not isinstance(tokens,dict): raise ValueError("tokens must be an object")
results=[]; local_id=machine_identity()["id"]; restart_local_api=False
for host_id in dict.fromkeys(str(x) for x in targets):
try:
if host_id==local_id:
backup=update_ini(updates)
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
restart_local_api="api" in updates
results.append({"id":host_id,"ok":True,"backup":backup})
else:
host=find_host(host_id); token=str(tokens.get(host_id,""))
if not token: raise ValueError("remote bearer token required")
response=remote_json(host["url"],"/api/v1/config","PUT",{"updates":updates},token)
results.append({"id":host_id,"ok":True,"backup":response.get("backup","")})
except Exception as exc: results.append({"id":host_id,"ok":False,"error":str(exc)})
self.send_json(200,{"ok":all(x["ok"] for x in results),"results":results})
if restart_local_api: threading.Timer(0.2,self.server.shutdown).start()
return
if path=="/api/v1/services/control": if path=="/api/v1/services/control":
control_service(str(body.get("unit","")),str(body.get("action",""))) control_service(str(body.get("unit","")),str(body.get("action","")))
print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True) print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True)
@@ -537,6 +386,8 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e: self.send_json(500,{"error":str(e)}) except Exception as e: self.send_json(500,{"error":str(e)})
def main(): def main():
if not load_cfg().getboolean("api","enabled",fallback=False):
print("API_DISABLED",flush=True);sys.exit(78)
host=api_setting("bind","0.0.0.0") host=api_setting("bind","0.0.0.0")
port=api_setting("port",6001,int) port=api_setting("port",6001,int)
server=ThreadingHTTPServer((host,port),Handler) server=ThreadingHTTPServer((host,port),Handler)
+3 -8
View File
@@ -13,15 +13,10 @@ identifier = auto
name = auto name = auto
[hosts]
# 在作为聚合入口的机器上登记其他节点,每行格式:
# 唯一标识 = http://节点IP:6001
# 示例:
# living-room = http://192.168.1.20:6001
[api] [api]
# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问; # 默认关闭远程 API。开启后所有请求均需令牌;本地监控和插件联动不受此开关影响。
enabled = false
# API 监听地址和端口。0.0.0.0 允许局域网访问;
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。 # 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
bind = 0.0.0.0 bind = 0.0.0.0
port = 6001 port = 6001
+10
View File
@@ -79,3 +79,13 @@ fallback; actual execution still depends on functioning hardware.
Stopping a monitor, pausing its link, or unlinking a plugin does not stop the Stopping a monitor, pausing its link, or unlinking a plugin does not stop the
plugin service. No API/Web service has a systemd Wants dependency that starts the plugin service. No API/Web service has a systemd Wants dependency that starts the
other application. Do not run two drivers for the same MCU, GPIO or fan. other application. Do not run two drivers for the same MCU, GPIO or fan.
## Optional listeners
Agent network API defaults to disabled (`[api] enabled=false`) and has no Web UI.
When enabled, every request requires a device bearer token.
Hardware plugin integration defaults to disabled (`[integration] enabled=false`):
no Unix API socket is created, while local telemetry and hardware workers continue.
Change integration locally and restart the plugin. Monitor-to-plugin linkage does
not require the Agent network API. The independent Web Manager stores registered
device addresses and tokens; it is not implicitly a monitored device.
+29 -8
View File
@@ -2,22 +2,24 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
NO_START=0 NO_START=0
API_ENABLED=""
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--enable-api) API_ENABLED=true ;;
--disable-api) API_ENABLED=false ;;
--no-start) NO_START=1 ;; --no-start) NO_START=1 ;;
-h|--help) echo 'Usage: sudo ./install.sh [--no-start]'; exit 0 ;; -h|--help) echo 'Usage: sudo ./install.sh [--no-start] [--enable-api|--disable-api]'; exit 0 ;;
*) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;; *) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
esac esac
done done
for required in app/pigway_pi_control.py app/plugin_api.py app/pigway_pi_control_api.py web/index.html config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do for required in app/pigway_pi_control.py app/plugin_api.py app/pigway_pi_control_api.py config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
[ -f "$required" ] || { echo "ERROR: required file missing: $required" >&2; exit 1; } [ -f "$required" ] || { echo "ERROR: required file missing: $required" >&2; exit 1; }
done done
[ "$(id -u)" -eq 0 ] || { echo 'ERROR: run installer as root' >&2; exit 1; } [ "$(id -u)" -eq 0 ] || { echo 'ERROR: run installer as root' >&2; exit 1; }
# The monitor has no I2C, GPIO, Pillow, smbus2 or cooling-board requirement. # The monitor has no I2C, GPIO, Pillow, smbus2 or cooling-board requirement.
command -v python3 >/dev/null || { apt-get update; apt-get install -y python3; } command -v python3 >/dev/null || { apt-get update; apt-get install -y python3; }
mkdir -p /usr/local/share/pigway-pi-control/web mkdir -p /usr/local/share/pigway-pi-control
install -m 0644 app/plugin_api.py /usr/local/share/pigway-pi-control/plugin_api.py install -m 0644 app/plugin_api.py /usr/local/share/pigway-pi-control/plugin_api.py
install -m 0644 web/index.html /usr/local/share/pigway-pi-control/web/index.html
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
if [ -f /etc/pigway-pi-control.conf ]; then if [ -f /etc/pigway-pi-control.conf ]; then
@@ -32,7 +34,7 @@ from pathlib import Path
import configparser import configparser
p=Path('/etc/pigway-pi-control.conf');c=configparser.ConfigParser();c.read(p) p=Path('/etc/pigway-pi-control.conf');c=configparser.ConfigParser();c.read(p)
with p.open('a') as f: with p.open('a') as f:
for section,values in {'device':{'identifier':'auto','name':'auto'},'hosts':{},'plugins':{},'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items(): for section,values in {'device':{'identifier':'auto','name':'auto'},'plugins':{},'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
if not c.has_section(section): if not c.has_section(section):
f.write('\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items())) f.write('\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items()))
PY PY
@@ -42,12 +44,31 @@ fi
chmod 0600 /etc/pigway-pi-control-api.token chmod 0600 /etc/pigway-pi-control-api.token
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/ install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/
install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/ install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/
if [ -z "$API_ENABLED" ]; then
API_ENABLED=false
if [ -t 0 ]; then
read -r -p 'Enable remote management API? [y/N] ' answer
case "$answer" in y|Y|yes|YES) API_ENABLED=true ;; esac
fi
fi
API_ENABLED="$API_ENABLED" python3 - <<'PYAPI'
import os,sys
sys.path.insert(0,'app')
import pigway_pi_control_api as api
api.save_ini_text(api.replace_ini_value('api','enabled',os.environ['API_ENABLED']),'install')
PYAPI
systemctl daemon-reload systemctl daemon-reload
if [ "$NO_START" -eq 0 ]; then if [ "$NO_START" -eq 0 ]; then
systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null systemctl enable pigway-pi-control.service >/dev/null
systemctl restart pigway-pi-control.service pigway-pi-control-api.service systemctl restart pigway-pi-control.service
systemctl is-active --quiet pigway-pi-control.service systemctl is-active --quiet pigway-pi-control.service
systemctl is-active --quiet pigway-pi-control-api.service if [ "$API_ENABLED" = true ]; then
systemctl enable pigway-pi-control-api.service >/dev/null
systemctl restart pigway-pi-control-api.service
systemctl is-active --quiet pigway-pi-control-api.service
else
systemctl disable --now pigway-pi-control-api.service
fi
fi fi
echo 'Monitor installed. Hardware plugins are installed and connected separately.' echo 'Monitor installed. Hardware plugins are installed and connected separately.'
echo 'Config: /etc/pigway-pi-control.conf' echo 'Config: /etc/pigway-pi-control.conf'
+3 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=PIGWay Pi Control API and Web Description=PIGWay Pi Control optional local API
After=network.target pigway-pi-control.service After=network.target pigway-pi-control.service
Wants=network.target Wants=network.target
@@ -7,6 +7,8 @@ Wants=network.target
Type=simple Type=simple
ExecStart=/usr/local/sbin/pigway-pi-control-api ExecStart=/usr/local/sbin/pigway-pi-control-api
Restart=always Restart=always
RestartPreventExitStatus=78
SuccessExitStatus=78
RestartSec=2 RestartSec=2
User=root User=root
NoNewPrivileges=true NoNewPrivileges=true
-134
View File
File diff suppressed because one or more lines are too long