feat: Added python version of the wrapper

- GPT-OSS:120b did the converson of the script from bash to python3
- Updated rsyslog definition to work with either bash or python -file
- updated SSH config to use the python version
This commit is contained in:
Juha Leivo 2025-11-03 21:33:26 +02:00
parent 5994d0e7a0
commit f5681397a7
4 changed files with 160 additions and 8 deletions

View file

@ -19,14 +19,14 @@ These were tested and validated on Ubuntu 24.04 LTS, 2025-11-03
SSH server configuration is formed out of two files
1. `10-jellyfin-limits.conf` - SSH config
2. `limited-wrapper.sh` - a script to limit what commands can be run
2. `limited-wrapper.sh` or `limited-wrapper.py` - a script to limit what commands can be run
### 10-jellyfin-limits.conf
This config file does few things
- allows only jellyfin user to SSH from jellyfin server
- limits jellyfin user login options to be only from jellyfin server
- limits the commands jellyfin user can run to `limited-wrapper.sh`
- limits the commands jellyfin user can run to `limited-wrapper.py`
1. Copy `10-jellyfin-limits.conf` to `/etc/ssh/sshd_config.d`
2. Update the IP of the jellyfin server to the file
@ -35,16 +35,16 @@ This config file does few things
sudo systemctl restart ssh
```
### limited-wrapper.sh
### limited-wrapper.sh and limited-wrapper.py
This file analyses what commands are being run over SSH and limits them
to the ones we defined.
1. Update the ALLOWED list to match your `ffmpeg` file locations in the script
2. Copy the script to `/usr/local/bin/limited-wrapper.sh` and allow only root to modify it
2. Copy the script to `/usr/local/bin/limited-wrapper.py` and allow only root to modify it
```bash
sudo chwon root:root /usr/local/bin/limited-wrapper.sh &&\
sudo chmod 755 /usr/local/bin/limited-wrapper.sh
sudo chwon root:root /usr/local/bin/limited-wrapper.py &&\
sudo chmod 755 /usr/local/bin/limited-wrapper.py
```
### Test configuration

View file

@ -6,7 +6,7 @@ Match Address IPJELLYFIN
Match User jellyfin, Address IPJELLYFIN
AllowUsers jellyfin@IPJELLYFIN
ForceCommand /usr/local/bin/limited-wrapper.sh
ForceCommand /usr/local/bin/limited-wrapper.py
PermitTTY no
X11Forwarding no
AllowAgentForwarding no

View file

@ -1,3 +1,3 @@
# Match the tag *including* the trailing colon
:syslogtag, startswith, "limited-wrapper.sh" /var/log/jellyfin_commands.log
:syslogtag, startswith, "limited-wrapper" /var/log/jellyfin_commands.log
& stop

View file

@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""limited-wrapper.py
Author: GPT-OSS:120b
Version: 1.1.0
Date: 2025-11-03
Python 3 implementation of the limited-wrapper.sh script.
It restricts SSH command execution to a whitelist of allowed binaries
and logs activity either to the console (interactive) or to syslog.
History
1.0.0 - 2025-11-03, initial version
"""
import os
import sys
import shlex
import logging
import logging.handlers
from typing import List
# ---------------------------------------------------------------------------
# Logging utilities
# ---------------------------------------------------------------------------
def _setup_logger() -> logging.Logger:
logger = logging.getLogger("limited-wrapper.py")
logger.setLevel(logging.DEBUG) # Capture all levels; handlers will filter
# Ensure no duplicate handlers if the module is reloaded
logger.handlers.clear()
if sys.stdout.isatty():
# Interactive TTY simple console output without timestamp or level prefix
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(console)
else:
# Noninteractive forward to syslog. Let syslog generate its own timestamp,
# hostname, and program identifier (the logger name). No extra formatter is
# needed to avoid adding the PID or duplicate timestamps.
try:
syslog = logging.handlers.SysLogHandler(address="/dev/log")
except OSError:
# Fallback for systems without /dev/log (e.g., macOS)
syslog = logging.handlers.SysLogHandler(address=("localhost", 514))
syslog.setLevel(logging.DEBUG)
# Prefix with logger name (script tag) to match original format
syslog.setFormatter(logging.Formatter("%(name)s: %(message)s"))
logger.addHandler(syslog)
return logger
_logger = _setup_logger()
def log_msg(level: str, *msg: str) -> None:
"""Log a message with an explicit level prefix.
The original Bash implementation prefixed the log line with the level
(e.g. ``DEBUG`` or ``INFO``) before sending it to syslog. To preserve that
format we construct ``full_msg = f"{level.upper()} {text}"`` and log the
resulting string. This ensures syslog entries look like:
``limited-wrapper.sh: DEBUG <message>`` while interactive console output
remains readable.
"""
text = " ".join(msg)
level = level.upper()
full_msg = f"{level} {text}"
if level == "DEBUG":
_logger.debug(full_msg)
elif level == "INFO":
_logger.info(full_msg)
elif level in ("WARN", "WARNING"):
_logger.warning(full_msg)
elif level == "ERROR":
_logger.error(full_msg)
else:
_logger.info(full_msg)
def log_debug(*msg: str) -> None:
log_msg("DEBUG", *msg)
def log_info(*msg: str) -> None:
log_msg("INFO", *msg)
def log_warn(*msg: str) -> None:
log_msg("WARN", *msg)
def log_error(*msg: str) -> None:
log_msg("ERROR", *msg)
# ---------------------------------------------------------------------------
# Whitelist of absolute paths to allowed binaries
# ---------------------------------------------------------------------------
ALLOWED: List[str] = [
"/usr/bin/ffmpeg",
"/usr/bin/ffprobe",
"/usr/local/bin/ffmpeg",
"/usr/local/bin/ffprobe",
"/usr/lib/jellyfin-ffmpeg/ffmpeg",
"/usr/lib/jellyfin-ffmpeg/ffprobe",
]
def main() -> None:
req_cmd = os.getenv("SSH_ORIGINAL_COMMAND", "")
if not req_cmd:
# No command supplied show the whitelist and exit successfully
print("You may run only: " + " ".join(ALLOWED))
sys.exit(0)
# Parse the command string respecting shell quoting (handles spaces in arguments)
# Using shlex.split provides proper handling of quoted arguments, unlike the
# original bash script which split on whitespace only.
try:
args = shlex.split(req_cmd, posix=True)
except ValueError as e:
log_error(f"Failed to parse SSH_ORIGINAL_COMMAND: {e}")
print("ERROR: could not parse command.")
sys.exit(1)
if not args:
log_error("Empty command after parsing.")
print("ERROR: empty command.")
sys.exit(1)
bin_path = os.path.realpath(args[0])
log_debug(f"Checking for bin {bin_path}")
if bin_path in ALLOWED:
log_info(f"Running command {req_cmd}")
# Ensure the argument list uses the resolved binary path as argv[0]
args[0] = bin_path
# Replace the current process with the requested command without PATH lookup
os.execv(bin_path, args)
# execv only returns on failure
log_error(f"Failed to exec {req_cmd}")
sys.exit(1)
else:
log_error(f"Not allowed {req_cmd}")
print("ERROR: command not allowed.")
sys.exit(1)
if __name__ == "__main__":
main()