diff --git a/docs/HARDENING b/docs/HARDENING index 9043391..ad7d24d 100644 --- a/docs/HARDENING +++ b/docs/HARDENING @@ -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 diff --git a/hardening/10-jellyfin-limits.conf b/hardening/10-jellyfin-limits.conf index 06254e9..f431dea 100644 --- a/hardening/10-jellyfin-limits.conf +++ b/hardening/10-jellyfin-limits.conf @@ -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 diff --git a/hardening/limited-wrapper-log.conf b/hardening/limited-wrapper-log.conf index ca19f51..ff812e2 100644 --- a/hardening/limited-wrapper-log.conf +++ b/hardening/limited-wrapper-log.conf @@ -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 \ No newline at end of file diff --git a/hardening/limited-wrapper.py b/hardening/limited-wrapper.py new file mode 100644 index 0000000..5e89c97 --- /dev/null +++ b/hardening/limited-wrapper.py @@ -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: + # Non‑interactive – 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 `` 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()