Files
tools_scripts/install_rtorrent.py
2026-04-19 23:43:54 +02:00

521 lines
19 KiB
Python

#!/usr/bin/env python3
import argparse
import os
import pwd
import re
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_XMLRPC_REF = "latest-stable"
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')}, "
f"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 parse_version(version):
parts = [int(x) for x in re.findall(r"\d+", version)]
return tuple(parts[:3]) if parts else (0,)
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,
])
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 download_file(url, destination):
run(["curl", "-fL", url, "-o", str(destination)])
def find_xmlrpc_config(base_dir, preferred_install=None):
candidates = []
if preferred_install is not None:
preferred = Path(preferred_install) / "bin" / "xmlrpc-c-config"
if preferred.exists():
candidates.append(preferred.resolve())
root = Path(base_dir)
if root.exists():
for match in root.rglob("xmlrpc-c-config"):
if match.is_file():
candidates.append(match.resolve())
unique = []
seen = set()
for candidate in candidates:
if candidate not in seen:
seen.add(candidate)
unique.append(candidate)
if preferred_install is not None:
preferred_prefix = str(Path(preferred_install).resolve())
for candidate in unique:
if str(candidate).startswith(preferred_prefix):
return candidate
return unique[0] if unique else None
def verify_xmlrpc_environment(xmlrpc_config_path):
tool = Path(xmlrpc_config_path)
if not tool.exists():
raise InstallError(f"xmlrpc-c-config was not found: {tool}")
version = capture([str(tool), "--version"], check=True)
if parse_version(version) < (1, 11):
raise InstallError(
f"xmlrpc-c version is too old: {version}. Version 1.11 or newer is required."
)
print(f"Detected xmlrpc-c version: {version} ({tool})")
def build_xmlrpc_c(base_dir, xmlrpc_ref):
source_root = Path(base_dir) / "xmlrpc-c-src"
install_dir = Path(base_dir) / "xmlrpc-c_install"
build_root = Path(base_dir) / "_sources"
tarball = build_root / "xmlrpc-c.tar.gz"
existing_config = find_xmlrpc_config(base_dir, install_dir)
if existing_config and str(existing_config).startswith(str(install_dir.resolve())):
print(f"Reusing existing xmlrpc-c installation: {existing_config}")
verify_xmlrpc_environment(existing_config)
return install_dir
ensure_dir(build_root)
if source_root.exists():
shutil.rmtree(source_root)
source_root.mkdir(parents=True, exist_ok=True)
if xmlrpc_ref == "latest-stable":
url = "https://sourceforge.net/projects/xmlrpc-c/files/latest/download"
elif re.match(r"^\d+\.\d+\.\d+$", xmlrpc_ref):
url = (
"https://downloads.sourceforge.net/project/xmlrpc-c/Xmlrpc-c%20Super%20Stable/"
f"{xmlrpc_ref}/xmlrpc-c-{xmlrpc_ref}.tgz"
)
else:
url = xmlrpc_ref
print(f"Downloading xmlrpc-c from: {url}")
download_file(url, tarball)
run(["tar", "-xzf", str(tarball), "-C", str(source_root), "--strip-components=1"])
run(["./configure", f"--prefix={install_dir}"], cwd=str(source_root))
run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_root))
run(["make", "install"], cwd=str(source_root))
xmlrpc_config = find_xmlrpc_config(base_dir, install_dir)
if not xmlrpc_config or not str(xmlrpc_config).startswith(str(install_dir.resolve())):
raise InstallError(
f"Custom xmlrpc-c build finished, but xmlrpc-c-config was not found under {install_dir}."
)
verify_xmlrpc_environment(xmlrpc_config)
return install_dir
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, xmlrpc_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)
xmlrpc_config = find_xmlrpc_config(base_dir, xmlrpc_install)
if not xmlrpc_config:
raise InstallError(
f"Could not find custom xmlrpc-c-config under {base_dir}. "
"Build xmlrpc-c first or remove the broken installation and retry."
)
if not str(xmlrpc_config).startswith(str(Path(xmlrpc_install).resolve())):
raise InstallError(
f"Wrong xmlrpc-c-config selected: {xmlrpc_config}. Expected one under: {xmlrpc_install}"
)
verify_xmlrpc_environment(xmlrpc_config)
env = os.environ.copy()
include_flags = f"-I{libtorrent_install}/include -I{xmlrpc_install}/include"
ld_flags = f"-L{libtorrent_install}/lib -L{xmlrpc_install}/lib"
existing_cflags = env.get("CFLAGS", "")
existing_cppflags = env.get("CPPFLAGS", "")
existing_ldflags = env.get("LDFLAGS", "")
env["CFLAGS"] = include_flags + (f" {existing_cflags}" if existing_cflags else "")
env["CPPFLAGS"] = include_flags + (f" {existing_cppflags}" if existing_cppflags else "")
env["LDFLAGS"] = ld_flags + (f" {existing_ldflags}" if existing_ldflags else "")
existing_pkg = env.get("PKG_CONFIG_PATH", "")
pkg_paths = [f"{libtorrent_install}/lib/pkgconfig", f"{xmlrpc_install}/lib/pkgconfig"]
env["PKG_CONFIG_PATH"] = ":".join(pkg_paths + ([existing_pkg] if existing_pkg else []))
env["PATH"] = f"{xmlrpc_config.parent}:" + env.get("PATH", "")
env["XMLRPC_C_CONFIG"] = str(xmlrpc_config)
resolved_xmlrpc_config = capture(["sh", "-c", "command -v xmlrpc-c-config"], env=env, check=False)
print(f"Resolved xmlrpc-c-config for build: {resolved_xmlrpc_config or 'not found'}")
if resolved_xmlrpc_config != str(xmlrpc_config):
raise InstallError(
f"Wrong xmlrpc-c-config selected: {resolved_xmlrpc_config or 'not found'}. Expected: {xmlrpc_config}"
)
run(["autoreconf", "-i"], cwd=str(source_dir), env=env)
run(["make", "distclean"], cwd=str(source_dir), env=env, check=False)
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, xmlrpc_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-custom-libs.conf")
ld_conf.write_text(f"{libtorrent_install}/lib\n{xmlrpc_install}/lib\n")
run(["ldconfig"])
def write_service(service_path, binary_path, runtime_lib_dirs):
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={runtime_lib_dirs}
[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
network.bind_address.set = 0.0.0.0
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, xmlrpc_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)
libtorrent_lines = [line for line in linked.splitlines() if "libtorrent" in line]
print("Linked libtorrent lines:")
for line in libtorrent_lines:
print(line)
expected_libtorrent = str(Path(libtorrent_install) / "lib")
if not any(expected_libtorrent in line for line in libtorrent_lines):
raise InstallError("rtorrent does not appear to be linked against the compiled libtorrent from /opt.")
xmlrpc_lines = [line for line in linked.splitlines() if "xmlrpc" in line]
print("Linked xmlrpc-c lines:")
for line in xmlrpc_lines:
print(line)
expected_xmlrpc = str(Path(xmlrpc_install) / "lib")
if not any(expected_xmlrpc in line for line in xmlrpc_lines):
raise InstallError("rtorrent does not appear to be linked against the compiled xmlrpc-c from /opt.")
env = os.environ.copy()
env["LANG"] = "C"
env["LC_ALL"] = "C"
env["TERM"] = env.get("TERM", "xterm")
env["LD_LIBRARY_PATH"] = f"{Path(libtorrent_install) / 'lib'}:{Path(xmlrpc_install) / 'lib'}"
probe = subprocess.run(
[str(rtorrent_bin), "-h"],
env=env,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
help_output = ((probe.stdout or "") + "\n" + (probe.stderr or "")).lower()
if "xmlrpc-c" in help_output and "i8" in help_output:
raise InstallError(
"rTorrent was built against an xmlrpc-c library without i8 support. "
"Make sure the custom xmlrpc-c build is used and that no older local installation shadows it."
)
def build_parser():
parser = argparse.ArgumentParser(
description="Interactive Debian installer for xmlrpc-c + 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("--xmlrpc-ref", default=DEFAULT_XMLRPC_REF, help="xmlrpc-c source version or URL (default: latest-stable)")
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",
"libexpat1-dev",
"curl",
"tar",
]
print("This script will:")
print(f" - build xmlrpc-c from '{args.xmlrpc_ref}'")
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)
xmlrpc_install = build_xmlrpc_c(args.base_dir, args.xmlrpc_ref)
libtorrent_install = build_libtorrent(args.base_dir, args.libtorrent_ref)
rtorrent_install = build_rtorrent(args.base_dir, args.rtorrent_ref, libtorrent_install, xmlrpc_install)
install_symlinks(rtorrent_install, libtorrent_install, xmlrpc_install)
verify_install(rtorrent_install, libtorrent_install, xmlrpc_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)
runtime_lib_dirs = f"{libtorrent_install / 'lib'}:{xmlrpc_install / 'lib'}"
write_service(DEFAULT_SERVICE_PATH, "/usr/local/bin/rtorrent", runtime_lib_dirs)
enable_service(args.user)
print(f"\nService status hint: systemctl status rtorrent@{args.user}.service")
print("\nDone.")
print("rtorrent binary: /usr/local/bin/rtorrent")
print(f"libtorrent path: {libtorrent_install / 'lib'}")
print(f"xmlrpc-c path: {xmlrpc_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)