add delete script
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
This commit is contained in:
@@ -1,36 +1,31 @@
|
|||||||
# script_router_named.py
|
# script_router_named_with_delete.py
|
||||||
"""
|
"""
|
||||||
APIRouter: upload scripts with a supplied name, optional requirements -> create per-script venv.
|
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.
|
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.
|
||||||
|
|
||||||
Endpoints:
|
Endpoints:
|
||||||
- POST /scripts -> upload script (multipart): script file, optional requirements file, required 'name' form field
|
- POST /scripts -> upload script (multipart): script file, optional requirements file, required 'name' form field
|
||||||
- GET /scripts -> list scripts
|
- GET /scripts -> list scripts
|
||||||
- GET /scripts/{name} -> download script
|
- GET /scripts/{name} -> download script
|
||||||
- POST /scripts/{name}/enable -> enable systemd service for script on given qnum
|
- POST /scripts/{name}/enable -> enable systemd service for script on given qnum
|
||||||
- POST /scripts/{name}/disable -> disable service for script on qnum
|
- POST /scripts/{name}/disable -> disable service for script on qnum
|
||||||
|
- DELETE /scripts/{name} -> disable all or a specific qnum service(s) and delete script + venv + requirements
|
||||||
- GET /scripts/status -> status of all fw-script units
|
- GET /scripts/status -> status of all fw-script units
|
||||||
- GET /scripts/{name}/status -> status of units for that script
|
- GET /scripts/{name}/status -> status of units for that script
|
||||||
|
|
||||||
Notes:
|
|
||||||
- This relies on systemd and writes units to /etc/systemd/system
|
|
||||||
- Script files are stored at SCRIPT_DIR/<name>.py
|
|
||||||
- Venv stored at VENV_BASE/<name> (if requirements provided)
|
|
||||||
- 'name' must match regex [A-Za-z0-9_.-]+ (no path separators)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import re
|
import re
|
||||||
import uuid
|
|
||||||
import json
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict
|
||||||
|
|
||||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Query
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -46,13 +41,12 @@ 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-named")
|
logger = logging.getLogger("script-router-named-delete")
|
||||||
|
|
||||||
# ---------- Router ----------
|
# ---------- Router ----------
|
||||||
router = APIRouter(prefix="/scripts", tags=["scripts"])
|
router = APIRouter(prefix="/scripts", tags=["scripts"])
|
||||||
|
|
||||||
# ---------- Name validation ----------
|
# ---------- Name validation ----------
|
||||||
# Accept only safe file-name characters to avoid path traversal: letters, digits, dot, underscore, hyphen
|
|
||||||
_NAME_RE = re.compile(r'^[A-Za-z0-9_.-]+$')
|
_NAME_RE = re.compile(r'^[A-Za-z0-9_.-]+$')
|
||||||
|
|
||||||
def validate_name(name: str) -> None:
|
def validate_name(name: str) -> None:
|
||||||
@@ -60,7 +54,6 @@ def validate_name(name: str) -> None:
|
|||||||
raise ValueError("name must be provided")
|
raise ValueError("name must be provided")
|
||||||
if not _NAME_RE.match(name):
|
if not _NAME_RE.match(name):
|
||||||
raise ValueError("invalid name; allowed characters: letters, digits, dot, underscore, hyphen")
|
raise ValueError("invalid name; allowed characters: letters, digits, dot, underscore, hyphen")
|
||||||
# prevent reserved names or dots-only
|
|
||||||
if name in (".", ".."):
|
if name in (".", ".."):
|
||||||
raise ValueError("invalid name")
|
raise ValueError("invalid name")
|
||||||
|
|
||||||
@@ -105,18 +98,12 @@ def create_venv(name: str, timeout: int = 60) -> str:
|
|||||||
return venv_dir
|
return venv_dir
|
||||||
|
|
||||||
def pip_install_requirements(name: 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 name from requirements_path.
|
|
||||||
Returns dict with stdout/stderr. Raises RuntimeError on failure including outputs.
|
|
||||||
"""
|
|
||||||
venv_dir = create_venv(name)
|
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 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:
|
||||||
logger.warning("pip upgrade warning for %s: %s", name, getattr(e, "stderr", str(e)))
|
logger.warning("pip upgrade warning for %s: %s", name, getattr(e, "stderr", str(e)))
|
||||||
# run install
|
|
||||||
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)
|
||||||
@@ -168,7 +155,7 @@ WantedBy=multi-user.target
|
|||||||
logger.warning("Failed to enable %s at boot", service_name)
|
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) -> None:
|
||||||
unit_path = unit_path_for(service_name)
|
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)
|
||||||
@@ -183,11 +170,11 @@ def remove_unit(service_name: str):
|
|||||||
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)
|
||||||
|
|
||||||
def start_unit(service_name: str):
|
def start_unit(service_name: str) -> None:
|
||||||
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):
|
def stop_unit(service_name: str) -> None:
|
||||||
subprocess.run(["systemctl", "stop", service_name], check=True)
|
subprocess.run(["systemctl", "stop", service_name], check=True)
|
||||||
logger.info("Stopped service %s", service_name)
|
logger.info("Stopped service %s", service_name)
|
||||||
|
|
||||||
@@ -196,7 +183,6 @@ 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]:
|
||||||
"""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):
|
||||||
@@ -206,7 +192,6 @@ def list_fw_units() -> List[str]:
|
|||||||
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 + /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<name>[A-Za-z0-9_.-]+)\.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]:
|
||||||
@@ -244,29 +229,21 @@ async def upload_script(
|
|||||||
name: str = Form(...),
|
name: str = Form(...),
|
||||||
requirements: Optional[UploadFile] = File(None),
|
requirements: Optional[UploadFile] = File(None),
|
||||||
):
|
):
|
||||||
"""
|
|
||||||
Upload a script with a supplied name (Form field 'name'), optional requirements file.
|
|
||||||
If requirements provided, create venv and install; on failure delete files and venv and return error details.
|
|
||||||
"""
|
|
||||||
# validate name
|
|
||||||
try:
|
try:
|
||||||
validate_name(name)
|
validate_name(name)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning("Invalid name provided: %s", name)
|
logger.warning("Invalid name provided: %s", name)
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
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("Upload rejected: script not .py (name=%s original=%s)", name, 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)
|
spath = script_path_for(name)
|
||||||
if os.path.exists(spath):
|
if os.path.exists(spath):
|
||||||
logger.warning("Upload rejected: script with name already exists: %s", name)
|
logger.warning("Upload rejected: script with name already exists: %s", name)
|
||||||
raise HTTPException(status_code=409, detail="script with that name already exists")
|
raise HTTPException(status_code=409, detail="script with that name already exists")
|
||||||
|
|
||||||
# write script
|
|
||||||
data = await script.read()
|
data = await script.read()
|
||||||
try:
|
try:
|
||||||
with open(spath, "wb") as fh:
|
with open(spath, "wb") as fh:
|
||||||
@@ -283,25 +260,21 @@ async def upload_script(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if requirements is not None:
|
if requirements is not None:
|
||||||
# save requirements file
|
|
||||||
req_data = await requirements.read()
|
req_data = await requirements.read()
|
||||||
req_path = requirements_path_for(name)
|
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 %s at %s", name, req_path)
|
logger.info("Saved requirements for %s at %s", name, req_path)
|
||||||
# create venv and install
|
|
||||||
try:
|
try:
|
||||||
# create venv and pip install
|
|
||||||
create_venv(name)
|
create_venv(name)
|
||||||
venv_created = True
|
venv_created = True
|
||||||
res = pip_install_requirements(name, req_path)
|
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("pip install completed for %s", name)
|
logger.info("pip install completed for %s", name)
|
||||||
except Exception as pip_exc:
|
except Exception as pip_exc:
|
||||||
# pip install failed: cleanup and report
|
|
||||||
err_msg = str(pip_exc)
|
err_msg = str(pip_exc)
|
||||||
logger.error("pip install error for %s: %s", name, err_msg[:2000])
|
logger.error("pip install error for %s: %s", name, err_msg[:2000])
|
||||||
# cleanup: remove script file, req file, venv dir if created
|
# cleanup
|
||||||
try:
|
try:
|
||||||
if os.path.exists(spath):
|
if os.path.exists(spath):
|
||||||
os.remove(spath)
|
os.remove(spath)
|
||||||
@@ -311,13 +284,10 @@ async def upload_script(
|
|||||||
shutil.rmtree(venv_path_for(name), ignore_errors=True)
|
shutil.rmtree(venv_path_for(name), ignore_errors=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Cleanup after pip failure partially failed for %s", name)
|
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}")
|
raise HTTPException(status_code=500, detail=f"pip install failed: {err_msg}")
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
# pass-through to ensure upstream returns after cleanup
|
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# unexpected failure: attempt cleanup of script + req + venv
|
|
||||||
logger.exception("Unexpected error during upload for %s: %s", name, e)
|
logger.exception("Unexpected error during upload for %s: %s", name, e)
|
||||||
try:
|
try:
|
||||||
if os.path.exists(spath):
|
if os.path.exists(spath):
|
||||||
@@ -336,7 +306,6 @@ async def upload_script(
|
|||||||
pass
|
pass
|
||||||
raise HTTPException(status_code=500, detail="internal error during upload")
|
raise HTTPException(status_code=500, detail="internal error during upload")
|
||||||
|
|
||||||
# success
|
|
||||||
resp = {"name": name, "path": spath}
|
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
|
||||||
@@ -415,7 +384,6 @@ def disable_script(name: str, qnum: int):
|
|||||||
service_name = make_service_name(name, qnum)
|
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):
|
||||||
# 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:
|
||||||
@@ -432,6 +400,117 @@ def disable_script(name: str, qnum: int):
|
|||||||
logger.info("Disabled script %s on qnum=%s (removed service %s)", name, qnum, service_name)
|
logger.info("Disabled script %s on qnum=%s (removed service %s)", name, qnum, service_name)
|
||||||
return {"status": "ok", "name": name, "qnum": qnum}
|
return {"status": "ok", "name": name, "qnum": qnum}
|
||||||
|
|
||||||
|
@router.delete("/{name}")
|
||||||
|
def delete_script(name: str, qnum: Optional[int] = Query(None, description="If given, only remove the unit for this qnum; otherwise remove all units for the script")):
|
||||||
|
"""
|
||||||
|
Delete a script and its associated resources.
|
||||||
|
- If qnum is provided: stop/remove fw-script-<name>-q<qnum>.service (if present).
|
||||||
|
- If qnum is not provided: stop/remove all fw-script-<name>-q*.service units found.
|
||||||
|
- Remove script file, requirements file, and venv directory.
|
||||||
|
Returns JSON summarizing performed actions and any errors.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
validate_name(name)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
removed_units: List[str] = []
|
||||||
|
failed_units: List[str] = []
|
||||||
|
errors: List[str] = []
|
||||||
|
|
||||||
|
# determine which units to remove
|
||||||
|
if qnum is not None:
|
||||||
|
svc = make_service_name(name, qnum)
|
||||||
|
units_to_handle = [svc]
|
||||||
|
else:
|
||||||
|
# scan unit directory for matching units
|
||||||
|
all_units = list_fw_units()
|
||||||
|
prefix = f"{UNIT_PREFIX}-{name}-q"
|
||||||
|
units_to_handle = [u for u in all_units if u.startswith(prefix)]
|
||||||
|
|
||||||
|
# stop and remove units
|
||||||
|
for svc in units_to_handle:
|
||||||
|
try:
|
||||||
|
# attempt to stop via systemctl even if unit file missing
|
||||||
|
try:
|
||||||
|
subprocess.run(["systemctl", "stop", svc], check=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
unit_file = unit_path_for(svc)
|
||||||
|
if os.path.exists(unit_file):
|
||||||
|
try:
|
||||||
|
remove_unit(svc)
|
||||||
|
removed_units.append(svc)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to remove unit %s: %s", svc, e)
|
||||||
|
failed_units.append(svc)
|
||||||
|
errors.append(f"remove_unit {svc}: {e}")
|
||||||
|
else:
|
||||||
|
# unit file doesn't exist - treat as stopped/absent but attempt systemd stop above
|
||||||
|
removed_units.append(svc)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error handling unit %s: %s", svc, e)
|
||||||
|
failed_units.append(svc)
|
||||||
|
errors.append(f"error handling {svc}: {e}")
|
||||||
|
|
||||||
|
# remove files: script, requirements, venv
|
||||||
|
spath = script_path_for(name)
|
||||||
|
rpath = requirements_path_for(name)
|
||||||
|
vpath = venv_path_for(name)
|
||||||
|
file_removed = []
|
||||||
|
file_failed = []
|
||||||
|
|
||||||
|
# remove script file
|
||||||
|
try:
|
||||||
|
if os.path.exists(spath):
|
||||||
|
os.remove(spath)
|
||||||
|
file_removed.append(spath)
|
||||||
|
logger.info("Removed script file %s", spath)
|
||||||
|
else:
|
||||||
|
logger.debug("Script file %s not present", spath)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed removing script file %s: %s", spath, e)
|
||||||
|
file_failed.append(spath)
|
||||||
|
errors.append(f"remove_script {spath}: {e}")
|
||||||
|
|
||||||
|
# remove requirements file
|
||||||
|
try:
|
||||||
|
if os.path.exists(rpath):
|
||||||
|
os.remove(rpath)
|
||||||
|
file_removed.append(rpath)
|
||||||
|
logger.info("Removed requirements file %s", rpath)
|
||||||
|
else:
|
||||||
|
logger.debug("Requirements file %s not present", rpath)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed removing requirements file %s: %s", rpath, e)
|
||||||
|
file_failed.append(rpath)
|
||||||
|
errors.append(f"remove_requirements {rpath}: {e}")
|
||||||
|
|
||||||
|
# remove venv dir
|
||||||
|
try:
|
||||||
|
if os.path.isdir(vpath):
|
||||||
|
shutil.rmtree(vpath, ignore_errors=False)
|
||||||
|
file_removed.append(vpath)
|
||||||
|
logger.info("Removed venv directory %s", vpath)
|
||||||
|
else:
|
||||||
|
logger.debug("Venv dir %s not present", vpath)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed removing venv %s: %s", vpath, e)
|
||||||
|
file_failed.append(vpath)
|
||||||
|
errors.append(f"remove_venv {vpath}: {e}")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"name": name,
|
||||||
|
"units_removed": removed_units,
|
||||||
|
"units_failed": failed_units,
|
||||||
|
"files_removed": file_removed,
|
||||||
|
"files_failed": file_failed,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Delete script %s completed: removed_units=%d files_removed=%d errors=%d", name, len(removed_units), len(file_removed), len(errors))
|
||||||
|
return result
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
def status_all():
|
def status_all():
|
||||||
units = list_fw_units()
|
units = list_fw_units()
|
||||||
|
|||||||
Reference in New Issue
Block a user