diff --git a/backend/src/api/packet_scripting_api.py b/backend/src/api/packet_scripting_api.py index f4f5856..6301e51 100644 --- a/backend/src/api/packet_scripting_api.py +++ b/backend/src/api/packet_scripting_api.py @@ -1,10 +1,5 @@ -# script_router_named_with_delete.py """ -APIRouter: upload scripts with a supplied name, optional requirements -> create per-script venv. -If venv install fails, response includes pip output and the router deletes the uploaded files and venv. - -Added: DELETE /scripts/{name} to disable any enabled services for that script and remove files/venv. - +NFQUEUE Python-Scripting API Router Endpoints: - POST /scripts -> upload script (multipart): script file, optional requirements file, required 'name' form field - GET /scripts -> list scripts @@ -32,7 +27,7 @@ from pydantic import BaseModel # ---------- Configuration ---------- SCRIPT_DIR = "/srv/fw-scripts" VENV_BASE = "/srv/fw-scripts/venvs" -UNIT_DIR = "/etc/systemd/system" +UNIT_DIR = "/etc/systemd/system" # retained for writing new units, but discovery uses systemctl UNIT_PREFIX = "fw-script" # ensure dirs exist @@ -41,7 +36,7 @@ os.makedirs(VENV_BASE, exist_ok=True) # ---------- Logging ---------- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s") -logger = logging.getLogger("script-router-named-delete") +logger = logging.getLogger("script-router-systemd") # ---------- Router ---------- router = APIRouter(prefix="/scripts", tags=["scripts"]) @@ -57,7 +52,7 @@ def validate_name(name: str) -> None: if name in (".", ".."): raise ValueError("invalid name") -# ---------- Utility paths ---------- +# ---------- Paths ---------- def script_path_for(name: str) -> str: return os.path.join(SCRIPT_DIR, f"{name}.py") @@ -74,12 +69,188 @@ def venv_python_for(name: str) -> str: return "/usr/bin/python3" def make_service_name(name: str, qnum: int) -> str: + # safe, deterministic service name return f"{UNIT_PREFIX}-{name}-q{qnum}" -def unit_path_for(service_name: str) -> str: +def unit_path_for_name(service_name: str) -> str: + # default location for units we write return os.path.join(UNIT_DIR, service_name + ".service") -# ---------- Venv helpers ---------- +# ---------- systemd interaction (systemctl-based, no fallback) ---------- +def _systemctl_unit_name(unit: str) -> str: + """Return unit with .service suffix if missing.""" + return unit if unit.endswith(".service") else unit + ".service" + +def list_fw_units() -> List[str]: + """ + Return list of systemd units (without .service suffix) whose name starts with UNIT_PREFIX-. + Uses `systemctl list-units` to be canonical. + """ + units: List[str] = [] + try: + p = subprocess.run(["systemctl", "list-units", "--type=service", "--all", "--no-legend"], + capture_output=True, text=True, check=False, timeout=3) + out = p.stdout or "" + # each line starts with unit name + for line in out.splitlines(): + line = line.strip() + if not line: + continue + cols = line.split() + unit = cols[0] + if unit.startswith(UNIT_PREFIX + "-") and unit.endswith(".service"): + units.append(unit[:-8]) # strip ".service" + except Exception: + logger.exception("systemctl list-units failed") + return units + +def get_unit_fragment_path(service_name: str) -> Optional[str]: + """ + Use `systemctl show -p FragmentPath --value` to obtain the unit file path (if any). + Returns None if unknown / empty. + """ + unit = _systemctl_unit_name(service_name) + try: + p = subprocess.run(["systemctl", "show", "-p", "FragmentPath", "--value", unit], + capture_output=True, text=True, check=False, timeout=2) + frag = (p.stdout or "").strip() + if not frag: + return None + return frag + except Exception: + logger.exception("systemctl show FragmentPath failed for %s", service_name) + return None + +def parse_unit_execstart(service_name: str) -> Optional[Dict]: + """ + Query systemd for ExecStart (most reliable) and parse the script name and qnum. + Returns dict with keys: service, exec_start, name, script_path, qnum, extra + or None on irrecoverable error. + """ + unit = _systemctl_unit_name(service_name) + try: + p = subprocess.run(["systemctl", "show", "-p", "ExecStart", "--value", unit], + capture_output=True, text=True, check=False, timeout=2) + exec_start_raw = (p.stdout or "").strip() + except Exception: + logger.exception("systemctl show ExecStart failed for %s", service_name) + return {"service": service_name, "exec_start": None, "name": None, "script_path": None, "qnum": None, "extra": None} + + if not exec_start_raw: + # no ExecStart known + return {"service": service_name, "exec_start": None, "name": None, "script_path": None, "qnum": None, "extra": None} + + exec_start = exec_start_raw.strip() + + # Find script path under /srv/fw-scripts + m = re.search(r'(/[^ \t\n\r"]*/srv/fw-scripts/(?P[A-Za-z0-9_.-]+)\.py)\b', exec_start) + if not m: + # Return raw ExecStart but no parsed metadata + return {"service": service_name, "exec_start": exec_start, "name": None, "script_path": None, "qnum": None, "extra": None} + + script_path = m.group(1) + name = m.group("name") + + # Find integer queue number after the script path (first integer token after the script) + after = exec_start[m.end():].strip() + qnum = None + extra = None + if after: + toks = after.split() + for i, t in enumerate(toks): + try: + val = int(t) + if 0 <= val <= 65535: + qnum = val + extra = " ".join(toks[i+1:]) if len(toks) > i+1 else "" + break + except Exception: + continue + + return {"service": service_name, "exec_start": exec_start, "name": name, "script_path": script_path, "qnum": qnum, "extra": extra} + +def is_unit_active(service_name: str) -> bool: + unit = _systemctl_unit_name(service_name) + p = subprocess.run(["systemctl", "is-active", "--quiet", unit]) + return p.returncode == 0 + +def write_unit(service_name: str, exec_start: str, description: str = "", enable_at_boot: bool = False) -> str: + """ + Write unit file to default UNIT_DIR and daemon-reload. This writes to disk as systemd expects. + """ + unit_path = unit_path_for_name(service_name) + unit_text = f"""[Unit] +Description={description} +After=network.target + +[Service] +Type=simple +ExecStart={exec_start} +Restart=always +RestartSec=2 +StandardOutput=syslog +StandardError=syslog + +[Install] +WantedBy=multi-user.target +""" + with open(unit_path, "w") as fh: + fh.write(unit_text) + subprocess.run(["systemctl", "daemon-reload"], check=True) + logger.info("Wrote unit %s", unit_path) + if enable_at_boot: + try: + subprocess.run(["systemctl", "enable", _systemctl_unit_name(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 + +def start_unit(service_name: str) -> None: + subprocess.run(["systemctl", "start", _systemctl_unit_name(service_name)], check=True) + logger.info("Started service %s", service_name) + +def stop_unit(service_name: str) -> None: + subprocess.run(["systemctl", "stop", _systemctl_unit_name(service_name)], check=True) + logger.info("Stopped service %s", service_name) + +def disable_unit(service_name: str) -> None: + subprocess.run(["systemctl", "disable", _systemctl_unit_name(service_name)], check=False) + logger.info("Disabled service %s", service_name) + +def remove_unit(service_name: str) -> None: + """ + Stop + disable the unit, remove the unit file using FragmentPath (if present), + and daemon-reload. All via systemctl queries (no fallback to scanning /etc). + """ + unit = _systemctl_unit_name(service_name) + try: + subprocess.run(["systemctl", "stop", unit], check=False) + except Exception: + logger.exception("Failed stopping %s", unit) + try: + subprocess.run(["systemctl", "disable", unit], check=False) + except Exception: + pass + + frag = get_unit_fragment_path(service_name) + if frag: + try: + if os.path.exists(frag): + os.remove(frag) + logger.info("Removed unit file at %s for %s", frag, service_name) + except Exception: + logger.exception("Failed to remove unit file %s", frag) + else: + logger.debug("No FragmentPath for %s; nothing to delete on-disk", service_name) + + # ensure systemd reloads units + try: + subprocess.run(["systemctl", "daemon-reload"], check=True) + except Exception: + logger.exception("daemon-reload failed after removing unit %s", service_name) + +# ---------- venv + pip helpers ---------- def create_venv(name: str, timeout: int = 60) -> str: venv_dir = venv_path_for(name) if os.path.exists(venv_dir): @@ -125,91 +296,6 @@ def jsonify_cmd_output(out: Dict[str, str]) -> str: 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_text = f"""[Unit] -Description={description} -After=network.target - -[Service] -Type=simple -ExecStart={exec_start} -Restart=always -RestartSec=2 -StandardOutput=syslog -StandardError=syslog - -[Install] -WantedBy=multi-user.target -""" - with open(unit_path, "w") as fh: - fh.write(unit_text) - subprocess.run(["systemctl", "daemon-reload"], check=True) - 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 - -def remove_unit(service_name: str) -> None: - unit_path = unit_path_for(service_name) - try: - subprocess.run(["systemctl", "stop", service_name], check=False) - except Exception: - logger.exception("systemctl stop failed for %s", service_name) - if os.path.exists(unit_path): - try: - subprocess.run(["systemctl", "disable", service_name], check=False) - except Exception: - pass - os.remove(unit_path) - subprocess.run(["systemctl", "daemon-reload"], check=True) - logger.info("Removed unit %s", unit_path) - -def start_unit(service_name: str) -> None: - subprocess.run(["systemctl", "start", service_name], check=True) - logger.info("Started service %s", service_name) - -def stop_unit(service_name: str) -> None: - subprocess.run(["systemctl", "stop", service_name], check=True) - logger.info("Stopped service %s", service_name) - -def is_unit_active(service_name: str) -> bool: - p = subprocess.run(["systemctl", "is-active", "--quiet", service_name]) - return p.returncode == 0 - -def list_fw_units() -> List[str]: - units = [] - try: - for fn in os.listdir(UNIT_DIR): - if fn.startswith(UNIT_PREFIX + "-") and fn.endswith(".service"): - units.append(fn[:-8]) - except FileNotFoundError: - logger.warning("Unit dir %s not found", UNIT_DIR) - return units - -_RE_EXECSTART = re.compile(r'(?P/\S*python\S*)\s+(?P