From c8c12b7b70a18faf9ddb1389cad93da2d57832cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 27 Sep 2026 22:28:03 +0800 Subject: [PATCH] refactor: separate fleet manager from local monitoring --- README.md | 63 ++++----- SHA256SUMS | 13 +- app/pigway_pi_control_api.py | 179 +++----------------------- config/pigway-pi-control.conf | 11 +- docs/HARDWARE_PLUGIN_API.md | 10 ++ install.sh | 37 ++++-- systemd/pigway-pi-control-api.service | 4 +- web/index.html | 134 ------------------- 8 files changed, 90 insertions(+), 361 deletions(-) delete mode 100644 web/index.html diff --git a/README.md b/README.md index d6ac5dd..28f3c33 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,50 @@ # PIGWay Pi Control v3.7.0 -独立的 Raspberry Pi / Linux 系统监控与多机 Web 管理平台。提供本机状态、告警、事件日志和多机配置管理,可按需接入独立插件。 - -## 功能 - -- CPU、温度、内存和磁盘容量/用量;主网络接口、IPv4/IPv6、Wi-Fi 信号百分比。 -- systemd 服务及进程监控;暂停监控与启停服务是不同操作。 -- Raspberry Pi 当前欠压、降频、频率受限、温度限制和根分区只读监控。 -- 当前故障统一管理;历史电源位仅记录日志,不生成当前告警。 -- 事件日志按日期、严重程度、事件类型和机器查询、排序。 -- 多机状态、机器标识、配置与独立的批量配置同步。 -- 中英文、跟随系统/深色/浅色主题。 -- 独立硬件插件发现、手动接入、暂停联动、断开、能力配置。 +极轻的 Raspberry Pi / Linux 本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。无 Web 界面;无需硬件插件,也无需管理中心。 ## 安装 ```bash sudo ./install.sh -# 仅安装文件,不改变服务运行状态: -sudo ./install.sh --no-start +# 显式启用 API: +sudo ./install.sh --enable-api +# 不启用 API: +sudo ./install.sh --disable-api ``` -仅需要 Python 3。插件独立安装与运行,主程序不会自动安装或启动插件。 -配置 `/etc/pigway-pi-control.conf`;Web 默认端口 6001,可配置。 +交互安装询问是否启用远程 API,默认否;非交互安装未指定时也关闭。`--no-start` 仅更新文件和配置,不改变现有服务运行状态,接口开关在下次启动生效。 -API 写入令牌在首次安装时生成,保存为 root 可读文件;安装器会显示令牌查询方法,不直接显示令牌内容: +配置文件:`/etc/pigway-pi-control.conf`。修改监控配置后执行: ```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 ``` -服务分别是 `pigway-pi-control.service` 和 `pigway-pi-control-api.service`。 -Web 重启不会自动启动已停止的监控。监控与硬件插件的运行状态彼此独立。 +默认端口6001。读取及写入都需要 `Authorization: Bearer `。令牌只用于该设备;安装器显示查询方法,不直接显示令牌内容。关闭 API:配置改为 false,执行 `sudo systemctl disable --now pigway-pi-control-api.service`。 -## 已接入插件与扩展 +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//api.sock`。协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。插件声明自身能力,Web 据此展示状态和配置。其他设备可通过独立插件实现此协议。 - -监控和插件互不依赖:插件离线不影响监控、日志和 Web;监控离线不停止插件的本地功能。通信恢复后同步最新状态,过期告警不会继续显示。 - -当前可用插件: - -| 插件 | 独立功能 | 仓库 | -| --- | --- | --- | -| PIGWay Cooling HAT | 本机状态采集、OLED 展示、风扇温控、RGB 灯效 | [pigway-cooling-hat](https://tea.pigway.com/way/pigway-cooling-hat) | +远程 API 与本地插件联动相互独立。插件启用接入后,可在 `[plugins]` 显式登记 `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` 并重启监控,或通过管理中心接入。发现不会自动绑定;暂停/断开联动不停止插件本地功能。 ## 运维 ```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 journalctl -u pigway-pi-control.service -f ``` -`--diagnose` 使用同一套监控配置和检查函数,仅通过只读 API 发现插件,不访问硬件总线。 -卸载监控不会停止或删除独立插件;卸载插件也不会影响监控。建议先在 Web 断开关联,避免保留离线插件条目。 - -静态检查:Python 编译、`bash -n install.sh`、`git diff --check`、`sha256sum -c SHA256SUMS`。测试工具不随安装包发布。 +日志依照系统 journal 的保留策略,不额外建库。卸载任何一个项目不会卸载另外两个项目。插件协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。 diff --git a/SHA256SUMS b/SHA256SUMS index ceef624..5c1e0ad 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,11 +1,10 @@ -e83c52906953a723fc79208aecf08372a875e0bc2f74b4283329a475744f8984 README.md -8372d65296273c034cac7d3a6c702ceb1d8168dac2bf81586fecf2fe57219059 install.sh +70eb073f87f2e75830c545d809f28a188afc3cd2c2c6379418b9ad665f6339df README.md +5223c7b71c8d18b3dd9b946a24f398b80afd0bf115e1273bac0f01df8e2ce46d install.sh 9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh 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 -3da8b5d94e2206bffc19e1c4fa493b31880c9a90fd6a02546461fe54a7255dbd config/pigway-pi-control.conf -20c584762cae3a50a498df25e11272c3c191a62c9ce823e63f8224d14637b1d4 systemd/pigway-pi-control-api.service +60665659712a0c7311e7bfa8fd09b300b0ea164cf53746feda91c7cc2a28145d config/pigway-pi-control.conf +18a62bbe2b2a187a9184d7637f36cd22146f432236c2f95f4588ef6162de97dc systemd/pigway-pi-control-api.service 35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service -217474cf1fea5aebe54dea8b4c3434a7e3560d4fac8ad51d7d8faa9c7819a4f2 web/index.html -73b7922be93bf57169589755137dc5ec7ec7c8059c85c2bf2ee53a344e534d7e docs/HARDWARE_PLUGIN_API.md +e3bf3e7070ea62c0526791bed922688c3fb49fee36ca9c375d080119968feb72 docs/HARDWARE_PLUGIN_API.md diff --git a/app/pigway_pi_control_api.py b/app/pigway_pi_control_api.py index 08f8d14..32c6811 100644 --- a/app/pigway_pi_control_api.py +++ b/app/pigway_pi_control_api.py @@ -14,8 +14,7 @@ APP_VERSION="3.7.0" CFG=Path("/etc/pigway-pi-control.conf") STATUS=Path("/run/pigway-pi-control/status.json") TOKEN=Path("/etc/pigway-pi-control-api.token") -WEB=Path("/usr/local/share/pigway-pi-control/web/index.html") -ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","timing","dark_mode","api"} +ALLOWED_SECTIONS={"device","services","processes","alerts","timing","dark_mode","api"} NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$") 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]$") @@ -65,60 +64,6 @@ def machine_identity(): if name.lower()=="auto": name=identifier 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(): cfg=configparser.ConfigParser(); cfg.read(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} 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 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}") 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") @@ -163,8 +111,7 @@ def validate_value(section,key,value,existing): ipaddress.ip_address(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") - elif section=="hosts": - normalize_url(value) + def replace_ini_value(section,key,value): text=CFG.read_text() @@ -172,7 +119,7 @@ def replace_ini_value(section,key,value): if not section_match: if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n" 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()) chunk=text[section_match.end():end] 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) 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): if not isinstance(updates,dict): raise ValueError("updates must be an object") text=CFG.read_text() @@ -217,7 +155,7 @@ def update_ini(updates): if not section_match: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n" 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()) chunk=text[section_match.end():end] 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, "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(): cfg=load_cfg() 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 value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n" 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()) chunk=text[section_match.end():end] key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk) @@ -422,19 +316,13 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): parsed=urlparse(self.path) try: - if parsed.path=="/": - 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 not self.authorized():self.send_json(401,{"error":"bearer token required"});return if parsed.path=="/api/v1/health": - 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 + self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()});return if parsed.path=="/api/v1/status": if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); 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) value=lambda key,default="":query.get(key,[default])[0] 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") sort=value("sort","timestamp"); order=value("order","desc") 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 order not in {"asc","desc"}: raise ValueError("invalid log order") 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 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 if parsed.path in {"/api/v1/plugins","/api/v1/plugin/config"}: query=parse_qs(parsed.query);host_id=query.get("host",[""])[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"): self.send_json(200,{"plugins":plugin_inventory()});return item=find_plugin(plugin_id) self.send_json(200,plugin_request(item["socket"],"GET","/v1/config"));return if parsed.path=="/api/v1/config": 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 if parsed.path=="/api/v1/services": 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(404,{"error":"not found"}) except ValueError as e: self.send_json(400,{"error":str(e)}) @@ -478,44 +356,15 @@ class Handler(BaseHTTPRequestHandler): def do_PUT(self): 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 try: body=self.read_json() if path in {"/api/v1/plugins","/api/v1/plugin/config"}: 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 item=find_plugin(str(body.get("id",""))) 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": control_service(str(body.get("unit","")),str(body.get("action",""))) 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)}) 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") port=api_setting("port",6001,int) server=ThreadingHTTPServer((host,port),Handler) diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index f1ae863..bab5077 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -13,15 +13,10 @@ identifier = auto name = auto -[hosts] -# 在作为聚合入口的机器上登记其他节点,每行格式: -# 唯一标识 = http://节点IP:6001 -# 示例: -# living-room = http://192.168.1.20:6001 - - [api] -# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问; +# 默认关闭远程 API。开启后所有请求均需令牌;本地监控和插件联动不受此开关影响。 +enabled = false +# API 监听地址和端口。0.0.0.0 允许局域网访问; # 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。 bind = 0.0.0.0 port = 6001 diff --git a/docs/HARDWARE_PLUGIN_API.md b/docs/HARDWARE_PLUGIN_API.md index f8b1301..e6c7eb3 100644 --- a/docs/HARDWARE_PLUGIN_API.md +++ b/docs/HARDWARE_PLUGIN_API.md @@ -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 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. + +## 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. diff --git a/install.sh b/install.sh index acb20c5..84f967b 100755 --- a/install.sh +++ b/install.sh @@ -2,22 +2,24 @@ set -euo pipefail cd "$(dirname "$0")" NO_START=0 +API_ENABLED="" for arg in "$@"; do case "$arg" in + --enable-api) API_ENABLED=true ;; + --disable-api) API_ENABLED=false ;; --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 ;; esac 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; } done [ "$(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. 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 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_api.py /usr/local/sbin/pigway-pi-control-api if [ -f /etc/pigway-pi-control.conf ]; then @@ -32,7 +34,7 @@ from pathlib import Path import configparser p=Path('/etc/pigway-pi-control.conf');c=configparser.ConfigParser();c.read(p) 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): f.write('\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items())) PY @@ -42,12 +44,31 @@ fi 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-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 if [ "$NO_START" -eq 0 ]; then - systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null - systemctl restart pigway-pi-control.service pigway-pi-control-api.service + systemctl enable pigway-pi-control.service >/dev/null + systemctl restart 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 echo 'Monitor installed. Hardware plugins are installed and connected separately.' echo 'Config: /etc/pigway-pi-control.conf' diff --git a/systemd/pigway-pi-control-api.service b/systemd/pigway-pi-control-api.service index 4379837..5b6c1e0 100644 --- a/systemd/pigway-pi-control-api.service +++ b/systemd/pigway-pi-control-api.service @@ -1,5 +1,5 @@ [Unit] -Description=PIGWay Pi Control API and Web +Description=PIGWay Pi Control optional local API After=network.target pigway-pi-control.service Wants=network.target @@ -7,6 +7,8 @@ Wants=network.target Type=simple ExecStart=/usr/local/sbin/pigway-pi-control-api Restart=always +RestartPreventExitStatus=78 +SuccessExitStatus=78 RestartSec=2 User=root NoNewPrivileges=true diff --git a/web/index.html b/web/index.html deleted file mode 100644 index eef440c..0000000 --- a/web/index.html +++ /dev/null @@ -1,134 +0,0 @@ - - - - -PIGWay Pi Control - - - -
-

PIGWay Pi Control

正在连接树莓派…
离线
- -
正在读取主机状态…
- - - -