From 261297e3aff21c17c507c1636d1ffa2c048b73d6 Mon Sep 17 00:00:00 2001 From: gru Date: Sun, 19 Apr 2026 23:05:48 +0200 Subject: [PATCH] Add install_rtorrent.py --- install_rtorrent.py | 377 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 install_rtorrent.py diff --git a/install_rtorrent.py b/install_rtorrent.py new file mode 100644 index 0000000..085995b --- /dev/null +++ b/install_rtorrent.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +import argparse +import os +import pwd +import shutil +import subprocess +import sys +from pathlib import Path + + +DEFAULT_USER = "rtorrent" +DEFAULT_GROUP = "rtorrent" +DEFAULT_HOME = "/home/rtorrent" +DEFAULT_BASE_DIR = "/opt/rtorrent_build" +DEFAULT_LIBTORRENT_REF = "master" +DEFAULT_RTORRENT_REF = "master" +DEFAULT_SERVICE_PATH = "/etc/systemd/system/rtorrent@.service" +DEFAULT_SCGI_PORT = 5000 +DEFAULT_TORRENT_PORT = 51300 + + +class InstallError(Exception): + pass + + +def run(cmd, *, cwd=None, env=None, check=True): + print(f"\n>>> {' '.join(cmd)}") + result = subprocess.run(cmd, cwd=cwd, env=env, check=False) + if check and result.returncode != 0: + raise InstallError(f"Command failed with exit code {result.returncode}: {' '.join(cmd)}") + return result.returncode + + +def capture(cmd, *, cwd=None, env=None, check=True): + result = subprocess.run( + cmd, + cwd=cwd, + env=env, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if check and result.returncode != 0: + raise InstallError( + f"Command failed with exit code {result.returncode}: {' '.join(cmd)}\n{result.stderr.strip()}" + ) + return result.stdout.strip() + + +def require_root(): + if os.geteuid() != 0: + raise InstallError("This script must be run as root (use sudo).") + + +def detect_debian(): + os_release = Path("/etc/os-release") + if not os_release.exists(): + raise InstallError("Cannot detect operating system: /etc/os-release is missing.") + + data = {} + for line in os_release.read_text().splitlines(): + if "=" in line: + key, value = line.split("=", 1) + data[key] = value.strip().strip('"') + + distro_id = data.get("ID", "").lower() + distro_like = data.get("ID_LIKE", "").lower() + if distro_id != "debian" and "debian" not in distro_like: + raise InstallError( + f"Unsupported distribution: ID={data.get('ID', 'unknown')}, ID_LIKE={data.get('ID_LIKE', 'unknown')}. " + "This installer currently supports Debian only." + ) + + print(f"Detected Debian-compatible system: {data.get('PRETTY_NAME', distro_id)}") + + +def prompt_yes_no(question, default=True, assume_yes=False): + if assume_yes: + print(f"{question} [{'Y/n' if default else 'y/N'}] -> auto-yes") + return True + + suffix = "[Y/n]" if default else "[y/N]" + while True: + reply = input(f"{question} {suffix} ").strip().lower() + if not reply: + return default + if reply in {"y", "yes"}: + return True + if reply in {"n", "no"}: + return False + print("Please answer yes or no.") + + +def package_exists(name): + return run(["apt-cache", "show", name], check=False) == 0 + + +def resolve_xmlrpc_packages(): + candidates = [ + "libxmlrpc-c++9-dev", + "libxmlrpc-c++8-dev", + ] + chosen = None + for package in candidates: + if package_exists(package): + chosen = package + break + if not chosen: + raise InstallError("No supported xmlrpc-c++ development package found in APT repositories.") + return [chosen, "libxmlrpc-core-c3-dev"] + + +def ensure_packages(packages): + print("Updating APT metadata...") + run(["apt-get", "update"]) + print("Installing build and runtime dependencies...") + run(["apt-get", "install", "-y", *packages]) + + +def ensure_dir(path, owner=None, group=None, mode=None): + Path(path).mkdir(parents=True, exist_ok=True) + if owner is not None or group is not None: + shutil.chown(path, user=owner, group=group) + if mode is not None: + os.chmod(path, mode) + + +def create_system_user(user, group, home, assume_yes=False): + try: + pwd.getpwnam(user) + print(f"User '{user}' already exists.") + except KeyError: + if not prompt_yes_no(f"Create system user '{user}' with home '{home}'?", default=True, assume_yes=assume_yes): + raise InstallError("User creation declined.") + run(["groupadd", "--system", group], check=False) + run([ + "useradd", + "--system", + "--home-dir", home, + "--create-home", + "--shell", "/usr/sbin/nologin", + "--gid", group, + user, + ]) + print(f"Created user '{user}'.") + + +def clone_or_update_repo(repo_url, repo_dir, ref): + repo_dir = Path(repo_dir) + if not repo_dir.exists(): + run(["git", "clone", repo_url, str(repo_dir)]) + else: + print(f"Repository already exists: {repo_dir}") + run(["git", "fetch", "--all", "--tags"], cwd=str(repo_dir)) + + run(["git", "fetch", "--all", "--tags"], cwd=str(repo_dir)) + run(["git", "checkout", ref], cwd=str(repo_dir)) + run(["git", "pull", "--ff-only"], cwd=str(repo_dir), check=False) + + +def build_libtorrent(base_dir, libtorrent_ref): + source_dir = Path(base_dir) / "libtorrent" + install_dir = Path(base_dir) / "libtorrent_install" + + clone_or_update_repo("https://github.com/rakshasa/libtorrent.git", source_dir, libtorrent_ref) + run(["autoreconf", "-i"], cwd=str(source_dir)) + run(["./configure", f"--prefix={install_dir}"], cwd=str(source_dir)) + run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_dir)) + run(["make", "install"], cwd=str(source_dir)) + return install_dir + + +def build_rtorrent(base_dir, rtorrent_ref, libtorrent_install): + source_dir = Path(base_dir) / "rtorrent" + install_dir = Path(base_dir) / "rtorrent_install" + + clone_or_update_repo("https://github.com/rakshasa/rtorrent.git", source_dir, rtorrent_ref) + + env = os.environ.copy() + env["CFLAGS"] = f"-I{libtorrent_install}/include" + env["LDFLAGS"] = f"-L{libtorrent_install}/lib" + existing_pkg = env.get("PKG_CONFIG_PATH", "") + env["PKG_CONFIG_PATH"] = f"{libtorrent_install}/lib/pkgconfig" + (f":{existing_pkg}" if existing_pkg else "") + + run(["autoreconf", "-i"], cwd=str(source_dir), env=env) + run([ + "./configure", + f"--prefix={install_dir}", + "--with-xmlrpc-c", + ], cwd=str(source_dir), env=env) + run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_dir), env=env) + run(["make", "install"], cwd=str(source_dir), env=env) + return install_dir + + +def install_symlinks(rtorrent_install, libtorrent_install): + rtorrent_bin = Path(rtorrent_install) / "bin" / "rtorrent" + if not rtorrent_bin.exists(): + raise InstallError(f"Compiled rtorrent binary not found: {rtorrent_bin}") + + usr_local_bin = Path("/usr/local/bin/rtorrent") + if usr_local_bin.exists() or usr_local_bin.is_symlink(): + usr_local_bin.unlink() + usr_local_bin.symlink_to(rtorrent_bin) + print(f"Symlinked {usr_local_bin} -> {rtorrent_bin}") + + ld_conf = Path("/etc/ld.so.conf.d/rtorrent-libtorrent.conf") + ld_conf.write_text(f"{libtorrent_install}/lib\n") + run(["ldconfig"]) + + +def write_service(service_path, binary_path, libtorrent_lib_dir): + service_content = f"""[Unit] +Description=rTorrent for %I +After=network.target + +[Service] +Type=simple +User=%I +Group=%I +KillMode=process +WorkingDirectory=/home/%I +ExecStartPre=-/bin/rm -f /home/%I/.session/rtorrent.lock +ExecStart={binary_path} -o system.daemon.set=true -n -o import=/home/%I/.rtorrent.rc +ExecStop=/bin/kill -9 $MAINPID +Restart=always +RestartSec=3 +Environment=LD_LIBRARY_PATH={libtorrent_lib_dir} + +[Install] +WantedBy=multi-user.target +""" + Path(service_path).write_text(service_content) + print(f"Wrote systemd unit: {service_path}") + run(["systemctl", "daemon-reload"]) + + +def write_rtorrent_config(user_home, username): + config_path = Path(user_home) / ".rtorrent.rc" + config_content = f"""directory = /home/{username}/downloads +session = /home/{username}/.session +port_random = no +encoding_list = UTF-8 + +schedule2 = watch_directory,5,5,load.normal=/home/{username}/watch/*.torrent +execute.nothrow = chmod,777,/home/{username}/downloads +network.scgi.open_port = 127.0.0.1:{DEFAULT_SCGI_PORT} +network.port_range.set = {DEFAULT_TORRENT_PORT}-{DEFAULT_TORRENT_PORT} +network.port_random.set = no +system.file.allocate.set = 1 +dht.mode.set = disable +protocol.pex.set = no +trackers.use_udp.set = no +protocol.encryption.set = allow_incoming,enable_retry,prefer_plaintext +schedule2 = session_save,1200,43200,((session.save)) +""" + config_path.write_text(config_content) + shutil.chown(config_path, user=username, group=username) + print(f"Wrote config: {config_path}") + + +def prepare_user_dirs(user_home, username): + dirs = [ + Path(user_home) / "downloads", + Path(user_home) / ".session", + Path(user_home) / "watch", + ] + for d in dirs: + ensure_dir(d, owner=username, group=username, mode=0o755) + home_path = Path(user_home) + shutil.chown(home_path, user=username, group=username) + + +def enable_service(user): + unit_name = f"rtorrent@{user}.service" + run(["systemctl", "enable", "--now", unit_name]) + print(f"Enabled and started {unit_name}") + + +def verify_install(rtorrent_install, libtorrent_install): + rtorrent_bin = Path(rtorrent_install) / "bin" / "rtorrent" + which_rtorrent = capture(["which", "rtorrent"], check=False) or "not found in PATH" + print(f"Resolved rtorrent from PATH: {which_rtorrent}") + linked = capture(["ldd", str(rtorrent_bin)], check=True) + matches = [line for line in linked.splitlines() if "libtorrent" in line] + print("Linked libtorrent lines:") + for line in matches: + print(line) + expected = str(Path(libtorrent_install) / "lib") + if not any(expected in line for line in matches): + raise InstallError("rtorrent does not appear to be linked against the compiled libtorrent from /opt.") + + +def build_parser(): + parser = argparse.ArgumentParser( + description="Interactive Debian installer for libtorrent + rTorrent under /opt with optional systemd setup." + ) + parser.add_argument("--base-dir", default=DEFAULT_BASE_DIR, help=f"Base build/install directory (default: {DEFAULT_BASE_DIR})") + parser.add_argument("--libtorrent-ref", default=DEFAULT_LIBTORRENT_REF, help="Git branch, tag or commit for libtorrent (default: master)") + parser.add_argument("--rtorrent-ref", default=DEFAULT_RTORRENT_REF, help="Git branch, tag or commit for rtorrent (default: master)") + parser.add_argument("--user", default=DEFAULT_USER, help=f"System user for the service (default: {DEFAULT_USER})") + parser.add_argument("--group", default=DEFAULT_GROUP, help=f"System group for the service (default: {DEFAULT_GROUP})") + parser.add_argument("--home", default=DEFAULT_HOME, help=f"Home directory for the service user (default: {DEFAULT_HOME})") + parser.add_argument("--only-build", action="store_true", help="Only build and install libtorrent/rTorrent under /opt. Skip user, config and systemd.") + parser.add_argument("--yes", action="store_true", help="Assume yes for interactive prompts.") + return parser + + +def main(): + parser = build_parser() + args = parser.parse_args() + + require_root() + detect_debian() + + packages = [ + "build-essential", + "pkg-config", + "libtool", + "autoconf", + "automake", + "git", + "ca-certificates", + "libssl-dev", + "libcurl4-openssl-dev", + "libncurses5-dev", + "libncursesw5-dev", + "curl", + ] + + packages.extend(resolve_xmlrpc_packages()) + + print("This script will:") + print(f" - build libtorrent from '{args.libtorrent_ref}'") + print(f" - build rtorrent from '{args.rtorrent_ref}'") + print(f" - install everything under '{args.base_dir}'") + if args.only_build: + print(" - skip service user, config and systemd setup") + else: + print(f" - configure systemd service for user '{args.user}'") + + if not prompt_yes_no("Continue?", default=True, assume_yes=args.yes): + print("Aborted by user.") + return 1 + + ensure_packages(packages) + ensure_dir(args.base_dir) + + libtorrent_install = build_libtorrent(args.base_dir, args.libtorrent_ref) + rtorrent_install = build_rtorrent(args.base_dir, args.rtorrent_ref, libtorrent_install) + install_symlinks(rtorrent_install, libtorrent_install) + verify_install(rtorrent_install, libtorrent_install) + + if not args.only_build: + create_system_user(args.user, args.group, args.home, assume_yes=args.yes) + prepare_user_dirs(args.home, args.user) + write_rtorrent_config(args.home, args.user) + write_service(DEFAULT_SERVICE_PATH, "/usr/local/bin/rtorrent", str(libtorrent_install / "lib")) + enable_service(args.user) + print(f"\nService status hint: systemctl status rtorrent@{args.user}.service") + + print("\nDone.") + print(f"rtorrent binary: /usr/local/bin/rtorrent") + print(f"libtorrent path: {libtorrent_install / 'lib'}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nInterrupted.") + sys.exit(130) + except InstallError as exc: + print(f"\nERROR: {exc}", file=sys.stderr) + sys.exit(1)