first commit

This commit is contained in:
root
2026-05-19 13:43:37 +00:00
commit 9dcd0abd7d
107 changed files with 33622 additions and 0 deletions

198
scripts/INSTALL.md Normal file
View File

@@ -0,0 +1,198 @@
# pyTorrent stack installer
This document describes the one-command installer for installing **rTorrent + pyTorrent** from a clean server.
The installer is split into two layers:
- `scripts/install_stack.sh` - public bootstrap script intended to be downloaded directly from Git.
- `scripts/stack_installers/` - OS-specific installers and helper scripts used by the bootstrap script.
## Quick install
Run as root or through `sudo`:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh | sudo bash
```
The bootstrap script downloads the current pyTorrent repository, detects the operating system family, and runs the matching installer:
- Debian / Ubuntu: `scripts/stack_installers/install_stack_debian_ubuntu.sh`
- RHEL-compatible systems: `scripts/stack_installers/install_stack_rhel.sh`
Supported RHEL-compatible systems include RHEL, Rocky Linux, AlmaLinux, CentOS Stream, and Fedora-like systems where `dnf` or `yum` is available.
## What gets installed
Default installation includes:
- rTorrent `v0.16.11`
- libtorrent `v0.16.11`
- minimal rTorrent build without c-ares/custom curl
- rTorrent system user: `rtorrent`
- rTorrent SCGI endpoint: `scgi://127.0.0.1:5000`
- rTorrent incoming BitTorrent port: `51300`
- pyTorrent application directory: `/opt/pytorrent`
- pyTorrent HTTP port: `8090`
- pyTorrent profile configured through the HTTP API
The installer creates or updates a pyTorrent rTorrent profile through API after both services are installed.
## Recommended usage with overrides
Environment variables must be passed to the `sudo bash` process.
Example:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo PYTORRENT_PORT=8091 RTORRENT_SCGI_PORT=5001 bash
```
Another example with a custom profile name:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo PYTORRENT_PROFILE_NAME="Local rTorrent" PYTORRENT_PORT=8090 bash
```
## Bootstrap parameters
These variables are used by `scripts/install_stack.sh`.
| Variable | Default | Description |
| --- | --- | --- |
| `PYTORRENT_REPO_URL` | `https://git.linuxiarz.pl/gru/pyTorrent` | Git repository base URL. |
| `PYTORRENT_REPO_BRANCH` | `master` | Branch used to download the repository archive. |
| `PYTORRENT_ARCHIVE_URL` | derived from repo URL and branch | Custom repository archive URL. |
| `PYTORRENT_BOOTSTRAP_DIR` | `/tmp/pytorrent-stack-installer` | Temporary directory used by the bootstrap script. |
| `PYTORRENT_KEEP_BOOTSTRAP_DIR` | `0` | Set to `1` to keep the temporary directory after installation. |
Example using a different branch:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo PYTORRENT_REPO_BRANCH=develop bash
```
## rTorrent parameters
These variables are used by both stack installers.
| Variable | Default | Description |
| --- | --- | --- |
| `RTORRENT_USER` | `rtorrent` | System user used to run rTorrent. |
| `RTORRENT_HOME` | `/home/${RTORRENT_USER}` | Home directory for the rTorrent user. |
| `RTORRENT_BASE_DIR` | `/opt/rtorrent_build` | Build and install directory for xmlrpc-c, libtorrent and rTorrent. |
| `RTORRENT_SCGI_PORT` | `5000` | Local SCGI port for rTorrent XMLRPC/SCGI. |
| `RTORRENT_TORRENT_PORT` | `51300` | Incoming BitTorrent listen port. |
| `RTORRENT_REF` | `v0.16.11` | rTorrent Git tag, branch, or commit. |
| `LIBTORRENT_REF` | `v0.16.11` | libtorrent Git tag, branch, or commit. |
Example:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo RTORRENT_USER=rtorrent RTORRENT_SCGI_PORT=5001 RTORRENT_TORRENT_PORT=51400 bash
```
## pyTorrent parameters
| Variable | Default | Description |
| --- | --- | --- |
| `PYTORRENT_APP_DIR` | `/opt/pytorrent` | pyTorrent installation directory. |
| `PYTORRENT_PORT` | `8090` | HTTP port used by the pyTorrent service. |
| `PYTORRENT_BASE_URL` | `http://127.0.0.1:${PYTORRENT_PORT}` | Base URL used by the API configurator. |
| `PYTORRENT_PROFILE_NAME` | `Local rTorrent` | Name of the rTorrent profile created in pyTorrent. |
| `PYTORRENT_API_TOKEN` | empty | Bearer token used when pyTorrent API authentication is enabled. |
| `PYTORRENT_SERVICE_NAME` | `pytorrent` | systemd service name for pyTorrent. |
| `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:${RTORRENT_SCGI_PORT}` | SCGI URL saved in the pyTorrent rTorrent profile. |
Example with API token:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo PYTORRENT_API_TOKEN="pt_xxx" bash
```
## API configurator parameters
The API configurator can be run manually:
```bash
/opt/pytorrent/venv/bin/python /opt/pytorrent/scripts/stack_installers/configure_pytorrent_api.py \
--base-url http://127.0.0.1:8090 \
--profile-name "Local rTorrent" \
--scgi-url scgi://127.0.0.1:5000
```
CLI options:
| Option | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `--base-url` | `PYTORRENT_BASE_URL` | `http://127.0.0.1:8090` | pyTorrent API base URL. |
| `--api-token` | `PYTORRENT_API_TOKEN` | empty | Bearer token for authenticated API calls. |
| `--profile-name` | `PYTORRENT_RTORRENT_PROFILE_NAME` | `Local rTorrent` | Profile name to create or update. |
| `--scgi-url` | `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:5000` | rTorrent SCGI URL. |
| `--timeout` | `PYTORRENT_RTORRENT_TIMEOUT` | `10` | rTorrent request timeout in seconds. |
| `--wait` | `PYTORRENT_API_WAIT_SECONDS` | `90` | Time to wait for the pyTorrent API to become available. |
| `--remote` | `PYTORRENT_RTORRENT_REMOTE` | `0` | Mark profile as remote. Accepts `1`, `true`, `yes`, `on`. |
## Local installation without bootstrap
If the repository is already cloned:
Debian / Ubuntu:
```bash
sudo bash scripts/stack_installers/install_stack_debian_ubuntu.sh
```
RHEL-compatible systems:
```bash
sudo bash scripts/stack_installers/install_stack_rhel.sh
```
## Installed service hints
Check services:
```bash
systemctl status pytorrent
systemctl status rtorrent@rtorrent.service
```
Check logs:
```bash
tail -f /data/logs/app.log /data/logs/error.log
journalctl -u pytorrent -f
journalctl -u rtorrent@rtorrent.service -f
```
## Notes
- The default rTorrent build is intentionally minimal.
- c-ares and custom curl are not enabled by the stack installer defaults.
- The rTorrent installer overwrites the generated `.rtorrent.rc` because the stack installer passes `--force-config`.
- pyTorrent is configured through the HTTP API after the service starts.
- If API authentication is enabled before profile configuration, pass `PYTORRENT_API_TOKEN`.
## Build logs and troubleshooting
The stack installer writes quiet build output to `/var/log/pytorrent-installer` by default.
Override it with:
```bash
PYTORRENT_STACK_LOG_DIR=/tmp/pytorrent-build-logs
```
For full command output during rTorrent/libtorrent compilation, run with:
```bash
PYTORRENT_DEBUG_INSTALL=1
```
On RHEL-compatible systems the installer also tries to enable CRB/PowerTools and installs `libcurl-devel`, `redhat-rpm-config`, `patch`, `diffutils`, `findutils`, `file`, and `libstdc++-devel`, because minimal Alma/Rocky images often do not include enough build tooling.

View File

@@ -0,0 +1,11 @@
#!/bin/sh
# Note: Simple Nagios-compatible pyTorrent API check; set PYTORRENT_URL if the app is not local.
URL="${PYTORRENT_URL:-http://127.0.0.1:8000/api/health/nagios}"
OUT=$(curl -fsS --max-time "${PYTORRENT_HEALTH_TIMEOUT:-5}" "$URL" 2>&1)
RC=$?
if [ "$RC" -eq 0 ]; then
printf '%s\n' "$OUT"
exit 0
fi
printf 'CRITICAL - pyTorrent health check failed: %s\n' "$OUT"
exit 2

113
scripts/download_frontend_libs.py Executable file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
from __future__ import annotations
import re
from pathlib import Path
from urllib.parse import urljoin
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parents[1]
LIBS_STATIC_DIR = "libs"
BOOTSTRAP_VERSION = "5.3.3"
BOOTSWATCH_VERSION = "5.3.3"
FONTAWESOME_VERSION = "6.5.2"
FLAG_ICONS_VERSION = "7.2.3"
SWAGGER_UI_VERSION = "5"
SOCKET_IO_VERSION = "4.7.5"
BOOTSTRAP_THEMES = (
"default",
"flatly",
"litera",
"lumen",
"minty",
"sketchy",
"solar",
"spacelab",
"united",
"zephyr",
)
STATIC_ASSETS = {
"bootstrap_js": {
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/js/bootstrap.bundle.min.js",
},
"socket_io_js": {
"local": f"{LIBS_STATIC_DIR}/socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
"cdn": f"https://cdn.socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
},
"fontawesome_css": {
"local": f"{LIBS_STATIC_DIR}/fontawesome/{FONTAWESOME_VERSION}/css/all.min.css",
"cdn": f"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/{FONTAWESOME_VERSION}/css/all.min.css",
},
"flag_icons_css": {
"local": f"{LIBS_STATIC_DIR}/flag-icons/{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
"cdn": f"https://cdn.jsdelivr.net/gh/lipis/flag-icons@{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
},
"swagger_css": {
"local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui.css",
"cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui.css",
},
"swagger_js": {
"local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui-bundle.js",
"cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui-bundle.js",
},
}
URL_RE = re.compile(r"url\((['\"]?)(?!data:)(?!https?:)([^)'\"]+)\1\)")
def bootstrap_css_asset(theme: str) -> dict[str, str]:
if theme == "default":
return {
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
}
return {
"local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css",
}
def download(url: str, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
req = Request(url, headers={"User-Agent": "pyTorrent installer"})
with urlopen(req, timeout=60) as response:
data = response.read()
if not data:
raise RuntimeError(f"Empty response for {url}")
tmp = dest.with_suffix(dest.suffix + ".tmp")
tmp.write_bytes(data)
tmp.replace(dest)
print(f"OK {dest.relative_to(ROOT)}")
def download_css_with_assets(url: str, dest: Path) -> None:
download(url, dest)
text = dest.read_text(encoding="utf-8", errors="ignore")
for match in URL_RE.finditer(text):
rel = match.group(2).split("#", 1)[0].split("?", 1)[0]
if not rel:
continue
asset_url = urljoin(url, rel)
asset_dest = (dest.parent / rel).resolve()
try:
asset_dest.relative_to(ROOT)
except ValueError:
continue
if not asset_dest.exists():
download(asset_url, asset_dest)
def main() -> None:
items = list(STATIC_ASSETS.values())
items.extend(bootstrap_css_asset(theme) for theme in BOOTSTRAP_THEMES)
for item in items:
url = item["cdn"]
dest = ROOT / "pytorrent" / "static" / item["local"]
if dest.suffix == ".css":
download_css_with_assets(url, dest)
else:
download(url, dest)
if __name__ == "__main__":
main()

41
scripts/download_geoip.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
DB_PATH="${1:-data/GeoLite2-City.mmdb}"
PRIMARY_URL="https://git.io/GeoLite2-City.mmdb"
FALLBACK_URL="https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb"
DB_DIR="$(dirname "$DB_PATH")"
TMP_FILE="${DB_PATH}.tmp"
mkdir -p "$DB_DIR"
chmod 755 "$DB_DIR"
if [ -s "$DB_PATH" ]; then
chmod 644 "$DB_PATH"
echo "GeoIP database already exists: $DB_PATH"
exit 0
fi
download() {
url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fL --retry 3 --connect-timeout 15 --output "$TMP_FILE" "$url"
elif command -v wget >/dev/null 2>&1; then
wget -O "$TMP_FILE" "$url"
else
echo "Missing downloader: install curl or wget" >&2
return 127
fi
}
rm -f "$TMP_FILE"
if ! download "$PRIMARY_URL"; then
rm -f "$TMP_FILE"
download "$FALLBACK_URL"
fi
test -s "$TMP_FILE"
mv "$TMP_FILE" "$DB_PATH"
chmod 644 "$DB_PATH"
echo "GeoIP database downloaded: $DB_PATH"

133
scripts/install_debian_ubuntu.sh Executable file
View File

@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail
APP_USER="${PYTORRENT_USER:-pytorrent}"
APP_DIR="${PYTORRENT_APP_DIR:-/opt/pytorrent}"
SERVICE_NAME="${PYTORRENT_SERVICE_NAME:-pytorrent}"
PYTHON_BIN="${PYTHON_BIN:-python3}"
PYTORRENT_HOST_VALUE="${PYTORRENT_HOST:-0.0.0.0}"
PYTORRENT_PORT_VALUE="${PYTORRENT_PORT:-8090}"
PYTORRENT_LOG_DIR_VALUE="${PYTORRENT_LOG_DIR:-/data/logs}"
PYTORRENT_LOG_RETENTION_HOURS_VALUE="${PYTORRENT_LOG_RETENTION_HOURS:-24}"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo $0" >&2
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
tar \
gzip \
sudo \
git \
rsync \
pkg-config \
python3 \
python3-venv \
python3-dev \
python3-pip
if ! id -u "${APP_USER}" >/dev/null 2>&1; then
useradd \
--system \
--create-home \
--home-dir "/var/lib/${APP_USER}" \
--shell /usr/sbin/nologin \
"${APP_USER}"
fi
mkdir -p "${APP_DIR}"
rsync -a --delete \
--exclude '.git' \
--exclude 'venv' \
--exclude '__pycache__' \
--exclude '*.pyc' \
./ "${APP_DIR}/"
cd "${APP_DIR}"
"${PYTHON_BIN}" -m venv venv
venv/bin/pip install --upgrade pip wheel
venv/bin/pip install -r requirements.txt
mkdir -p data instance logs
chown -R "${APP_USER}:${APP_USER}" "${APP_DIR}" "/var/lib/${APP_USER}"
upsert_env_value() {
local key="$1"
local value="$2"
local file="${3:-.env}"
touch "${file}"
if grep -qE "^${key}=" "${file}"; then
sed -i "s|^${key}=.*|${key}=${value}|" "${file}"
else
printf '%s=%s\n' "${key}" "${value}" >> "${file}"
fi
}
if [[ ! -f .env && -f .env.example ]]; then
cp .env.example .env
chown "${APP_USER}:${APP_USER}" .env
fi
# Keep systemd service config aligned with installer overrides.
upsert_env_value "PYTORRENT_HOST" "${PYTORRENT_HOST_VALUE}" .env
upsert_env_value "PYTORRENT_PORT" "${PYTORRENT_PORT_VALUE}" .env
upsert_env_value "PYTORRENT_LOG_DIR" "${PYTORRENT_LOG_DIR_VALUE}" .env
upsert_env_value "PYTORRENT_LOG_RETENTION_HOURS" "${PYTORRENT_LOG_RETENTION_HOURS_VALUE}" .env
mkdir -p "${PYTORRENT_LOG_DIR_VALUE}"
chown -R "${APP_USER}:${APP_USER}" "${PYTORRENT_LOG_DIR_VALUE}" || true
chown "${APP_USER}:${APP_USER}" .env
if [[ -f scripts/download_frontend_libs.py ]]; then
sudo -u "${APP_USER}" \
"${APP_DIR}/venv/bin/python" \
scripts/download_frontend_libs.py || true
fi
if [[ -f scripts/download_geoip.sh ]]; then
sudo -u "${APP_USER}" \
bash scripts/download_geoip.sh || true
fi
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<SERVICE
[Unit]
Description=pyTorrent Web UI
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_USER}
WorkingDirectory=${APP_DIR}
Environment="PYTHONUNBUFFERED=1"
EnvironmentFile=${APP_DIR}/.env
ExecStart=${APP_DIR}/venv/bin/gunicorn -c ${APP_DIR}/gunicorn.conf.py --worker-class gthread --workers 1 --threads 32 --bind \${PYTORRENT_HOST}:\${PYTORRENT_PORT} --access-logfile - --error-logfile - wsgi:app
Restart=always
RestartSec=3
KillSignal=SIGINT
TimeoutStopSec=20
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
SERVICE
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
systemctl status "${SERVICE_NAME}" --no-pager --lines=20 || true
echo "pyTorrent installed in ${APP_DIR}. Service: ${SERVICE_NAME}."

163
scripts/install_stack.sh Executable file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env bash
set -euo pipefail
# Bootstrap installer for pyTorrent + rTorrent.
# Intended usage from a clean server:
# curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh | sudo bash
#
# The script downloads the current pyTorrent repository, detects the OS family,
# and runs the matching installer from scripts/stack_installers/.
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root, for example: curl -fsSL <url> | sudo bash" >&2
exit 1
fi
REPO_URL="${PYTORRENT_REPO_URL:-https://git.linuxiarz.pl/gru/pyTorrent}"
REPO_BRANCH="${PYTORRENT_REPO_BRANCH:-master}"
WORK_DIR="${PYTORRENT_BOOTSTRAP_DIR:-/tmp/pytorrent-stack-installer}"
KEEP_WORK_DIR="${PYTORRENT_KEEP_BOOTSTRAP_DIR:-0}"
RAW_BASE="${REPO_URL%/}/raw/branch/${REPO_BRANCH}"
ARCHIVE_URL="${PYTORRENT_ARCHIVE_URL:-${REPO_URL%/}/archive/${REPO_BRANCH}.tar.gz}"
PROJECT_DIR="${WORK_DIR}/src"
ARCHIVE_PATH="${WORK_DIR}/pytorrent.tar.gz"
log() {
printf '[pyTorrent stack] %s\n' "$*"
}
fail() {
printf '[pyTorrent stack] ERROR: %s\n' "$*" >&2
exit 1
}
command_exists() {
command -v "$1" >/dev/null 2>&1
}
prepare_downloader() {
# Bootstrap needs both a downloader and tar before repository extraction.
if command_exists apt-get; then
apt-get update
apt-get install -y --no-install-recommends ca-certificates tar curl gzip python3 sudo
elif command_exists dnf; then
dnf install -y ca-certificates tar curl gzip python3 sudo
elif command_exists yum; then
yum install -y ca-certificates tar curl gzip python3 sudo
fi
if command_exists curl; then
DOWNLOADER="curl"
return
fi
if command_exists wget; then
DOWNLOADER="wget"
return
fi
if command_exists apt-get; then
apt-get update
apt-get install -y --no-install-recommends curl ca-certificates tar gzip python3 sudo
DOWNLOADER="curl"
return
fi
if command_exists dnf; then
dnf install -y curl ca-certificates tar gzip python3 sudo
DOWNLOADER="curl"
return
fi
if command_exists yum; then
yum install -y curl ca-certificates tar gzip python3 sudo
DOWNLOADER="curl"
return
fi
fail "curl or wget is required and no supported package manager was found."
}
download_file() {
local url="$1"
local destination="$2"
if [[ "${DOWNLOADER}" == "curl" ]]; then
curl -fL "${url}" -o "${destination}"
else
wget -O "${destination}" "${url}"
fi
}
detect_os_family() {
if [[ ! -f /etc/os-release ]]; then
fail "Cannot detect OS: /etc/os-release is missing."
fi
# shellcheck disable=SC1091
. /etc/os-release
local os_id="${ID:-}"
local os_like="${ID_LIKE:-}"
case "${os_id} ${os_like}" in
*debian*|*ubuntu*)
echo "debian"
;;
*rhel*|*fedora*|*centos*|*rocky*|*almalinux*)
echo "rhel"
;;
*)
fail "Unsupported OS: ID=${ID:-unknown}, ID_LIKE=${ID_LIKE:-unknown}."
;;
esac
}
cleanup() {
if [[ "${KEEP_WORK_DIR}" != "1" ]]; then
rm -rf "${WORK_DIR}"
else
log "Keeping bootstrap directory: ${WORK_DIR}"
fi
}
trap cleanup EXIT
prepare_downloader
rm -rf "${WORK_DIR}"
mkdir -p "${WORK_DIR}"
log "Downloading pyTorrent from ${ARCHIVE_URL}"
if ! download_file "${ARCHIVE_URL}" "${ARCHIVE_PATH}"; then
log "Archive download failed, trying raw stack installer fallback."
mkdir -p "${PROJECT_DIR}/scripts/stack_installers"
for file in \
install_stack_debian_ubuntu.sh \
install_stack_rhel.sh \
install_pytorrent_rhel.sh \
install_rtorrent.py \
install_rtorrent_rhel.py \
configure_pytorrent_api.py \
INSTALL_STACK.md
do
download_file "${RAW_BASE}/scripts/stack_installers/${file}" "${PROJECT_DIR}/scripts/stack_installers/${file}"
done
download_file "${RAW_BASE}/scripts/install_debian_ubuntu.sh" "${PROJECT_DIR}/scripts/install_debian_ubuntu.sh"
else
mkdir -p "${PROJECT_DIR}"
tar -xzf "${ARCHIVE_PATH}" -C "${PROJECT_DIR}" --strip-components=1
fi
[[ -d "${PROJECT_DIR}/scripts/stack_installers" ]] || fail "Missing scripts/stack_installers in downloaded repository."
OS_FAMILY="$(detect_os_family)"
case "${OS_FAMILY}" in
debian)
INSTALLER="${PROJECT_DIR}/scripts/stack_installers/install_stack_debian_ubuntu.sh"
;;
rhel)
INSTALLER="${PROJECT_DIR}/scripts/stack_installers/install_stack_rhel.sh"
;;
*)
fail "Unsupported OS family: ${OS_FAMILY}."
;;
esac
chmod +x "${PROJECT_DIR}/scripts/stack_installers/"*.sh || true
log "Running ${INSTALLER}"
bash "${INSTALLER}"

286
scripts/rtorrent_cli.py Normal file
View File

@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""
rtorrent_cli.py - simple CLI for bulk rTorrent management over XML-RPC/SCGI.
Default endpoint:
scgi://127.0.0.1:5000
Examples:
python3 rtorrent_cli.py ping
python3 rtorrent_cli.py list
python3 rtorrent_cli.py list --only-stopped --only-complete
python3 rtorrent_cli.py show HASH
python3 rtorrent_cli.py start HASH
python3 rtorrent_cli.py bulk-start --only-stopped --only-complete
python3 rtorrent_cli.py bulk-stop --name-regex "ubuntu|debian"
python3 rtorrent_cli.py bulk-announce --only-active
python3 rtorrent_cli.py bulk-check-hash --only-stopped --name-regex "movie"
python3 rtorrent_cli.py dump-methods
"""
from __future__ import annotations
import argparse
import json
import re
import socket
import sys
import xmlrpc.client
from dataclasses import dataclass, asdict
from typing import Any, Iterable
from urllib.parse import urlparse
DEFAULT_URL = "scgi://127.0.0.1:5000"
# ----------------------------
# SCGI XML-RPC transport
# ----------------------------
class SCGITransport(xmlrpc.client.Transport):
def __init__(self, host: str, port: int, timeout: int = 15):
super().__init__()
self.host = host
self.port = port
self.timeout = timeout
def request(self, host: str, handler: str, request_body: bytes, verbose: bool = False):
body = request_body.encode("utf-8") if isinstance(request_body, str) else request_body
headers = {
"CONTENT_LENGTH": str(len(body)),
"SCGI": "1",
"REQUEST_METHOD": "POST",
"REQUEST_URI": handler or "/RPC2",
}
header_bytes = b""
for key, value in headers.items():
header_bytes += key.encode("utf-8") + b"\x00" + value.encode("utf-8") + b"\x00"
packet = str(len(header_bytes)).encode("ascii") + b":" + header_bytes + b"," + body
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
sock.sendall(packet)
response = b""
while True:
chunk = sock.recv(65536)
if not chunk:
break
response += chunk
# rTorrent over SCGI usually returns raw XML body,
# but some proxies may prepend HTTP headers.
if b"\r\n\r\n" in response:
response = response.split(b"\r\n\r\n", 1)[1]
return self.parse_response_bytes(response)
def parse_response_bytes(self, data: bytes):
p, u = self.getparser()
p.feed(data)
p.close()
return u.close()
def make_rpc_client(url: str, timeout: int):
parsed = urlparse(url)
if parsed.scheme == "scgi":
if not parsed.hostname:
raise ValueError("SCGI URL must include a host, e.g. scgi://127.0.0.1:5000")
transport = SCGITransport(parsed.hostname, parsed.port or 5000, timeout=timeout)
return xmlrpc.client.ServerProxy(
"http://rtorrent/RPC2",
transport=transport,
allow_none=True,
)
return xmlrpc.client.ServerProxy(url, allow_none=True)
# ----------------------------
# Helpers
# ----------------------------
@dataclass
class Torrent:
hash: str
name: str
state: int
active: int
complete: int
size_bytes: int
completed_bytes: int
ratio: int
down_rate: int
up_rate: int
message: str
@property
def stopped(self) -> bool:
return self.state == 0
@property
def started_or_paused(self) -> bool:
return self.state == 1
@property
def is_active(self) -> bool:
return self.active == 1
@property
def is_complete(self) -> bool:
return self.complete == 1
def rpc_error(exc: Exception, context: dict[str, Any] | None = None) -> dict[str, Any]:
payload = {
"ok": False,
"error_type": exc.__class__.__name__,
"error": str(exc),
}
if context:
payload["context"] = context
return payload
def print_json(data: Any) -> None:
print(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=False))
def call_method(rpc, method: str, *args):
return getattr(rpc, method)(*args)
def safe_call(rpc, method: str, *args, context: dict[str, Any] | None = None):
try:
return True, call_method(rpc, method, *args)
except Exception as exc:
return False, rpc_error(exc, context=context or {"method": method, "args": args})
def human_bytes(num: int) -> str:
value = float(num)
for unit in ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]:
if abs(value) < 1024:
return f"{value:.1f} {unit}"
value /= 1024
return f"{value:.1f} EiB"
# ----------------------------
# rTorrent API
# ----------------------------
def get_torrent_hashes(rpc, view: str = "main") -> list[str]:
ok, result = safe_call(rpc, "d.multicall2", "", view, "d.hash=")
if not ok:
raise RuntimeError(json.dumps(result, ensure_ascii=False))
hashes: list[str] = []
for row in result:
if isinstance(row, list) and row:
hashes.append(str(row[0]))
elif isinstance(row, str):
hashes.append(row)
return hashes
def list_torrents(rpc, view: str = "main") -> list[Torrent]:
methods = [
"d.hash=",
"d.name=",
"d.state=",
"d.is_active=",
"d.complete=",
"d.size_bytes=",
"d.completed_bytes=",
"d.ratio=",
"d.down.rate=",
"d.up.rate=",
"d.message=",
]
ok, result = safe_call(rpc, "d.multicall2", "", view, *methods)
if not ok:
raise RuntimeError(json.dumps(result, ensure_ascii=False))
torrents: list[Torrent] = []
for row in result:
torrents.append(Torrent(
hash=str(row[0]),
name=str(row[1]),
state=int(row[2]),
active=int(row[3]),
complete=int(row[4]),
size_bytes=int(row[5]),
completed_bytes=int(row[6]),
ratio=int(row[7]),
down_rate=int(row[8]),
up_rate=int(row[9]),
message=str(row[10]),
))
return torrents
def get_torrent(rpc, hash_: str) -> Torrent:
torrents = list_torrents(rpc)
for torrent in torrents:
if torrent.hash.lower() == hash_.lower():
return torrent
raise KeyError(f"Torrent not found: {hash_}")
def filter_torrents(torrents: Iterable[Torrent], args) -> list[Torrent]:
result = list(torrents)
if getattr(args, "only_stopped", False):
result = [t for t in result if t.stopped]
if getattr(args, "only_started", False):
result = [t for t in result if t.started_or_paused]
if getattr(args, "only_active", False):
result = [t for t in result if t.is_active]
if getattr(args, "only_complete", False):
result = [t for t in result if t.is_complete]
if getattr(args, "only_incomplete", False):
result = [t for t in result if not t.is_complete]
if getattr(args, "name_regex", None):
pattern = re.compile(args.name_regex, re.IGNORECASE)
result = [t for t in result if pattern.search(t.name)]
if getattr(args, "hash_regex", None):
pattern = re.compile(args.hash_regex, re.IGNORECASE)
result = [t for t in result if pattern.search(t.hash)]
return result
def torrent_to_dict(t: Torrent) -> dict[str, Any]:
data = asdict(t)
data["size"] = human_bytes(t.size_bytes)
data["completed"] = human_bytes(t.completed_bytes)
data["ratio_float"] = round(t.ratio / 1000, 3)
return data
# ----------------------------
# Commands
# ----------------------------
def cmd_ping(rpc, args) -> int:
ok, result = safe_call(rpc, "system.client_version")
if ok:
print_json({"ok": True, "client_version": result})
return 0
# fallback for older builds
ok, result = safe_call(rpc, "system.listMethods")
print_json({"ok": ok, "result": result})
return 0 if ok else 1

View File

@@ -0,0 +1,198 @@
# pyTorrent stack installer
This document describes the one-command installer for installing **rTorrent + pyTorrent** from a clean server.
The installer is split into two layers:
- `scripts/install_stack.sh` - public bootstrap script intended to be downloaded directly from Git.
- `scripts/stack_installers/` - OS-specific installers and helper scripts used by the bootstrap script.
## Quick install
Run as root or through `sudo`:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh | sudo bash
```
The bootstrap script downloads the current pyTorrent repository, detects the operating system family, and runs the matching installer:
- Debian / Ubuntu: `scripts/stack_installers/install_stack_debian_ubuntu.sh`
- RHEL-compatible systems: `scripts/stack_installers/install_stack_rhel.sh`
Supported RHEL-compatible systems include RHEL, Rocky Linux, AlmaLinux, CentOS Stream, and Fedora-like systems where `dnf` or `yum` is available.
## What gets installed
Default installation includes:
- rTorrent `v0.16.11`
- libtorrent `v0.16.11`
- minimal rTorrent build without c-ares/custom curl
- rTorrent system user: `rtorrent`
- rTorrent SCGI endpoint: `scgi://127.0.0.1:5000`
- rTorrent incoming BitTorrent port: `51300`
- pyTorrent application directory: `/opt/pytorrent`
- pyTorrent HTTP port: `8090`
- pyTorrent profile configured through the HTTP API
The installer creates or updates a pyTorrent rTorrent profile through API after both services are installed.
## Recommended usage with overrides
Environment variables must be passed to the `sudo bash` process.
Example:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo PYTORRENT_PORT=8091 RTORRENT_SCGI_PORT=5001 bash
```
Another example with a custom profile name:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo PYTORRENT_PROFILE_NAME="Local rTorrent" PYTORRENT_PORT=8090 bash
```
## Bootstrap parameters
These variables are used by `scripts/install_stack.sh`.
| Variable | Default | Description |
| --- | --- | --- |
| `PYTORRENT_REPO_URL` | `https://git.linuxiarz.pl/gru/pyTorrent` | Git repository base URL. |
| `PYTORRENT_REPO_BRANCH` | `master` | Branch used to download the repository archive. |
| `PYTORRENT_ARCHIVE_URL` | derived from repo URL and branch | Custom repository archive URL. |
| `PYTORRENT_BOOTSTRAP_DIR` | `/tmp/pytorrent-stack-installer` | Temporary directory used by the bootstrap script. |
| `PYTORRENT_KEEP_BOOTSTRAP_DIR` | `0` | Set to `1` to keep the temporary directory after installation. |
Example using a different branch:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo PYTORRENT_REPO_BRANCH=develop bash
```
## rTorrent parameters
These variables are used by both stack installers.
| Variable | Default | Description |
| --- | --- | --- |
| `RTORRENT_USER` | `rtorrent` | System user used to run rTorrent. |
| `RTORRENT_HOME` | `/home/${RTORRENT_USER}` | Home directory for the rTorrent user. |
| `RTORRENT_BASE_DIR` | `/opt/rtorrent_build` | Build and install directory for xmlrpc-c, libtorrent and rTorrent. |
| `RTORRENT_SCGI_PORT` | `5000` | Local SCGI port for rTorrent XMLRPC/SCGI. |
| `RTORRENT_TORRENT_PORT` | `51300` | Incoming BitTorrent listen port. |
| `RTORRENT_REF` | `v0.16.11` | rTorrent Git tag, branch, or commit. |
| `LIBTORRENT_REF` | `v0.16.11` | libtorrent Git tag, branch, or commit. |
Example:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo RTORRENT_USER=rtorrent RTORRENT_SCGI_PORT=5001 RTORRENT_TORRENT_PORT=51400 bash
```
## pyTorrent parameters
| Variable | Default | Description |
| --- | --- | --- |
| `PYTORRENT_APP_DIR` | `/opt/pytorrent` | pyTorrent installation directory. |
| `PYTORRENT_PORT` | `8090` | HTTP port used by the pyTorrent service. |
| `PYTORRENT_BASE_URL` | `http://127.0.0.1:${PYTORRENT_PORT}` | Base URL used by the API configurator. |
| `PYTORRENT_PROFILE_NAME` | `Local rTorrent` | Name of the rTorrent profile created in pyTorrent. |
| `PYTORRENT_API_TOKEN` | empty | Bearer token used when pyTorrent API authentication is enabled. |
| `PYTORRENT_SERVICE_NAME` | `pytorrent` | systemd service name for pyTorrent. |
| `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:${RTORRENT_SCGI_PORT}` | SCGI URL saved in the pyTorrent rTorrent profile. |
Example with API token:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo PYTORRENT_API_TOKEN="pt_xxx" bash
```
## API configurator parameters
The API configurator can be run manually:
```bash
/opt/pytorrent/venv/bin/python /opt/pytorrent/scripts/stack_installers/configure_pytorrent_api.py \
--base-url http://127.0.0.1:8090 \
--profile-name "Local rTorrent" \
--scgi-url scgi://127.0.0.1:5000
```
CLI options:
| Option | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `--base-url` | `PYTORRENT_BASE_URL` | `http://127.0.0.1:8090` | pyTorrent API base URL. |
| `--api-token` | `PYTORRENT_API_TOKEN` | empty | Bearer token for authenticated API calls. |
| `--profile-name` | `PYTORRENT_RTORRENT_PROFILE_NAME` | `Local rTorrent` | Profile name to create or update. |
| `--scgi-url` | `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:5000` | rTorrent SCGI URL. |
| `--timeout` | `PYTORRENT_RTORRENT_TIMEOUT` | `10` | rTorrent request timeout in seconds. |
| `--wait` | `PYTORRENT_API_WAIT_SECONDS` | `90` | Time to wait for the pyTorrent API to become available. |
| `--remote` | `PYTORRENT_RTORRENT_REMOTE` | `0` | Mark profile as remote. Accepts `1`, `true`, `yes`, `on`. |
## Local installation without bootstrap
If the repository is already cloned:
Debian / Ubuntu:
```bash
sudo bash scripts/stack_installers/install_stack_debian_ubuntu.sh
```
RHEL-compatible systems:
```bash
sudo bash scripts/stack_installers/install_stack_rhel.sh
```
## Installed service hints
Check services:
```bash
systemctl status pytorrent
systemctl status rtorrent@rtorrent.service
```
Check logs:
```bash
tail -f /data/logs/app.log /data/logs/error.log
journalctl -u pytorrent -f
journalctl -u rtorrent@rtorrent.service -f
```
## Notes
- The default rTorrent build is intentionally minimal.
- c-ares and custom curl are not enabled by the stack installer defaults.
- The rTorrent installer overwrites the generated `.rtorrent.rc` because the stack installer passes `--force-config`.
- pyTorrent is configured through the HTTP API after the service starts.
- If API authentication is enabled before profile configuration, pass `PYTORRENT_API_TOKEN`.
## Build logs and troubleshooting
The stack installer writes quiet build output to `/var/log/pytorrent-installer` by default.
Override it with:
```bash
PYTORRENT_STACK_LOG_DIR=/tmp/pytorrent-build-logs
```
For full command output during rTorrent/libtorrent compilation, run with:
```bash
PYTORRENT_DEBUG_INSTALL=1
```
On RHEL-compatible systems the installer also tries to enable CRB/PowerTools and installs `libcurl-devel`, `redhat-rpm-config`, `patch`, `diffutils`, `findutils`, `file`, and `libstdc++-devel`, because minimal Alma/Rocky images often do not include enough build tooling.

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Configure pyTorrent through its HTTP API after rTorrent is installed."""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
def _request(base_url: str, method: str, path: str, payload: dict | None = None, token: str | None = None, timeout: int = 10) -> dict:
url = base_url.rstrip("/") + path
data = None if payload is None else json.dumps(payload).encode("utf-8")
headers = {"Accept": "application/json"}
if payload is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, data=data, method=method.upper(), headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", "replace")
return json.loads(raw or "{}")
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
raise RuntimeError(f"API {method} {path} failed with HTTP {exc.code}: {raw}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"API {method} {path} failed: {exc.reason}") from exc
def _wait_for_api(base_url: str, token: str | None, seconds: int) -> None:
deadline = time.time() + seconds
last_error = None
while time.time() < deadline:
try:
_request(base_url, "GET", "/api/profiles", token=token, timeout=5)
return
except Exception as exc: # noqa: BLE001 - installation helper should keep retrying.
last_error = exc
time.sleep(2)
raise RuntimeError(f"pyTorrent API is not ready after {seconds}s at {base_url}: {last_error}. Check PYTORRENT_PORT in .env and systemctl status pytorrent.")
def _find_profile(profiles: list[dict], name: str, scgi_url: str) -> dict | None:
for profile in profiles:
if str(profile.get("name") or "") == name:
return profile
for profile in profiles:
if str(profile.get("scgi_url") or "") == scgi_url:
return profile
return None
def main() -> int:
parser = argparse.ArgumentParser(description="Create/update and activate a pyTorrent rTorrent profile through the HTTP API.")
parser.add_argument("--base-url", default=os.getenv("PYTORRENT_BASE_URL", "http://127.0.0.1:8090"))
parser.add_argument("--api-token", default=os.getenv("PYTORRENT_API_TOKEN", ""), help="Bearer token when pyTorrent auth is enabled.")
parser.add_argument("--profile-name", default=os.getenv("PYTORRENT_RTORRENT_PROFILE_NAME", "Local rTorrent"))
parser.add_argument("--scgi-url", default=os.getenv("PYTORRENT_RTORRENT_SCGI_URL", "scgi://127.0.0.1:5000"))
parser.add_argument("--timeout", type=int, default=int(os.getenv("PYTORRENT_RTORRENT_TIMEOUT", "10")))
parser.add_argument("--wait", type=int, default=int(os.getenv("PYTORRENT_API_WAIT_SECONDS", "90")))
parser.add_argument("--remote", action="store_true", default=os.getenv("PYTORRENT_RTORRENT_REMOTE", "0").lower() in {"1", "true", "yes", "on"})
args = parser.parse_args()
token = args.api_token.strip() or None
_wait_for_api(args.base_url, token, args.wait)
current = _request(args.base_url, "GET", "/api/profiles", token=token)
profiles = current.get("profiles") or []
payload = {
"name": args.profile_name,
"scgi_url": args.scgi_url,
"is_default": True,
"timeout_seconds": args.timeout,
"max_parallel_jobs": 5,
"light_parallel_jobs": 4,
"light_job_timeout_seconds": 300,
"heavy_job_timeout_seconds": 7200,
"pending_job_timeout_seconds": 900,
"is_remote": bool(args.remote),
}
existing = _find_profile(profiles, args.profile_name, args.scgi_url)
if existing:
profile_id = int(existing["id"])
result = _request(args.base_url, "PUT", f"/api/profiles/{profile_id}", payload, token=token)
action = "updated"
else:
result = _request(args.base_url, "POST", "/api/profiles", payload, token=token)
profile_id = int((result.get("profile") or {}).get("id") or 0)
action = "created"
if not profile_id:
raise RuntimeError(f"Profile {action}, but API response did not include an id: {result}")
_request(args.base_url, "POST", f"/api/profiles/{profile_id}/activate", token=token)
test = _request(args.base_url, "GET", f"/api/profiles/{profile_id}/diagnostics", token=token)
print(json.dumps({"ok": True, "action": action, "profile_id": profile_id, "diagnostics": test.get("diagnostics")}, indent=2))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc: # noqa: BLE001 - user-facing installer output.
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env bash
set -euo pipefail
APP_USER="${PYTORRENT_USER:-pytorrent}"
APP_DIR="${PYTORRENT_APP_DIR:-/opt/pytorrent}"
SERVICE_NAME="${PYTORRENT_SERVICE_NAME:-pytorrent}"
PYTHON_BIN="${PYTHON_BIN:-python3}"
PYTORRENT_HOST_VALUE="${PYTORRENT_HOST:-0.0.0.0}"
PYTORRENT_PORT_VALUE="${PYTORRENT_PORT:-8090}"
PYTORRENT_LOG_DIR_VALUE="${PYTORRENT_LOG_DIR:-/data/logs}"
PYTORRENT_LOG_RETENTION_HOURS_VALUE="${PYTORRENT_LOG_RETENTION_HOURS:-24}"
PKG_MANAGER="$(command -v dnf || command -v yum || true)"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo $0" >&2
exit 1
fi
if [[ -z "${PKG_MANAGER}" ]]; then
echo "dnf or yum is required." >&2
exit 1
fi
"${PKG_MANAGER}" install -y \
ca-certificates \
curl \
git \
rsync \
gcc \
python3 \
python3-devel \
python3-pip
if ! id -u "${APP_USER}" >/dev/null 2>&1; then
useradd \
--system \
--create-home \
--home-dir "/var/lib/${APP_USER}" \
--shell /sbin/nologin \
"${APP_USER}"
fi
mkdir -p "${APP_DIR}"
rsync -a --delete \
--exclude '.git' \
--exclude 'venv' \
--exclude '__pycache__' \
--exclude '*.pyc' \
./ "${APP_DIR}/"
cd "${APP_DIR}"
"${PYTHON_BIN}" -m venv venv
venv/bin/pip install --upgrade pip wheel
venv/bin/pip install -r requirements.txt
mkdir -p data instance logs data/logs
chown -R "${APP_USER}:${APP_USER}" "${APP_DIR}" "/var/lib/${APP_USER}"
upsert_env_value() {
local key="$1"
local value="$2"
local file="${3:-.env}"
touch "${file}"
if grep -qE "^${key}=" "${file}"; then
sed -i "s|^${key}=.*|${key}=${value}|" "${file}"
else
printf '%s=%s\n' "${key}" "${value}" >> "${file}"
fi
}
if [[ ! -f .env && -f .env.example ]]; then
cp .env.example .env
python3 - .env <<'PY'
from pathlib import Path
import secrets
import sys
path = Path(sys.argv[1])
text = path.read_text()
if "PYTORRENT_SECRET_KEY=change-me" in text:
text = text.replace("PYTORRENT_SECRET_KEY=change-me", "PYTORRENT_SECRET_KEY=" + secrets.token_urlsafe(48))
path.write_text(text)
PY
chown "${APP_USER}:${APP_USER}" .env
fi
# Keep systemd service config aligned with installer overrides.
upsert_env_value "PYTORRENT_HOST" "${PYTORRENT_HOST_VALUE}" .env
upsert_env_value "PYTORRENT_PORT" "${PYTORRENT_PORT_VALUE}" .env
upsert_env_value "PYTORRENT_LOG_DIR" "${PYTORRENT_LOG_DIR_VALUE}" .env
upsert_env_value "PYTORRENT_LOG_RETENTION_HOURS" "${PYTORRENT_LOG_RETENTION_HOURS_VALUE}" .env
mkdir -p "${PYTORRENT_LOG_DIR_VALUE}"
chown -R "${APP_USER}:${APP_USER}" "${PYTORRENT_LOG_DIR_VALUE}" || true
chown "${APP_USER}:${APP_USER}" .env
if [[ -f scripts/download_frontend_libs.py ]]; then
sudo -u "${APP_USER}" "${APP_DIR}/venv/bin/python" scripts/download_frontend_libs.py || true
fi
if [[ -f scripts/download_geoip.sh ]]; then
sudo -u "${APP_USER}" bash scripts/download_geoip.sh || true
fi
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<SERVICE
[Unit]
Description=pyTorrent Web UI
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_USER}
WorkingDirectory=${APP_DIR}
Environment="PYTHONUNBUFFERED=1"
EnvironmentFile=${APP_DIR}/.env
ExecStart=${APP_DIR}/venv/bin/gunicorn -c ${APP_DIR}/gunicorn.conf.py --worker-class gthread --workers 1 --threads 32 --bind \${PYTORRENT_HOST}:\${PYTORRENT_PORT} --access-logfile - --error-logfile - wsgi:app
Restart=always
RestartSec=3
KillSignal=SIGINT
TimeoutStopSec=20
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
SERVICE
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
systemctl status "${SERVICE_NAME}" --no-pager --lines=20 || true
echo "pyTorrent installed in ${APP_DIR}. Service: ${SERVICE_NAME}."

View File

@@ -0,0 +1,891 @@
#!/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 = "v0.16.11"
DEFAULT_RTORRENT_REF = "v0.16.11"
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
class InstallError(Exception):
pass
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 build_log_dir():
path = Path(os.environ.get("PYTORRENT_STACK_LOG_DIR", "/var/log/pytorrent-installer"))
path.mkdir(parents=True, exist_ok=True)
return path
def tail_file(path, lines=80):
try:
data = Path(path).read_text(errors="replace").splitlines()
except OSError:
return ""
return "\n".join(data[-lines:])
def run(cmd, *, cwd=None, env=None, check=True, debug=False, capture_output=False, log_name=None):
if debug:
print(f"\n>>> {' '.join(cmd)}")
log_path = None
log_handle = None
if log_name and not capture_output and not debug:
safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", log_name).strip("_") or "command"
log_path = build_log_dir() / f"{safe_name}.log"
log_handle = open(log_path, "a", encoding="utf-8")
log_handle.write(f"\n>>> {' '.join(cmd)}\n")
log_handle.flush()
try:
stdout = subprocess.PIPE if capture_output else (None if debug else (log_handle or subprocess.DEVNULL))
stderr = subprocess.PIPE if capture_output else (None if debug else (subprocess.STDOUT if log_handle else subprocess.DEVNULL))
result = subprocess.run(cmd, cwd=cwd, env=env, check=False, text=True, stdout=stdout, stderr=stderr)
finally:
if log_handle:
log_handle.close()
if check and result.returncode != 0:
stderr_text = ""
if capture_output and result.stderr:
stderr_text = f"\n{result.stderr.strip()}"
if log_path:
stderr_text += f"\nBuild log: {log_path}\n--- last log lines ---\n{tail_file(log_path)}"
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():
if os.geteuid() != 0:
raise InstallError("This script must be run as root (use sudo).")
def read_os_release():
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:
k, v = line.split("=", 1)
data[k] = v.strip().strip('"')
return data
def is_ubuntu_2604():
data = read_os_release()
return data.get("ID", "").lower() == "ubuntu" and data.get("VERSION_ID", "") == "26.04"
def detect_debian():
data = read_os_release()
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, *, debug=False):
print("Updating APT metadata...")
run(["apt-get", "update"], debug=debug)
print("Installing build and runtime dependencies...")
run(["apt-get", "install", "-y", *packages], debug=debug, log_name="apt_install_rtorrent_deps")
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, 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, debug=debug)
run([
"useradd",
"--system",
"--home-dir", home,
"--create-home",
"--shell", "/usr/sbin/nologin",
"--gid", group,
user,
], debug=debug)
def clone_or_update_repo(repo_url, repo_dir, ref, *, debug=False):
repo_dir = Path(repo_dir)
if not repo_dir.exists():
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}")
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, *, 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):
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, *, 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, 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.")
print(f"Detected xmlrpc-c version: {version} ({tool})")
return version
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"
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}")
version = verify_xmlrpc_environment(existing_config, debug=debug)
return install_dir, version
ensure_dir(build_root)
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
with Spinner("Downloading xmlrpc-c", enabled=not debug):
download_file(url, tarball, debug=debug)
extract_tarball(tarball, source_root, debug=debug)
xmlrpc_env = os.environ.copy()
# Ubuntu 26.04 defaults to a newer C standard where bool/true/false are keywords.
# Older xmlrpc-c releases still define them manually, so pin only this build to GNU17.
if is_ubuntu_2604():
xmlrpc_env["CFLAGS"] = f"-std=gnu17 {xmlrpc_env.get('CFLAGS', '')}".strip()
print("Detected Ubuntu 26.04; using CFLAGS=-std=gnu17 for xmlrpc-c only.")
with Spinner("Configuring xmlrpc-c", enabled=not debug):
run(["./configure", f"--prefix={install_dir}"], cwd=str(source_root), env=xmlrpc_env, debug=debug)
with Spinner("Building xmlrpc-c", enabled=not debug):
run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_root), env=xmlrpc_env, debug=debug, log_name=f"make_{Path(source_root).name}")
with Spinner("Installing xmlrpc-c", enabled=not debug):
run(["make", "install"], cwd=str(source_root), env=xmlrpc_env, debug=debug, log_name=f"make_install_{Path(source_root).name}")
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}.")
version = verify_xmlrpc_environment(xmlrpc_config, debug=debug)
return install_dir, version
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, log_name=f"make_{Path(source_root).name}")
with Spinner("Installing curl", enabled=not debug):
run(["make", "install"], cwd=str(source_root), env=env, debug=debug, log_name=f"make_install_{Path(source_root).name}")
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)
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, log_name=f"make_{Path(source_dir).name}")
with Spinner("Installing libtorrent", enabled=not debug):
run(["make", "install"], cwd=str(source_dir), env=env, debug=debug, log_name=f"make_install_{Path(source_dir).name}")
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, 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, 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}.")
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, debug=debug)
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)
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)
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, log_name=f"make_{Path(source_dir).name}")
with Spinner("Installing rTorrent", enabled=not debug):
run(["make", "install"], cwd=str(source_dir), env=env, debug=debug, log_name=f"make_install_{Path(source_dir).name}")
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, 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}")
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}")
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("\n".join(lib_dirs) + "\n")
run(["ldconfig"], debug=debug)
def write_service(service_path, binary_path, runtime_lib_dirs):
service_content = f"""[Unit]
Description=rTorrent for %I | https://git.linuxiarz.pl/gru/tools_scripts/_edit/master/install_rtorrent.py
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
KillSignal=SIGTERM
TimeoutStopSec=300
Restart=always
RestartSec=3
LimitNOFILE=1048576
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 extract_version_tuple(text):
if not text:
return None
match = re.search(r"(?:^|[^0-9])(\d+)\.(\d+)\.(\d+)(?:[^0-9]|$)", str(text))
if not match:
return None
return tuple(int(part) for part in match.groups())
def rtorrent_bind_address_directive(rtorrent_ref, rtorrent_version=None):
version = extract_version_tuple(rtorrent_ref) or extract_version_tuple(rtorrent_version)
if version and version < (0, 16, 0):
return "network.bind_address.set"
return "network.bind_address.ipv4.set"
def build_rtorrent_config_content(username, scgi_port, torrent_port, bind_address_directive):
return f"""
## https://git.linuxiarz.pl/gru/tools_scripts/_edit/master/install_rtorrent.py
# Generated by install_rtorrent.py
directory.default.set = /home/{username}/downloads
session.path.set = /home/{username}/.session
encoding.add = UTF-8
network.scgi.open_port = 127.0.0.1:{scgi_port}
network.port_range.set = {torrent_port}-{torrent_port}
network.port_random.set = no
{bind_address_directive} = 0.0.0.0
system.file.allocate.set = 0
system.umask.set = 0022
dht.mode.set = disable
protocol.pex.set = no
trackers.use_udp.set = no
protocol.encryption.set = allow_incoming,enable_retry,prefer_plaintext
#schedule2 = tied_directory,6,5,start_tied=
#schedule2 = untied_directory,7,5,stop_untied=
schedule2 = session_save,300,300,((session.save))
schedule2 = watch_directory,60,60,load.normal=/home/{username}/watch/*.torrent
ratio.max.set = -1
network.xmlrpc.size_limit.set = 33554432
network.http.max_open.set = 64
network.max_open_sockets.set = 8192
network.max_open_files.set = 32768
network.http.dns_cache_timeout.set = 0
#pieces.memory.max.set = 1800M
""".lstrip()
def write_rtorrent_config(user_home, username, scgi_port, torrent_port, bind_address_directive, *, force_config=False):
config_path = Path(user_home) / ".rtorrent.rc"
config_content = build_rtorrent_config_content(username, scgi_port, torrent_port, bind_address_directive)
if config_path.exists() and not force_config:
print(f"Config already exists: {config_path}")
print("Not overwriting existing config. Proposed generated config would be:")
print("--- BEGIN PROPOSED .rtorrent.rc ---")
print(config_content, end="")
print("--- END PROPOSED .rtorrent.rc ---")
print("Use --force-config to overwrite the existing config.")
return False
config_path.write_text(config_content)
shutil.chown(config_path, user=username, group=username)
print(f"Wrote config: {config_path}")
return True
def prepare_user_dirs(user_home, username):
for d in [Path(user_home) / "downloads", Path(user_home) / ".session", Path(user_home) / "watch"]:
ensure_dir(d, owner=username, group=username, mode=0o755)
shutil.chown(Path(user_home), user=username, group=username)
def enable_service(user, *, debug=False):
unit_name = f"rtorrent@{user}.service"
run(["systemctl", "enable", "--now", unit_name], debug=debug)
print(f"Enabled and started {unit_name}")
def print_link_lines(title, lines):
print(title)
for line in lines:
print(line)
def print_optional_libs_explanation():
print("Optional libraries:")
print(" - c-ares: asynchronous DNS resolver. It helps avoid blocking DNS lookups and can improve tracker/DHT-heavy workloads when curl is built with AsynchDNS support.")
print(" - curl: HTTP/HTTPS transfer library used by libtorrent for tracker/web requests. Building a fresh curl can provide newer TLS/HTTP fixes and c-ares based async DNS.")
print(" - minimal build: builds only xmlrpc-c, libtorrent and rTorrent; it uses the system libraries already available on Debian.")
def resolve_optional_build_mode(args):
requested = [name for name, enabled in [
("--minimal", args.minimal),
("--with-cares", args.with_cares),
("--with-curl", args.with_curl),
("--no-cares", args.no_cares),
("--no-curl", args.no_curl),
] if enabled]
if args.minimal and (args.with_cares or args.with_curl):
raise InstallError("Conflicting options: --minimal cannot be used with --with-cares or --with-curl.")
if args.no_curl and args.with_curl:
raise InstallError("Conflicting options: --no-curl cannot be used with --with-curl.")
if args.no_cares and (args.with_cares or args.with_curl):
raise InstallError("Conflicting options: --no-cares cannot be used with --with-cares or --with-curl.")
if args.minimal or args.no_curl:
return False
if args.with_curl or args.with_cares:
return True
if args.no_cares:
return False
if args.yes:
return False
print_optional_libs_explanation()
return prompt_yes_no(
"Build additional c-ares and newest custom curl?",
default=False,
assume_yes=False,
)
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, debug=debug) or "not found in PATH"
print(f"Resolved rtorrent from PATH: {which_rtorrent}")
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}.")
if curl_install:
verify_libtorrent_curl_integration(base_dir, libtorrent_install, curl_install, cares_install, debug=debug)
env = build_env(libtorrent_install, xmlrpc_install, curl_install, cares_install)
env["LANG"] = "C"
env["LC_ALL"] = "C"
env["TERM"] = env.get("TERM", "xterm")
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(
"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="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=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("--scgi-port", type=int, default=DEFAULT_SCGI_PORT, help=f"SCGI listen port for rTorrent XMLRPC/SCGI (default: {DEFAULT_SCGI_PORT})")
parser.add_argument("--torrent-port", type=int, default=DEFAULT_TORRENT_PORT, help=f"Incoming BitTorrent listen port (default: {DEFAULT_TORRENT_PORT})")
parser.add_argument("--force-config", action="store_true", help="Overwrite existing ~/.rtorrent.rc. By default, existing config is left unchanged and the proposed changes are printed.")
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; optional c-ares/curl remain disabled unless --with-curl or --with-cares is used.")
parser.add_argument("--debug", action="store_true", help="Show full command output during build steps.")
parser.add_argument("--minimal", "--core-only", action="store_true", help="Build only xmlrpc-c, libtorrent and rTorrent. Do not build c-ares or custom curl.")
parser.add_argument("--no-cares", "--without-cares", dest="no_cares", action="store_true", help="Do not build c-ares. This also disables custom curl integration.")
parser.add_argument("--no-curl", "--without-curl", dest="no_curl", action="store_true", help="Do not build custom curl. Implies no c-ares integration for libtorrent.")
parser.add_argument("--with-cares", action="store_true", help="Build c-ares and custom curl with asynchronous DNS support.")
parser.add_argument("--with-curl", action="store_true", help="Build newest custom curl; c-ares is enabled unless --no-cares is used.")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
args.use_cares = resolve_optional_build_mode(args)
require_root()
detect_debian()
packages = [
"build-essential", "pkg-config", "libtool", "autoconf", "automake", "git", "ca-certificates",
"libssl-dev", "libncurses-dev", "libncurses5-dev", "libncursesw5-dev", "libexpat1-dev",
"libcurl4-openssl-dev", "libxml2-dev", "libreadline-dev", "curl", "tar", "gzip", "xz-utils",
"zlib1g-dev", "bison", "flex", "m4", "gettext", "texinfo", "patch", "diffutils", "file", "procps"
]
if args.use_cares:
packages.extend(["cmake", "libpsl-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")
print(" - benefit: async DNS via c-ares and newer curl for HTTP/HTTPS tracker requests")
else:
print(" - minimal build: skip c-ares/custom curl")
print(" - build only xmlrpc-c, libtorrent and rTorrent; use Debian system libraries")
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}'")
print(f" - use SCGI port {args.scgi_port} and torrent port {args.torrent_port}")
if not prompt_yes_no("Continue?", default=True, assume_yes=args.yes):
print("Aborted by user.")
return 1
ensure_packages(packages, debug=args.debug)
ensure_dir(args.base_dir)
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, debug=args.debug)
prepare_user_dirs(args.home, args.user)
bind_address_directive = rtorrent_bind_address_directive(args.rtorrent_ref, rtorrent_version)
print(f"Using rTorrent bind address directive: {bind_address_directive}")
write_rtorrent_config(args.home, args.user, args.scgi_port, args.torrent_port, bind_address_directive, force_config=args.force_config)
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.")
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)

View File

@@ -0,0 +1,891 @@
#!/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 = "v0.16.11"
DEFAULT_RTORRENT_REF = "v0.16.11"
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
class InstallError(Exception):
pass
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 build_log_dir():
path = Path(os.environ.get("PYTORRENT_STACK_LOG_DIR", "/var/log/pytorrent-installer"))
path.mkdir(parents=True, exist_ok=True)
return path
def tail_file(path, lines=80):
try:
data = Path(path).read_text(errors="replace").splitlines()
except OSError:
return ""
return "\n".join(data[-lines:])
def run(cmd, *, cwd=None, env=None, check=True, debug=False, capture_output=False, log_name=None):
if debug:
print(f"\n>>> {' '.join(cmd)}")
log_path = None
log_handle = None
if log_name and not capture_output and not debug:
safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", log_name).strip("_") or "command"
log_path = build_log_dir() / f"{safe_name}.log"
log_handle = open(log_path, "a", encoding="utf-8")
log_handle.write(f"\n>>> {' '.join(cmd)}\n")
log_handle.flush()
try:
stdout = subprocess.PIPE if capture_output else (None if debug else (log_handle or subprocess.DEVNULL))
stderr = subprocess.PIPE if capture_output else (None if debug else (subprocess.STDOUT if log_handle else subprocess.DEVNULL))
result = subprocess.run(cmd, cwd=cwd, env=env, check=False, text=True, stdout=stdout, stderr=stderr)
finally:
if log_handle:
log_handle.close()
if check and result.returncode != 0:
stderr_text = ""
if capture_output and result.stderr:
stderr_text = f"\n{result.stderr.strip()}"
if log_path:
stderr_text += f"\nBuild log: {log_path}\n--- last log lines ---\n{tail_file(log_path)}"
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():
if os.geteuid() != 0:
raise InstallError("This script must be run as root (use sudo).")
def detect_rhel():
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:
k, v = line.split("=", 1)
data[k] = v.strip().strip('"')
distro_id = data.get("ID", "").lower()
distro_like = data.get("ID_LIKE", "").lower()
rhel_markers = {"rhel", "centos", "rocky", "almalinux", "fedora", "ol", "scientific"}
if distro_id not in rhel_markers and not any(marker in distro_like for marker in ("rhel", "fedora", "centos")):
raise InstallError(
f"Unsupported distribution: ID={data.get('ID', 'unknown')}, "
f"ID_LIKE={data.get('ID_LIKE', 'unknown')}. This installer supports RHEL-compatible systems only."
)
print(f"Detected RHEL-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 enable_rhel_optional_repos(*, debug=False):
manager = shutil.which("dnf") or shutil.which("yum")
if not manager:
return
# CRB/PowerTools is required by many EPEL/devel packages on RHEL-compatible systems.
crb = shutil.which("crb")
if crb:
run([crb, "enable"], check=False, debug=debug, log_name="enable_crb")
config_manager = shutil.which("dnf") or shutil.which("yum")
run([config_manager, "config-manager", "--set-enabled", "crb"], check=False, debug=debug, log_name="enable_crb")
run([config_manager, "config-manager", "--set-enabled", "powertools"], check=False, debug=debug, log_name="enable_powertools")
def ensure_packages(packages, *, debug=False):
manager = shutil.which("dnf") or shutil.which("yum")
if not manager:
raise InstallError("dnf or yum was not found on this RHEL-compatible system.")
print("Installing build and runtime dependencies...")
enable_rhel_optional_repos(debug=debug)
# RHEL-compatible systems do not provide Debian's build-essential package.
# The closest equivalent is the Development Tools group.
run([manager, "groupinstall", "-y", "Development Tools"], check=False, debug=debug, log_name="dnf_groupinstall_development_tools")
run([manager, "install", "-y", *packages], debug=debug, log_name="dnf_install_rtorrent_deps")
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, 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, debug=debug)
run([
"useradd",
"--system",
"--home-dir", home,
"--create-home",
"--shell", "/usr/sbin/nologin",
"--gid", group,
user,
], debug=debug)
def clone_or_update_repo(repo_url, repo_dir, ref, *, debug=False):
repo_dir = Path(repo_dir)
if not repo_dir.exists():
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}")
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, *, 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):
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, *, 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, 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.")
print(f"Detected xmlrpc-c version: {version} ({tool})")
return version
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"
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}")
version = verify_xmlrpc_environment(existing_config, debug=debug)
return install_dir, version
ensure_dir(build_root)
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
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, log_name=f"make_{Path(source_root).name}")
with Spinner("Installing xmlrpc-c", enabled=not debug):
run(["make", "install"], cwd=str(source_root), debug=debug, log_name=f"make_install_{Path(source_root).name}")
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}.")
version = verify_xmlrpc_environment(xmlrpc_config, debug=debug)
return install_dir, version
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, log_name=f"make_{Path(source_root).name}")
with Spinner("Installing curl", enabled=not debug):
run(["make", "install"], cwd=str(source_root), env=env, debug=debug, log_name=f"make_install_{Path(source_root).name}")
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)
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, log_name=f"make_{Path(source_dir).name}")
with Spinner("Installing libtorrent", enabled=not debug):
run(["make", "install"], cwd=str(source_dir), env=env, debug=debug, log_name=f"make_install_{Path(source_dir).name}")
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, 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, 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}.")
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, debug=debug)
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)
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)
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, log_name=f"make_{Path(source_dir).name}")
with Spinner("Installing rTorrent", enabled=not debug):
run(["make", "install"], cwd=str(source_dir), env=env, debug=debug, log_name=f"make_install_{Path(source_dir).name}")
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, 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}")
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}")
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("\n".join(lib_dirs) + "\n")
run(["ldconfig"], debug=debug)
def write_service(service_path, binary_path, runtime_lib_dirs):
service_content = f"""[Unit]
Description=rTorrent for %I | https://git.linuxiarz.pl/gru/tools_scripts/_edit/master/install_rtorrent.py
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
KillSignal=SIGTERM
TimeoutStopSec=300
Restart=always
RestartSec=3
LimitNOFILE=1048576
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 extract_version_tuple(text):
if not text:
return None
match = re.search(r"(?:^|[^0-9])(\d+)\.(\d+)\.(\d+)(?:[^0-9]|$)", str(text))
if not match:
return None
return tuple(int(part) for part in match.groups())
def rtorrent_bind_address_directive(rtorrent_ref, rtorrent_version=None):
version = extract_version_tuple(rtorrent_ref) or extract_version_tuple(rtorrent_version)
if version and version < (0, 16, 0):
return "network.bind_address.set"
return "network.bind_address.ipv4.set"
def build_rtorrent_config_content(username, scgi_port, torrent_port, bind_address_directive):
return f"""
## https://git.linuxiarz.pl/gru/tools_scripts/_edit/master/install_rtorrent.py
# Generated by install_rtorrent.py
directory.default.set = /home/{username}/downloads
session.path.set = /home/{username}/.session
encoding.add = UTF-8
network.scgi.open_port = 127.0.0.1:{scgi_port}
network.port_range.set = {torrent_port}-{torrent_port}
network.port_random.set = no
{bind_address_directive} = 0.0.0.0
system.file.allocate.set = 0
system.umask.set = 0022
dht.mode.set = disable
protocol.pex.set = no
trackers.use_udp.set = no
protocol.encryption.set = allow_incoming,enable_retry,prefer_plaintext
#schedule2 = tied_directory,6,5,start_tied=
#schedule2 = untied_directory,7,5,stop_untied=
schedule2 = session_save,300,300,((session.save))
schedule2 = watch_directory,60,60,load.normal=/home/{username}/watch/*.torrent
ratio.max.set = -1
network.xmlrpc.size_limit.set = 33554432
network.http.max_open.set = 64
network.max_open_sockets.set = 8192
network.max_open_files.set = 32768
network.http.dns_cache_timeout.set = 0
#pieces.memory.max.set = 1800M
""".lstrip()
def write_rtorrent_config(user_home, username, scgi_port, torrent_port, bind_address_directive, *, force_config=False):
config_path = Path(user_home) / ".rtorrent.rc"
config_content = build_rtorrent_config_content(username, scgi_port, torrent_port, bind_address_directive)
if config_path.exists() and not force_config:
print(f"Config already exists: {config_path}")
print("Not overwriting existing config. Proposed generated config would be:")
print("--- BEGIN PROPOSED .rtorrent.rc ---")
print(config_content, end="")
print("--- END PROPOSED .rtorrent.rc ---")
print("Use --force-config to overwrite the existing config.")
return False
config_path.write_text(config_content)
shutil.chown(config_path, user=username, group=username)
print(f"Wrote config: {config_path}")
return True
def prepare_user_dirs(user_home, username):
for d in [Path(user_home) / "downloads", Path(user_home) / ".session", Path(user_home) / "watch"]:
ensure_dir(d, owner=username, group=username, mode=0o755)
shutil.chown(Path(user_home), user=username, group=username)
def enable_service(user, *, debug=False):
unit_name = f"rtorrent@{user}.service"
run(["systemctl", "enable", "--now", unit_name], debug=debug)
print(f"Enabled and started {unit_name}")
def print_link_lines(title, lines):
print(title)
for line in lines:
print(line)
def print_optional_libs_explanation():
print("Optional libraries:")
print(" - c-ares: asynchronous DNS resolver. It helps avoid blocking DNS lookups and can improve tracker/DHT-heavy workloads when curl is built with AsynchDNS support.")
print(" - curl: HTTP/HTTPS transfer library used by libtorrent for tracker/web requests. Building a fresh curl can provide newer TLS/HTTP fixes and c-ares based async DNS.")
print(" - minimal build: builds only xmlrpc-c, libtorrent and rTorrent; it uses the system libraries already available on Debian.")
def resolve_optional_build_mode(args):
requested = [name for name, enabled in [
("--minimal", args.minimal),
("--with-cares", args.with_cares),
("--with-curl", args.with_curl),
("--no-cares", args.no_cares),
("--no-curl", args.no_curl),
] if enabled]
if args.minimal and (args.with_cares or args.with_curl):
raise InstallError("Conflicting options: --minimal cannot be used with --with-cares or --with-curl.")
if args.no_curl and args.with_curl:
raise InstallError("Conflicting options: --no-curl cannot be used with --with-curl.")
if args.no_cares and (args.with_cares or args.with_curl):
raise InstallError("Conflicting options: --no-cares cannot be used with --with-cares or --with-curl.")
if args.minimal or args.no_curl:
return False
if args.with_curl or args.with_cares:
return True
if args.no_cares:
return False
if args.yes:
return False
print_optional_libs_explanation()
return prompt_yes_no(
"Build additional c-ares and newest custom curl?",
default=False,
assume_yes=False,
)
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, debug=debug) or "not found in PATH"
print(f"Resolved rtorrent from PATH: {which_rtorrent}")
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}.")
if curl_install:
verify_libtorrent_curl_integration(base_dir, libtorrent_install, curl_install, cares_install, debug=debug)
env = build_env(libtorrent_install, xmlrpc_install, curl_install, cares_install)
env["LANG"] = "C"
env["LC_ALL"] = "C"
env["TERM"] = env.get("TERM", "xterm")
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(
"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="RHEL-compatible 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=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("--scgi-port", type=int, default=DEFAULT_SCGI_PORT, help=f"SCGI listen port for rTorrent XMLRPC/SCGI (default: {DEFAULT_SCGI_PORT})")
parser.add_argument("--torrent-port", type=int, default=DEFAULT_TORRENT_PORT, help=f"Incoming BitTorrent listen port (default: {DEFAULT_TORRENT_PORT})")
parser.add_argument("--force-config", action="store_true", help="Overwrite existing ~/.rtorrent.rc. By default, existing config is left unchanged and the proposed changes are printed.")
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; optional c-ares/curl remain disabled unless --with-curl or --with-cares is used.")
parser.add_argument("--debug", action="store_true", help="Show full command output during build steps.")
parser.add_argument("--minimal", "--core-only", action="store_true", help="Build only xmlrpc-c, libtorrent and rTorrent. Do not build c-ares or custom curl.")
parser.add_argument("--no-cares", "--without-cares", dest="no_cares", action="store_true", help="Do not build c-ares. This also disables custom curl integration.")
parser.add_argument("--no-curl", "--without-curl", dest="no_curl", action="store_true", help="Do not build custom curl. Implies no c-ares integration for libtorrent.")
parser.add_argument("--with-cares", action="store_true", help="Build c-ares and custom curl with asynchronous DNS support.")
parser.add_argument("--with-curl", action="store_true", help="Build newest custom curl; c-ares is enabled unless --no-cares is used.")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
args.use_cares = resolve_optional_build_mode(args)
require_root()
detect_rhel()
packages = [
"gcc", "gcc-c++", "make", "pkgconf-pkg-config", "libtool", "autoconf", "automake", "git", "ca-certificates",
"openssl-devel", "ncurses-devel", "expat-devel", "curl", "libcurl-devel", "tar", "gzip", "zlib-devel", "which",
"patch", "diffutils", "findutils", "file", "redhat-rpm-config", "libstdc++-devel"
]
if args.use_cares:
packages.extend(["cmake", "libpsl-devel", "brotli-devel", "libzstd-devel"])
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")
print(" - benefit: async DNS via c-ares and newer curl for HTTP/HTTPS tracker requests")
else:
print(" - minimal build: skip c-ares/custom curl")
print(" - build only xmlrpc-c, libtorrent and rTorrent; use RHEL system libraries")
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}'")
print(f" - use SCGI port {args.scgi_port} and torrent port {args.torrent_port}")
if not prompt_yes_no("Continue?", default=True, assume_yes=args.yes):
print("Aborted by user.")
return 1
ensure_packages(packages, debug=args.debug)
ensure_dir(args.base_dir)
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, debug=args.debug)
prepare_user_dirs(args.home, args.user)
bind_address_directive = rtorrent_bind_address_directive(args.rtorrent_ref, rtorrent_version)
print(f"Using rTorrent bind address directive: {bind_address_directive}")
write_rtorrent_config(args.home, args.user, args.scgi_port, args.torrent_port, bind_address_directive, force_config=args.force_config)
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.")
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)

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -euo pipefail
# One-command installer for rTorrent + pyTorrent on Debian/Ubuntu.
# Notes:
# - rTorrent is built as a minimal v0.16.11 install by default.
# - pyTorrent is configured through its HTTP API after the service starts.
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo $0" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
RTORRENT_USER="${RTORRENT_USER:-rtorrent}"
RTORRENT_HOME="${RTORRENT_HOME:-/home/${RTORRENT_USER}}"
RTORRENT_BASE_DIR="${RTORRENT_BASE_DIR:-/opt/rtorrent_build}"
RTORRENT_SCGI_PORT="${RTORRENT_SCGI_PORT:-5000}"
RTORRENT_TORRENT_PORT="${RTORRENT_TORRENT_PORT:-51300}"
RTORRENT_REF="${RTORRENT_REF:-v0.16.11}"
LIBTORRENT_REF="${LIBTORRENT_REF:-v0.16.11}"
PYTORRENT_APP_DIR="${PYTORRENT_APP_DIR:-/opt/pytorrent}"
PYTORRENT_PORT="${PYTORRENT_PORT:-8090}"
PYTORRENT_BASE_URL="${PYTORRENT_BASE_URL:-http://127.0.0.1:${PYTORRENT_PORT}}"
PYTORRENT_PROFILE_NAME="${PYTORRENT_PROFILE_NAME:-Local rTorrent}"
PYTORRENT_API_TOKEN="${PYTORRENT_API_TOKEN:-}"
PYTORRENT_SERVICE_NAME="${PYTORRENT_SERVICE_NAME:-pytorrent}"
PYTORRENT_RTORRENT_SCGI_URL="${PYTORRENT_RTORRENT_SCGI_URL:-scgi://127.0.0.1:${RTORRENT_SCGI_PORT}}"
export PYTORRENT_APP_DIR PYTORRENT_PORT PYTORRENT_SERVICE_NAME PYTORRENT_API_TOKEN
install_debian_stack_prerequisites() {
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
tar \
gzip \
sudo \
python3 \
python3-venv \
python3-pip \
build-essential \
pkg-config \
libtool \
autoconf \
automake \
git \
make \
gcc \
g++ \
libssl-dev \
libncurses-dev \
libncurses5-dev \
libncursesw5-dev \
libexpat1-dev \
libcurl4-openssl-dev \
libxml2-dev \
libreadline-dev \
zlib1g-dev \
bison \
flex \
m4 \
gettext \
texinfo \
patch \
diffutils \
file \
procps \
xz-utils
}
install_debian_stack_prerequisites
RTORRENT_INSTALL_ARGS=(
--yes
--minimal
)
if [[ "${PYTORRENT_DEBUG_INSTALL:-0}" == "1" ]]; then
RTORRENT_INSTALL_ARGS+=(--debug)
fi
python3 "${SCRIPT_DIR}/install_rtorrent.py" \
"${RTORRENT_INSTALL_ARGS[@]}" \
--force-config \
--base-dir "${RTORRENT_BASE_DIR}" \
--user "${RTORRENT_USER}" \
--group "${RTORRENT_USER}" \
--home "${RTORRENT_HOME}" \
--scgi-port "${RTORRENT_SCGI_PORT}" \
--torrent-port "${RTORRENT_TORRENT_PORT}" \
--rtorrent-ref "${RTORRENT_REF}" \
--libtorrent-ref "${LIBTORRENT_REF}"
cd "${PROJECT_DIR}"
bash "${PROJECT_DIR}/scripts/install_debian_ubuntu.sh"
if [[ -f "${PYTORRENT_APP_DIR}/.env" ]]; then
python3 - "${PYTORRENT_APP_DIR}/.env" <<'PY'
from pathlib import Path
import secrets
import sys
path = Path(sys.argv[1])
text = path.read_text()
if "PYTORRENT_SECRET_KEY=change-me" in text:
text = text.replace("PYTORRENT_SECRET_KEY=change-me", "PYTORRENT_SECRET_KEY=" + secrets.token_urlsafe(48))
path.write_text(text)
PY
chown "${PYTORRENT_USER:-pytorrent}:${PYTORRENT_USER:-pytorrent}" "${PYTORRENT_APP_DIR}/.env" || true
systemctl restart "${PYTORRENT_SERVICE_NAME}"
fi
CONFIGURE_ARGS=(
--base-url "${PYTORRENT_BASE_URL}"
--profile-name "${PYTORRENT_PROFILE_NAME}"
--scgi-url "${PYTORRENT_RTORRENT_SCGI_URL}"
)
if [[ -n "${PYTORRENT_API_TOKEN}" ]]; then
CONFIGURE_ARGS+=(--api-token "${PYTORRENT_API_TOKEN}")
fi
"${PYTORRENT_APP_DIR}/venv/bin/python" "${PYTORRENT_APP_DIR}/scripts/stack_installers/configure_pytorrent_api.py" "${CONFIGURE_ARGS[@]}"
echo "Done. pyTorrent: ${PYTORRENT_BASE_URL} | rTorrent SCGI: ${PYTORRENT_RTORRENT_SCGI_URL}"

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
set -euo pipefail
# One-command installer for rTorrent + pyTorrent on RHEL-compatible systems.
# Notes:
# - rTorrent is built as a minimal v0.16.11 install by default.
# - pyTorrent is configured through its HTTP API after the service starts.
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo $0" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
RTORRENT_USER="${RTORRENT_USER:-rtorrent}"
RTORRENT_HOME="${RTORRENT_HOME:-/home/${RTORRENT_USER}}"
RTORRENT_BASE_DIR="${RTORRENT_BASE_DIR:-/opt/rtorrent_build}"
RTORRENT_SCGI_PORT="${RTORRENT_SCGI_PORT:-5000}"
RTORRENT_TORRENT_PORT="${RTORRENT_TORRENT_PORT:-51300}"
RTORRENT_REF="${RTORRENT_REF:-v0.16.11}"
LIBTORRENT_REF="${LIBTORRENT_REF:-v0.16.11}"
PYTORRENT_APP_DIR="${PYTORRENT_APP_DIR:-/opt/pytorrent}"
PYTORRENT_PORT="${PYTORRENT_PORT:-8090}"
PYTORRENT_BASE_URL="${PYTORRENT_BASE_URL:-http://127.0.0.1:${PYTORRENT_PORT}}"
PYTORRENT_PROFILE_NAME="${PYTORRENT_PROFILE_NAME:-Local rTorrent}"
PYTORRENT_API_TOKEN="${PYTORRENT_API_TOKEN:-}"
PYTORRENT_SERVICE_NAME="${PYTORRENT_SERVICE_NAME:-pytorrent}"
PYTORRENT_RTORRENT_SCGI_URL="${PYTORRENT_RTORRENT_SCGI_URL:-scgi://127.0.0.1:${RTORRENT_SCGI_PORT}}"
export PYTORRENT_APP_DIR PYTORRENT_PORT PYTORRENT_SERVICE_NAME PYTORRENT_API_TOKEN
install_rhel_stack_prerequisites() {
local manager=""
if command -v dnf >/dev/null 2>&1; then
manager="dnf"
elif command -v yum >/dev/null 2>&1; then
manager="yum"
else
echo "dnf or yum is required on RHEL-compatible systems." >&2
exit 1
fi
"${manager}" install -y ca-certificates tar curl gzip sudo python3 dnf-plugins-core epel-release || \
"${manager}" install -y ca-certificates tar curl gzip sudo python3
if command -v crb >/dev/null 2>&1; then
crb enable || true
fi
"${manager}" config-manager --set-enabled crb || true
"${manager}" config-manager --set-enabled powertools || true
"${manager}" makecache || true
"${manager}" groupinstall -y "Development Tools" || true
"${manager}" install -y \
git \
gcc \
gcc-c++ \
make \
autoconf \
automake \
libtool \
pkgconf-pkg-config \
ncurses-devel \
openssl-devel \
expat-devel \
zlib-devel \
libcurl-devel \
redhat-rpm-config \
patch \
diffutils \
findutils \
file \
which \
libstdc++-devel
}
install_rhel_stack_prerequisites
RTORRENT_INSTALL_ARGS=(
--yes
--minimal
--force-config
)
if [[ "${PYTORRENT_DEBUG_INSTALL:-0}" == "1" ]]; then
RTORRENT_INSTALL_ARGS+=(--debug)
fi
python3 "${SCRIPT_DIR}/install_rtorrent_rhel.py" \
"${RTORRENT_INSTALL_ARGS[@]}" \
--base-dir "${RTORRENT_BASE_DIR}" \
--user "${RTORRENT_USER}" \
--group "${RTORRENT_USER}" \
--home "${RTORRENT_HOME}" \
--scgi-port "${RTORRENT_SCGI_PORT}" \
--torrent-port "${RTORRENT_TORRENT_PORT}" \
--rtorrent-ref "${RTORRENT_REF}" \
--libtorrent-ref "${LIBTORRENT_REF}"
cd "${PROJECT_DIR}"
bash "${SCRIPT_DIR}/install_pytorrent_rhel.sh"
CONFIGURE_ARGS=(
--base-url "${PYTORRENT_BASE_URL}"
--profile-name "${PYTORRENT_PROFILE_NAME}"
--scgi-url "${PYTORRENT_RTORRENT_SCGI_URL}"
)
if [[ -n "${PYTORRENT_API_TOKEN}" ]]; then
CONFIGURE_ARGS+=(--api-token "${PYTORRENT_API_TOKEN}")
fi
"${PYTORRENT_APP_DIR}/venv/bin/python" "${PYTORRENT_APP_DIR}/scripts/stack_installers/configure_pytorrent_api.py" "${CONFIGURE_ARGS[@]}"
echo "Done. pyTorrent: ${PYTORRENT_BASE_URL} | rTorrent SCGI: ${PYTORRENT_RTORRENT_SCGI_URL}"