From c5dd6466bac74a8d3949c1282738f30774b82828 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 12 Aug 2026 01:55:16 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20pos=20network=20download=20=E2=80=94=20?= =?UTF-8?q?aria2=20RPC=20daemon=20+=20queue=20control=20(add/torrent/metal?= =?UTF-8?q?ink,=20watch,=20limits)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persistent aria2c as a systemd user service (pos-aria2.service, enable --now, linger warning) on localhost:6800 with a generated RPC secret in ~/.config/linux_post_install/download.env (chmod 600, env override). 18 commands: start/stop/status, add/torrent/metalink (auto-start, --tmux live view), list/info/files/peers, pause/resume/remove/purge/move, limit/set, watch (2s live repoll). JSON built via jq -nc --arg, never string interpolation. Deps: aria2 in preinstall PACKAGES, aria2c/jq/curl guards before --help. Docs: POS.md + howto/network.md + indices + Common Tasks row. --- AGENT_TODO.md | 2 + DOC/AGENT_Context_Project.md | 36 ++- DOC/HOWTO.md | 2 +- DOC/POS.md | 25 ++ DOC/howto/network.md | 47 +++ bin/pos | 1 + bin/pos-network-download | 558 +++++++++++++++++++++++++++++++++++ completions/pos.bash | 2 + preinstall.sh | 2 +- 9 files changed, 657 insertions(+), 18 deletions(-) create mode 100755 bin/pos-network-download diff --git a/AGENT_TODO.md b/AGENT_TODO.md index 66c6c01..02ecdf6 100644 --- a/AGENT_TODO.md +++ b/AGENT_TODO.md @@ -57,6 +57,8 @@ summary (newest last). - **2026-08-07** — `pos system health --send` notification-only; listener `@quiet` prefix (run mapped command without replying, for commands that self-notify). `/status=@quiet pos system health --send` = exactly one digest. +- **2026-08-12** — `pos network download` (`bin/pos-network-download`) — aria2 JSON-RPC daemon + queue control. **Daemon:** persistent `aria2c` as a systemd **user** service (`pos-aria2.service`, `${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user`, `enable --now` + linger warning on headless boxes), `--rpc-listen-port=6800`, generated `RPC_SECRET` in `~/.config/linux_post_install/download.env` (chmod 600, env override), unit flags `--continue=true --max-connection-per-server=16 --split=16 --seed-time=0 --dir=$HOME/Downloads`. **Commands (18):** `start`/`stop`/`status` (+ bare overview = status+list), `add [--dir --out --split --tmux]`, `torrent [--dir --seed --tmux]` (base64 `addTorrent`), `metalink [--tmux]`, `list` (active/waiting/stopped table), `info`/`files`/`peers `, `pause|resume|remove [gid|all]` (`--force` → `force*`), `purge`, `move `, `limit [gid] ` (`--upload`, 0=unlimited, `2M`/`512K`), `set [--gid]`, `watch [gid]` (2s live repoll; exits when that gid completes). `--tmux` opens a detached `dl-` session running `watch ` (name from `--out`/URL basename, sanitized, 40-char truncate, `-2` on collision; closes itself on completion). Deps: `aria2c`/`jq`/`curl` guards before `--help`; `aria2` added to preinstall PACKAGES. No stdin → not in `INTERACTIVE_CMDS`. JSON built with `jq -nc --arg` (never string interpolation — fixes JSON-quote bugs); `# POS_SUBCMDS:` (18) + `# POS_FLAGS:` → completions. Test seams `RPC_PORT`/`RPC_SECRET`/`DOWNLOAD_DIR`/`USER_SYSTEMD_DIR`/`ACTIVE_MARKER`; 76-case stub-PATH behavior suite green (unit content, secret 600, add→gid, tables, queue ops, error paths). Docs: POS.md network row+detail, howto/network.md section, HOWTO index, AGENT_Context Common Tasks row. `make gen && make check` green. + ## Now - (none — Tier 1 shipped: `pos system health`, `lib/notify.sh`, digest timer) diff --git a/DOC/AGENT_Context_Project.md b/DOC/AGENT_Context_Project.md index 9663717..8019bb9 100644 --- a/DOC/AGENT_Context_Project.md +++ b/DOC/AGENT_Context_Project.md @@ -10,19 +10,19 @@ | ## 1. Project Overview | 28–43 | -| ## 2. Directory Structure | 44–188 | -| ## 3. Installation Flow | 189–241 | -| ## 4. The `pos` CLI System | 242–310 | -| ## 5. Shared Library — `lib/common.sh` | 311–342 | -| ## 6. Docker Compose / ScaleTail | 343–385 | -| ## 7. Optional Apps (`apps/`) | 386–415 | -| ## 8. Entertainment Module | 416–429 | -| ## 9. Systemd Services | 430–442 | -| ## 10. Configuration Files | 443–469 | -| ## 11. Coding Conventions | 470–502 | -| ## 12. Development Workflow | 503–555 | -| ## 13. Key File Quick Reference | 556–611 | -| ## 14. Common Tasks for Agents | 612–639 | +| ## 2. Directory Structure | 44–189 | +| ## 3. Installation Flow | 190–242 | +| ## 4. The `pos` CLI System | 243–312 | +| ## 5. Shared Library — `lib/common.sh` | 313–344 | +| ## 6. Docker Compose / ScaleTail | 345–387 | +| ## 7. Optional Apps (`apps/`) | 388–417 | +| ## 8. Entertainment Module | 418–431 | +| ## 9. Systemd Services | 432–444 | +| ## 10. Configuration Files | 445–471 | +| ## 11. Coding Conventions | 472–504 | +| ## 12. Development Workflow | 505–557 | +| ## 13. Key File Quick Reference | 558–614 | +| ## 14. Common Tasks for Agents | 615–643 | ## 1. Project Overview @@ -76,6 +76,7 @@ Linux_post_install/ │ ├── pos-media-mp3 # Download audio as MP3 (yt-dlp) │ ├── pos-media-mp4 # Download video as MP4 (smart/interactive format select) │ ├── pos-network-checkport # Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view +│ ├── pos-network-download # aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) │ ├── pos-network-hotspot # Wi-Fi hotspot via create_ap + wihotspot-gui │ ├── pos-network-ip # Show interfaces, routes, public IP + location │ ├── pos-network-scan # Parallel ping sweep of CIDR @@ -276,6 +277,7 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst | media | mp3 | `pos-media-mp3` | Download audio as MP3 (yt-dlp) | | media | mp4 | `pos-media-mp4` | Download video as MP4 (smart/interactive format select) | | network | checkport | `pos-network-checkport` | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view | +| network | download | `pos-network-download` | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) | | network | hotspot | `pos-network-hotspot` | Wi-Fi hotspot via create_ap + wihotspot-gui | | network | ip | `pos-network-ip` | Show interfaces, routes, public IP + location | | network | scan | `pos-network-scan` | Parallel ping sweep of CIDR | @@ -558,7 +560,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | File | Lines | Purpose | |------|-------|---------| | `install.sh` | 206 | Main orchestrator — 4 phases with CLI flags, `--feature`, prebuilt arch bins | -| `preinstall.sh` | 55 | System packages + hotspot deps + yt-dlp + fail2ban | +| `preinstall.sh` | 73 | System packages + hotspot deps + yt-dlp + fail2ban | | `postinstall.sh` | 152 | fail2ban config, PATH, bash completion, systemd (flag-gated) | | `lib/common.sh` | 144 | Shared library (log/warn/err/run/spawn, dry-run aware, `load_system_env`) | | `lib/flags.sh` | 60 | Feature flag store (set/clear/is_set/value/list/status) | @@ -570,7 +572,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/flag-clear` | 21 | Unset a flag | | `features/autostart.sh` | 14 | Boot-time feature (moved from `bin/`, flag-gated service) | -| `bin/pos` | 290 | CLI dispatcher with smart arg matching + logging + category help | +| `bin/pos` | 291 | CLI dispatcher with smart arg matching + logging + category help | | `bin/pos-ai-gemini` | 311 | Chat with Google Gemini (ask, chat, models, sessions) | | `bin/pos-communication-matrix-listener` | 565 | Matrix listener: map /command → bash, run them on room messages | | `bin/pos-communication-matrix-sender` | 224 | Send messages to a Matrix room via the client-server API (send, test, login) | @@ -589,6 +591,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/pos-media-mp3` | 80 | Download audio as MP3 (yt-dlp) | | `bin/pos-media-mp4` | 126 | Download video as MP4 (smart/interactive format select) | | `bin/pos-network-checkport` | 496 | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view | +| `bin/pos-network-download` | 558 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) | | `bin/pos-network-hotspot` | 93 | Wi-Fi hotspot via create_ap + wihotspot-gui | | `bin/pos-network-ip` | 69 | Show interfaces, routes, public IP + location | | `bin/pos-network-scan` | 271 | Parallel ping sweep of CIDR | @@ -603,7 +606,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/pos-system-firewall` | 291 | Interactive UFW management | | `bin/pos-system-health` | 209 | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL | | `bin/pos-tree` | 112 | Show the pos CLI command tree: categories, commands, and subcommands | -| `completions/pos.bash` | 289 | Dynamic bash completion | +| `completions/pos.bash` | 291 | Dynamic bash completion | | `apps/install.sh` | 171 | App install/uninstall picker/orchestrator | @@ -629,6 +632,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | Modify Docker health check | Edit `bin/pos-docker-health` | | Modify vbox (Docker VM) logic | Edit `bin/pos-docker-vbox` | | Modify USB forwarding logic | Edit `bin/pos-share-usb-server` | +| Modify aria2 download daemon / queue logic | Edit `bin/pos-network-download` | | Modify NFS share logic | Edit `bin/pos-share-nfs-server` / `bin/pos-share-nfs-client` | | Modify SMB share logic | Edit `bin/pos-share-smb-server` / `bin/pos-share-smb-client` | | Modify AI/Gemini logic | Edit `bin/pos-ai-gemini` (config scope `ai` via `pos config ai`; `AI_GEMINI_API_KEY`/`AI_GEMINI_MODEL` in `~/.config/linux_post_install/ai.env`) | diff --git a/DOC/HOWTO.md b/DOC/HOWTO.md index 0096f05..c8b1edd 100644 --- a/DOC/HOWTO.md +++ b/DOC/HOWTO.md @@ -10,7 +10,7 @@ authoritative one-line reference (every command + flag), see | Category | What you can do | Guide | |----------|-----------------|-------| | `pos ai` | Chat with Google Gemini from CLI or Telegram | [ai](howto/ai.md) | -| `pos network` | IP info, hotspot, scan, port check | [network](howto/network.md) | +| `pos network` | IP info, hotspot, scan, port check, aria2 download daemon | [network](howto/network.md) | | `pos docker` | Compose services, container dashboards, disposable VMs | [docker](howto/docker.md) | | `pos media` | Download audio/video via yt-dlp | [media](howto/media.md) | | `pos system` | Backups, firewall, health dashboard | [system](howto/system.md) | diff --git a/DOC/POS.md b/DOC/POS.md index f76dbd8..cfef4cb 100644 --- a/DOC/POS.md +++ b/DOC/POS.md @@ -86,6 +86,7 @@ Precedence: `--model` flag > `AI_GEMINI_MODEL` env > config file > `gemini-2.5-f | `pos network checkport ` | `bin/pos-network-checkport` | Check if a TCP port is open | None. Uses `/dev/tcp` with a 2s timeout; exit 0/1 via OPEN/CLOSED | | `pos network scan [--full] [--retries N]` | `bin/pos-network-scan` | Two-phase nmap scan | See below | | `pos network hotspot [cmd]` | `bin/pos-network-hotspot` | Wi-Fi hotspot via `create_ap` (CLI) or `wihotspot-gui` (GUI) | Uses the precompiled binaries from `x64_bin/`; see below | +| `pos network download ` | `bin/pos-network-download` | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) | `aria2c`/`jq`/`curl`; daemon = systemd user service; secret in `~/.config/linux_post_install/download.env`; see below | **`pos network scan` in detail:** @@ -107,6 +108,30 @@ Backed by the precompiled binaries shipped in `x64_bin/` (see [SCRIPTS.md → x6 | `pos network hotspot stop []` | Stops the running access point via `create_ap --stop`; `` is an interface name or PID, auto-detected if omitted | | `pos network hotspot status` | Runs `create_ap --list-running` | +**`pos network download` in detail:** + +Runs a persistent `aria2c` JSON-RPC daemon (`localhost:6800`) as a **systemd user service** (`pos-aria2.service`, enabled with `systemctl --user enable --now`; prints a linger warning on headless boxes). `start` installs the unit and generates a random `RPC_SECRET` into `~/.config/linux_post_install/download.env` (chmod 600); the secret is also respected as the `RPC_SECRET` env var. Unit flags: `--continue=true --max-connection-per-server=16 --split=16 --seed-time=0 --dir=$HOME/Downloads`. + +| Command | Behavior | +|---------|----------| +| `pos network download start` / `stop` | Install+enable the systemd user service / stop and remove it | +| `pos network download status` | Daemon health + global transfer stats (`getGlobalStat`) | +| `pos network download add ...` | Enqueue HTTP/FTP downloads (auto-starts the daemon); options `--dir`, `--out`, `--split`, `--tmux` | +| `pos network download torrent ...` | Enqueue `.torrent` files (base64 via `addTorrent`) or magnet links; `--seed` keeps seeding (default `--seed-time=0`), `--dir`, `--tmux` | +| `pos network download metalink ...` | Enqueue `.metalink` files or URLs; `--tmux` | +| `pos network download list` | Table of active / waiting / finished downloads (GID, status, %, dl/up speeds, name) | +| `pos network download info ` | Full `tellStatus` dump (status, progress, speeds, ETA, error) | +| `pos network download files ` / `peers ` | Files of a download / peers of a torrent | +| `pos network download pause\|resume [gid\|all]` | Pause/resume one or all (default `all`) | +| `pos network download remove [gid\|all]` | Remove one or all; `--force` = `forceRemove` (kills immediately) | +| `pos network download purge` | Clear finished/error history | +| `pos network download move ` | Reorder the waiting queue (`changePosition`) | +| `pos network download limit [gid] ` | Speed limit, global or per-download (`--upload` = upload speed; `0` = unlimited); accepts `2M`/`512K` | +| `pos network download set ...` | Set global aria2 options (`--gid ` = per-download) | +| `pos network download watch [gid]` | Live table, 2 s refresh; with a GID it exits when that download completes | + +**`--tmux`:** after enqueueing, `add`/`torrent`/`metalink` open a detached tmux session `dl-` running `watch ` (name from `--out` or the URL basename, sanitized and truncated to 40 chars; `-2` suffix on collision). The session closes itself when the download finishes — attach with `tmux attach -t dl-`. + ### docker | Command | File | Purpose | Configuration | diff --git a/DOC/howto/network.md b/DOC/howto/network.md index 47074cb..cc237e2 100644 --- a/DOC/howto/network.md +++ b/DOC/howto/network.md @@ -109,6 +109,53 @@ local firewall — check `sudo pos system firewall` rules and service binds --- +## `pos network download` — aria2 download daemon + queue + +Runs a persistent `aria2c` JSON-RPC daemon as a systemd **user** service +(`pos-aria2.service`, enabled via `systemctl --user enable --now`; a linger +warning is printed when no session is active, so the daemon survives logout). +The RPC secret lives in `~/.config/linux_post_install/download.env` (chmod 600). +Downloads land in `~/Downloads` by default. + +```bash +# enqueue one or many files (auto-starts the daemon if needed) +pos network download add https://example.com/ubuntu.iso +pos network download add https://example.com/a.bin https://example.com/b.bin --dir /mnt/hdd/dl + +# torrents / magnets — keep seeding with --seed (default: stop at 100%) +pos network download torrent debian.torrent --seed +pos network download torrent "magnet:?xt=urn:btih:…" + +# watch progress live (Ctrl+C detaches); with a GID it exits when that one finishes +pos network download watch +pos network download watch a1b2c3d4e5f60718 + +# the queue +pos network download list +pos network download pause a1b2c3d4e5f60718 # resume / remove work the same +pos network download remove --force all # kill everything immediately +pos network download limit 2M # global speed cap (0 = unlimited) +pos network download move a1b2c3d4e5f60718 0 # jump to the front of the queue +``` + +**Recipe:** grab a big ISO in the background and keep an eye on it from a +terminal: + +```bash +pos network download add https://example.com/ubuntu.iso --tmux +tmux attach -t dl-ubuntu.iso # live progress; session closes itself when done +``` + +`--tmux` opens a detached `dl-` session running `watch ` (name from +`--out` or the URL basename, sanitized; `-2` suffix on collision). + +**Troubleshooting:** "RPC failed — is the daemon running?" → `pos network +download status`; if the unit failed, check the journal with +`journalctl --user -u pos-aria2 -n 50`. Seed/limit knobs live on `add`/`torrent` +(`--seed`, `--split`) and on `limit`/`set` for the running queue. + +--- + ## Related - Reference tables: [DOC/POS.md → network](../POS.md) diff --git a/bin/pos b/bin/pos index e67fdea..e83f584 100755 --- a/bin/pos +++ b/bin/pos @@ -142,6 +142,7 @@ EXAMPLES pos network checkport 10.0.0.1:80 Check if a TCP port is open pos network scan 192.168.1.0/24 Fast parallel ping sweep pos network hotspot Launch wihotspot-gui + pos network download add Enqueue an aria2 download (--tmux = live view) pos docker ps List containers (health, IPs, ports) pos docker compose ls List available ScaleTail services diff --git a/bin/pos-network-download b/bin/pos-network-download new file mode 100755 index 0000000..04278f1 --- /dev/null +++ b/bin/pos-network-download @@ -0,0 +1,558 @@ +#!/usr/bin/env bash +set -euo pipefail +# POS: network download — aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) +# POS_SUBCMDS: start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch +# POS_FLAGS: --dir --out --split --seed --force --upload --gid --tmux + +source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" + +command -v aria2c &>/dev/null || err "aria2c not found (install aria2)" +command -v jq &>/dev/null || err "jq not found (install jq)" +command -v curl &>/dev/null || err "curl not found (install curl)" + +# ── Config / seams (env overrides for tests) ─────────────────── +RPC_PORT="${RPC_PORT:-6800}" +RPC_URL="http://127.0.0.1:$RPC_PORT/jsonrpc" +CONFIG_DIR="${CONFIG_DIR:-$HOME/.config/linux_post_install}" +CONFIG_FILE="$CONFIG_DIR/download.env" +DOWNLOAD_DIR="${DOWNLOAD_DIR:-$HOME/Downloads}" +USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}" +SERVICE="pos-aria2.service" + +load_secret() { + if [ -z "${RPC_SECRET:-}" ] && [ -f "$CONFIG_FILE" ]; then + RPC_SECRET=$(grep -E '^RPC_SECRET=' "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2-) + fi + RPC_SECRET="${RPC_SECRET:-}" +} +load_secret + +# Shared awk helpers: hs = human speed, et = human eta (seconds). +ROW_AWK=' +function hs(s){ if(s>=1073741824)return sprintf("%.1fG/s", s/1073741824); if(s>=1048576)return sprintf("%.1fM/s", s/1048576); if(s>=1024)return sprintf("%.1fK/s", s/1024); return sprintf("%dB/s", s) } +function et(s){ s=s+0; if(s<=0)return "—"; if(s>=86400)return sprintf("%dd%dh", int(s/86400), int(s%86400/3600)); if(s>=3600)return sprintf("%dh%dm", int(s/3600), int(s%3600/60)); if(s>=60)return sprintf("%dm%ds", int(s/60), s%60); return sprintf("%ds", s) } +' + +usage() { + cat < [args] + +aria2 download daemon + queue control. Runs a persistent aria2c with JSON-RPC +(systemd user service on localhost:$RPC_PORT) and drives it via the RPC API. + +Commands: + start Start the daemon (installs the systemd user service, generates RPC secret) + stop Stop the daemon and remove the service + status Daemon health + global transfer stats + add [...] Enqueue HTTP/FTP downloads (auto-starts the daemon) + torrent ... Enqueue .torrent files or magnet links + metalink Enqueue a .metalink file or URL + list Show active / waiting / finished downloads + info Full status of one download + files Files of a download + peers Peers of a torrent download + pause|resume [gid|all] Pause / resume one or all downloads (default all) + remove [gid|all] Remove one or all (--force kills immediately) + purge Clear finished/error download history + move Reorder the waiting queue + limit [gid] Speed limit, global or per-download (--upload = up; 0 = unlimited) + set ... Set global aria2 options (--gid = per-download) + watch [gid] Live progress, 2s refresh (Ctrl+C to detach) + +Options (add / torrent / metalink): + --dir download directory (default: $DOWNLOAD_DIR) + --out output file name (add only) + --split connections per download (default: 16) + --seed torrent: keep seeding after download (torrent only) + --tmux show live progress in a tmux session named after the file + (attach with: tmux attach -t dl-) + +Examples: + pos network download add https://example.com/ubuntu.iso --tmux + pos network download add https://example.com/a.bin https://example.com/b.bin --dir /mnt/hdd/dl + pos network download torrent file.torrent --seed + pos network download list + pos network download limit 2M + pos network download watch + +Config: RPC secret in $CONFIG_FILE (chmod 600). +Env: RPC_PORT, RPC_SECRET, DOWNLOAD_DIR override defaults (test seams). +EOF + exit 0 +} + +# ── JSON helpers ──────────────────────────────────────────────── +json_arr() { jq -nc '$ARGS.positional' --args "$@"; } +json_str() { jq -nc --arg s "$1" '$s'; } + +opts_json() { # key=value... → JSON object + local kv k v o="{}" + for kv in "$@"; do + k="${kv%%=*}"; v="${kv#*=}" + o=$(jq -nc --argjson o "$o" --arg k "$k" --arg v "$v" '$o + {($k): $v}') + done + printf '%s' "$o" +} + +# ── RPC client ───────────────────────────────────────────────── +rpc() { # rpc [json-args...] + local method="$1"; shift + local params="" a + for a in "$@"; do params="$params,$a"; done + local body + body=$(curl -fsS --noproxy '*' -m 60 -H 'Content-Type: application/json' \ + -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$method\",\"params\":[\"token:${RPC_SECRET}\"${params}]}" \ + "$RPC_URL" 2>/dev/null) \ + || err "RPC failed — is the daemon running? (pos network download start)" + local ecode + ecode=$(printf '%s' "$body" | jq -r '.error.code // empty' 2>/dev/null || true) + if [ -n "$ecode" ]; then + err "aria2 RPC error $ecode: $(printf '%s' "$body" | jq -r '.error.message // "?"')" + fi + printf '%s' "$body" +} + +# ── Daemon lifecycle (systemd user service) ──────────────────── +daemon_active() { systemctl --user is-active --quiet "$SERVICE" 2>/dev/null; } + +cmd_start() { + if [ -z "$RPC_SECRET" ] && [ -f "$CONFIG_FILE" ]; then + RPC_SECRET=$(grep -E '^RPC_SECRET=' "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2-) + fi + if [ -z "$RPC_SECRET" ]; then + RPC_SECRET=$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n') + if [ "${DRY_RUN:-0}" -eq 1 ]; then + log "(dry-run) write RPC secret to $CONFIG_FILE" + else + mkdir -p "$CONFIG_DIR" + printf 'RPC_SECRET=%s\n' "$RPC_SECRET" > "$CONFIG_FILE" + chmod 600 "$CONFIG_FILE" + log "rpc secret written: $CONFIG_FILE" + fi + fi + mkdir -p "$DOWNLOAD_DIR" 2>/dev/null || warn "cannot create download dir: $DOWNLOAD_DIR" + if [ "${DRY_RUN:-0}" -eq 1 ]; then + log "(dry-run) install unit $USER_SYSTEMD_DIR/$SERVICE" + log "(dry-run) systemctl --user daemon-reload && enable --now $SERVICE" + else + mkdir -p "$USER_SYSTEMD_DIR" + cat > "$USER_SYSTEMD_DIR/$SERVICE" </dev/null 2>&1; then + if ! loginctl show-user "$(id -un)" 2>/dev/null | grep -q '^Linger=yes'; then + warn "enable linger so the daemon survives logout: sudo loginctl enable-linger $(id -un)" + fi + fi +} + +cmd_stop() { + if [ ! -f "$USER_SYSTEMD_DIR/$SERVICE" ]; then + warn "no aria2 daemon service installed ($SERVICE)" + exit 0 + fi + if [ "${DRY_RUN:-0}" -eq 1 ]; then + log "(dry-run) systemctl --user disable --now $SERVICE; remove unit" + else + systemctl --user disable --now "$SERVICE" 2>/dev/null || true + rm -f "$USER_SYSTEMD_DIR/$SERVICE" + systemctl --user daemon-reload + fi + log "aria2 daemon stopped and removed" +} + +cmd_status() { + if daemon_active; then echo "daemon: running"; else echo "daemon: not running"; fi + if systemctl --user is-enabled "$SERVICE" >/dev/null 2>&1; then + echo "autostart: enabled (starts on login)" + else + echo "autostart: disabled" + fi + echo "rpc: http://127.0.0.1:$RPC_PORT" + [ -f "$CONFIG_FILE" ] && echo "secret: $CONFIG_FILE" + echo "dir: $DOWNLOAD_DIR" + if daemon_active; then + rpc aria2.getGlobalStat | jq -r '"active: \(.result.numActive) waiting: \(.result.numWaiting) stopped: \(.result.numStopped) (history: \(.result.numStoppedTotal))"' + echo "aria2: $(rpc aria2.getVersion | jq -r '.result.version')" + fi +} + +ensure_daemon() { + if ! daemon_active; then + log "daemon not running — starting it" + cmd_start + fi +} + +# ── Submit ───────────────────────────────────────────────────── +basename_url() { + local u="$1" b + b="${u##*/}"; b="${b%%\?*}"; b="${b%%\#*}" + [ -n "$b" ] || b="download" + printf '%s' "$b" +} + +cmd_add() { + local dir="$DOWNLOAD_DIR" out="" split="" tmux=0 + local urls=() + while [ $# -gt 0 ]; do + case "$1" in + --dir) dir="${2:-$DOWNLOAD_DIR}"; shift 2 ;; + --dir=*) dir="${1#*=}"; shift ;; + --out) out="${2:-}"; shift 2 ;; + --out=*) out="${1#*=}"; shift ;; + --split) split="${2:-}"; shift 2 ;; + --split=*) split="${1#*=}"; shift ;; + --tmux) tmux=1; shift ;; + -h|--help) usage ;; + -*) err "add: unknown option: $1" ;; + *) urls+=("$1"); shift ;; + esac + done + [ ${#urls[@]} -gt 0 ] || err "add: no URL given" + [ "$tmux" -eq 1 ] && { command -v tmux &>/dev/null || err "tmux not found (install tmux) — needed for --tmux"; } + dir="${dir/#\~/$HOME}" + ensure_daemon + local oa=() + [ -n "$dir" ] && oa+=("dir=$dir") + [ -n "$out" ] && oa+=("out=$out") + [ -n "$split" ] && oa+=("split=$split") + local resp gid + resp=$(rpc aria2.addUri "$(json_arr "${urls[@]}")" "$(opts_json "${oa[@]}")") + gid=$(printf '%s' "$resp" | jq -r '.result') + log "added $gid: ${urls[*]}" + if [ "$tmux" -eq 1 ]; then tmux_watch "$gid" "${out:-$(basename_url "${urls[0]}")}"; fi +} + +cmd_torrent() { + local dir="$DOWNLOAD_DIR" seed=0 tmux=0 + local items=() + while [ $# -gt 0 ]; do + case "$1" in + --dir) dir="${2:-$DOWNLOAD_DIR}"; shift 2 ;; + --dir=*) dir="${1#*=}"; shift ;; + --seed) seed=1; shift ;; + --tmux) tmux=1; shift ;; + -h|--help) usage ;; + -*) err "torrent: unknown option: $1" ;; + *) items+=("$1"); shift ;; + esac + done + [ ${#items[@]} -gt 0 ] || err "torrent: need a .torrent file or magnet link" + [ "$tmux" -eq 1 ] && { command -v tmux &>/dev/null || err "tmux not found (install tmux) — needed for --tmux"; } + dir="${dir/#\~/$HOME}" + ensure_daemon + local item + for item in "${items[@]}"; do + local resp gid name + if [[ "$item" =~ ^magnet: ]]; then + resp=$(rpc aria2.addUri "$(json_arr "$item")" "$(opts_json "dir=$dir")") + name="magnet" + else + [ -f "$item" ] || err "torrent: no such file: $item" + name=$(basename "$item"); name="${name%.torrent}" + resp=$(rpc aria2.addTorrent "$(json_str "$(base64 -w0 "$item")")" "[]" "$(opts_json "dir=$dir")") + fi + gid=$(printf '%s' "$resp" | jq -r '.result') + if [ "$seed" -eq 1 ]; then + rpc aria2.changeOption "$(json_str "$gid")" "$(opts_json "seed-ratio=0")" >/dev/null + log "added torrent $gid: $item (seeding until stopped)" + else + log "added torrent $gid: $item" + fi + if [ "$tmux" -eq 1 ]; then tmux_watch "$gid" "$name"; fi + done +} + +cmd_metalink() { + local dir="$DOWNLOAD_DIR" tmux=0 item="" + while [ $# -gt 0 ]; do + case "$1" in + --dir) dir="${2:-$DOWNLOAD_DIR}"; shift 2 ;; + --dir=*) dir="${1#*=}"; shift ;; + --tmux) tmux=1; shift ;; + -h|--help) usage ;; + -*) err "metalink: unknown option: $1" ;; + *) item="$1"; shift ;; + esac + done + [ -n "$item" ] || err "metalink: need a .metalink file or URL" + [ "$tmux" -eq 1 ] && { command -v tmux &>/dev/null || err "tmux not found (install tmux) — needed for --tmux"; } + dir="${dir/#\~/$HOME}" + ensure_daemon + local src="$item" name + if [[ "$item" =~ ^https?:// ]]; then + src=$(mktemp) + trap 'rm -f "$src"' EXIT + curl -fsSL --noproxy '*' -m 120 -o "$src" "$item" || err "metalink: failed to fetch $item" + name=$(basename_url "$item") + else + [ -f "$item" ] || err "metalink: no such file: $item" + name=$(basename "$item") + fi + local resp gid + resp=$(rpc aria2.addMetalink "$(json_str "$(base64 -w0 "$src")")" "{}") + gid=$(printf '%s' "$resp" | jq -r '.result') + if [[ "$item" =~ ^https?:// ]]; then + trap - EXIT + rm -f "$src" + fi + log "added metalink $gid: $item" + if [ "$tmux" -eq 1 ]; then tmux_watch "$gid" "$name"; fi +} + +# ── Listing / inspection ─────────────────────────────────────── +LIST_JQ='.result[] | [.gid[0:8], .status, + (if ((.totalLength|tonumber) > 0) then (100*(.completedLength|tonumber)/(.totalLength|tonumber)) else 0 end), + (.downloadSpeed|tonumber), (.uploadSpeed|tonumber), + (.bittorrent.info.name // ((.files[0].path // "")|split("/")|last))] | @tsv' + +fmt_list() { + awk -F'\t' "$ROW_AWK"'{ printf " %-8s %-9s %6.1f%% %9s %9s %s\n", $1, $2, $3, hs($4), hs($5), $6 }' +} + +cmd_list() { + local act wait stp n + act=$(rpc aria2.tellActive) + wait=$(rpc aria2.tellWaiting 0 100) + stp=$(rpc aria2.tellStopped 0 50) + n=$(printf '%s' "$act" | jq '.result | length') + printf 'ACTIVE (%s)\n' "$n" + printf ' %-8s %-9s %7s %9s %9s %s\n' GID STATUS PCT DL UL NAME + printf '%s' "$act" | jq -r "$LIST_JQ" | fmt_list + n=$(printf '%s' "$wait" | jq '.result | length') + printf 'WAITING (%s)\n' "$n" + printf ' %-8s %-9s %7s %9s %9s %s\n' GID STATUS PCT DL UL NAME + printf '%s' "$wait" | jq -r "$LIST_JQ" | fmt_list + n=$(printf '%s' "$stp" | jq '.result | length') + printf 'STOPPED (last %s)\n' "$n" + printf ' %-8s %-9s %7s %9s %9s %s\n' GID STATUS PCT DL UL NAME + printf '%s' "$stp" | jq -r "$LIST_JQ" | fmt_list +} + +cmd_info() { + local gid="${1:-}" + [ -n "$gid" ] || err "info: gid required (see 'pos network download list')" + rpc aria2.tellStatus "$(json_str "$gid")" | jq -r ' + .result as $r | + "gid: \($r.gid)", + "status: \($r.status)", + (if (($r.totalLength|tonumber) > 0) then "progress: \(100*($r.completedLength|tonumber)/($r.totalLength|tonumber)|floor)% \($r.completedLength)/\($r.totalLength) bytes" else "progress: unknown (metadata still downloading)" end), + "dl speed: \($r.downloadSpeed) B/s", + "up speed: \($r.uploadSpeed) B/s", + "connections: \($r.connections)", + (if ($r.numSeeders // 0 | tonumber) > 0 then "seeders: \($r.numSeeders)" else empty end), + (if ($r.errorCode | tonumber) > 0 then "error: \($r.errorCode) — \($r.errorMessage // "?")" else empty end), + (if $r.bittorrent then "torrent: \($r.bittorrent.info.name // "?")" + "\n" + "info-hash: \($r.infoHash)" else empty end), + "dir: \($r.dir)", + "files:", + ($r.files[] | " [\(.index)] \(.path) \(.completedLength)/\(.length) bytes")' +} + +cmd_files() { + local gid="${1:-}" + [ -n "$gid" ] || err "files: gid required" + rpc aria2.getFiles "$(json_str "$gid")" | jq -r '.result[] | [.index, .path, .length, .completedLength] | @tsv' \ + | awk -F'\t' '{ printf " [%s] %s %s/%s bytes\n", $1, $2, $4, $3 }' +} + +cmd_peers() { + local gid="${1:-}" + [ -n "$gid" ] || err "peers: gid required" + rpc aria2.getPeers "$(json_str "$gid")" | jq -r '.result[] | [.peerId[0:12], .ip, (.port|tostring), .downloadSpeed, .uploadSpeed, ((.progress|tonumber) * 100)] | @tsv' \ + | awk -F'\t' "$ROW_AWK"'{ printf " %-12s %-16s %6s dl %s up %s %5.1f%%\n", $1, $2, $3, hs($4), hs($5), $6 }' +} + +# ── Queue control ────────────────────────────────────────────── +queue_cmd() { + local single="$1" all="$2" verb="$3" target="${4:-all}" + if [ "$target" = "all" ]; then + rpc "$all" >/dev/null + else + rpc "$single" "$(json_str "$target")" >/dev/null + fi + log "$verb: $target" +} + +cmd_pause() { queue_cmd aria2.pause aria2.pauseAll "paused" "$@"; } +cmd_resume() { queue_cmd aria2.unpause aria2.unpauseAll "resumed" "$@"; } + +cmd_remove() { + local force=0 target="${1:-all}" + if [ "${1:-}" = "--force" ]; then force=1; target="${2:-all}"; fi + if [ "$target" = "all" ]; then + if [ "$force" -eq 1 ]; then rpc aria2.forceRemoveAll >/dev/null; else rpc aria2.removeAll >/dev/null; fi + else + if [ "$force" -eq 1 ]; then rpc aria2.forceRemove "$(json_str "$target")" >/dev/null; else rpc aria2.remove "$(json_str "$target")" >/dev/null; fi + fi + log "removed: $target" +} + +cmd_purge() { rpc aria2.purgeDownloadResult >/dev/null; log "download history purged"; } + +cmd_move() { + local gid="${1:-}" pos="${2:-}" + [ -n "$gid" ] && [ -n "$pos" ] || err "move: usage: pos network download move " + local newpos + newpos=$(rpc aria2.changePosition "$(json_str "$gid")" "$(json_str "$pos")" "$(json_str "POS_SET")" | jq -r '.result') + log "moved $gid to position $newpos" +} + +# ── Tuning ───────────────────────────────────────────────────── +cmd_limit() { + local upload=0 gid="" speed="" + while [ $# -gt 0 ]; do + case "$1" in + --upload) upload=1; shift ;; + -h|--help) usage ;; + -*) err "limit: unknown option: $1" ;; + *) if [ -z "$speed" ]; then speed="$1"; else gid="$speed"; speed="$1"; fi; shift ;; + esac + done + [ -n "$speed" ] || err "limit: speed required (0 = unlimited; e.g. 2M)" + local key="max-overall-download-limit" + if [ "$upload" -eq 1 ]; then key="max-overall-upload-limit"; fi + if [ -n "$gid" ]; then + key="max-download-limit"; [ "$upload" -eq 1 ] && key="max-upload-limit" + rpc aria2.changeOption "$(json_str "$gid")" "$(opts_json "$key=$speed")" >/dev/null + log "limit: $gid $key=$speed" + else + rpc aria2.changeGlobalOption "$(opts_json "$key=$speed")" >/dev/null + log "limit: global $key=$speed" + fi +} + +cmd_set() { + local gid="" kvs=() + while [ $# -gt 0 ]; do + case "$1" in + --gid) gid="${2:-}"; shift 2 ;; + --gid=*) gid="${1#*=}"; shift ;; + -h|--help) usage ;; + -*) err "set: unknown option: $1" ;; + *) kvs+=("$1"); shift ;; + esac + done + [ ${#kvs[@]} -gt 0 ] || err "set: need key=value (see: aria2c --help=#rpc-options)" + local opts + opts=$(opts_json "${kvs[@]}") + if [ -n "$gid" ]; then + rpc aria2.changeOption "$gid" "$opts" >/dev/null + log "set: gid $gid — ${kvs[*]}" + else + rpc aria2.changeGlobalOption "$opts" >/dev/null + log "set: global — ${kvs[*]}" + fi +} + +# ── Live view ────────────────────────────────────────────────── +cmd_watch() { + local gid="${1:-}" + printf '\033[?25l' + trap 'printf "\033[?25h"; exit 0' INT TERM EXIT + if [ -n "$gid" ]; then + while :; do + local raw row st + raw=$(rpc aria2.tellStatus "$(json_str "$gid")") + row=$(printf '%s' "$raw" | jq -r ' + .result as $r | + [$r.status, + (if (($r.totalLength|tonumber) > 0) then (100*($r.completedLength|tonumber)/($r.totalLength|tonumber)) else 0 end), + $r.completedLength, $r.totalLength, + ($r.downloadSpeed|tonumber), ($r.uploadSpeed|tonumber), + ($r.connections|tonumber), + (if (($r.downloadSpeed|tonumber) > 0) then (((($r.totalLength|tonumber) - ($r.completedLength|tonumber)) / ($r.downloadSpeed|tonumber))) else 0 end)] + | @tsv' 2>/dev/null || true) + st=$(printf '%s' "$row" | cut -f1) + printf '\r\033[J' + if [ -n "$row" ]; then + printf '%s' "$row" | awk -F'\t' "$ROW_AWK"'{ printf " %-9s %6.1f%% %s / %s dl %s up %s conn %s eta %s", $1, $2, $3, $4, hs($5), hs($6), $7, et($8) }' + fi + case "$st" in + complete|error|removed) printf '\n'; exit 0 ;; + esac + sleep 2 + done + else + while :; do + local act g n + act=$(rpc aria2.tellActive) + g=$(rpc aria2.getGlobalStat) + n=$(printf '%s' "$act" | jq '.result | length') + printf '\r\033[J' + if [ "$n" -eq 0 ]; then + printf 'no active downloads\n' + exit 0 + fi + printf '%s' "$g" | jq -r '[.result.numActive, (.result.downloadSpeed|tonumber), (.result.uploadSpeed|tonumber)] | @tsv' | awk -F'\t' "$ROW_AWK"'{ printf "active %s dl %s up %s", $1, hs($2), hs($3) }' + printf ' %-8s %-9s %7s %9s %9s %s\n' GID STATUS PCT DL UL NAME + printf '%s' "$act" | jq -r "$LIST_JQ" | fmt_list + sleep 2 + done + fi +} + +tmux_watch() { + local gid="$1" name="$2" + name=$(printf '%s' "$name" | tr -cs 'A-Za-z0-9._-' '-' | cut -c1-40) + name="${name%-}" + [ -n "$name" ] || name="download" + local base sname n=2 + base="dl-$name" + sname="$base" + while tmux has-session -t "$sname" 2>/dev/null; do + sname="${base}-${n}"; n=$((n+1)) + done + local self + self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" + if [ "${DRY_RUN:-0}" -eq 1 ]; then + log "(dry-run) tmux new-session -d -s $sname -- $self watch $gid" + else + TERM="${TERM:-xterm-256color}" tmux new-session -d -s "$sname" "$self watch $gid" + fi + log "tmux session '$sname' started — attach: tmux attach -t '$sname'" +} + +# ── Dispatch ─────────────────────────────────────────────────── +overview() { + cmd_status + if daemon_active; then echo; cmd_list; fi +} + +main() { + local cmd="${1:-}" + shift 2>/dev/null || true + case "$cmd" in + "") overview ;; + -h|--help) usage ;; + start) cmd_start ;; + stop) cmd_stop ;; + status) cmd_status ;; + add) cmd_add "$@" ;; + torrent) cmd_torrent "$@" ;; + metalink) cmd_metalink "$@" ;; + list) cmd_list ;; + info|files|peers|pause|resume|remove|move|limit|set|watch) + "cmd_$cmd" "$@" ;; + purge) cmd_purge ;; + *) err "unknown command: $cmd (see 'pos network download --help')" ;; + esac +} + +main "$@" diff --git a/completions/pos.bash b/completions/pos.bash index 4a49322..0d5d776 100644 --- a/completions/pos.bash +++ b/completions/pos.bash @@ -11,6 +11,7 @@ _pos_flags[entertainment-send]="--print --markdown" _pos_flags[media-mp3]="--output --no-playlist --cookies --by-artist --dry-run" _pos_flags[media-mp4]="--format --best --worst --output --no-playlist --cookies --dry-run" _pos_flags[network-checkport]="--tcp --udp --ping --no-banner --versions --timeout" +_pos_flags[network-download]="--dir --out --split --seed --force --upload --gid --tmux" _pos_flags[network-hotspot]="--foreground" _pos_flags[share-usb-server]="--ls --ls-shared --share --unshare --auto-share --callback --close-callback --auto-connect --disconnect --nickname --timeout --port --info --version" _pos_flags[system-backup]="--service" @@ -24,6 +25,7 @@ _pos_subcmds[communication-matrix-sender]="send test login" _pos_subcmds[communication-telegram-sender]="send test" _pos_subcmds[docker-compose]="ls installed up down restart logs update config" _pos_subcmds[docker-vbox]="create enter stop start rm ls" +_pos_subcmds[network-download]="start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch" _pos_subcmds[system-event-trigger]="run config list enable disable status" # GEN:END possubcmds # GEN:START posconfigscopes diff --git a/preinstall.sh b/preinstall.sh index 90099fe..e859100 100755 --- a/preinstall.sh +++ b/preinstall.sh @@ -26,7 +26,7 @@ while [[ $# -gt 0 ]]; do done PACKAGES=( - git curl wget vim nano tmux tree jq + git curl wget aria2 vim nano tmux tree jq unzip zip rsync htop btop telnet net-tools iputils-ping traceroute tcpdump nmap openssh-client openssh-server ufw fail2ban