mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:33 +00:00
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 <devel@cybermind.fr>
This commit is contained in:
parent
7890587a92
commit
6edd0dfe9d
|
|
@ -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
|
|||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <devel@cybermind.fr>
|
||||
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)
|
||||
|
|
@ -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 <devel@cybermind.fr> 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,27 +1,53 @@
|
|||
#!/bin/bash
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
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
|
||||
|
|
|
|||
17
packages/secubox-torrent/debian/postrm
Executable file
17
packages/secubox-torrent/debian/postrm
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/sh
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
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
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
#!/bin/bash
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
52
packages/secubox-torrent/tests/test_packaging.py
Normal file
52
packages/secubox-torrent/tests/test_packaging.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# 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
|
||||
Loading…
Reference in New Issue
Block a user