mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 09:06:06 +00:00
Some checks failed
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
Base Image Build / prepare (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
gevent registers a pthread_atfork handler that never yields. Any subprocess.Popen/run call in a uWSGI greenlet hangs indefinitely at fork() before the child process even starts. - dispatcharr/gevent_patch.py: new; monkey.patch_all() + psycogreen, loaded via uWSGI import= in all four ini files - live_proxy/input/manager.py: posix_spawn + _SpawnedProcess; removed dead forwarder code - live_proxy/input/http_streamer.py: O_NONBLOCK on relay pipe + EAGAIN retry (blocking write to a full pipe stalls the gevent hub) - live_proxy/utils.py: new posix_spawn_proc() helper with O_NONBLOCK stdin, shared by both output managers - live_proxy/output/fmp4/manager.py, output/profile/manager.py: posix_spawn_proc(); _write_all() treats EAGAIN (None) as cooperative select.select wait instead of fatal error - core/views.py (stream_view): posix_spawn; fixed pre-existing bug where return StreamingHttpResponse(...) was indented inside stream_generator, making the success path always return None - connect/handlers/script.py: _posix_run() with posix_spawn + cooperative select reads + non-blocking waitpid; fixes deadlock when a script integration fires on a uWSGI-context event (client_connect fires in the live proxy, not Celery)
150 lines
5 KiB
Python
150 lines
5 KiB
Python
# connect/handlers/script.py
|
|
import os
|
|
import select as _select
|
|
import signal as _signal
|
|
import stat
|
|
import time
|
|
from django.conf import settings
|
|
from .base import IntegrationHandler
|
|
|
|
|
|
def _waitpid_nonblocking(pid, timeout_s=2.0):
|
|
deadline = time.monotonic() + timeout_s
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
wpid, status = os.waitpid(pid, os.WNOHANG)
|
|
except ChildProcessError:
|
|
return -1
|
|
if wpid == pid:
|
|
return os.WEXITSTATUS(status) if os.WIFEXITED(status) else -os.WTERMSIG(status)
|
|
time.sleep(0.01)
|
|
return -1
|
|
|
|
|
|
def _posix_run(path, env, timeout):
|
|
stdout_r, stdout_w = os.pipe()
|
|
stderr_r, stderr_w = os.pipe()
|
|
devnull_r = os.open(os.devnull, os.O_RDONLY)
|
|
file_actions = [
|
|
(os.POSIX_SPAWN_DUP2, devnull_r, 0),
|
|
(os.POSIX_SPAWN_DUP2, stdout_w, 1),
|
|
(os.POSIX_SPAWN_DUP2, stderr_w, 2),
|
|
(os.POSIX_SPAWN_CLOSE, devnull_r),
|
|
(os.POSIX_SPAWN_CLOSE, stdout_w),
|
|
(os.POSIX_SPAWN_CLOSE, stderr_w),
|
|
(os.POSIX_SPAWN_CLOSE, stdout_r),
|
|
(os.POSIX_SPAWN_CLOSE, stderr_r),
|
|
]
|
|
try:
|
|
pid = os.posix_spawn(path, [path], env, file_actions=file_actions)
|
|
except Exception:
|
|
for fd in (stdout_r, stdout_w, stderr_r, stderr_w, devnull_r):
|
|
try:
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
for fd in (devnull_r, stdout_w, stderr_w):
|
|
os.close(fd)
|
|
|
|
out, err = [], []
|
|
done = set()
|
|
deadline = time.monotonic() + timeout
|
|
timed_out = False
|
|
|
|
try:
|
|
while len(done) < 2:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
timed_out = True
|
|
try:
|
|
os.kill(pid, _signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
break
|
|
|
|
fds = [fd for fd in (stdout_r, stderr_r) if fd not in done]
|
|
readable, _, _ = _select.select(fds, [], [], min(remaining, 0.5))
|
|
for fd in readable:
|
|
data = os.read(fd, 8192)
|
|
if data:
|
|
(out if fd == stdout_r else err).append(data)
|
|
else:
|
|
done.add(fd)
|
|
finally:
|
|
for fd in (stdout_r, stderr_r):
|
|
try:
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
|
|
rc = _waitpid_nonblocking(pid)
|
|
if timed_out:
|
|
raise TimeoutError(f"Script exceeded {timeout}s")
|
|
return rc, b"".join(out).decode("utf-8", errors="replace"), b"".join(err).decode("utf-8", errors="replace")
|
|
|
|
|
|
def _is_path_allowed(real_path: str) -> bool:
|
|
# Ensure path is within one of the allowed directories
|
|
for base in getattr(settings, "CONNECT_ALLOWED_SCRIPT_DIRS", []):
|
|
base_abs = os.path.abspath(base) + os.sep
|
|
if real_path.startswith(base_abs):
|
|
return True
|
|
return False
|
|
|
|
|
|
class ScriptHandler(IntegrationHandler):
|
|
def execute(self):
|
|
raw_path = self.integration.config.get("path")
|
|
if not raw_path:
|
|
raise ValueError("Missing 'path' in integration config")
|
|
|
|
# Resolve and validate path
|
|
real_path = os.path.abspath(os.path.realpath(raw_path))
|
|
|
|
if not os.path.exists(real_path):
|
|
raise FileNotFoundError(f"Script not found: {real_path}")
|
|
|
|
if not _is_path_allowed(real_path):
|
|
raise PermissionError(
|
|
f"Script path '{real_path}' not within allowed directories: "
|
|
f"{getattr(settings, 'CONNECT_ALLOWED_SCRIPT_DIRS', [])}"
|
|
)
|
|
|
|
if getattr(settings, "CONNECT_SCRIPT_REQUIRE_EXECUTABLE", True):
|
|
if not os.access(real_path, os.X_OK):
|
|
raise PermissionError(f"Script is not executable: {real_path}")
|
|
|
|
if getattr(settings, "CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE", True):
|
|
st = os.stat(real_path)
|
|
if st.st_mode & stat.S_IWOTH:
|
|
raise PermissionError(
|
|
f"Refusing to execute world-writable script: {real_path}"
|
|
)
|
|
|
|
# Build a sanitized minimal environment; avoid inheriting secrets
|
|
env = {
|
|
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
}
|
|
for key, value in (self.payload or {}).items():
|
|
env_key = f"DISPATCHARR_{str(key).upper()}"
|
|
env[env_key] = "" if value is None else str(value)
|
|
|
|
# Run with a timeout to prevent hanging scripts
|
|
timeout = getattr(settings, "CONNECT_SCRIPT_TIMEOUT", 10)
|
|
max_out = getattr(settings, "CONNECT_SCRIPT_MAX_OUTPUT", 65536)
|
|
|
|
rc, stdout, stderr = _posix_run(real_path, env, timeout)
|
|
|
|
# Truncate outputs to avoid excessive memory/logging
|
|
if len(stdout) > max_out:
|
|
stdout = stdout[:max_out] + "... [truncated]"
|
|
if len(stderr) > max_out:
|
|
stderr = stderr[:max_out] + "... [truncated]"
|
|
|
|
return {
|
|
"exit_code": rc,
|
|
"stdout": stdout,
|
|
"stderr": stderr,
|
|
"success": rc == 0,
|
|
}
|