venv script improv
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s

This commit is contained in:
2026-01-28 18:43:24 +01:00
parent 90087039f7
commit eded798271
2 changed files with 228 additions and 160 deletions

View File

@@ -1,39 +1,40 @@
# script_router_with_venv.py # script_router_named.py
""" """
APIRouter for uploading scripts (with optional requirements.txt), APIRouter: upload scripts with a supplied name, optional requirements -> create per-script venv.
creating per-script venvs (when requirements provided), and managing If venv install fails, response includes pip output and the router deletes the uploaded files and venv.
systemd services that run scripts with a queue number argument.
Usage: Endpoints:
from fastapi import FastAPI - POST /scripts -> upload script (multipart): script file, optional requirements file, required 'name' form field
from script_router_with_venv import router, register_lifecycle - GET /scripts -> list scripts
app = FastAPI() - GET /scripts/{name} -> download script
app.include_router(router) - POST /scripts/{name}/enable -> enable systemd service for script on given qnum
register_lifecycle(app) # optional: stops/removes manager-created units on shutdown - POST /scripts/{name}/disable -> disable service for script on qnum
- GET /scripts/status -> status of all fw-script units
- GET /scripts/{name}/status -> status of units for that script
Notes: Notes:
- The router does NOT interact with nft. You should create/delete nft queue rules with your separate API. - This relies on systemd and writes units to /etc/systemd/system
- Script files are stored under SCRIPT_DIR. - Script files are stored at SCRIPT_DIR/<name>.py
- Virtualenvs (if created) are stored under VENV_BASE/<sid>. - Venv stored at VENV_BASE/<name> (if requirements provided)
- Systemd units are created under /etc/systemd/system with names: fw-script-<sid>-q<qnum>.service - 'name' must match regex [A-Za-z0-9_.-]+ (no path separators)
- This code must run with permissions to create venvs, write unit files and call systemctl (typically root).
""" """
import os import os
import sys import sys
import re
import uuid import uuid
import json
import shutil import shutil
import subprocess import subprocess
import time import time
import re
import logging import logging
from typing import Optional, List, Dict from typing import Optional, List, Dict
from fastapi import APIRouter, UploadFile, File, HTTPException from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
# ---------- Config ---------- # ---------- Configuration ----------
SCRIPT_DIR = "/srv/fw-scripts" SCRIPT_DIR = "/srv/fw-scripts"
VENV_BASE = "/srv/fw-scripts/venvs" VENV_BASE = "/srv/fw-scripts/venvs"
UNIT_DIR = "/etc/systemd/system" UNIT_DIR = "/etc/systemd/system"
@@ -45,87 +46,91 @@ 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-venv") logger = logging.getLogger("script-router-named")
# ---------- Router ---------- # ---------- Router ----------
router = APIRouter(prefix="/scripts", tags=["scripts"]) router = APIRouter(prefix="/scripts", tags=["scripts"])
# ---------- Models ---------- # ---------- Name validation ----------
class ScriptInfo(BaseModel): # Accept only safe file-name characters to avoid path traversal: letters, digits, dot, underscore, hyphen
id: str _NAME_RE = re.compile(r'^[A-Za-z0-9_.-]+$')
name: str
path: str
class EnableRequest(BaseModel): def validate_name(name: str) -> None:
qnum: int if not name:
service_name: Optional[str] = None raise ValueError("name must be provided")
extra_args: Optional[str] = None if not _NAME_RE.match(name):
enable_at_boot: Optional[bool] = False raise ValueError("invalid name; allowed characters: letters, digits, dot, underscore, hyphen")
# prevent reserved names or dots-only
if name in (".", ".."):
raise ValueError("invalid name")
# ---------- Utilities: service names / unit paths ---------- # ---------- Utility paths ----------
def make_service_name(sid: str, qnum: int) -> str: def script_path_for(name: str) -> str:
return f"{UNIT_PREFIX}-{sid}-q{qnum}" return os.path.join(SCRIPT_DIR, f"{name}.py")
def requirements_path_for(name: str) -> str:
return os.path.join(SCRIPT_DIR, f"{name}-requirements.txt")
def venv_path_for(name: str) -> str:
return os.path.join(VENV_BASE, name)
def venv_python_for(name: str) -> str:
vpy = os.path.join(venv_path_for(name), "bin", "python")
if os.path.exists(vpy):
return vpy
return "/usr/bin/python3"
def make_service_name(name: str, qnum: int) -> str:
return f"{UNIT_PREFIX}-{name}-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")
# ---------- Venv helpers ---------- # ---------- Venv helpers ----------
def venv_path_for(sid: str) -> str: def create_venv(name: str, timeout: int = 60) -> str:
return os.path.join(VENV_BASE, sid) venv_dir = venv_path_for(name)
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): if os.path.exists(venv_dir):
logger.debug("Venv already exists for sid=%s: %s", sid, venv_dir) logger.debug("Venv already exists for %s", name)
return venv_dir return venv_dir
os.makedirs(os.path.dirname(venv_dir), exist_ok=True) os.makedirs(os.path.dirname(venv_dir), exist_ok=True)
logger.info("Creating venv for sid=%s at %s", sid, venv_dir) logger.info("Creating venv for %s at %s", name, venv_dir)
try: try:
subprocess.run([sys.executable, "-m", "venv", venv_dir], check=True, timeout=timeout) subprocess.run([sys.executable, "-m", "venv", venv_dir], check=True, timeout=timeout, capture_output=True, text=True)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
raise RuntimeError(f"venv creation failed: {e}") logger.exception("venv creation failed for %s: %s", name, e.stderr if hasattr(e, "stderr") else str(e))
raise RuntimeError("venv creation failed: " + (e.stderr or str(e)))
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.exception("venv creation timed out for %s", name)
raise RuntimeError("venv creation timed out") raise RuntimeError("venv creation timed out")
return venv_dir return venv_dir
def pip_install_requirements(sid: str, requirements_path: str, timeout: int = 600) -> Dict[str, str]: def pip_install_requirements(name: str, requirements_path: str, timeout: int = 600) -> Dict[str, str]:
""" """
Install requirements into the venv for sid from requirements_path. Install requirements into the venv for name from requirements_path.
Returns dict: { "stdout": "...", "stderr": "..." } Returns dict with stdout/stderr. Raises RuntimeError on failure including outputs.
Raises on failure with details in exception message.
""" """
venv_dir = create_venv(sid) venv_dir = create_venv(name)
pip_path = os.path.join(venv_dir, "bin", "pip") pip_path = os.path.join(venv_dir, "bin", "pip")
# ensure pip exists and upgrade # ensure pip exists and attempt to upgrade
try: try:
subprocess.run([pip_path, "install", "--upgrade", "pip"], check=True, capture_output=True, text=True, timeout=300) subprocess.run([pip_path, "install", "--upgrade", "pip"], check=True, capture_output=True, text=True, timeout=300)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
# continue but warn logger.warning("pip upgrade warning for %s: %s", name, getattr(e, "stderr", str(e)))
logger.warning("pip upgrade failed for sid=%s: %s", sid, e.stderr if hasattr(e, "stderr") else str(e)) # run install
# install requirements
try: try:
p = subprocess.run([pip_path, "install", "-r", requirements_path, "--no-cache-dir"], p = subprocess.run([pip_path, "install", "-r", requirements_path, "--no-cache-dir"],
check=True, capture_output=True, text=True, timeout=timeout) check=True, capture_output=True, text=True, timeout=timeout)
logger.info("pip install success for sid=%s", sid) logger.info("pip install succeeded for %s", name)
return {"stdout": p.stdout, "stderr": p.stderr} return {"stdout": p.stdout or "", "stderr": p.stderr or ""}
except subprocess.CalledProcessError as e: 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)} out = {"stdout": getattr(e, "stdout", "") or "", "stderr": getattr(e, "stderr", "") or str(e)}
logger.error("pip install failed for %s: %s", name, out["stderr"][:4000])
raise RuntimeError(jsonify_cmd_output(out)) raise RuntimeError(jsonify_cmd_output(out))
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.error("pip install timeout for sid=%s", sid) logger.error("pip install timed out for %s", name)
raise RuntimeError("pip install timed out") raise RuntimeError("pip install timed out")
def jsonify_cmd_output(out: Dict[str, str]) -> str: def jsonify_cmd_output(out: Dict[str, str]) -> str:
# helper to pack stdout/stderr into a single string message
s = "" s = ""
if out.get("stdout"): if out.get("stdout"):
s += "STDOUT:\n" + out["stdout"] + "\n" s += "STDOUT:\n" + out["stdout"] + "\n"
@@ -153,7 +158,6 @@ 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
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: if enable_at_boot:
@@ -192,18 +196,18 @@ def is_unit_active(service_name: str) -> bool:
return p.returncode == 0 return p.returncode == 0
def list_fw_units() -> List[str]: def list_fw_units() -> List[str]:
"""Return list of fw unit names without .service suffix.""" """List our fw-script units (without .service suffix)."""
units = [] units = []
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]) # remove .service units.append(fn[:-8])
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 parse pattern: python path + script path + qnum + optional extra # ExecStart parse pattern: python + /srv/fw-scripts/<name>.py + 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<name>[A-Za-z0-9_.-]+)\.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]:
unit_path = unit_path_for(service_name) unit_path = unit_path_for(service_name)
@@ -218,59 +222,122 @@ def parse_unit_execstart(service_name: str) -> Optional[Dict]:
match = _RE_EXECSTART.search(exec_start) match = _RE_EXECSTART.search(exec_start)
if match: if match:
sd = match.groupdict() sd = match.groupdict()
return {"service": service_name, "exec_start": exec_start, "sid": sd["sid"], "script_path": sd["script"], "qnum": int(sd["qnum"]), "extra": sd.get("extra") or ""} return {"service": service_name, "exec_start": exec_start, "name": sd["name"], "script_path": sd["script"], "qnum": int(sd["qnum"]), "extra": sd.get("extra") or ""}
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, "name": None, "script_path": None, "qnum": None, "extra": None}
# ---------- End utilities ---------- # ---------- Models ----------
class ScriptInfo(BaseModel):
name: str
path: str
class EnableRequest(BaseModel):
qnum: int
service_name: Optional[str] = None
extra_args: Optional[str] = None
enable_at_boot: Optional[bool] = False
# ---------- Endpoints ---------- # ---------- Endpoints ----------
@router.post("", response_model=ScriptInfo) @router.post("", response_model=ScriptInfo)
async def upload_script(script: UploadFile = File(...), requirements: Optional[UploadFile] = File(None)): async def upload_script(
script: UploadFile = File(...),
name: str = Form(...),
requirements: Optional[UploadFile] = File(None),
):
""" """
Upload a script and optional requirements.txt. Upload a script with a supplied name (Form field 'name'), optional requirements file.
If requirements is supplied, a venv for the script will be created and pip will install the requirements. If requirements provided, create venv and install; on failure delete files and venv and return error details.
Returns sid, path, and pip output if any.
""" """
# validate name
try:
validate_name(name)
except ValueError as e:
logger.warning("Invalid name provided: %s", name)
raise HTTPException(status_code=400, detail=str(e))
# ensure script file extension is .py
if not script.filename.endswith(".py"): if not script.filename.endswith(".py"):
logger.warning("Rejected upload with invalid extension: %s", script.filename) logger.warning("Upload rejected: script not .py (name=%s original=%s)", name, script.filename)
raise HTTPException(status_code=400, detail="only .py scripts allowed") raise HTTPException(status_code=400, detail="only .py scripts allowed")
# ensure uniqueness
spath = script_path_for(name)
if os.path.exists(spath):
logger.warning("Upload rejected: script with name already exists: %s", name)
raise HTTPException(status_code=409, detail="script with that name already exists")
# write script
data = await script.read() data = await script.read()
if len(data) > 2_000_000: try:
logger.warning("Rejected upload too large: %s size=%d", script.filename, len(data)) with open(spath, "wb") as fh:
raise HTTPException(status_code=400, detail="script too large")
sid = uuid.uuid4().hex
fname = f"{sid}.py"
path = os.path.join(SCRIPT_DIR, fname)
with open(path, "wb") as fh:
fh.write(data) fh.write(data)
os.chmod(path, 0o700) os.chmod(spath, 0o700)
logger.info("Uploaded script %s as sid=%s path=%s", script.filename, sid, path) logger.info("Saved script for name=%s at %s", name, spath)
except Exception as e:
logger.exception("Failed to write script file for %s: %s", name, e)
raise HTTPException(status_code=500, detail="failed to save script")
req_path = None
pip_output = None pip_output = None
venv_created = False
try:
if requirements is not None: if requirements is not None:
# save requirements to a temp path # save requirements file
req_data = await requirements.read() req_data = await requirements.read()
req_path = os.path.join(SCRIPT_DIR, f"{sid}-requirements.txt") req_path = requirements_path_for(name)
with open(req_path, "wb") as fh: with open(req_path, "wb") as fh:
fh.write(req_data) fh.write(req_data)
logger.info("Saved requirements for sid=%s at %s (size=%d)", sid, req_path, len(req_data)) logger.info("Saved requirements for %s at %s", name, req_path)
# create venv and install # create venv and install
try: try:
create_venv(sid) # create venv and pip install
res = pip_install_requirements(sid, req_path) create_venv(name)
venv_created = True
res = pip_install_requirements(name, req_path)
pip_output = {"stdout": res.get("stdout", ""), "stderr": res.get("stderr", "")} pip_output = {"stdout": res.get("stdout", ""), "stderr": res.get("stderr", "")}
logger.info("Installed requirements for sid=%s", sid) logger.info("pip install completed for %s", name)
except Exception as pip_exc:
# pip install failed: cleanup and report
err_msg = str(pip_exc)
logger.error("pip install error for %s: %s", name, err_msg[:2000])
# cleanup: remove script file, req file, venv dir if created
try:
if os.path.exists(spath):
os.remove(spath)
if req_path and os.path.exists(req_path):
os.remove(req_path)
if venv_created and os.path.isdir(venv_path_for(name)):
shutil.rmtree(venv_path_for(name), ignore_errors=True)
except Exception:
logger.exception("Cleanup after pip failure partially failed for %s", name)
# respond with failure detail (include pip output if available)
raise HTTPException(status_code=500, detail=f"pip install failed: {err_msg}")
except HTTPException:
# pass-through to ensure upstream returns after cleanup
raise
except Exception as e: except Exception as e:
# cleanup venv on failure (optional) # unexpected failure: attempt cleanup of script + req + venv
logger.exception("pip install failed for sid=%s: %s", sid, e) logger.exception("Unexpected error during upload for %s: %s", name, e)
# Provide useful error to client but keep script stored for inspection try:
raise HTTPException(status_code=500, detail=f"pip install failed: {e}") if os.path.exists(spath):
os.remove(spath)
except Exception:
pass
try:
if req_path and os.path.exists(req_path):
os.remove(req_path)
except Exception:
pass
try:
if os.path.isdir(venv_path_for(name)):
shutil.rmtree(venv_path_for(name), ignore_errors=True)
except Exception:
pass
raise HTTPException(status_code=500, detail="internal error during upload")
# return pip_output in headers/body? We'll include in body (if present). # success
resp = {"id": sid, "name": script.filename, "path": path} resp = {"name": name, "path": spath}
if pip_output is not None: if pip_output is not None:
resp["pip"] = pip_output resp["pip"] = pip_output
return resp return resp
@@ -281,90 +348,94 @@ def list_scripts():
for fn in os.listdir(SCRIPT_DIR): for fn in os.listdir(SCRIPT_DIR):
if not fn.endswith(".py"): if not fn.endswith(".py"):
continue continue
sid = fn.rsplit(".", 1)[0] name = fn.rsplit(".", 1)[0]
p = os.path.join(SCRIPT_DIR, fn) out.append({"name": name, "path": os.path.join(SCRIPT_DIR, fn)})
out.append({"id": sid, "name": fn, "path": p}) logger.debug("Listed %d scripts", len(out))
logger.debug("Listed scripts: %d", len(out))
return out return out
@router.get("/{sid}") @router.get("/{name}")
def download_script(sid: str): def download_script(name: str):
path = os.path.join(SCRIPT_DIR, f"{sid}.py") try:
validate_name(name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
path = script_path_for(name)
if not os.path.exists(path): if not os.path.exists(path):
logger.warning("Download requested for missing sid=%s", sid) logger.warning("Download requested for missing script %s", name)
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")
logger.info("Download script sid=%s path=%s", sid, path) logger.info("Download script %s", name)
return FileResponse(path, media_type="text/x-python", filename=f"{sid}.py") return FileResponse(path, media_type="text/x-python", filename=f"{name}.py")
@router.post("/{sid}/enable") @router.post("/{name}/enable")
def enable_script(sid: str, req: EnableRequest): def enable_script(name: str, req: EnableRequest):
""" try:
Create and start systemd service that runs the script with the queue number. validate_name(name)
If a venv was created at upload time, ExecStart will use that venv's python. except ValueError as e:
""" raise HTTPException(status_code=400, detail=str(e))
script_path = os.path.join(SCRIPT_DIR, f"{sid}.py")
script_path = script_path_for(name)
if not os.path.exists(script_path): if not os.path.exists(script_path):
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(name, qnum)
# prefer venv python if exists python_path = venv_python_for(name)
python_path = venv_python_for(sid)
exec_start = f"{python_path} {script_path} {qnum}" 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
try: try:
write_unit(service_name, exec_start, description=f"FW script {sid} queue {qnum}", enable_at_boot=req.enable_at_boot) write_unit(service_name, exec_start, description=f"FW script {name} queue {qnum}", enable_at_boot=req.enable_at_boot)
# 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 service %s for sid=%s: %s", service_name, sid, e) logger.exception("Failed to start service %s for %s: %s", service_name, name, 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}")
except Exception as e: except Exception as e:
logger.exception("Unknown error starting service %s for sid=%s: %s", service_name, sid, e) logger.exception("Unknown error starting service %s for %s: %s", service_name, name, e)
try: try:
remove_unit(service_name) remove_unit(service_name)
except Exception: except Exception:
pass pass
raise HTTPException(status_code=500, detail=str(e)) 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) logger.info("Enabled script %s on qnum=%d as service=%s (python=%s)", name, qnum, service_name, python_path)
return {"status": "ok", "sid": sid, "qnum": qnum, "service": service_name, "python": python_path} return {"status": "ok", "name": name, "qnum": qnum, "service": service_name, "python": python_path}
@router.post("/{sid}/disable") @router.post("/{name}/disable")
def disable_script(sid: str, qnum: int): def disable_script(name: str, qnum: int):
service_name = make_service_name(sid, qnum) try:
validate_name(name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
service_name = make_service_name(name, qnum)
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):
# try stopping anyway in case only registered with systemd without unit file # attempt to stop anyway
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")
try: try:
stop_unit(service_name) stop_unit(service_name)
except subprocess.CalledProcessError: except Exception:
logger.exception("Failed stopping service %s", service_name) logger.exception("Failed stopping service %s", service_name)
try: try:
remove_unit(service_name) remove_unit(service_name)
except Exception: except Exception:
logger.exception("Failed removing unit %s", service_name) logger.exception("Failed removing unit %s", service_name)
logger.info("Disabled script sid=%s on qnum=%s (removed service %s)", sid, qnum, service_name) logger.info("Disabled script %s on qnum=%s (removed service %s)", name, qnum, service_name)
return {"status": "ok", "sid": sid, "qnum": qnum} return {"status": "ok", "name": name, "qnum": qnum}
@router.get("/status") @router.get("/status")
def status_all(): def status_all():
units = list_fw_units() units = list_fw_units()
results: Dict[str, Dict] = {} results = {}
for svc in units: for svc in units:
parsed = parse_unit_execstart(svc) parsed = parse_unit_execstart(svc)
try: try:
@@ -372,37 +443,36 @@ def status_all():
except Exception: except Exception:
active = False active = False
results[svc] = {"parsed": parsed, "active": active} results[svc] = {"parsed": parsed, "active": active}
logger.debug("Status queried: found %d fw units", len(results)) logger.debug("Status queried: found %d units", len(results))
return results return results
@router.get("/{sid}/status") @router.get("/{name}/status")
def status_for_sid(sid: str): def status_for_name(name: str):
try:
validate_name(name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
units = list_fw_units() units = list_fw_units()
matches = [] matches = []
prefix = f"{UNIT_PREFIX}-{name}-q"
for svc in units: for svc in units:
if svc.startswith(f"{UNIT_PREFIX}-{sid}-q"): if svc.startswith(prefix):
parsed = parse_unit_execstart(svc) parsed = parse_unit_execstart(svc)
try: try:
active = is_unit_active(svc) active = is_unit_active(svc)
except Exception: except Exception:
active = False active = False
matches.append({"service": svc, "parsed": parsed, "active": active}) matches.append({"service": svc, "parsed": parsed, "active": active})
logger.debug("Status for sid=%s -> %d matches", sid, len(matches)) logger.debug("Status for %s -> %d matches", name, len(matches))
return {"sid": sid, "mappings": matches} return {"name": name, "mappings": matches}
# ---------- Lifecycle helper (optional) ---------- # ---------- Lifecycle helper ----------
def register_lifecycle(app): 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") @app.on_event("shutdown")
def _shutdown_event(): def _shutdown_event():
logger.info("Shutdown: removing manager-created systemd units with prefix %s", UNIT_PREFIX) logger.info("Shutdown: stopping/removing manager-created units with prefix %s", UNIT_PREFIX)
units = list_fw_units() units = list_fw_units()
for svc in units: for svc in units:
# only remove units that match our naming convention (fw-script-<sid>-q<qnum>)
if svc.startswith(UNIT_PREFIX + "-"): if svc.startswith(UNIT_PREFIX + "-"):
try: try:
subprocess.run(["systemctl", "stop", svc], check=False) subprocess.run(["systemctl", "stop", svc], check=False)
@@ -413,5 +483,3 @@ def register_lifecycle(app):
except Exception: except Exception:
logger.exception("Failed to remove unit %s on shutdown", svc) logger.exception("Failed to remove unit %s on shutdown", svc)
logger.info("Shutdown cleanup complete") logger.info("Shutdown cleanup complete")
# End of router

View File

@@ -24,7 +24,7 @@ PYTHON_VERSION="3" # aktuelle Python 3 Version
# ----------------------------- # -----------------------------
echo "==> Update & Upgrade" echo "==> Update & Upgrade"
apt update && apt upgrade -y apt update && apt upgrade -y
apt install -y git curl build-essential nginx python3 python3-pip python3-venv unzip wget apt install -y git curl build-essential nginx python3 python3-pip python3-venv unzip wget python3-dev libnetfilter-queue-dev libnfnetlink-dev libpcap-dev
# ----------------------------- # -----------------------------
# Node.js installieren (LTS) # Node.js installieren (LTS)