From 6edd0dfe9d3647da3f731efd0fa218b73b347c9b Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 28 Jul 2026 15:51:10 +0200 Subject: [PATCH] feat(torrent): debian packaging v2.0.0, remove Transmission (ref #917) Pivots the secubox-torrent Debian package to the WebTorrent/LXC-native design (Tasks 1-7): control/rules/postinst/postrm now ship the Node app to /usr/lib/secubox/torrent/app, run install-lxc.sh at configure, load the nft egress scope from /etc/nftables.d/ (not the brief's unused /etc/secubox/nft.d/), and symlink the standalone vhost into sites-available/sites-enabled instead of the location-snippet secubox.d/. Deletes the old host-side Transmission FastAPI (api/main.py) and its systemd unit; Depends drops python3-uvicorn/transmission in favour of lxc, debootstrap, nftables. Structural packaging test added (tests/test_packaging.py). Builds cleanly to secubox-torrent_2.0.0-1~bookworm1_all.deb. Co-Authored-By: Gerald KERMA --- packages/secubox-torrent/README.md | 31 +- packages/secubox-torrent/api/main.py | 903 ------------------ packages/secubox-torrent/debian/changelog | 8 + packages/secubox-torrent/debian/control | 23 +- packages/secubox-torrent/debian/postinst | 58 +- packages/secubox-torrent/debian/postrm | 17 + packages/secubox-torrent/debian/prerm | 20 +- packages/secubox-torrent/debian/rules | 47 +- packages/secubox-torrent/debian/secubox.yaml | 6 +- .../systemd/secubox-torrent.service | 28 - .../secubox-torrent/tests/test_packaging.py | 52 + 11 files changed, 189 insertions(+), 1004 deletions(-) delete mode 100644 packages/secubox-torrent/api/main.py create mode 100755 packages/secubox-torrent/debian/postrm delete mode 100644 packages/secubox-torrent/systemd/secubox-torrent.service create mode 100644 packages/secubox-torrent/tests/test_packaging.py diff --git a/packages/secubox-torrent/README.md b/packages/secubox-torrent/README.md index f2036461..5a91288f 100644 --- a/packages/secubox-torrent/README.md +++ b/packages/secubox-torrent/README.md @@ -1,6 +1,8 @@ # 🌊 Torrent -BitTorrent client +WebTorrent streaming, LXC-native (v2.0.0). Paste a magnet and stream it in +the browser over HTTP Range while it downloads. Ephemeral by default, with +an optional Keep into a persistent library on `/data`. **Category:** Media @@ -8,12 +10,19 @@ BitTorrent client ![Torrent](../../docs/screenshots/vm/torrent.png) -## Features +## Architecture -- Downloads -- RSS -- Remote control -- Bandwidth limits +The hybrid WebRTC+BitTorrent engine (`webtorrent` + `fastify` + +`better-sqlite3`) runs isolated inside a dedicated LXC (`torrent`, +`10.100.0.160`). The host only ships: + +- the public vhost `torrent.gk2.secubox.in` (nginx, proxies to the LXC's + `:8090`, streamed via HTTP Range with no buffering), +- an nft egress visibility scope for the LXC's veth, +- `install-lxc.sh`, which provisions the container and deploys the app. + +v1 (Transmission via Docker/Podman, host FastAPI on +`/api/v1/torrent/*`) was removed in 2.0.0 β€” see `debian/changelog`. ## Installation @@ -27,12 +36,10 @@ sudo apt install secubox-torrent ## Configuration -Configuration file: `/etc/secubox/torrent.toml` - -## API Endpoints - -- `GET /api/v1/torrent/status` - Module status -- `GET /api/v1/torrent/health` - Health check +Configuration file: `/etc/secubox/torrent.toml` (LXC network, engine +`max_active`/`webrtc`, ephemeral retention `ephemeral_ttl_hours`/ +`disk_floor_gb`). Edit then re-run +`/usr/lib/secubox/torrent/install-lxc.sh` to apply. ## License diff --git a/packages/secubox-torrent/api/main.py b/packages/secubox-torrent/api/main.py deleted file mode 100644 index dde911b7..00000000 --- a/packages/secubox-torrent/api/main.py +++ /dev/null @@ -1,903 +0,0 @@ -"""secubox-torrent β€” FastAPI application for BitTorrent client management. - -Provides Transmission/qBittorrent Docker container management with -torrent lifecycle, RSS feeds, and speed limiting. - -SecuBox-Deb :: secubox-torrent -CyberMind β€” https://cybermind.fr -Author: Gerald Kerma -License: Proprietary / ANSSI CSPN candidate -""" -import asyncio -import json -import shutil -import subprocess -from datetime import datetime -from pathlib import Path -from typing import List, Optional, Dict, Any - -from fastapi import FastAPI, APIRouter, Depends, HTTPException, UploadFile, File, Form -from pydantic import BaseModel - -from secubox_core.auth import router as auth_router, require_jwt -from secubox_core.logger import get_logger - -app = FastAPI(title="secubox-torrent", version="1.0.0", root_path="/api/v1/torrent") - -# ══════════════════════════════════════════════════════════════════ -# Health Check Endpoint (public, no auth) -# ══════════════════════════════════════════════════════════════════ - -@app.get("/health") -async def health_check(): - """Public health check endpoint for sidebar status.""" - return {"status": "ok", "module": "deb"} - -app.include_router(auth_router, prefix="/auth") -router = APIRouter() -log = get_logger("torrent") - -# Configuration -CONFIG_FILE = Path("/etc/secubox/torrent.toml") -CONTAINER_NAME = "secbx-torrent" -CACHE_DIR = Path("/var/cache/secubox/torrent") -RSS_FILE = Path("/etc/secubox/torrent-rss.json") -CATEGORIES_FILE = Path("/etc/secubox/torrent-categories.json") - -DEFAULT_CONFIG = { - "enabled": False, - "client": "transmission", # transmission or qbittorrent - "image": "linuxserver/transmission:latest", - "port": 9091, - "peer_port": 51413, - "data_path": "/srv/torrent", - "download_dir": "/srv/torrent/downloads", - "watch_dir": "/srv/torrent/watch", - "timezone": "Europe/Paris", - "domain": "torrent.secubox.local", - "haproxy": False, - "auth_enabled": True, - "username": "admin", - "password_hash": "", - # Speed limits (KB/s, 0 = unlimited) - "download_limit": 0, - "upload_limit": 0, - "alt_download_limit": 1000, - "alt_upload_limit": 500, - "alt_speed_enabled": False, - # Schedule (24h format) - "schedule_enabled": False, - "schedule_start": "08:00", - "schedule_end": "23:00", - # Seeding - "seed_ratio_limit": 2.0, - "seed_ratio_enabled": True, -} - - -# ============================================================================ -# Models -# ============================================================================ - -class TorrentConfig(BaseModel): - enabled: bool = False - client: str = "transmission" - image: str = "linuxserver/transmission:latest" - port: int = 9091 - peer_port: int = 51413 - data_path: str = "/srv/torrent" - download_dir: str = "/srv/torrent/downloads" - watch_dir: str = "/srv/torrent/watch" - timezone: str = "Europe/Paris" - domain: str = "torrent.secubox.local" - haproxy: bool = False - download_limit: int = 0 - upload_limit: int = 0 - alt_download_limit: int = 1000 - alt_upload_limit: int = 500 - alt_speed_enabled: bool = False - schedule_enabled: bool = False - schedule_start: str = "08:00" - schedule_end: str = "23:00" - seed_ratio_limit: float = 2.0 - seed_ratio_enabled: bool = True - - -class AddTorrentRequest(BaseModel): - magnet: Optional[str] = None - url: Optional[str] = None - paused: bool = False - download_dir: Optional[str] = None - category: Optional[str] = None - - -class RSSFeed(BaseModel): - name: str - url: str - category: Optional[str] = None - auto_download: bool = False - filter_pattern: Optional[str] = None - - -class Category(BaseModel): - name: str - save_path: Optional[str] = None - - -# ============================================================================ -# Helpers -# ============================================================================ - -def get_config() -> dict: - """Load torrent configuration.""" - if CONFIG_FILE.exists(): - try: - import tomllib - return tomllib.loads(CONFIG_FILE.read_text()) - except Exception: - pass - return DEFAULT_CONFIG.copy() - - -def save_config(config: dict): - """Save torrent configuration.""" - CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) - lines = ["# Torrent client configuration"] - for k, v in config.items(): - if isinstance(v, bool): - lines.append(f"{k} = {str(v).lower()}") - elif isinstance(v, (int, float)): - lines.append(f"{k} = {v}") - elif isinstance(v, list): - lines.append(f'{k} = {v}') - else: - lines.append(f'{k} = "{v}"') - CONFIG_FILE.write_text("\n".join(lines) + "\n") - - -def detect_runtime() -> Optional[str]: - """Detect container runtime.""" - if shutil.which("podman"): - return "podman" - if shutil.which("docker"): - return "docker" - return None - - -def get_container_status() -> dict: - """Get torrent container status.""" - rt = detect_runtime() - if not rt: - return {"status": "no_runtime", "uptime": ""} - - try: - result = subprocess.run( - [rt, "ps", "--filter", f"name={CONTAINER_NAME}", "--format", "{{.Status}}"], - capture_output=True, text=True, timeout=5 - ) - if result.stdout.strip(): - return {"status": "running", "uptime": result.stdout.strip()} - - result = subprocess.run( - [rt, "ps", "-a", "--filter", f"name={CONTAINER_NAME}", "--format", "{{.Status}}"], - capture_output=True, text=True, timeout=5 - ) - if result.stdout.strip(): - return {"status": "stopped", "uptime": ""} - - return {"status": "not_installed", "uptime": ""} - except Exception: - return {"status": "error", "uptime": ""} - - -def is_running() -> bool: - """Check if container is running.""" - return get_container_status()["status"] == "running" - - -def get_rss_feeds() -> List[dict]: - """Get RSS feed subscriptions.""" - if RSS_FILE.exists(): - try: - return json.loads(RSS_FILE.read_text()) - except Exception: - pass - return [] - - -def save_rss_feeds(feeds: List[dict]): - """Save RSS feeds.""" - RSS_FILE.parent.mkdir(parents=True, exist_ok=True) - RSS_FILE.write_text(json.dumps(feeds, indent=2)) - - -def get_categories() -> List[dict]: - """Get torrent categories.""" - if CATEGORIES_FILE.exists(): - try: - return json.loads(CATEGORIES_FILE.read_text()) - except Exception: - pass - # Default categories - return [ - {"id": "movies", "name": "Movies", "save_path": "/downloads/movies"}, - {"id": "tv", "name": "TV Shows", "save_path": "/downloads/tv"}, - {"id": "music", "name": "Music", "save_path": "/downloads/music"}, - {"id": "software", "name": "Software", "save_path": "/downloads/software"}, - ] - - -def save_categories(categories: List[dict]): - """Save categories.""" - CATEGORIES_FILE.parent.mkdir(parents=True, exist_ok=True) - CATEGORIES_FILE.write_text(json.dumps(categories, indent=2)) - - -def get_transmission_rpc_url() -> str: - """Get Transmission RPC URL.""" - cfg = get_config() - return f"http://127.0.0.1:{cfg.get('port', 9091)}/transmission/rpc" - - -def transmission_rpc(method: str, arguments: dict = None) -> dict: - """Call Transmission RPC API.""" - import urllib.request - import urllib.error - - cfg = get_config() - url = get_transmission_rpc_url() - session_id = "" - - payload = {"method": method} - if arguments: - payload["arguments"] = arguments - - headers = { - "Content-Type": "application/json", - "X-Transmission-Session-Id": session_id, - } - - try: - # First request to get session ID - req = urllib.request.Request( - url, - data=json.dumps(payload).encode(), - headers=headers, - method="POST" - ) - urllib.request.urlopen(req, timeout=5) - except urllib.error.HTTPError as e: - if e.code == 409: - # Get session ID from response - session_id = e.headers.get("X-Transmission-Session-Id", "") - headers["X-Transmission-Session-Id"] = session_id - else: - raise - - # Retry with session ID - req = urllib.request.Request( - url, - data=json.dumps(payload).encode(), - headers=headers, - method="POST" - ) - response = urllib.request.urlopen(req, timeout=10) - return json.loads(response.read().decode()) - - -# ============================================================================ -# Cache for stats -# ============================================================================ - -_stats_cache: dict = {} - - -async def refresh_stats_cache(): - """Background task to refresh statistics cache.""" - global _stats_cache - while True: - try: - if is_running(): - # Get session stats from Transmission - try: - result = transmission_rpc("session-stats") - if result.get("result") == "success": - args = result.get("arguments", {}) - _stats_cache = { - "download_speed": args.get("downloadSpeed", 0), - "upload_speed": args.get("uploadSpeed", 0), - "active_torrents": args.get("activeTorrentCount", 0), - "paused_torrents": args.get("pausedTorrentCount", 0), - "total_downloaded": args.get("cumulative-stats", {}).get("downloadedBytes", 0), - "total_uploaded": args.get("cumulative-stats", {}).get("uploadedBytes", 0), - "updated": datetime.now().isoformat(), - } - except Exception as e: - log.debug(f"Stats cache refresh failed: {e}") - else: - _stats_cache = {} - - # Cache to file - CACHE_DIR.mkdir(parents=True, exist_ok=True) - (CACHE_DIR / "stats.json").write_text(json.dumps(_stats_cache)) - except Exception as e: - log.error(f"Stats cache error: {e}") - - await asyncio.sleep(10) - - -@app.on_event("startup") -async def startup(): - asyncio.create_task(refresh_stats_cache()) - - -# ============================================================================ -# Public Endpoints -# ============================================================================ - -@router.get("/health") -async def health(): - """Health check.""" - return {"status": "ok", "module": "torrent"} - - -@router.get("/status") -def status(): - """Get torrent service status.""" - cfg = get_config() - rt = detect_runtime() - container = get_container_status() - rss_feeds = get_rss_feeds() - categories = get_categories() - - # Disk usage - disk_usage = "" - data_path = Path(cfg.get("data_path", "/srv/torrent")) - if data_path.exists(): - try: - result = subprocess.run( - ["du", "-sh", str(data_path)], - capture_output=True, text=True, timeout=10 - ) - disk_usage = result.stdout.split()[0] if result.stdout else "" - except Exception: - pass - - return { - "enabled": cfg.get("enabled", False), - "client": cfg.get("client", "transmission"), - "image": cfg.get("image", "linuxserver/transmission:latest"), - "port": cfg.get("port", 9091), - "peer_port": cfg.get("peer_port", 51413), - "data_path": cfg.get("data_path", "/srv/torrent"), - "download_dir": cfg.get("download_dir", "/srv/torrent/downloads"), - "timezone": cfg.get("timezone", "Europe/Paris"), - "domain": cfg.get("domain", "torrent.secubox.local"), - "haproxy": cfg.get("haproxy", False), - "docker_available": rt is not None, - "runtime": rt or "none", - "container_status": container["status"], - "container_uptime": container["uptime"], - "disk_usage": disk_usage, - "rss_count": len(rss_feeds), - "category_count": len(categories), - "download_limit": cfg.get("download_limit", 0), - "upload_limit": cfg.get("upload_limit", 0), - "alt_speed_enabled": cfg.get("alt_speed_enabled", False), - } - - -# ============================================================================ -# Protected Endpoints β€” Configuration -# ============================================================================ - -@router.get("/config") -async def get_torrent_config(user=Depends(require_jwt)): - """Get torrent configuration.""" - return get_config() - - -@router.post("/config") -async def set_torrent_config(config: TorrentConfig, user=Depends(require_jwt)): - """Update torrent configuration.""" - cfg = get_config() - cfg.update(config.dict()) - save_config(cfg) - log.info(f"Config updated by {user.get('sub', 'unknown')}") - return {"success": True} - - -# ============================================================================ -# Protected Endpoints β€” Torrents -# ============================================================================ - -@router.get("/torrents") -async def list_torrents(user=Depends(require_jwt)): - """List all torrents.""" - if not is_running(): - return {"torrents": [], "error": "Service not running"} - - try: - result = transmission_rpc("torrent-get", { - "fields": [ - "id", "name", "status", "percentDone", "totalSize", - "downloadedEver", "uploadedEver", "rateDownload", "rateUpload", - "eta", "addedDate", "doneDate", "error", "errorString", - "trackerStats", "labels", "downloadDir", "isFinished", - "peersConnected", "seedRatioLimit", "uploadRatio" - ] - }) - - if result.get("result") != "success": - return {"torrents": [], "error": result.get("result")} - - torrents = [] - for t in result.get("arguments", {}).get("torrents", []): - status_map = { - 0: "stopped", - 1: "queued_verify", - 2: "verifying", - 3: "queued_download", - 4: "downloading", - 5: "queued_seed", - 6: "seeding", - } - torrents.append({ - "id": t["id"], - "name": t["name"], - "status": status_map.get(t["status"], "unknown"), - "progress": round(t["percentDone"] * 100, 1), - "size": t["totalSize"], - "downloaded": t["downloadedEver"], - "uploaded": t["uploadedEver"], - "download_speed": t["rateDownload"], - "upload_speed": t["rateUpload"], - "eta": t["eta"] if t["eta"] >= 0 else None, - "added": datetime.fromtimestamp(t["addedDate"]).isoformat() if t["addedDate"] else None, - "completed": datetime.fromtimestamp(t["doneDate"]).isoformat() if t["doneDate"] else None, - "error": t["errorString"] if t["error"] else None, - "peers": t["peersConnected"], - "ratio": round(t["uploadRatio"], 2), - "category": t.get("labels", [""])[0] if t.get("labels") else "", - "download_dir": t["downloadDir"], - }) - - return {"torrents": torrents} - - except Exception as e: - log.error(f"Failed to list torrents: {e}") - return {"torrents": [], "error": str(e)} - - -@router.post("/torrent/add") -async def add_torrent( - request: AddTorrentRequest = None, - torrent_file: UploadFile = File(None), - magnet: str = Form(None), - user=Depends(require_jwt) -): - """Add a torrent from magnet link, URL, or file.""" - if not is_running(): - raise HTTPException(503, "Service not running") - - try: - args = {"paused": False} - - # Handle different input types - if torrent_file: - # Upload torrent file - import base64 - content = await torrent_file.read() - args["metainfo"] = base64.b64encode(content).decode() - elif magnet: - args["filename"] = magnet - elif request: - if request.magnet: - args["filename"] = request.magnet - elif request.url: - args["filename"] = request.url - args["paused"] = request.paused - if request.download_dir: - args["download-dir"] = request.download_dir - else: - raise HTTPException(400, "No torrent source provided") - - result = transmission_rpc("torrent-add", args) - - if result.get("result") == "success": - torrent_info = result.get("arguments", {}) - added = torrent_info.get("torrent-added") or torrent_info.get("torrent-duplicate") - log.info(f"Torrent added by {user.get('sub', 'unknown')}: {added.get('name', 'unknown')}") - return { - "success": True, - "torrent": { - "id": added.get("id"), - "name": added.get("name"), - "hash": added.get("hashString"), - } - } - else: - return {"success": False, "error": result.get("result")} - - except HTTPException: - raise - except Exception as e: - log.error(f"Add torrent failed: {e}") - return {"success": False, "error": str(e)} - - -@router.delete("/torrent/{torrent_id}") -async def remove_torrent(torrent_id: int, delete_data: bool = False, user=Depends(require_jwt)): - """Remove a torrent.""" - if not is_running(): - raise HTTPException(503, "Service not running") - - try: - result = transmission_rpc("torrent-remove", { - "ids": [torrent_id], - "delete-local-data": delete_data, - }) - - if result.get("result") == "success": - log.info(f"Torrent {torrent_id} removed by {user.get('sub', 'unknown')} (delete_data={delete_data})") - return {"success": True} - else: - return {"success": False, "error": result.get("result")} - - except Exception as e: - log.error(f"Remove torrent failed: {e}") - return {"success": False, "error": str(e)} - - -@router.post("/torrent/{torrent_id}/pause") -async def pause_torrent(torrent_id: int, user=Depends(require_jwt)): - """Pause a torrent.""" - if not is_running(): - raise HTTPException(503, "Service not running") - - try: - result = transmission_rpc("torrent-stop", {"ids": [torrent_id]}) - if result.get("result") == "success": - log.info(f"Torrent {torrent_id} paused by {user.get('sub', 'unknown')}") - return {"success": True} - return {"success": False, "error": result.get("result")} - except Exception as e: - return {"success": False, "error": str(e)} - - -@router.post("/torrent/{torrent_id}/resume") -async def resume_torrent(torrent_id: int, user=Depends(require_jwt)): - """Resume a torrent.""" - if not is_running(): - raise HTTPException(503, "Service not running") - - try: - result = transmission_rpc("torrent-start", {"ids": [torrent_id]}) - if result.get("result") == "success": - log.info(f"Torrent {torrent_id} resumed by {user.get('sub', 'unknown')}") - return {"success": True} - return {"success": False, "error": result.get("result")} - except Exception as e: - return {"success": False, "error": str(e)} - - -@router.get("/torrent/{torrent_id}/files") -async def get_torrent_files(torrent_id: int, user=Depends(require_jwt)): - """Get files in a torrent.""" - if not is_running(): - raise HTTPException(503, "Service not running") - - try: - result = transmission_rpc("torrent-get", { - "ids": [torrent_id], - "fields": ["files", "fileStats"] - }) - - if result.get("result") != "success": - return {"files": [], "error": result.get("result")} - - torrents = result.get("arguments", {}).get("torrents", []) - if not torrents: - return {"files": [], "error": "Torrent not found"} - - t = torrents[0] - files = [] - for i, f in enumerate(t.get("files", [])): - stats = t.get("fileStats", [])[i] if i < len(t.get("fileStats", [])) else {} - files.append({ - "name": f["name"], - "size": f["length"], - "downloaded": f["bytesCompleted"], - "progress": round(f["bytesCompleted"] / f["length"] * 100, 1) if f["length"] > 0 else 0, - "wanted": stats.get("wanted", True), - "priority": stats.get("priority", 0), - }) - - return {"files": files} - - except Exception as e: - return {"files": [], "error": str(e)} - - -# ============================================================================ -# Statistics -# ============================================================================ - -@router.get("/stats") -async def get_stats(user=Depends(require_jwt)): - """Get download/upload statistics.""" - if _stats_cache: - return _stats_cache - - # Try loading from cache file - cache_file = CACHE_DIR / "stats.json" - if cache_file.exists(): - try: - return json.loads(cache_file.read_text()) - except Exception: - pass - - return { - "download_speed": 0, - "upload_speed": 0, - "active_torrents": 0, - "paused_torrents": 0, - "total_downloaded": 0, - "total_uploaded": 0, - } - - -# ============================================================================ -# RSS Feeds -# ============================================================================ - -@router.get("/rss/feeds") -async def list_rss_feeds(user=Depends(require_jwt)): - """List RSS feed subscriptions.""" - return {"feeds": get_rss_feeds()} - - -@router.post("/rss/add") -async def add_rss_feed(feed: RSSFeed, user=Depends(require_jwt)): - """Add an RSS feed subscription.""" - feeds = get_rss_feeds() - - # Check for duplicate - for f in feeds: - if f.get("url") == feed.url: - return {"success": False, "error": "Feed URL already exists"} - - feeds.append({ - "id": f"rss_{len(feeds)+1}_{int(datetime.now().timestamp())}", - "name": feed.name, - "url": feed.url, - "category": feed.category, - "auto_download": feed.auto_download, - "filter_pattern": feed.filter_pattern, - "added": datetime.now().isoformat(), - }) - save_rss_feeds(feeds) - - log.info(f"RSS feed added: {feed.name} by {user.get('sub', 'unknown')}") - return {"success": True} - - -@router.delete("/rss/{feed_id}") -async def remove_rss_feed(feed_id: str, user=Depends(require_jwt)): - """Remove an RSS feed.""" - feeds = get_rss_feeds() - new_feeds = [f for f in feeds if f.get("id") != feed_id] - - if len(new_feeds) == len(feeds): - return {"success": False, "error": "Feed not found"} - - save_rss_feeds(new_feeds) - log.info(f"RSS feed removed: {feed_id} by {user.get('sub', 'unknown')}") - return {"success": True} - - -# ============================================================================ -# Categories -# ============================================================================ - -@router.get("/categories") -async def list_categories(user=Depends(require_jwt)): - """List torrent categories.""" - return {"categories": get_categories()} - - -@router.post("/categories") -async def add_category(category: Category, user=Depends(require_jwt)): - """Add a category.""" - categories = get_categories() - - for c in categories: - if c.get("name").lower() == category.name.lower(): - return {"success": False, "error": "Category already exists"} - - categories.append({ - "id": category.name.lower().replace(" ", "_"), - "name": category.name, - "save_path": category.save_path or f"/downloads/{category.name.lower()}", - }) - save_categories(categories) - - log.info(f"Category added: {category.name} by {user.get('sub', 'unknown')}") - return {"success": True} - - -@router.delete("/categories/{category_id}") -async def remove_category(category_id: str, user=Depends(require_jwt)): - """Remove a category.""" - categories = get_categories() - new_cats = [c for c in categories if c.get("id") != category_id] - - if len(new_cats) == len(categories): - return {"success": False, "error": "Category not found"} - - save_categories(new_cats) - log.info(f"Category removed: {category_id} by {user.get('sub', 'unknown')}") - return {"success": True} - - -# ============================================================================ -# Container Management -# ============================================================================ - -@router.get("/container/status") -async def container_status(user=Depends(require_jwt)): - """Get container status.""" - return get_container_status() - - -@router.post("/container/install") -def install_container(user=Depends(require_jwt)): - """Install torrent container.""" - rt = detect_runtime() - if not rt: - return {"success": False, "error": "No container runtime (docker/podman) found"} - - cfg = get_config() - data_path = Path(cfg.get("data_path", "/srv/torrent")) - - # Create directories - (data_path / "config").mkdir(parents=True, exist_ok=True) - (data_path / "downloads").mkdir(parents=True, exist_ok=True) - (data_path / "watch").mkdir(parents=True, exist_ok=True) - - image = cfg.get("image", "linuxserver/transmission:latest") - log.info(f"Installing torrent client ({image}) by {user.get('sub', 'unknown')}") - - try: - result = subprocess.run( - [rt, "pull", image], - capture_output=True, text=True, timeout=300 - ) - if result.returncode != 0: - return {"success": False, "error": result.stderr.strip(), "output": result.stdout} - - return {"success": True, "output": "Image pulled successfully"} - except subprocess.TimeoutExpired: - return {"success": False, "error": "Pull timeout"} - except Exception as e: - return {"success": False, "error": str(e)} - - -@router.post("/container/start") -async def start_container(user=Depends(require_jwt)): - """Start torrent container.""" - if is_running(): - return {"success": False, "error": "Already running"} - - rt = detect_runtime() - if not rt: - return {"success": False, "error": "No container runtime"} - - cfg = get_config() - data_path = Path(cfg.get("data_path", "/srv/torrent")) - port = cfg.get("port", 9091) - peer_port = cfg.get("peer_port", 51413) - image = cfg.get("image", "linuxserver/transmission:latest") - tz = cfg.get("timezone", "Europe/Paris") - - # Ensure directories exist - (data_path / "config").mkdir(parents=True, exist_ok=True) - (data_path / "downloads").mkdir(parents=True, exist_ok=True) - (data_path / "watch").mkdir(parents=True, exist_ok=True) - - cmd = [ - rt, "run", "-d", - "--name", CONTAINER_NAME, - "-v", f"{data_path}/config:/config", - "-v", f"{data_path}/downloads:/downloads", - "-v", f"{data_path}/watch:/watch", - "-e", f"TZ={tz}", - "-e", "PUID=1000", - "-e", "PGID=1000", - "-p", f"127.0.0.1:{port}:9091", - "-p", f"{peer_port}:51413", - "-p", f"{peer_port}:51413/udp", - "--restart", "unless-stopped", - ] - - cmd.append(image) - - log.info(f"Starting torrent client by {user.get('sub', 'unknown')}") - - try: - subprocess.run([rt, "rm", "-f", CONTAINER_NAME], capture_output=True, timeout=10) - result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) - await asyncio.sleep(3) - - if is_running(): - return {"success": True} - else: - return {"success": False, "error": result.stderr.strip() or "Failed to start"} - except Exception as e: - return {"success": False, "error": str(e)} - - -@router.post("/container/stop") -async def stop_container(user=Depends(require_jwt)): - """Stop torrent container.""" - rt = detect_runtime() - if not rt: - return {"success": False, "error": "No container runtime"} - - log.info(f"Stopping torrent client by {user.get('sub', 'unknown')}") - - try: - subprocess.run([rt, "stop", CONTAINER_NAME], capture_output=True, timeout=30) - return {"success": True} - except Exception as e: - return {"success": False, "error": str(e)} - - -@router.post("/container/restart") -async def restart_container(user=Depends(require_jwt)): - """Restart torrent container.""" - await stop_container(user) - await asyncio.sleep(2) - return await start_container(user) - - -@router.post("/container/uninstall") -def uninstall_container(user=Depends(require_jwt)): - """Uninstall torrent container.""" - rt = detect_runtime() - if not rt: - return {"success": False, "error": "No container runtime"} - - log.info(f"Uninstalling torrent client by {user.get('sub', 'unknown')}") - - try: - subprocess.run([rt, "stop", CONTAINER_NAME], capture_output=True, timeout=30) - subprocess.run([rt, "rm", "-f", CONTAINER_NAME], capture_output=True, timeout=10) - return {"success": True} - except Exception as e: - return {"success": False, "error": str(e)} - - -# ============================================================================ -# Logs -# ============================================================================ - -@router.get("/logs") -def get_logs(lines: int = 100, user=Depends(require_jwt)): - """Get container logs.""" - rt = detect_runtime() - if not rt: - return {"logs": "No container runtime"} - - try: - result = subprocess.run( - [rt, "logs", "--tail", str(lines), CONTAINER_NAME], - capture_output=True, text=True, timeout=10 - ) - logs = result.stdout + result.stderr - return {"logs": logs} - except Exception: - return {"logs": "No logs available"} - - -app.include_router(router) diff --git a/packages/secubox-torrent/debian/changelog b/packages/secubox-torrent/debian/changelog index d9b24c74..ff3b494a 100644 --- a/packages/secubox-torrent/debian/changelog +++ b/packages/secubox-torrent/debian/changelog @@ -1,3 +1,11 @@ +secubox-torrent (2.0.0-1~bookworm1) bookworm; urgency=medium + + * Pivot from Transmission to WebTorrent streaming (LXC-native, #917). + * Browser player (HTTP Range), ephemeral-by-default + Keep library on /data. + * Hybrid WebRTC+BitTorrent engine isolated in a dedicated LXC. + + -- Gerald KERMA Tue, 28 Jul 2026 14:00:00 +0200 + secubox-torrent (1.1.0-1~bookworm2) bookworm; urgency=medium * Phase 2: Requires=secubox-core.service -> Wants= on this module's unit diff --git a/packages/secubox-torrent/debian/control b/packages/secubox-torrent/debian/control index 1dba150d..cd9cb819 100644 --- a/packages/secubox-torrent/debian/control +++ b/packages/secubox-torrent/debian/control @@ -7,20 +7,9 @@ Standards-Version: 4.6.2 Package: secubox-torrent Architecture: all -Depends: ${misc:Depends}, secubox-core (>= 1.0), python3-uvicorn | python3-pip -Recommends: docker.io | podman -Description: BitTorrent client management for SecuBox - SecuBox module for managing BitTorrent downloads via Transmission. - Provides Docker container management, torrent lifecycle (add/remove/ - pause/resume), RSS feed subscriptions, category management, and - speed limiting. - . - Features: - - Transmission daemon via Docker/Podman - - Torrent add via magnet links, URLs, or .torrent files - - Download/upload speed limiting with scheduling - - RSS feed subscriptions for automatic downloads - - Category/label management - - P31 Phosphor light theme web UI - . - Provides FastAPI backend on /api/v1/torrent/ via Unix socket. +Depends: ${misc:Depends}, secubox-core (>= 1.0), lxc, debootstrap, nftables +Description: WebTorrent streaming for SecuBox (LXC-native) + Paste a magnet and stream it in the browser (HTTP Range) while it downloads. + Ephemeral by default with an optional Keep to a persistent library on /data. + The WebTorrent engine (hybrid WebRTC+BitTorrent) runs isolated in a dedicated + LXC; the host only proxies an authenticated vhost. diff --git a/packages/secubox-torrent/debian/postinst b/packages/secubox-torrent/debian/postinst index 2ff65538..a28236f8 100755 --- a/packages/secubox-torrent/debian/postinst +++ b/packages/secubox-torrent/debian/postinst @@ -1,27 +1,53 @@ -#!/bin/bash +#!/bin/sh +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind β€” GΓ©rald Kerma set -e case "$1" in configure) - # Ensure secubox user exists + # Ensure secubox user exists (shared across all secubox-* modules). id -u secubox >/dev/null 2>&1 || \ adduser --system --group --no-create-home \ --home /var/lib/secubox --shell /usr/sbin/nologin secubox - # Create runtime directories + + # Runtime + state directories (shared conventions; do not chown the + # /etc/secubox parent here β€” secubox-core owns it). install -d -o root -g root -m 1777 /run/secubox install -d -o secubox -g secubox -m 755 /var/lib/secubox - install -d -o secubox -g secubox -m 750 /var/cache/secubox/torrent - # Create data directories - install -d -m 755 /srv/torrent/config - install -d -m 755 /srv/torrent/downloads - install -d -m 755 /srv/torrent/watch - # Ensure nginx secubox.d directory exists - install -d -m 755 /etc/nginx/secubox.d - # Enable and start service - systemctl daemon-reload - systemctl enable secubox-torrent.service - systemctl start secubox-torrent.service || true - # Reload nginx to pick up new location - systemctl reload nginx 2>/dev/null || true + [ -d /etc/secubox ] || install -d -o secubox -g secubox -m 755 /etc/secubox + + # Parse the operator-facing TOML into the env vars install-lxc.sh (via + # lxc/install-lxc.sh's torrent.env writer) expects. Minimal grep parse β€” + # matches the flat `key = value` lines under [engine]/[retention]. + TORRENT_MAX_ACTIVE=$(grep -E '^max_active' /etc/secubox/torrent.toml 2>/dev/null | grep -oE '[0-9]+' | head -1) + TORRENT_WEBRTC=$(grep -E '^webrtc' /etc/secubox/torrent.toml 2>/dev/null | grep -oE 'true|false' | head -1) + TORRENT_TTL=$(grep -E '^ephemeral_ttl_hours' /etc/secubox/torrent.toml 2>/dev/null | grep -oE '[0-9]+' | head -1) + TORRENT_FLOOR=$(grep -E '^disk_floor_gb' /etc/secubox/torrent.toml 2>/dev/null | grep -oE '[0-9]+' | head -1) + export TORRENT_MAX_ACTIVE TORRENT_WEBRTC TORRENT_TTL TORRENT_FLOOR + + # Provision/refresh the dedicated WebTorrent LXC (idempotent: creates + # the container once, then redeploys the app + restarts the in-LXC + # unit on every upgrade). Never fail the postinst if LXC tooling isn't + # ready yet (e.g. first apt run before /data is mounted). + sh /usr/lib/secubox/torrent/install-lxc.sh \ + || echo "secubox-torrent: LXC provisioning deferred (run /usr/lib/secubox/torrent/install-lxc.sh manually)" + + # Load the egress visibility scope for the torrent LXC's veth. + if [ -f /etc/nftables.d/zz-torrent-egress.nft ]; then + nft -f /etc/nftables.d/zz-torrent-egress.nft 2>/dev/null || true + fi + + # Symlink the public vhost into sites-enabled (idempotent). Proxies + # torrent.gk2.secubox.in -> torrent LXC at 10.100.0.160:8090. Operator + # wires the HAProxy SNI ACL + ACME cert + sbxwaf route separately. + if [ -f /etc/nginx/sites-available/torrent.conf ] && [ -d /etc/nginx/sites-enabled ]; then + ln -sf ../sites-available/torrent.conf /etc/nginx/sites-enabled/torrent.conf + fi + + if command -v nginx >/dev/null 2>&1 && nginx -t >/dev/null 2>&1; then + systemctl reload nginx 2>/dev/null || true + fi ;; esac + #DEBHELPER# +exit 0 diff --git a/packages/secubox-torrent/debian/postrm b/packages/secubox-torrent/debian/postrm new file mode 100755 index 00000000..21e9a517 --- /dev/null +++ b/packages/secubox-torrent/debian/postrm @@ -0,0 +1,17 @@ +#!/bin/sh +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind β€” GΓ©rald Kerma +set -e +case "$1" in + purge) + # Destroy the dedicated WebTorrent LXC and its provisioning sentinel. + # Downloaded/kept media on /data/torrent is left in place (operator + # data, not package state) β€” same convention as other LXC-native + # modules (peertube, picobrew). + lxc-stop -n torrent -P /data/lxc 2>/dev/null || true + lxc-destroy -n torrent -P /data/lxc 2>/dev/null || true + rm -f /var/lib/secubox/torrent/.lxc-provisioned 2>/dev/null || true + ;; +esac +#DEBHELPER# +exit 0 diff --git a/packages/secubox-torrent/debian/prerm b/packages/secubox-torrent/debian/prerm index bb4fc3b1..d19a7583 100755 --- a/packages/secubox-torrent/debian/prerm +++ b/packages/secubox-torrent/debian/prerm @@ -1,14 +1,16 @@ -#!/bin/bash +#!/bin/sh +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind β€” GΓ©rald Kerma set -e case "$1" in - remove|upgrade) - # Remove nginx location config - rm -f /etc/nginx/secubox.d/torrent.conf - # Stop and disable service - systemctl stop secubox-torrent.service 2>/dev/null || true - systemctl disable secubox-torrent.service 2>/dev/null || true - # Reload nginx to remove location - systemctl reload nginx 2>/dev/null || true + remove|upgrade|deconfigure) + # No host-side daemon to stop (the WebTorrent engine runs inside the + # dedicated LXC, not as a host systemd unit). Just unwire the vhost. + rm -f /etc/nginx/sites-enabled/torrent.conf + if command -v nginx >/dev/null 2>&1 && nginx -t >/dev/null 2>&1; then + systemctl reload nginx 2>/dev/null || true + fi ;; esac #DEBHELPER# +exit 0 diff --git a/packages/secubox-torrent/debian/rules b/packages/secubox-torrent/debian/rules index b2880ff6..b60728a7 100755 --- a/packages/secubox-torrent/debian/rules +++ b/packages/secubox-torrent/debian/rules @@ -3,18 +3,37 @@ dh $@ override_dh_auto_install: - # API files - install -d debian/secubox-torrent/usr/lib/secubox/torrent/ - cp -r api debian/secubox-torrent/usr/lib/secubox/torrent/ - # Static www files - install -d debian/secubox-torrent/usr/share/secubox/www - [ -d www ] && cp -r www/. debian/secubox-torrent/usr/share/secubox/www/ || true - # Menu definitions + # WebTorrent LXC app source. Ships package-lock.json (needed by + # `npm ci` at provision time inside the LXC) but NOT node_modules + # (gitignored dev artifact; install-lxc.sh runs npm ci in the LXC). + install -d debian/secubox-torrent/usr/lib/secubox/torrent/app + cp -r lxc/app/. debian/secubox-torrent/usr/lib/secubox/torrent/app/ + rm -rf debian/secubox-torrent/usr/lib/secubox/torrent/app/node_modules + # LXC bootstrap script + in-LXC systemd unit (installed side by side so + # install-lxc.sh's $(dirname "$0") resolves the unit file on the board). + install -m 755 lxc/install-lxc.sh debian/secubox-torrent/usr/lib/secubox/torrent/install-lxc.sh + install -m 644 lxc/secubox-torrent.service debian/secubox-torrent/usr/lib/secubox/torrent/secubox-torrent.service + # Webui (browser player) + install -d debian/secubox-torrent/usr/share/secubox/www/torrent + cp -r www/torrent/. debian/secubox-torrent/usr/share/secubox/www/torrent/ + # Menu definition install -d debian/secubox-torrent/usr/share/secubox/menu.d - [ -d menu.d ] && cp -r menu.d/. debian/secubox-torrent/usr/share/secubox/menu.d/ || true - # Modular nginx config - install -d debian/secubox-torrent/etc/nginx/secubox.d - [ -f nginx/torrent.conf ] && cp nginx/torrent.conf debian/secubox-torrent/etc/nginx/secubox.d/ || true - # Systemd service - install -d debian/secubox-torrent/lib/systemd/system - [ -f systemd/secubox-torrent.service ] && cp systemd/secubox-torrent.service debian/secubox-torrent/lib/systemd/system/ || true + install -m 644 menu.d/612-torrent.json debian/secubox-torrent/usr/share/secubox/menu.d/ + # Config + install -d debian/secubox-torrent/etc/secubox + install -m 644 conf/torrent.toml debian/secubox-torrent/etc/secubox/torrent.toml + # Public vhost: full standalone server{} block (own server_name), so it + # MUST land in sites-available/ (+ postinst symlink into sites-enabled), + # NOT etc/nginx/secubox.d/ β€” that dir is location-only snippets merged + # into the shared hub server block (see nginx/torrent.conf header and + # packages/secubox-peertube/debian/rules for the verified pattern). + install -d debian/secubox-torrent/etc/nginx/sites-available + install -m 644 nginx/torrent.conf debian/secubox-torrent/etc/nginx/sites-available/torrent.conf + # nft egress scope: ship straight to /etc/nftables.d/ with a zz- prefix + # so it sorts after any table-creator base file (repo convention; see + # secubox-p2p/secubox-toolbox/secubox-threatmesh debian/rules+postinst). + # NOTE: /etc/secubox/nft.d/ is not an include path anywhere on the board + # (/etc/nftables.conf only does `include "/etc/nftables.d/*.nft"`) β€” the + # original task brief's path would ship a rule that never loads. + install -d debian/secubox-torrent/etc/nftables.d + install -m 644 nft/torrent-egress.nft debian/secubox-torrent/etc/nftables.d/zz-torrent-egress.nft diff --git a/packages/secubox-torrent/debian/secubox.yaml b/packages/secubox-torrent/debian/secubox.yaml index f294ef00..cb61b15a 100644 --- a/packages/secubox-torrent/debian/secubox.yaml +++ b/packages/secubox-torrent/debian/secubox.yaml @@ -4,14 +4,10 @@ name: secubox-torrent category: misc tier: lite -description: "BitTorrent client management for SecuBox" +description: "WebTorrent streaming for SecuBox (LXC-native)" depends: - secubox-core -api: - socket: /run/secubox/torrent.sock - health: /api/v1/torrent/health - ui: path: /srv/secubox/www/torrent diff --git a/packages/secubox-torrent/systemd/secubox-torrent.service b/packages/secubox-torrent/systemd/secubox-torrent.service deleted file mode 100644 index d24e9677..00000000 --- a/packages/secubox-torrent/systemd/secubox-torrent.service +++ /dev/null @@ -1,28 +0,0 @@ -[Unit] -# Only start if explicitly enabled (backend installed) -ConditionPathExists=/etc/secubox/torrent/enabled -Description=SecuBox Torrent β€” BitTorrent Client Management API -After=network.target secubox-core.service -Wants=secubox-core.service - -[Service] -UMask=0000 -Type=simple -User=secubox -Group=secubox -WorkingDirectory=/usr/lib/secubox/torrent -ExecStart=/usr/bin/python3 -m uvicorn api.main:app \ - --uds /run/secubox/torrent.sock \ - --log-level warning -Restart=on-failure -RestartSec=5 -PrivateTmp=true -NoNewPrivileges=true -RuntimeDirectory=secubox -RuntimeDirectoryPreserve=yes -RuntimeDirectoryMode=0775 -ProtectSystem=full -ReadWritePaths=/run/secubox /var/lib/secubox /var/cache/secubox /etc/secubox /srv/torrent - -[Install] -WantedBy=multi-user.target diff --git a/packages/secubox-torrent/tests/test_packaging.py b/packages/secubox-torrent/tests/test_packaging.py new file mode 100644 index 00000000..82dcd3c9 --- /dev/null +++ b/packages/secubox-torrent/tests/test_packaging.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind β€” GΓ©rald Kerma +# Source-Disclosed License β€” All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + +""" +SecuBox-Deb :: secubox-torrent :: packaging structural tests (v2.0.0 pivot) + +Guards the Transmission -> WebTorrent/LXC packaging pivot (#917, Task 8): +old FastAPI/Transmission control-plane gone, LXC deps present, install-lxc.sh +wired into postinst, and the public vhost lands in sites-available (not the +location-snippet secubox.d/ dir). +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def _read(p): + return (ROOT / p).read_text() + + +def test_control_is_v2_no_transmission(): + c = _read("debian/control") + assert "python3-uvicorn" not in c # old FastAPI gone + assert "transmission" not in c.lower() + assert "lxc" in c.lower() + + +def test_changelog_is_2_0_0(): + assert _read("debian/changelog").startswith("secubox-torrent (2.0.0-1~bookworm1)") + + +def test_postinst_runs_install_lxc(): + assert "install-lxc.sh" in _read("debian/postinst") + + +def test_no_old_fastapi_main(): + assert not (ROOT / "api" / "main.py").exists() + + +def test_no_old_host_systemd_unit(): + assert not (ROOT / "systemd" / "secubox-torrent.service").exists() + + +def test_vhost_installed_to_sites_available_not_secubox_d(): + rules = _read("debian/rules") + assert "sites-available" in rules + # Guard against reintroducing the location-snippet dir for this full vhost. + assert "secubox-torrent/etc/nginx/secubox.d/torrent.conf" not in rules + assert "secubox-torrent/etc/nginx/secubox-vhost.d" not in rules