venv script improv
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,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")
|
fh.write(data)
|
||||||
|
os.chmod(spath, 0o700)
|
||||||
sid = uuid.uuid4().hex
|
logger.info("Saved script for name=%s at %s", name, spath)
|
||||||
fname = f"{sid}.py"
|
except Exception as e:
|
||||||
path = os.path.join(SCRIPT_DIR, fname)
|
logger.exception("Failed to write script file for %s: %s", name, e)
|
||||||
with open(path, "wb") as fh:
|
raise HTTPException(status_code=500, detail="failed to save script")
|
||||||
fh.write(data)
|
|
||||||
os.chmod(path, 0o700)
|
|
||||||
logger.info("Uploaded script %s as sid=%s path=%s", script.filename, sid, path)
|
|
||||||
|
|
||||||
|
req_path = None
|
||||||
pip_output = None
|
pip_output = None
|
||||||
if requirements is not None:
|
venv_created = False
|
||||||
# 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).
|
try:
|
||||||
resp = {"id": sid, "name": script.filename, "path": path}
|
if requirements is not None:
|
||||||
|
# save requirements file
|
||||||
|
req_data = await requirements.read()
|
||||||
|
req_path = requirements_path_for(name)
|
||||||
|
with open(req_path, "wb") as fh:
|
||||||
|
fh.write(req_data)
|
||||||
|
logger.info("Saved requirements for %s at %s", name, req_path)
|
||||||
|
# create venv and install
|
||||||
|
try:
|
||||||
|
# create venv and pip install
|
||||||
|
create_venv(name)
|
||||||
|
venv_created = True
|
||||||
|
res = pip_install_requirements(name, req_path)
|
||||||
|
pip_output = {"stdout": res.get("stdout", ""), "stderr": res.get("stderr", "")}
|
||||||
|
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:
|
||||||
|
# unexpected failure: attempt cleanup of script + req + venv
|
||||||
|
logger.exception("Unexpected error during upload for %s: %s", name, e)
|
||||||
|
try:
|
||||||
|
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")
|
||||||
|
|
||||||
|
# success
|
||||||
|
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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user