fix: validate atomic configuration updates and expire stale monitoring snapshots

This commit is contained in:
way
2026-10-18 18:15:22 +08:00
parent c82d9ab3c2
commit 9b83937d60
10 changed files with 244 additions and 85 deletions
+67 -52
View File
@@ -2,26 +2,30 @@
import sys
sys.path.append("/usr/local/share/pigway-pi-control")
from plugin_api import discover,registered,request as plugin_request,socket_path
import math,functools,tempfile
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
from concurrent.futures import ThreadPoolExecutor,as_completed
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from pathlib import Path
STORAGE_SCAN_LOCK=threading.Lock()
from urllib.parse import parse_qs,urlencode,urlparse
from urllib.request import Request,urlopen
from urllib.parse import parse_qs,urlparse
APP_VERSION="3.11.2"
STORAGE_SCAN_LOCK=threading.Lock()
CONFIG_LOCK=threading.RLock()
def config_transaction(function):
@functools.wraps(function)
def locked(*args,**kwargs):
with CONFIG_LOCK:
return function(*args,**kwargs)
return locked
APP_VERSION="3.11.3"
CFG=Path("/etc/pigway-pi-control.conf")
STATUS=Path("/run/pigway-pi-control/status.json")
TOKEN=Path("/etc/pigway-pi-control-api.token")
ALLOWED_SECTIONS={"device","services","processes","alerts","timing","dark_mode","api","storage_names","storage_visible"}
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]$")
FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency|normal)_flash_(on|off)_ms$")
NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"}
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
PROTECTED_UNITS={"pigway-pi-control-api.service"}
def plugin_inventory():
@@ -36,6 +40,7 @@ def find_plugin(plugin_id):
raise ValueError("unknown plugin")
@config_transaction
def plugin_operation(body):
plugin_id=str(body.get("id",""))
if not NAME_RE.fullmatch(plugin_id):raise ValueError("invalid plugin id")
@@ -66,7 +71,7 @@ def machine_identity():
return {"id":identifier,"name":name,"hostname":socket.gethostname(),"local":True,"url":""}
def load_cfg():
cfg=configparser.ConfigParser(); cfg.read(CFG)
cfg=configparser.ConfigParser(interpolation=None); cfg.read(CFG)
return cfg
def api_setting(key,default,cast=str):
@@ -91,36 +96,39 @@ def validate_value(section,key,value,existing):
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"} 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")
elif section=="led" and key=="alert_mode":
if value.lower() not in ("breathe","flash"): raise ValueError("alert_mode must be breathe or flash")
elif section=="led" and key=="custom_min_hold_ms":
if not 1500<=int(value)<=60000: raise ValueError("custom_min_hold_ms must be 1500..60000")
elif RGB_KEY_RE.fullmatch(key):
number=int(value)
if not 0<=number<=255: raise ValueError(f"{key} must be 0..255")
elif FLASH_KEY_RE.fullmatch(key):
number=int(value)
if not 50<=number<=60000: raise ValueError(f"{key} must be 50..60000")
elif section=="led" and key=="normal_mode":
if value.lower() not in NORMAL_MODES: raise ValueError("normal_mode must be off, solid, flash, flow, breathe, marquee, rainbow or colorful")
elif section=="led" and key=="normal_effect_speed":
if int(value) not in (1,2,3): raise ValueError("normal_effect_speed must be 1, 2 or 3")
elif section=="led" and key=="normal_effect_color":
if value.lower() not in EFFECT_COLORS: raise ValueError("normal_effect_color must be red, green, blue, yellow, purple, cyan or white")
elif section=="api" and key=="port":
number=int(value)
if not 1<=number<=65535: raise ValueError("api.port must be 1..65535")
elif section=="api" and key=="log_limit":
number=int(value)
if not 1<=number<=1000: raise ValueError("api.log_limit must be 1..1000")
elif section=="api" and key=="bind":
ipaddress.ip_address(value)
if section in {"alerts","timing"}:
number=float(value)
if not math.isfinite(number):raise ValueError(f"{section}.{key} must be finite")
low,high=(0,86400)
if section=="timing":
low=0 if key in {"startup_grace_seconds","network_failure_seconds"} else 0.2
high=3600
elif key in {"cpu_high","cpu_recover","memory_high","memory_recover","disk_high","disk_critical"}:low,high=0,100
elif key in {"temp_high","temp_critical"}:low,high=-50,150
elif key in {"wifi_weak","wifi_recover"}:low,high=-120,0
elif key=="ethernet_min_speed_mbps":low,high=0,1000000
if not low<=number<=high:raise ValueError(f"{section}.{key} must be {low}..{high}")
if key in {"cpu_high_seconds","memory_high_seconds","ethernet_min_speed_mbps"} and not value.isdecimal():
raise ValueError(f"{key} must be a nonnegative integer")
elif section=="dark_mode" and value.lower() not in {"on","off","true","false","0","1","yes","no"}:
raise ValueError("LED setting must be on or off")
elif section=="services" and not UNIT_RE.fullmatch(value):raise ValueError("invalid systemd service name")
elif section=="api" and key in {"port","log_limit"}:
limit=65535 if key=="port" else 1000
if not 1<=int(value)<=limit:raise ValueError(f"api.{key} must be 1..{limit}")
elif section=="api" and key=="bind":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")
def validate_candidate(parsed):
for lower,upper in (("cpu_recover","cpu_high"),("memory_recover","memory_high"),
("temp_high","temp_critical"),("disk_high","disk_critical"),("wifi_weak","wifi_recover")):
if parsed.has_option("alerts",lower) and parsed.has_option("alerts",upper):
if not parsed.getfloat("alerts",lower)<parsed.getfloat("alerts",upper):
raise ValueError(f"alerts.{lower} must be less than alerts.{upper}")
def replace_ini_value(section,key,value):
return replace_ini_text(CFG.read_text(),section,key,value)
@@ -139,13 +147,23 @@ def replace_ini_text(text,section,key,value):
if value is not None:return text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
return text
@config_transaction
def save_ini_text(text,prefix="api"):
parsed=configparser.ConfigParser(); parsed.read_string(text)
backup=CFG.with_name(f"{CFG.name}.bak.{prefix}-{time.strftime('%Y%m%d-%H%M%S')}")
parsed=configparser.ConfigParser(interpolation=None); parsed.read_string(text)
validate_candidate(parsed)
backup=CFG.with_name(f"{CFG.name}.bak.{prefix}-{time.time_ns()}")
shutil.copy2(CFG,backup)
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
temporary=None
try:
with tempfile.NamedTemporaryFile(mode="w",dir=CFG.parent,prefix=CFG.name+".",delete=False) as file:
temporary=Path(file.name);file.write(text)
temporary.chmod(0o644);temporary.replace(CFG)
finally:
if temporary is not None:temporary.unlink(missing_ok=True)
return str(backup)
@config_transaction
def update_ini(updates):
if not isinstance(updates,dict): raise ValueError("updates must be an object")
text=CFG.read_text()
@@ -175,12 +193,7 @@ def update_ini(updates):
text=text[:start]+replacement+text[stop:]
else:
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
parsed=configparser.ConfigParser(); parsed.read_string(text)
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(CFG,backup)
temporary=CFG.with_suffix(".tmp")
temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
return str(backup)
return save_ini_text(text,"api")
def journal_query(since=None,until=None,severity="",event="",search="",sort="timestamp",order="desc",limit=100,offset=0):
command=["journalctl","-u","pigway-pi-control.service","--no-pager","-o","json"]
@@ -244,6 +257,7 @@ def service_inventory():
item["protected"]=item["unit"] in PROTECTED_UNITS
return sorted(units.values(),key=lambda x:(not x["monitored"],x["unit"]))
@config_transaction
def edit_service_monitor(operation,name,target,note="",previous_name=""):
if operation not in {"save","delete","pause","resume"}: raise ValueError("invalid monitor operation")
if not NAME_RE.fullmatch(name): raise ValueError("invalid monitor name")
@@ -281,10 +295,7 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
change("service_monitor_disabled",previous_name,None)
change("services",name,target); change("service_notes",name,note or None)
if was_paused: change("service_monitor_disabled",name,"true")
parsed=configparser.ConfigParser(); parsed.read_string(text)
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(CFG,backup)
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
backup=save_ini_text(text,"api")
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
return str(backup)
@@ -298,6 +309,7 @@ def process_monitors():
result.append({"name":name.upper(),"target":target,"up":up})
return result
@config_transaction
def edit_process_monitor(operation,name,target="",previous_name=""):
if operation not in {"save","delete"}: raise ValueError("invalid process monitor operation")
if not NAME_RE.fullmatch(name): raise ValueError("invalid process monitor name")
@@ -311,7 +323,7 @@ def edit_process_monitor(operation,name,target="",previous_name=""):
else:
if previous_name and previous_name.lower()!=name.lower(): text=replace_ini_text(text,"processes",previous_name,None)
text=replace_ini_text(text,"processes",name,target)
parsed=configparser.ConfigParser(); parsed.read_string(text)
parsed=configparser.ConfigParser(interpolation=None); parsed.read_string(text)
backup=save_ini_text(text,"api")
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
return backup
@@ -359,7 +371,10 @@ class Handler(BaseHTTPRequestHandler):
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
state=json.loads(STATUS.read_text())
if not 0<=time.time()-state.get("timestamp",0)<=10:
self.send_json(503,{"error":"agent status stale"});return
self.send_json(200,state); return
if parsed.path=="/api/v1/logs":
query=parse_qs(parsed.query)
value=lambda key,default="":query.get(key,[default])[0]