commit cc2320523e979ed0ca70813a4a6b3bf39476a2b8 Author: 渡口浪人 Date: Sun Sep 27 22:28:03 2026 +0800 feat: add standalone fleet web manager diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..11711c0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +__pycache__ +*.pyc +.data diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3431e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.DS_Store +.data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..14709ab --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..89e8f71 --- /dev/null +++ b/README.md @@ -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):独立显示、温控和灯效,可选本机接入,默认关闭。 +- 管理中心停止或卸载,不会停止任何设备上的服务或插件。 diff --git a/SHA256SUMS b/SHA256SUMS new file mode 100644 index 0000000..ea94a17 --- /dev/null +++ b/SHA256SUMS @@ -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 diff --git a/app/server.py b/app/server.py new file mode 100644 index 0000000..3cb68df --- /dev/null +++ b/app/server.py @@ -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&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' diff --git a/systemd/pigway-web-manager.service b/systemd/pigway-web-manager.service new file mode 100644 index 0000000..ad0b4a2 --- /dev/null +++ b/systemd/pigway-web-manager.service @@ -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 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..188a27f --- /dev/null +++ b/web/index.html @@ -0,0 +1,134 @@ + + + + +PIGWay Web Manager + + + +
+

PIGWay Web Manager

正在连接管理中心…
离线
+
+
正在读取主机状态…
+ + + +