scipting improvements
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s

This commit is contained in:
2026-01-28 18:25:17 +01:00
parent 0d66ff2694
commit 90087039f7

View File

@@ -1,40 +1,56 @@
# script_router_stateless.py # script_router_with_venv.py
""" """
Stateless APIRouter that manages uploaded scripts via systemd only. APIRouter for uploading scripts (with optional requirements.txt),
- Upload/list/download scripts (stored in SCRIPT_DIR) creating per-script venvs (when requirements provided), and managing
- Enable script on qnum => write systemd unit fw-script-<sid>-q<qnum>.service and start it systemd services that run scripts with a queue number argument.
- Disable script on qnum => stop service and remove unit file
- Status endpoints discover active services by scanning /etc/systemd/system for fw-script-*.service Usage:
No DB or run-dir used — systemd is the source of truth. from fastapi import FastAPI
from script_router_with_venv import router, register_lifecycle
app = FastAPI()
app.include_router(router)
register_lifecycle(app) # optional: stops/removes manager-created units on shutdown
Notes:
- The router does NOT interact with nft. You should create/delete nft queue rules with your separate API.
- Script files are stored under SCRIPT_DIR.
- Virtualenvs (if created) are stored under VENV_BASE/<sid>.
- Systemd units are created under /etc/systemd/system with names: fw-script-<sid>-q<qnum>.service
- This code must run with permissions to create venvs, write unit files and call systemctl (typically root).
""" """
import os import os
import sys
import uuid import uuid
import shutil
import subprocess import subprocess
import time import time
import re import re
import logging import logging
from typing import List, Dict, Optional from typing import Optional, List, Dict
from fastapi import APIRouter, UploadFile, File, HTTPException from fastapi import APIRouter, UploadFile, File, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
# ---------- config ---------- # ---------- Config ----------
SCRIPT_DIR = "/srv/fw-scripts" SCRIPT_DIR = "/srv/fw-scripts"
VENV_BASE = "/srv/fw-scripts/venvs"
UNIT_DIR = "/etc/systemd/system" UNIT_DIR = "/etc/systemd/system"
UNIT_PREFIX = "fw-script" # unit names like fw-script-<sid>-q<qnum>.service UNIT_PREFIX = "fw-script"
# ensure dirs exist
os.makedirs(SCRIPT_DIR, exist_ok=True) os.makedirs(SCRIPT_DIR, exist_ok=True)
os.makedirs(VENV_BASE, exist_ok=True)
# ---------- logging ---------- # ---------- Logging ----------
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
logger = logging.getLogger("script-router-stateless") logger = logging.getLogger("script-router-venv")
# ---------- router ---------- # ---------- Router ----------
router = APIRouter(prefix="/scripts", tags=["scripts"]) router = APIRouter(prefix="/scripts", tags=["scripts"])
# ---------- models ---------- # ---------- Models ----------
class ScriptInfo(BaseModel): class ScriptInfo(BaseModel):
id: str id: str
name: str name: str
@@ -44,17 +60,81 @@ class EnableRequest(BaseModel):
qnum: int qnum: int
service_name: Optional[str] = None service_name: Optional[str] = None
extra_args: Optional[str] = None extra_args: Optional[str] = None
enable_at_boot: Optional[bool] = False
# ---------- helpers ---------- # ---------- Utilities: service names / unit paths ----------
def make_service_name(sid: str, qnum: int) -> str: def make_service_name(sid: str, qnum: int) -> str:
# keep name safe for systemd
return f"{UNIT_PREFIX}-{sid}-q{qnum}" return f"{UNIT_PREFIX}-{sid}-q{qnum}"
def unit_path_for(service_name: str) -> str: def unit_path_for(service_name: str) -> str:
return os.path.join(UNIT_DIR, service_name + ".service") return os.path.join(UNIT_DIR, service_name + ".service")
def write_unit(service_name: str, exec_start: str, description: str = "") -> str: # ---------- Venv helpers ----------
"""Write unit file and daemon-reload systemd.""" def venv_path_for(sid: str) -> str:
return os.path.join(VENV_BASE, sid)
def venv_python_for(sid: str) -> str:
vpy = os.path.join(venv_path_for(sid), "bin", "python")
if os.path.exists(vpy):
return vpy
# fallback to system python
return "/usr/bin/python3"
def create_venv(sid: str, timeout: int = 60):
venv_dir = venv_path_for(sid)
if os.path.exists(venv_dir):
logger.debug("Venv already exists for sid=%s: %s", sid, venv_dir)
return venv_dir
os.makedirs(os.path.dirname(venv_dir), exist_ok=True)
logger.info("Creating venv for sid=%s at %s", sid, venv_dir)
try:
subprocess.run([sys.executable, "-m", "venv", venv_dir], check=True, timeout=timeout)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"venv creation failed: {e}")
except subprocess.TimeoutExpired:
raise RuntimeError("venv creation timed out")
return venv_dir
def pip_install_requirements(sid: str, requirements_path: str, timeout: int = 600) -> Dict[str, str]:
"""
Install requirements into the venv for sid from requirements_path.
Returns dict: { "stdout": "...", "stderr": "..." }
Raises on failure with details in exception message.
"""
venv_dir = create_venv(sid)
pip_path = os.path.join(venv_dir, "bin", "pip")
# ensure pip exists and upgrade
try:
subprocess.run([pip_path, "install", "--upgrade", "pip"], check=True, capture_output=True, text=True, timeout=300)
except subprocess.CalledProcessError as e:
# continue but warn
logger.warning("pip upgrade failed for sid=%s: %s", sid, e.stderr if hasattr(e, "stderr") else str(e))
# install requirements
try:
p = subprocess.run([pip_path, "install", "-r", requirements_path, "--no-cache-dir"],
check=True, capture_output=True, text=True, timeout=timeout)
logger.info("pip install success for sid=%s", sid)
return {"stdout": p.stdout, "stderr": p.stderr}
except subprocess.CalledProcessError as e:
logger.error("pip install failed for sid=%s: %s", sid, e.stderr if hasattr(e, "stderr") else str(e))
# return output for debugging
out = {"stdout": getattr(e, "stdout", "") or "", "stderr": getattr(e, "stderr", "") or str(e)}
raise RuntimeError(jsonify_cmd_output(out))
except subprocess.TimeoutExpired:
logger.error("pip install timeout for sid=%s", sid)
raise RuntimeError("pip install timed out")
def jsonify_cmd_output(out: Dict[str, str]) -> str:
# helper to pack stdout/stderr into a single string message
s = ""
if out.get("stdout"):
s += "STDOUT:\n" + out["stdout"] + "\n"
if out.get("stderr"):
s += "STDERR:\n" + out["stderr"] + "\n"
return s.strip()
# ---------- systemd helpers ----------
def write_unit(service_name: str, exec_start: str, description: str = "", enable_at_boot: bool = False) -> str:
unit_path = unit_path_for(service_name) unit_path = unit_path_for(service_name)
unit_text = f"""[Unit] unit_text = f"""[Unit]
Description={description} Description={description}
@@ -73,19 +153,28 @@ WantedBy=multi-user.target
""" """
with open(unit_path, "w") as fh: with open(unit_path, "w") as fh:
fh.write(unit_text) fh.write(unit_text)
# reload systemd to pick up new unit # reload systemd
subprocess.run(["systemctl", "daemon-reload"], check=True) subprocess.run(["systemctl", "daemon-reload"], check=True)
logger.info("Wrote unit %s", unit_path) logger.info("Wrote unit %s", unit_path)
if enable_at_boot:
try:
subprocess.run(["systemctl", "enable", service_name], check=True)
logger.info("Enabled %s at boot", service_name)
except subprocess.CalledProcessError:
logger.warning("Failed to enable %s at boot", service_name)
return unit_path return unit_path
def remove_unit(service_name: str): def remove_unit(service_name: str):
"""Stop and remove a unit file, then daemon-reload.""" unit_path = unit_path_for(service_name)
try: try:
subprocess.run(["systemctl", "stop", service_name], check=False) subprocess.run(["systemctl", "stop", service_name], check=False)
except Exception: except Exception:
logger.exception("Failed to stop %s", service_name) logger.exception("systemctl stop failed for %s", service_name)
unit_path = unit_path_for(service_name)
if os.path.exists(unit_path): if os.path.exists(unit_path):
try:
subprocess.run(["systemctl", "disable", service_name], check=False)
except Exception:
pass
os.remove(unit_path) os.remove(unit_path)
subprocess.run(["systemctl", "daemon-reload"], check=True) subprocess.run(["systemctl", "daemon-reload"], check=True)
logger.info("Removed unit %s", unit_path) logger.info("Removed unit %s", unit_path)
@@ -94,6 +183,10 @@ def start_unit(service_name: str):
subprocess.run(["systemctl", "start", service_name], check=True) subprocess.run(["systemctl", "start", service_name], check=True)
logger.info("Started service %s", service_name) logger.info("Started service %s", service_name)
def stop_unit(service_name: str):
subprocess.run(["systemctl", "stop", service_name], check=True)
logger.info("Stopped service %s", service_name)
def is_unit_active(service_name: str) -> bool: def is_unit_active(service_name: str) -> bool:
p = subprocess.run(["systemctl", "is-active", "--quiet", service_name]) p = subprocess.run(["systemctl", "is-active", "--quiet", service_name])
return p.returncode == 0 return p.returncode == 0
@@ -101,60 +194,86 @@ def is_unit_active(service_name: str) -> bool:
def list_fw_units() -> List[str]: def list_fw_units() -> List[str]:
"""Return list of fw unit names without .service suffix.""" """Return list of fw unit names without .service suffix."""
units = [] units = []
# scan UNIT_DIR for files matching UNIT_PREFIX-*.service
try: try:
for fn in os.listdir(UNIT_DIR): for fn in os.listdir(UNIT_DIR):
if fn.startswith(UNIT_PREFIX + "-") and fn.endswith(".service"): if fn.startswith(UNIT_PREFIX + "-") and fn.endswith(".service"):
units.append(fn[:-8]) # strip .service units.append(fn[:-8]) # remove .service
except FileNotFoundError: except FileNotFoundError:
logger.warning("Unit dir %s not found", UNIT_DIR) logger.warning("Unit dir %s not found", UNIT_DIR)
return units return units
# ExecStart parser: we expect ExecStart like "/usr/bin/python3 /srv/fw-scripts/<sid>.py <qnum> [extra]" # ExecStart parse pattern: python path + script path + qnum + optional extra
_RE_EXECSTART = re.compile(r'(?P<py>/\S*python\S*)\s+(?P<script>/\S*?/srv/fw-scripts/(?P<sid>[0-9a-fA-F]+)\.py)\s+(?P<qnum>\d+)(?:\s+(?P<extra>.*))?') _RE_EXECSTART = re.compile(r'(?P<py>/\S*python\S*)\s+(?P<script>/\S*?/srv/fw-scripts/(?P<sid>[0-9a-fA-F]+)\.py)\s+(?P<qnum>\d+)(?:\s+(?P<extra>.*))?')
def parse_unit_execstart(service_name: str) -> Optional[Dict]: def parse_unit_execstart(service_name: str) -> Optional[Dict]:
"""Read unit file and extract sid and qnum from ExecStart if it matches expected pattern."""
unit_path = unit_path_for(service_name) unit_path = unit_path_for(service_name)
if not os.path.exists(unit_path): if not os.path.exists(unit_path):
return None return None
with open(unit_path, "r") as fh: with open(unit_path, "r") as fh:
content = fh.read() content = fh.read()
# find ExecStart=
m = re.search(r'^ExecStart=(.+)$', content, flags=re.MULTILINE) m = re.search(r'^ExecStart=(.+)$', content, flags=re.MULTILINE)
if not m: if not m:
return None return None
exec_start = m.group(1).strip() exec_start = m.group(1).strip()
# try parse
match = _RE_EXECSTART.search(exec_start) match = _RE_EXECSTART.search(exec_start)
if match: if match:
sd = match.groupdict() sd = match.groupdict()
return { return {"service": service_name, "exec_start": exec_start, "sid": sd["sid"], "script_path": sd["script"], "qnum": int(sd["qnum"]), "extra": sd.get("extra") or ""}
"service": service_name,
"exec_start": exec_start,
"sid": sd["sid"],
"script_path": sd["script"],
"qnum": int(sd["qnum"]),
"extra": sd.get("extra") or ""
}
# fallback: return exec_start only
return {"service": service_name, "exec_start": exec_start, "sid": None, "script_path": None, "qnum": None, "extra": None} return {"service": service_name, "exec_start": exec_start, "sid": None, "script_path": None, "qnum": None, "extra": None}
# ---------- endpoints ---------- # ---------- End utilities ----------
# ---------- Endpoints ----------
@router.post("", response_model=ScriptInfo) @router.post("", response_model=ScriptInfo)
async def upload_script(file: UploadFile = File(...)): async def upload_script(script: UploadFile = File(...), requirements: Optional[UploadFile] = File(None)):
if not file.filename.endswith(".py"): """
logger.warning("Reject upload (not .py): %s", file.filename) Upload a script and optional requirements.txt.
If requirements is supplied, a venv for the script will be created and pip will install the requirements.
Returns sid, path, and pip output if any.
"""
if not script.filename.endswith(".py"):
logger.warning("Rejected upload with invalid extension: %s", script.filename)
raise HTTPException(status_code=400, detail="only .py scripts allowed") raise HTTPException(status_code=400, detail="only .py scripts allowed")
data = await file.read()
data = await script.read()
if len(data) > 2_000_000:
logger.warning("Rejected upload too large: %s size=%d", script.filename, len(data))
raise HTTPException(status_code=400, detail="script too large")
sid = uuid.uuid4().hex sid = uuid.uuid4().hex
fname = f"{sid}.py" fname = f"{sid}.py"
path = os.path.join(SCRIPT_DIR, fname) path = os.path.join(SCRIPT_DIR, fname)
with open(path, "wb") as fh: with open(path, "wb") as fh:
fh.write(data) fh.write(data)
os.chmod(path, 0o700) os.chmod(path, 0o700)
logger.info("Uploaded script %s as %s", file.filename, path) logger.info("Uploaded script %s as sid=%s path=%s", script.filename, sid, path)
return {"id": sid, "name": file.filename, "path": path}
pip_output = None
if requirements is not None:
# save requirements to a temp path
req_data = await requirements.read()
req_path = os.path.join(SCRIPT_DIR, f"{sid}-requirements.txt")
with open(req_path, "wb") as fh:
fh.write(req_data)
logger.info("Saved requirements for sid=%s at %s (size=%d)", sid, req_path, len(req_data))
# create venv and install
try:
create_venv(sid)
res = pip_install_requirements(sid, req_path)
pip_output = {"stdout": res.get("stdout", ""), "stderr": res.get("stderr", "")}
logger.info("Installed requirements for sid=%s", sid)
except Exception as e:
# cleanup venv on failure (optional)
logger.exception("pip install failed for sid=%s: %s", sid, e)
# Provide useful error to client but keep script stored for inspection
raise HTTPException(status_code=500, detail=f"pip install failed: {e}")
# return pip_output in headers/body? We'll include in body (if present).
resp = {"id": sid, "name": script.filename, "path": path}
if pip_output is not None:
resp["pip"] = pip_output
return resp
@router.get("", response_model=List[ScriptInfo]) @router.get("", response_model=List[ScriptInfo])
def list_scripts(): def list_scripts():
@@ -163,72 +282,91 @@ def list_scripts():
if not fn.endswith(".py"): if not fn.endswith(".py"):
continue continue
sid = fn.rsplit(".", 1)[0] sid = fn.rsplit(".", 1)[0]
out.append({"id": sid, "name": fn, "path": os.path.join(SCRIPT_DIR, fn)}) p = os.path.join(SCRIPT_DIR, fn)
logger.debug("Listed %d scripts", len(out)) out.append({"id": sid, "name": fn, "path": p})
logger.debug("Listed scripts: %d", len(out))
return out return out
@router.get("/{sid}") @router.get("/{sid}")
def download_script(sid: str): def download_script(sid: str):
path = os.path.join(SCRIPT_DIR, f"{sid}.py") path = os.path.join(SCRIPT_DIR, f"{sid}.py")
if not os.path.exists(path): if not os.path.exists(path):
logger.warning("Download missing script sid=%s", sid) logger.warning("Download requested for missing sid=%s", sid)
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")
logger.info("Download script sid=%s", sid) logger.info("Download script sid=%s path=%s", sid, path)
return FileResponse(path, media_type="text/x-python", filename=f"{sid}.py") return FileResponse(path, media_type="text/x-python", filename=f"{sid}.py")
@router.post("/{sid}/enable") @router.post("/{sid}/enable")
def enable_script(sid: str, req: EnableRequest): def enable_script(sid: str, req: EnableRequest):
"""
Create and start systemd service that runs the script with the queue number.
If a venv was created at upload time, ExecStart will use that venv's python.
"""
script_path = os.path.join(SCRIPT_DIR, f"{sid}.py") script_path = os.path.join(SCRIPT_DIR, f"{sid}.py")
if not os.path.exists(script_path): if not os.path.exists(script_path):
logger.warning("Enable requested for missing sid=%s", sid) logger.warning("Enable requested for missing sid=%s", sid)
raise HTTPException(status_code=404, detail="script not found") raise HTTPException(status_code=404, detail="script not found")
qnum = req.qnum qnum = req.qnum
service_name = req.service_name or make_service_name(sid, qnum) service_name = req.service_name or make_service_name(sid, qnum)
exec_start = f"/usr/bin/python3 {script_path} {qnum}" # prefer venv python if exists
python_path = venv_python_for(sid)
exec_start = f"{python_path} {script_path} {qnum}"
if req.extra_args: if req.extra_args:
exec_start += " " + req.extra_args exec_start += " " + req.extra_args
# write unit and start
try: try:
write_unit(service_name, exec_start, description=f"FW script {sid} queue {qnum}") write_unit(service_name, exec_start, description=f"FW script {sid} queue {qnum}", enable_at_boot=req.enable_at_boot)
# small pause # slight delay then start
time.sleep(0.05) time.sleep(0.05)
start_unit(service_name) start_unit(service_name)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.exception("Failed to start unit %s", service_name) logger.exception("Failed to start service %s for sid=%s: %s", service_name, sid, e)
try: try:
remove_unit(service_name) remove_unit(service_name)
except Exception: except Exception:
pass pass
raise HTTPException(status_code=500, detail=f"systemd start failed: {e}") raise HTTPException(status_code=500, detail=f"systemd start failed: {e}")
logger.info("Enabled script %s on qnum=%s as service=%s", sid, qnum, service_name) except Exception as e:
return {"status": "ok", "sid": sid, "qnum": qnum, "service": service_name} logger.exception("Unknown error starting service %s for sid=%s: %s", service_name, sid, e)
try:
remove_unit(service_name)
except Exception:
pass
raise HTTPException(status_code=500, detail=str(e))
logger.info("Enabled script sid=%s on qnum=%d as service=%s (python=%s)", sid, qnum, service_name, python_path)
return {"status": "ok", "sid": sid, "qnum": qnum, "service": service_name, "python": python_path}
@router.post("/{sid}/disable") @router.post("/{sid}/disable")
def disable_script(sid: str, qnum: int): def disable_script(sid: str, qnum: int):
service_name = make_service_name(sid, qnum) service_name = make_service_name(sid, qnum)
# if unit doesn't exist, still try to remove possible legacy name provided by user
unit_p = unit_path_for(service_name) unit_p = unit_path_for(service_name)
if not os.path.exists(unit_p): if not os.path.exists(unit_p):
logger.warning("Disable requested but unit missing for sid=%s qnum=%s", sid, qnum) # try stopping anyway in case only registered with systemd without unit file
# still attempt to stop in case it exists in systemd (but unit file missing)
try: try:
subprocess.run(["systemctl", "stop", service_name], check=False) subprocess.run(["systemctl", "stop", service_name], check=False)
except Exception: except Exception:
pass pass
logger.warning("Disable requested but unit missing for sid=%s qnum=%s", sid, qnum)
raise HTTPException(status_code=404, detail="service/unit not found") raise HTTPException(status_code=404, detail="service/unit not found")
# stop+remove unit try:
stop_unit(service_name)
except subprocess.CalledProcessError:
logger.exception("Failed stopping service %s", service_name)
try:
remove_unit(service_name) remove_unit(service_name)
logger.info("Disabled script %s on qnum=%s (removed service %s)", sid, qnum, service_name) except Exception:
logger.exception("Failed removing unit %s", service_name)
logger.info("Disabled script sid=%s on qnum=%s (removed service %s)", sid, qnum, service_name)
return {"status": "ok", "sid": sid, "qnum": qnum} return {"status": "ok", "sid": sid, "qnum": qnum}
@router.get("/status") @router.get("/status")
def status_all(): def status_all():
"""Discover all fw-script- units and report status and parsed metadata."""
units = list_fw_units() units = list_fw_units()
results = {} results: Dict[str, Dict] = {}
for svc in units: for svc in units:
parsed = parse_unit_execstart(svc) parsed = parse_unit_execstart(svc)
active = False
try: try:
active = is_unit_active(svc) active = is_unit_active(svc)
except Exception: except Exception:
@@ -239,11 +377,9 @@ def status_all():
@router.get("/{sid}/status") @router.get("/{sid}/status")
def status_for_sid(sid: str): def status_for_sid(sid: str):
"""Return status for all units that belong to sid (matching naming convention)."""
units = list_fw_units() units = list_fw_units()
matches = [] matches = []
for svc in units: for svc in units:
# svc name like fw-script-<sid>-q<qnum>
if svc.startswith(f"{UNIT_PREFIX}-{sid}-q"): if svc.startswith(f"{UNIT_PREFIX}-{sid}-q"):
parsed = parse_unit_execstart(svc) parsed = parse_unit_execstart(svc)
try: try:
@@ -254,4 +390,28 @@ def status_for_sid(sid: str):
logger.debug("Status for sid=%s -> %d matches", sid, len(matches)) logger.debug("Status for sid=%s -> %d matches", sid, len(matches))
return {"sid": sid, "mappings": matches} return {"sid": sid, "mappings": matches}
# ---------- Lifecycle helper (optional) ----------
def register_lifecycle(app):
"""
Optionally call this in your main FastAPI app to stop & remove any manager-created units at shutdown.
This will scan for units with our prefix and remove them -- WARNING: if you want systemd to keep services after manager stops,
do NOT register this lifecycle handler.
"""
@app.on_event("shutdown")
def _shutdown_event():
logger.info("Shutdown: removing manager-created systemd units with prefix %s", UNIT_PREFIX)
units = list_fw_units()
for svc in units:
# only remove units that match our naming convention (fw-script-<sid>-q<qnum>)
if svc.startswith(UNIT_PREFIX + "-"):
try:
subprocess.run(["systemctl", "stop", svc], check=False)
except Exception:
pass
try:
remove_unit(svc)
except Exception:
logger.exception("Failed to remove unit %s on shutdown", svc)
logger.info("Shutdown cleanup complete")
# End of router # End of router