diff --git a/install_rtorrent.py b/install_rtorrent.py index 30e8022..9d22339 100644 --- a/install_rtorrent.py +++ b/install_rtorrent.py @@ -1,21 +1,25 @@ #!/usr/bin/env python3 import argparse +import itertools import os import pwd import re import shutil import subprocess import sys +import threading +import time 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_LIBTORRENT_REF = "v0.15.7" +DEFAULT_RTORRENT_REF = "v0.15.7" DEFAULT_XMLRPC_REF = "latest-stable" +DEFAULT_CARES_REF = "1.34.6" +DEFAULT_CURL_REF = "8.19.0" DEFAULT_SERVICE_PATH = "/etc/systemd/system/rtorrent@.service" DEFAULT_SCGI_PORT = 5000 DEFAULT_TORRENT_PORT = 51300 @@ -25,29 +29,61 @@ 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 +class Spinner: + FRAMES = ["|", "/", "-", "\\"] + + def __init__(self, message, enabled=True): + self.message = message + self.enabled = enabled and sys.stdout.isatty() + self._stop = threading.Event() + self._thread = None + self._start = None + + def _run(self): + for frame in itertools.cycle(self.FRAMES): + if self._stop.is_set(): + break + elapsed = time.time() - self._start + sys.stdout.write(f"\r[ {frame} ] {self.message} ({elapsed:.1f}s)") + sys.stdout.flush() + time.sleep(0.12) + + def __enter__(self): + self._start = time.time() + if self.enabled: + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + return self + + def __exit__(self, exc_type, exc, tb): + elapsed = time.time() - self._start + if self.enabled: + self._stop.set() + self._thread.join(timeout=0.5) + status = "ERR" if exc else "OK " + sys.stdout.write(f"\r[ {status} ] {self.message} ({elapsed:.1f}s)\n") + sys.stdout.flush() -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, - ) +def run(cmd, *, cwd=None, env=None, check=True, debug=False, capture_output=False): + if debug: + print(f"\n>>> {' '.join(cmd)}") + stdout = subprocess.PIPE if capture_output else (None if debug else subprocess.DEVNULL) + stderr = subprocess.PIPE if capture_output else (None if debug else subprocess.DEVNULL) + result = subprocess.run(cmd, cwd=cwd, env=env, check=False, text=True, stdout=stdout, stderr=stderr) 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() + stderr_text = "" + if capture_output and result.stderr: + stderr_text = f"\n{result.stderr.strip()}" + raise InstallError(f"Command failed with exit code {result.returncode}: {' '.join(cmd)}{stderr_text}") + return result + + +def capture(cmd, **kwargs): + result = run(cmd, capture_output=True, **kwargs) + out = (result.stdout or "").strip() + err = (result.stderr or "").strip() + return out if out else err def require_root(): @@ -63,8 +99,8 @@ def detect_debian(): data = {} for line in os_release.read_text().splitlines(): if "=" in line: - key, value = line.split("=", 1) - data[key] = value.strip().strip('"') + k, v = line.split("=", 1) + data[k] = v.strip().strip('"') distro_id = data.get("ID", "").lower() distro_like = data.get("ID_LIKE", "").lower() @@ -99,11 +135,11 @@ def parse_version(version): return tuple(parts[:3]) if parts else (0,) -def ensure_packages(packages): +def ensure_packages(packages, *, debug=False): print("Updating APT metadata...") - run(["apt-get", "update"]) + run(["apt-get", "update"], debug=debug) print("Installing build and runtime dependencies...") - run(["apt-get", "install", "-y", *packages]) + run(["apt-get", "install", "-y", *packages], debug=debug) def ensure_dir(path, owner=None, group=None, mode=None): @@ -114,14 +150,14 @@ def ensure_dir(path, owner=None, group=None, mode=None): os.chmod(path, mode) -def create_system_user(user, group, home, assume_yes=False): +def create_system_user(user, group, home, assume_yes=False, debug=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(["groupadd", "--system", group], check=False, debug=debug) run([ "useradd", "--system", @@ -130,24 +166,31 @@ def create_system_user(user, group, home, assume_yes=False): "--shell", "/usr/sbin/nologin", "--gid", group, user, - ]) + ], debug=debug) -def clone_or_update_repo(repo_url, repo_dir, ref): +def clone_or_update_repo(repo_url, repo_dir, ref, *, debug=False): repo_dir = Path(repo_dir) if not repo_dir.exists(): - run(["git", "clone", repo_url, str(repo_dir)]) + with Spinner(f"Cloning {repo_dir.name}", enabled=not debug): + run(["git", "clone", repo_url, str(repo_dir)], debug=debug) 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) + with Spinner(f"Checking out {repo_dir.name} -> {ref}", enabled=not debug): + run(["git", "fetch", "--all", "--tags"], cwd=str(repo_dir), debug=debug) + run(["git", "checkout", ref], cwd=str(repo_dir), debug=debug) + run(["git", "pull", "--ff-only"], cwd=str(repo_dir), check=False, debug=debug) -def download_file(url, destination): - run(["curl", "-fL", url, "-o", str(destination)]) +def download_file(url, destination, *, debug=False): + run(["curl", "-fL", url, "-o", str(destination)], debug=debug) + + +def extract_tarball(tarball, destination, *, debug=False): + if destination.exists(): + shutil.rmtree(destination) + destination.mkdir(parents=True, exist_ok=True) + run(["tar", "-xzf", str(tarball), "-C", str(destination), "--strip-components=1"], debug=debug) def find_xmlrpc_config(base_dir, preferred_install=None): @@ -180,20 +223,54 @@ def find_xmlrpc_config(base_dir, preferred_install=None): return unique[0] if unique else None -def verify_xmlrpc_environment(xmlrpc_config_path): +def verify_xmlrpc_environment(xmlrpc_config_path, *, debug=False): 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) + version = capture([str(tool), "--version"], check=True, debug=debug) if parse_version(version) < (1, 11): - raise InstallError( - f"xmlrpc-c version is too old: {version}. Version 1.11 or newer is required." - ) + 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})") + return version -def build_xmlrpc_c(base_dir, xmlrpc_ref): +def build_env(*prefixes, extra_env=None): + env = os.environ.copy() + include_dirs = [] + lib_dirs = [] + pkg_dirs = [] + bin_dirs = [] + + for prefix in prefixes: + if not prefix: + continue + prefix = str(prefix) + include_dirs.append(f"-I{prefix}/include") + lib_dirs.append(f"-L{prefix}/lib") + pkg_dirs.append(f"{prefix}/lib/pkgconfig") + bin_dirs.append(f"{prefix}/bin") + + if include_dirs: + env["CPPFLAGS"] = " ".join(include_dirs + [env.get("CPPFLAGS", "")]).strip() + env["CFLAGS"] = " ".join(include_dirs + [env.get("CFLAGS", "")]).strip() + + if lib_dirs: + rpaths = [f"-Wl,-rpath,{d[2:]}" for d in lib_dirs] + env["LDFLAGS"] = " ".join(lib_dirs + rpaths + [env.get("LDFLAGS", "")]).strip() + + if pkg_dirs: + env["PKG_CONFIG_PATH"] = ":".join(pkg_dirs + ([env.get("PKG_CONFIG_PATH")] if env.get("PKG_CONFIG_PATH") else [])) + + if bin_dirs: + env["PATH"] = ":".join(bin_dirs + [env.get("PATH", "")]) + + if extra_env: + env.update(extra_env) + + return env + + +def build_xmlrpc_c(base_dir, xmlrpc_ref, *, debug=False): source_root = Path(base_dir) / "xmlrpc-c-src" install_dir = Path(base_dir) / "xmlrpc-c_install" build_root = Path(base_dir) / "_sources" @@ -202,13 +279,10 @@ def build_xmlrpc_c(base_dir, xmlrpc_ref): 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 + version = verify_xmlrpc_environment(existing_config, debug=debug) + return install_dir, version 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" @@ -220,89 +294,170 @@ def build_xmlrpc_c(base_dir, xmlrpc_ref): 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)) + with Spinner("Downloading xmlrpc-c", enabled=not debug): + download_file(url, tarball, debug=debug) + extract_tarball(tarball, source_root, debug=debug) + with Spinner("Configuring xmlrpc-c", enabled=not debug): + run(["./configure", f"--prefix={install_dir}"], cwd=str(source_root), debug=debug) + with Spinner("Building xmlrpc-c", enabled=not debug): + run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_root), debug=debug) + with Spinner("Installing xmlrpc-c", enabled=not debug): + run(["make", "install"], cwd=str(source_root), debug=debug) 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 + raise InstallError(f"Custom xmlrpc-c build finished, but xmlrpc-c-config was not found under {install_dir}.") + version = verify_xmlrpc_environment(xmlrpc_config, debug=debug) + return install_dir, version -def build_libtorrent(base_dir, libtorrent_ref): +def build_cares(base_dir, cares_version, *, debug=False): + source_root = Path(base_dir) / "c-ares-src" + install_dir = Path(base_dir) / "c-ares_install" + build_root = Path(base_dir) / "_sources" + tarball = build_root / f"c-ares-{cares_version}.tar.gz" + url = f"https://github.com/c-ares/c-ares/releases/download/v{cares_version}/c-ares-{cares_version}.tar.gz" + + ensure_dir(build_root) + with Spinner("Downloading c-ares", enabled=not debug): + download_file(url, tarball, debug=debug) + extract_tarball(tarball, source_root, debug=debug) + with Spinner("Configuring c-ares", enabled=not debug): + run([ + "cmake", + "-S", str(source_root), + "-B", str(source_root / "build"), + f"-DCMAKE_INSTALL_PREFIX={install_dir}", + "-DCARES_SHARED=ON", + "-DCARES_STATIC=OFF", + "-DCMAKE_BUILD_TYPE=Release", + ], debug=debug) + with Spinner("Building c-ares", enabled=not debug): + run(["cmake", "--build", str(source_root / "build"), "--parallel", str(os.cpu_count() or 1)], debug=debug) + with Spinner("Installing c-ares", enabled=not debug): + run(["cmake", "--install", str(source_root / "build")], debug=debug) + return install_dir, cares_version + + +def build_curl(base_dir, curl_version, cares_install, *, debug=False): + source_root = Path(base_dir) / "curl-src" + install_dir = Path(base_dir) / "curl_install" + build_root = Path(base_dir) / "_sources" + tarball = build_root / f"curl-{curl_version}.tar.gz" + url = f"https://curl.se/download/curl-{curl_version}.tar.gz" + + ensure_dir(build_root) + with Spinner("Downloading curl", enabled=not debug): + download_file(url, tarball, debug=debug) + extract_tarball(tarball, source_root, debug=debug) + + env = build_env(cares_install) + buildconf_script = source_root / "buildconf" + with Spinner("Preparing curl build system", enabled=not debug): + if buildconf_script.exists(): + run(["./buildconf"], cwd=str(source_root), env=env, debug=debug) + run(["make", "distclean"], cwd=str(source_root), env=env, check=False, debug=debug) + with Spinner("Configuring curl with c-ares", enabled=not debug): + run([ + "./configure", + f"--prefix={install_dir}", + "--with-openssl", + f"--enable-ares={cares_install}", + "--disable-static", + "--enable-shared", + ], cwd=str(source_root), env=env, debug=debug) + with Spinner("Building curl", enabled=not debug): + run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_root), env=env, debug=debug) + with Spinner("Installing curl", enabled=not debug): + run(["make", "install"], cwd=str(source_root), env=env, debug=debug) + + version = capture([str(install_dir / "bin" / "curl"), "--version"], env=build_env(install_dir, cares_install), debug=debug) + return install_dir, version + + +def build_libtorrent(base_dir, libtorrent_ref, curl_install=None, cares_install=None, *, debug=False): 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, debug=debug) - 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 + prefixes = [] + if curl_install: + prefixes.append(curl_install) + if cares_install: + prefixes.append(cares_install) + env = build_env(*prefixes) + configure_cmd = ["./configure", f"--prefix={install_dir}"] + + if curl_install: + curl_config = str(Path(curl_install) / "bin" / "curl-config") + env["CURL_CONFIG"] = curl_config + if Path(curl_config).exists(): + configure_cmd.append(f"--with-curl={curl_config}") + env["LIBS"] = f"-L{Path(curl_install) / 'lib'} -lcurl " + env.get("LIBS", "") + if cares_install: + env["LIBS"] = f"-L{Path(cares_install) / 'lib'} -lcares " + env.get("LIBS", "") + + with Spinner("Preparing libtorrent build system", enabled=not debug): + run(["autoreconf", "-i"], cwd=str(source_dir), env=env, debug=debug) + run(["make", "distclean"], cwd=str(source_dir), env=env, check=False, debug=debug) + with Spinner("Configuring libtorrent", enabled=not debug): + run(configure_cmd, cwd=str(source_dir), env=env, debug=debug) + with Spinner("Building libtorrent", enabled=not debug): + run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_dir), env=env, debug=debug) + with Spinner("Installing libtorrent", enabled=not debug): + run(["make", "install"], cwd=str(source_dir), env=env, debug=debug) + + version = capture(["git", "describe", "--tags", "--always"], cwd=str(source_dir), debug=debug) + return install_dir, version -def build_rtorrent(base_dir, rtorrent_ref, libtorrent_install, xmlrpc_install): +def build_rtorrent(base_dir, rtorrent_ref, libtorrent_install, xmlrpc_install, curl_install=None, cares_install=None, *, debug=False): 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) + clone_or_update_repo("https://github.com/rakshasa/rtorrent.git", source_dir, rtorrent_ref, debug=debug) 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." - ) + raise InstallError(f"Could not find custom xmlrpc-c-config under {base_dir}.") 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}" - ) + raise InstallError(f"Wrong xmlrpc-c-config selected: {xmlrpc_config}. Expected one under: {xmlrpc_install}") - verify_xmlrpc_environment(xmlrpc_config) + verify_xmlrpc_environment(xmlrpc_config, debug=debug) - 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 [])) + prefixes = [libtorrent_install, xmlrpc_install] + if curl_install: + prefixes.append(curl_install) + if cares_install: + prefixes.append(cares_install) + env = build_env(*prefixes) 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}" - ) + with Spinner("Preparing rTorrent build system", enabled=not debug): + run(["autoreconf", "-i"], cwd=str(source_dir), env=env, debug=debug) + run(["make", "distclean"], cwd=str(source_dir), env=env, check=False, debug=debug) - 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 + configure_cmd = ["./configure", f"--prefix={install_dir}", "--with-xmlrpc-c"] + with Spinner("Configuring rTorrent", enabled=not debug): + run(configure_cmd, cwd=str(source_dir), env=env, debug=debug) + with Spinner("Building rTorrent", enabled=not debug): + run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_dir), env=env, debug=debug) + with Spinner("Installing rTorrent", enabled=not debug): + run(["make", "install"], cwd=str(source_dir), env=env, debug=debug) + + runtime_prefixes = [libtorrent_install, xmlrpc_install] + if curl_install: + runtime_prefixes.append(curl_install) + if cares_install: + runtime_prefixes.append(cares_install) + runtime_env = build_env(*runtime_prefixes) + runtime_env["LD_LIBRARY_PATH"] = ":".join([f"{p}/lib" for p in runtime_prefixes]) + version = capture([str(install_dir / "bin" / "rtorrent"), "-h"], env=runtime_env, check=False, debug=debug) + return install_dir, version -def install_symlinks(rtorrent_install, libtorrent_install, xmlrpc_install): +def install_symlinks(rtorrent_install, libtorrent_install, xmlrpc_install, curl_install=None, cares_install=None, *, debug=False): rtorrent_bin = Path(rtorrent_install) / "bin" / "rtorrent" if not rtorrent_bin.exists(): raise InstallError(f"Compiled rtorrent binary not found: {rtorrent_bin}") @@ -313,9 +468,14 @@ def install_symlinks(rtorrent_install, libtorrent_install, xmlrpc_install): usr_local_bin.symlink_to(rtorrent_bin) print(f"Symlinked {usr_local_bin} -> {rtorrent_bin}") + lib_dirs = [f"{libtorrent_install}/lib", f"{xmlrpc_install}/lib"] + if curl_install: + lib_dirs.append(f"{curl_install}/lib") + if cares_install: + lib_dirs.append(f"{cares_install}/lib") 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"]) + ld_conf.write_text("\n".join(lib_dirs) + "\n") + run(["ldconfig"], debug=debug) def write_service(service_path, binary_path, runtime_lib_dirs): @@ -358,6 +518,7 @@ 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 +system.umask.set = 0022 dht.mode.set = disable protocol.pex.set = no trackers.use_udp.set = no @@ -370,58 +531,91 @@ schedule2 = session_save,1200,43200,((session.save)) def prepare_user_dirs(user_home, username): - dirs = [ - Path(user_home) / "downloads", - Path(user_home) / ".session", - Path(user_home) / "watch", - ] - for d in dirs: + for d in [Path(user_home) / "downloads", Path(user_home) / ".session", Path(user_home) / "watch"]: ensure_dir(d, owner=username, group=username, mode=0o755) - home_path = Path(user_home) - shutil.chown(home_path, user=username, group=username) + shutil.chown(Path(user_home), user=username, group=username) -def enable_service(user): +def enable_service(user, *, debug=False): unit_name = f"rtorrent@{user}.service" - run(["systemctl", "enable", "--now", unit_name]) + run(["systemctl", "enable", "--now", unit_name], debug=debug) print(f"Enabled and started {unit_name}") -def verify_install(rtorrent_install, libtorrent_install, xmlrpc_install): +def print_link_lines(title, lines): + print(title) + for line in lines: + print(line) + + +def verify_libtorrent_curl_integration(base_dir, libtorrent_install, curl_install, cares_install, *, debug=False): + libtorrent_so = next((p for p in sorted((Path(libtorrent_install) / "lib").glob("libtorrent.so*")) if p.is_file() and not p.is_symlink()), None) + if not libtorrent_so: + raise InstallError("Could not find compiled libtorrent shared object for verification.") + + libtorrent_linked = capture(["ldd", str(libtorrent_so)], check=True, debug=debug) + curl_lines = [line for line in libtorrent_linked.splitlines() if "libcurl" in line.lower()] + print_link_lines("Linked libcurl lines (from libtorrent):", curl_lines) + + expected_curl = str(Path(curl_install) / "lib") + if curl_lines: + if not any(expected_curl in line for line in curl_lines): + raise InstallError(f"libtorrent does not appear to be linked against the compiled libcurl from {expected_curl}.") + else: + config_log = Path(base_dir) / "libtorrent" / "config.log" + config_text = config_log.read_text(errors="ignore") if config_log.exists() else "" + curl_config = str(Path(curl_install) / "bin" / "curl-config") + if curl_config not in config_text and expected_curl not in config_text: + raise InstallError( + "libtorrent does not expose libcurl in ldd, and config.log does not show the custom curl path either. " + "The build likely used the system curl or no curl integration." + ) + print("libtorrent does not show libcurl in ldd; accepting config.log evidence of custom curl usage.") + + custom_curl = Path(curl_install) / "bin" / "curl" + curl_version = capture([str(custom_curl), "--version"], env=build_env(curl_install, cares_install), check=True, debug=debug) + print("Custom curl version:") + print(curl_version.splitlines()[0]) + lower = curl_version.lower() + if "asynchdns" not in lower: + raise InstallError("Custom curl does not report AsynchDNS support.") + if "c-ares" not in lower and "ares" not in lower: + print("Warning: curl --version does not explicitly show c-ares. Continuing because AsynchDNS is present.") + + if cares_install: + cares_lines = [line for line in libtorrent_linked.splitlines() if "cares" in line.lower()] + print_link_lines("Linked c-ares lines (from libtorrent):", cares_lines) + if not cares_lines: + print("c-ares is not visible in libtorrent ldd; this can still be valid when libcurl is resolved differently.") + + +def verify_install(base_dir, rtorrent_install, libtorrent_install, xmlrpc_install, curl_install=None, cares_install=None, *, debug=False): rtorrent_bin = Path(rtorrent_install) / "bin" / "rtorrent" - which_rtorrent = capture(["which", "rtorrent"], check=False) or "not found in PATH" + which_rtorrent = capture(["which", "rtorrent"], check=False, debug=debug) 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.") + linked = capture(["ldd", str(rtorrent_bin)], check=True, debug=debug) + for libname, expected in [("libtorrent", str(Path(libtorrent_install) / "lib")), ("xmlrpc", str(Path(xmlrpc_install) / "lib"))]: + lines = [line for line in linked.splitlines() if libname in line] + print_link_lines(f"Linked {libname} lines:", lines) + if not any(expected in line for line in lines): + raise InstallError(f"rtorrent does not appear to be linked against the compiled {libname} from {expected}.") - 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.") + if curl_install: + verify_libtorrent_curl_integration(base_dir, libtorrent_install, curl_install, cares_install, debug=debug) - env = os.environ.copy() + env = build_env(libtorrent_install, xmlrpc_install, curl_install, cares_install) 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, - ) + ld_paths = [str(Path(libtorrent_install) / "lib"), str(Path(xmlrpc_install) / "lib")] + if curl_install: + ld_paths.append(str(Path(curl_install) / "lib")) + if cares_install: + ld_paths.append(str(Path(cares_install) / "lib")) + env["LD_LIBRARY_PATH"] = ":".join(ld_paths) + + 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( @@ -431,18 +625,21 @@ def verify_install(rtorrent_install, libtorrent_install, xmlrpc_install): def build_parser(): - parser = argparse.ArgumentParser( - description="Interactive Debian installer for xmlrpc-c + libtorrent + rTorrent under /opt with optional systemd setup." - ) + parser = argparse.ArgumentParser(description="Debian installer for xmlrpc-c + libtorrent + rTorrent under /opt with optional c-ares/custom curl support.") 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("--libtorrent-ref", default=DEFAULT_LIBTORRENT_REF, help=f"Git branch, tag or commit for libtorrent (default: {DEFAULT_LIBTORRENT_REF})") + parser.add_argument("--rtorrent-ref", default=DEFAULT_RTORRENT_REF, help=f"Git branch, tag or commit for rtorrent (default: {DEFAULT_RTORRENT_REF})") parser.add_argument("--xmlrpc-ref", default=DEFAULT_XMLRPC_REF, help="xmlrpc-c source version or URL (default: latest-stable)") + parser.add_argument("--cares-ref", default=DEFAULT_CARES_REF, help=f"c-ares release version (default: {DEFAULT_CARES_REF})") + parser.add_argument("--curl-ref", default=DEFAULT_CURL_REF, help=f"curl release version (default: {DEFAULT_CURL_REF})") 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.") + parser.add_argument("--debug", action="store_true", help="Show full command output during build steps.") + parser.add_argument("--without-cares", dest="use_cares", action="store_false", help="Skip building c-ares and custom curl.") + parser.set_defaults(use_cares=True) return parser @@ -454,26 +651,20 @@ def main(): 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", + "build-essential", "pkg-config", "libtool", "autoconf", "automake", "git", "ca-certificates", + "libssl-dev", "libncurses5-dev", "libncursesw5-dev", "libexpat1-dev", "curl", "tar", "cmake", + "libpsl-dev", "zlib1g-dev", "libbrotli-dev", "libzstd-dev" ] 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}'") + if args.use_cares: + print(f" - build c-ares from '{args.cares_ref}'") + print(f" - build curl from '{args.curl_ref}' with c-ares") + else: + print(" - skip c-ares/custom curl and use system curl") print(f" - install everything under '{args.base_dir}'") if args.only_build: print(" - skip service user, config and systemd setup") @@ -484,28 +675,58 @@ def main(): print("Aborted by user.") return 1 - ensure_packages(packages) + ensure_packages(packages, debug=args.debug) 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) + xmlrpc_install, xmlrpc_version = build_xmlrpc_c(args.base_dir, args.xmlrpc_ref, debug=args.debug) + + cares_install = None + cares_version = None + curl_install = None + curl_version = None + + if args.use_cares: + cares_install, cares_version = build_cares(args.base_dir, args.cares_ref, debug=args.debug) + curl_install, curl_version = build_curl(args.base_dir, args.curl_ref, cares_install, debug=args.debug) + + libtorrent_install, libtorrent_version = build_libtorrent( + args.base_dir, args.libtorrent_ref, curl_install=curl_install, cares_install=cares_install, debug=args.debug + ) + rtorrent_install, rtorrent_version = build_rtorrent( + args.base_dir, args.rtorrent_ref, libtorrent_install, xmlrpc_install, curl_install=curl_install, + cares_install=cares_install, debug=args.debug + ) + + install_symlinks(rtorrent_install, libtorrent_install, xmlrpc_install, curl_install=curl_install, cares_install=cares_install, debug=args.debug) + verify_install(args.base_dir, rtorrent_install, libtorrent_install, xmlrpc_install, curl_install=curl_install, cares_install=cares_install, debug=args.debug) if not args.only_build: - create_system_user(args.user, args.group, args.home, assume_yes=args.yes) + create_system_user(args.user, args.group, args.home, assume_yes=args.yes, debug=args.debug) 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) + runtime_lib_dirs = [f"{libtorrent_install}/lib", f"{xmlrpc_install}/lib"] + if curl_install: + runtime_lib_dirs.append(f"{curl_install}/lib") + if cares_install: + runtime_lib_dirs.append(f"{cares_install}/lib") + write_service(DEFAULT_SERVICE_PATH, "/usr/local/bin/rtorrent", ":".join(runtime_lib_dirs)) + enable_service(args.user, debug=args.debug) print(f"\nService status hint: systemctl status rtorrent@{args.user}.service") + print("\nBuild summary") + print("-------------") + print(f"xmlrpc-c: {xmlrpc_version}") + print(f"libtorrent: {libtorrent_version}") + print(f"rtorrent: {rtorrent_version.splitlines()[0] if rtorrent_version else args.rtorrent_ref}") + if args.use_cares: + print(f"c-ares: {cares_version}") + print(f"curl: {curl_version.splitlines()[0] if curl_version else args.curl_ref}") + else: + print("c-ares: disabled") + print("curl: system") + print("binary: /usr/local/bin/rtorrent") + print(f"base dir: {args.base_dir}") 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