378 lines
13 KiB
Python
378 lines
13 KiB
Python
#!/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)
|