feat: add standalone fleet web manager
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.data
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
.data/
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY app ./app
|
||||||
|
COPY web ./web
|
||||||
|
RUN mkdir -p /data && chown -R 10001:10001 /data /app
|
||||||
|
USER 10001:10001
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIGWAY_DATA=/data PIGWAY_PORT=6001
|
||||||
|
EXPOSE 6001
|
||||||
|
CMD ["python3", "app/server.py"]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# PIGWay Web Manager
|
||||||
|
|
||||||
|
独立的多设备管理中心,可运行在 NAS、服务器或其他能访问设备 API 的主机上。所有设备都显式登记,没有默认“本机”。不采集所在主机状态,不控制硬件,也不依赖 systemd。
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose exec manager cat /data/manager.token
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 `http://管理中心地址:6001`,输入管理中心令牌。在“机器管理”中添加设备地址及该设备的 API 令牌。管理中心令牌与设备令牌不同;设备令牌仅保存在服务端的数据卷,列表不会返回令牌。管理中心登录令牌仅保存在当前浏览器会话中。
|
||||||
|
|
||||||
|
用 `PIGWAY_PORT=6002 docker compose up -d --build` 更改外部端口。数据卷保存设备登记与令牌,应限制备份的访问权限。日志按需查询设备 journal,不建立日志数据库。
|
||||||
|
|
||||||
|
## 直接运行(仅 Python 标准库)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PIGWAY_DATA="$PWD/.data" PIGWAY_PORT=6001 python3 app/server.py
|
||||||
|
cat .data/manager.token
|
||||||
|
```
|
||||||
|
|
||||||
|
不需要 root、I2C、监控 Agent 或硬件插件。远端部署需要到设备 API 的网络可达性;跨公网通过 HTTPS 反向代理或 VPN,不使用明文 HTTP 传输令牌。HTTP 客户端拒绝重定向,避免把设备凭据转发到其他地址。
|
||||||
|
|
||||||
|
## 可选 systemd 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./install.sh --port 6001
|
||||||
|
sudo cat /var/lib/pigway-web-manager/manager.token
|
||||||
|
```
|
||||||
|
|
||||||
|
独立服务 `pigway-web-manager.service`,不启动任何 Agent 或插件。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 统一设备列表,添加、更新地址/令牌、移除及连接检测。
|
||||||
|
- 多机状态卡片、按设备查询 journal、配置与插件管理。
|
||||||
|
- 按层级选择配置项、全选/反选、批量同步,逐设备报告结果。
|
||||||
|
- 中英文、自动/浅色/深色主题。
|
||||||
|
- 单台离线不会停止其他设备查询;日志查询失败会提示部分结果。
|
||||||
|
|
||||||
|
最多登记32台设备;并发查询最多8台。单设备单次日志聚合上限50000条,超出时明确提示缩小日期范围,不静默截断。
|
||||||
|
|
||||||
|
## 三个项目
|
||||||
|
|
||||||
|
- [本地监控服务](https://tea.pigway.com/way/pigway-pi-control):本机监控、告警、日志与可选 API,默认不开启 API,无 Web。
|
||||||
|
- [硬件插件](https://tea.pigway.com/way/pigway-cooling-hat):独立显示、温控和灯效,可选本机接入,默认关闭。
|
||||||
|
- 管理中心停止或卸载,不会停止任何设备上的服务或插件。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
535a162cf05549e3089c61b1850b25627b48bf606548a41e4c10bf461f5ccf97 .dockerignore
|
||||||
|
62dbe9a72fc425104d5dfe5c63d089cd7785e49ca94386463aa9372dee48163f .gitignore
|
||||||
|
e3d6adb86d19118a02a43aeb929e8fa8ca486e0721586652d908c1822f4a4dcc Dockerfile
|
||||||
|
170512d52464fa4ef36b5fd878bdd0b8267aed30ad35c06f19131ff877829dfb README.md
|
||||||
|
3c1531ef7c99dc3672d091715c88a0803eb117ad21601200a9b5753bf818fc76 app/server.py
|
||||||
|
d3cdab188f694f7216d3bb361394a2e160a8b3d425c1a904ede7372698582a3e compose.yaml
|
||||||
|
148e0523932cc4e65537ec2cff38ee93fda1a9447c411ae410cbec8b7b93d6bb install.sh
|
||||||
|
a9bbad4aa96390da1053cd0ef483f99ade6ca138349569dab004742666027c25 systemd/pigway-web-manager.service
|
||||||
|
138b907185479f91d10537b540381235bda83dbad08776406a8a243f37b16eac web/index.html
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Standalone fleet manager. No local sensors, systemd or hardware dependencies."""
|
||||||
|
import concurrent.futures
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse,parse_qs,urlencode
|
||||||
|
from urllib.request import Request,build_opener,HTTPRedirectHandler
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
|
||||||
|
DATA=Path(os.environ.get('PIGWAY_DATA','/data'))
|
||||||
|
WEB=Path(__file__).resolve().parent.parent/'web/index.html'
|
||||||
|
LOCK=threading.RLock()
|
||||||
|
MAX_HOSTS=32
|
||||||
|
|
||||||
|
|
||||||
|
def initialize():
|
||||||
|
DATA.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||||
|
token=DATA/'manager.token'
|
||||||
|
if not token.exists():
|
||||||
|
fd=os.open(token,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||||
|
with os.fdopen(fd,'w') as f:f.write(secrets.token_urlsafe(32)+'\n')
|
||||||
|
return token.read_text().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def registry():
|
||||||
|
with LOCK:
|
||||||
|
path=DATA/'hosts.json'
|
||||||
|
return json.loads(path.read_text()) if path.exists() else []
|
||||||
|
|
||||||
|
|
||||||
|
def public(host):return {k:v for k,v in host.items() if k!='token'}
|
||||||
|
|
||||||
|
|
||||||
|
def host_by_id(identifier):
|
||||||
|
return next((h for h in registry() if h['id']==identifier),None) or fail('unknown host')
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message):raise ValueError(message)
|
||||||
|
|
||||||
|
|
||||||
|
class NoRedirect(HTTPRedirectHandler):
|
||||||
|
def redirect_request(self,*args,**kwargs):return None
|
||||||
|
|
||||||
|
|
||||||
|
def remote(host,path,method='GET',payload=None):
|
||||||
|
body=None if payload is None else json.dumps(payload).encode()
|
||||||
|
req=Request(host['url']+path,data=body,method=method,headers={'Authorization':'Bearer '+host['token'],'Content-Type':'application/json'})
|
||||||
|
try:
|
||||||
|
with build_opener(NoRedirect).open(req,timeout=5) as response:
|
||||||
|
raw=response.read(16*1024*1024+1)
|
||||||
|
if len(raw)>16*1024*1024:raise ValueError('device response exceeds size limit')
|
||||||
|
return json.loads(raw)
|
||||||
|
except HTTPError as exc:raise ValueError('device API returned HTTP '+str(exc.code)) from None
|
||||||
|
|
||||||
|
|
||||||
|
def edit_host(body):
|
||||||
|
identifier=body.get('id','')
|
||||||
|
if not isinstance(identifier,str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,64}',identifier):fail('invalid host identifier')
|
||||||
|
with LOCK:
|
||||||
|
hosts=registry();old=next((h for h in hosts if h['id']==identifier),None)
|
||||||
|
operation=body.get('operation')
|
||||||
|
if operation=='save':
|
||||||
|
url=str(body.get('url','')).rstrip('/');parsed=urlparse(url)
|
||||||
|
if parsed.scheme not in ('http','https') or not parsed.hostname or parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment:fail('use an HTTP(S) origin without credentials or path')
|
||||||
|
token=body.get('token') or (old or {}).get('token')
|
||||||
|
if not isinstance(token,str) or not token or len(token)>1024 or '\n' in token or '\r' in token:fail('device token required')
|
||||||
|
if not old and len(hosts)>=MAX_HOSTS:fail('at most 32 devices')
|
||||||
|
entry={'id':identifier,'name':identifier,'url':url,'token':token,'local':False}
|
||||||
|
hosts=[entry if h['id']==identifier else h for h in hosts] if old else hosts+[entry]
|
||||||
|
elif operation=='delete':hosts=[h for h in hosts if h['id']!=identifier]
|
||||||
|
else:fail('invalid operation')
|
||||||
|
path=DATA/'hosts.tmp'
|
||||||
|
with path.open('w') as f:json.dump(hosts,f)
|
||||||
|
path.chmod(0o600);path.replace(DATA/'hosts.json')
|
||||||
|
return {'hosts':[public(h) for h in hosts]}
|
||||||
|
|
||||||
|
|
||||||
|
def parallel(hosts,fn):
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=min(8,max(1,len(hosts)))) as pool:
|
||||||
|
return list(pool.map(fn,hosts))
|
||||||
|
|
||||||
|
|
||||||
|
def fleet_status():
|
||||||
|
def fetch(host):
|
||||||
|
try:return {'host':public(host),'online':True,'status':remote(host,'/api/v1/status')}
|
||||||
|
except Exception:return {'host':public(host),'online':False,'error':'Device unavailable; check address, token and API status'}
|
||||||
|
return {'hosts':parallel(registry(),fetch),'timestamp':int(time.time())}
|
||||||
|
|
||||||
|
|
||||||
|
def fleet_logs(query):
|
||||||
|
hosts=registry();selected=query.get('machine','')
|
||||||
|
if selected:hosts=[h for h in hosts if h['id']==selected]
|
||||||
|
params={k:v for k,v in query.items() if k in ('since','until','severity','event','search')}
|
||||||
|
params.update(limit=1000,sort='timestamp',order='desc')
|
||||||
|
def fetch(host):
|
||||||
|
try:
|
||||||
|
rows=[];offset=0;events=set()
|
||||||
|
while True:
|
||||||
|
value=remote(host,'/api/v1/logs?'+urlencode({**params,'offset':offset}))
|
||||||
|
batch=value['logs'];rows.extend({**r,'machine_id':host['id'],'machine_name':host['name']} for r in batch)
|
||||||
|
events.update(value.get('event_types',[]));offset+=len(batch)
|
||||||
|
if offset>=value['total'] or not batch:break
|
||||||
|
if offset>=50000:return rows,events,{'id':host['id'],'error':'Device log query exceeds 50000 rows; narrow date range'}
|
||||||
|
return rows,events,None
|
||||||
|
except Exception:return [],set(),{'id':host['id'],'error':'Device log query failed'}
|
||||||
|
results=parallel(hosts,fetch);rows=[row for result in results for row in result[0]]
|
||||||
|
ranks={'EMERGENCY':0,'ALERT':1,'CRITICAL':2,'ERROR':3,'WARN':4,'NOTICE':5,'INFO':6,'DEBUG':7}
|
||||||
|
sort=query.get('sort','timestamp');order=query.get('order','desc')
|
||||||
|
if sort not in ('timestamp','machine','severity','event','message') or order not in ('asc','desc'):fail('invalid sorting')
|
||||||
|
rows.sort(key=lambda r: ranks.get(r['severity'],99) if sort=='severity' else r['machine_name'] if sort=='machine' else r[sort],reverse=order=='desc')
|
||||||
|
offset=max(0,int(query.get('offset',0)));limit=min(1000,max(1,int(query.get('limit',100))))
|
||||||
|
return {'logs':rows[offset:offset+limit],'total':len(rows),'offset':offset,'limit':limit,'event_types':sorted(set().union(*(r[1] for r in results))),
|
||||||
|
'machines':[{'id':h['id'],'name':h['name']} for h in registry()],'errors':[r[2] for r in results if r[2]],'retention':'device-systemd-journal'}
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def setup(self):super().setup();self.connection.settimeout(15)
|
||||||
|
def log_message(self,*args):pass
|
||||||
|
def send(self,code,data,html=False):
|
||||||
|
raw=data if html else json.dumps(data,ensure_ascii=False).encode()
|
||||||
|
self.send_response(code);self.send_header('Content-Type','text/html; charset=utf-8' if html else 'application/json')
|
||||||
|
self.send_header('Content-Length',str(len(raw)));self.send_header('Cache-Control','no-store');self.send_header('X-Content-Type-Options','nosniff');self.send_header('X-Frame-Options','DENY')
|
||||||
|
self.send_header('Content-Security-Policy',"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
|
||||||
|
self.end_headers();self.wfile.write(raw)
|
||||||
|
def authorized(self):
|
||||||
|
return secrets.compare_digest(self.headers.get('Authorization',''),'Bearer '+self.server.token)
|
||||||
|
def handle_request(self,method):
|
||||||
|
parsed=urlparse(self.path);path=parsed.path;q={k:v[0] for k,v in parse_qs(parsed.query).items()}
|
||||||
|
if method=='GET' and path=='/':self.send(200,WEB.read_bytes(),True);return
|
||||||
|
if not self.authorized():self.send(401,{'error':'Manager token required'});return
|
||||||
|
try:
|
||||||
|
if method=='GET':
|
||||||
|
if path=='/api/v1/hosts':result={'hosts':[public(h) for h in registry()]}
|
||||||
|
elif path=='/api/v1/fleet/status':result=fleet_status()
|
||||||
|
elif path=='/api/v1/fleet/logs':result=fleet_logs(q)
|
||||||
|
elif path in ('/api/v1/config','/api/v1/services','/api/v1/plugins','/api/v1/plugin/config'):
|
||||||
|
host=host_by_id(q.get('host',''));result=remote(host,path+('?' +urlencode({'id':q['id']}) if 'id' in q else ''))
|
||||||
|
else:self.send(404,{'error':'not found'});return
|
||||||
|
else:
|
||||||
|
length=int(self.headers.get('Content-Length',0))
|
||||||
|
if not 0<length<=65536:fail('invalid body length')
|
||||||
|
body=json.loads(self.rfile.read(length))
|
||||||
|
if not isinstance(body,dict):fail('object required')
|
||||||
|
if path=='/api/v1/hosts':result=edit_host(body)
|
||||||
|
elif path=='/api/v1/hosts/check':result=remote(host_by_id(body.get('id')),'/api/v1/health')
|
||||||
|
elif path=='/api/v1/fleet/config':
|
||||||
|
targets=body.get('targets',[])
|
||||||
|
if not isinstance(targets,list) or not 1<=len(targets)<=32:fail('select 1..32 devices')
|
||||||
|
results=[]
|
||||||
|
for identifier in dict.fromkeys(targets):
|
||||||
|
try:remote(host_by_id(identifier),'/api/v1/config','PUT',{'updates':body.get('updates')});results.append({'id':identifier,'ok':True})
|
||||||
|
except Exception:results.append({'id':identifier,'ok':False,'error':'Device update failed; check connection and configuration'})
|
||||||
|
result={'results':results}
|
||||||
|
elif path in ('/api/v1/services','/api/v1/services/control','/api/v1/plugins','/api/v1/plugin/config'):
|
||||||
|
result=remote(host_by_id(body.get('host','')),path,'PUT',{k:v for k,v in body.items() if k not in ('host','target_token')})
|
||||||
|
else:self.send(404,{'error':'not found'});return
|
||||||
|
self.send(200,result)
|
||||||
|
except (ValueError,TypeError,KeyError) as exc:self.send(400,{'error':str(exc)})
|
||||||
|
except Exception:self.send(502,{'error':'Device unavailable or request failed'})
|
||||||
|
def do_GET(self):self.handle_request('GET')
|
||||||
|
def do_PUT(self):self.handle_request('PUT')
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
token=initialize();server=ThreadingHTTPServer((os.environ.get('PIGWAY_BIND','0.0.0.0'),int(os.environ.get('PIGWAY_PORT','6001'))),Handler);server.token=token
|
||||||
|
print('Manager ready. Read access token from '+str(DATA/'manager.token'),flush=True)
|
||||||
|
try:server.serve_forever()
|
||||||
|
finally:server.server_close()
|
||||||
|
|
||||||
|
if __name__=='__main__':main()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
manager:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${PIGWAY_BIND:-0.0.0.0}:${PIGWAY_PORT:-6001}:6001"
|
||||||
|
volumes:
|
||||||
|
- manager-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
volumes:
|
||||||
|
manager-data:
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
PORT=6001
|
||||||
|
if [ "${1:-}" = --port ] && [ "$#" = 2 ]; then PORT="$2"; elif [ "$#" != 0 ]; then echo 'Usage: sudo ./install.sh [--port 6001]' >&2; exit 2; fi
|
||||||
|
[[ "$PORT" =~ ^[0-9]+$ ]] && (( PORT >= 1024 && PORT <= 65535 )) || { echo 'Invalid port' >&2; exit 2; }
|
||||||
|
for f in app/server.py web/index.html systemd/pigway-web-manager.service; do
|
||||||
|
[ -f "$f" ] || { echo "ERROR: required file missing: $f" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
[ "$(id -u)" = 0 ] || { echo 'Run as root' >&2; exit 1; }
|
||||||
|
command -v python3 >/dev/null
|
||||||
|
mkdir -p /usr/local/lib/pigway-web-manager/{app,web}
|
||||||
|
install -m 0644 app/server.py /usr/local/lib/pigway-web-manager/app/
|
||||||
|
install -m 0644 web/index.html /usr/local/lib/pigway-web-manager/web/
|
||||||
|
sed "s/PIGWAY_PORT=6001/PIGWAY_PORT=$PORT/" systemd/pigway-web-manager.service > /etc/systemd/system/pigway-web-manager.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now pigway-web-manager.service
|
||||||
|
systemctl restart pigway-web-manager.service
|
||||||
|
systemctl is-active --quiet pigway-web-manager.service
|
||||||
|
echo "Web Manager port: $PORT"
|
||||||
|
echo 'Token: sudo cat /var/lib/pigway-web-manager/manager.token'
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=PIGWay standalone Web Manager
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
DynamicUser=yes
|
||||||
|
StateDirectory=pigway-web-manager
|
||||||
|
StateDirectoryMode=0700
|
||||||
|
Environment=PIGWAY_DATA=/var/lib/pigway-web-manager
|
||||||
|
Environment=PIGWAY_PORT=6001
|
||||||
|
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ExecStart=/usr/bin/python3 /usr/local/lib/pigway-web-manager/app/server.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+134
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user