Merge feat/ultra-vpn into main
VPN split-tunnel, signed self-update, security hardening, IPFS mirror fallback, container CVE scan gate, and 0.9.1 release prep.
This commit is contained in:
commit
d0094e84bb
39 changed files with 2099 additions and 234 deletions
52
.github/workflows/docker-rebuild.yml
vendored
Normal file
52
.github/workflows/docker-rebuild.yml
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Rebuilds and re-pushes the `latest` image without a version bump so newly
|
||||
# *fixed* Alpine / ffmpeg / Go patches land between tagged releases. Versioned
|
||||
# tags are immutable and never touched here. Runs weekly and on demand.
|
||||
name: Docker rebuild
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Mondays 04:17 UTC (off the hour to avoid the scheduler rush)
|
||||
- cron: "17 4 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
rebuild:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Stamp the binary with the most recent release tag (not "dev").
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: echo "version=$(git describe --tags --abbrev=0 2>/dev/null || echo dev)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# Refresh the floating tag only — never overwrite a versioned release.
|
||||
tags: torrentclaw/unarr:latest
|
||||
build-args: |
|
||||
VERSION=${{ steps.ver.outputs.version }}
|
||||
# Force a fresh base pull so apk upgrade picks up new patches.
|
||||
no-cache: true
|
||||
|
||||
- name: Scan image for fixable CVEs (gate)
|
||||
uses: docker/scout-action@v1
|
||||
with:
|
||||
command: cves
|
||||
image: torrentclaw/unarr:latest
|
||||
only-severities: critical,high
|
||||
only-fixed: true
|
||||
exit-code: true
|
||||
47
.github/workflows/release.yml
vendored
47
.github/workflows/release.yml
vendored
|
|
@ -27,6 +27,28 @@ jobs:
|
|||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
# Empty when RELEASE_SIGNING_PUBKEY variable is unset — goreleaser
|
||||
# accepts it and the resulting binary disables signature checks
|
||||
# (back-compat: pre-signing releases continue to update). Set
|
||||
# RELEASE_SIGNING_PUBKEY (variable) + RELEASE_SIGNING_KEY (secret)
|
||||
# to turn verification on.
|
||||
RELEASE_SIGNING_PUBKEY: ${{ vars.RELEASE_SIGNING_PUBKEY }}
|
||||
|
||||
- name: Sign checksums.txt with ed25519
|
||||
# Reference secrets.X directly — step-level env defined in this same
|
||||
# step is unreliable to read from this step's own if: expression.
|
||||
if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }}
|
||||
env:
|
||||
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go run ./scripts/sign-checksums \
|
||||
-key "$RELEASE_SIGNING_KEY" \
|
||||
-in dist/checksums.txt \
|
||||
-out dist/checksums.txt.sig
|
||||
gh release upload "$RELEASE_TAG" dist/checksums.txt.sig --clobber
|
||||
|
||||
docker:
|
||||
needs: release
|
||||
|
|
@ -62,6 +84,31 @@ jobs:
|
|||
build-args: |
|
||||
VERSION=${{ github.ref_name }}
|
||||
|
||||
# CVE gate. Fails the release on FIXABLE critical/high only — unfixed
|
||||
# upstream ffmpeg codec CVEs are accepted (see SECURITY.md), so the
|
||||
# codec noise does not block. Runs post-push (image already published);
|
||||
# a failure here flags that a fixable CVE slipped through.
|
||||
- name: Scan image for fixable CVEs (gate)
|
||||
uses: docker/scout-action@v1
|
||||
with:
|
||||
command: cves
|
||||
image: torrentclaw/unarr:latest
|
||||
only-severities: critical,high
|
||||
only-fixed: true
|
||||
exit-code: true
|
||||
|
||||
# Sync the Docker Hub repo description from DOCKERHUB.md. Non-fatal: a
|
||||
# description-API auth hiccup must not undo a successful image push.
|
||||
- name: Update Docker Hub description
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: torrentclaw/unarr
|
||||
readme-filepath: ./DOCKERHUB.md
|
||||
short-description: "unarr — the single binary that replaces your *arr stack"
|
||||
|
||||
|
||||
virustotal:
|
||||
needs: release
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ builds:
|
|||
- -s -w
|
||||
- -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}}
|
||||
- -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }}
|
||||
# Release-signing public key — verified by the self-updater against
|
||||
# checksums.txt.sig. Empty when not configured; in that case
|
||||
# signature verification is skipped and a warning is logged.
|
||||
- -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64={{ .Env.RELEASE_SIGNING_PUBKEY }}
|
||||
|
||||
archives:
|
||||
- formats: [tar.gz]
|
||||
|
|
|
|||
190
CHANGELOG.md
190
CHANGELOG.md
|
|
@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.9.1] - 2026-05-21
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- **mirror**: update fallback URLs to use IPFS and remove GitHub Pages
|
||||
|
||||
### Fixed
|
||||
|
||||
- **security**: bump golang.org/x deps and add container CVE scan gate
|
||||
## [0.9.0] - 2026-05-21
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- **agent**: add mirror failover, agent client refactor, status 401 detection
|
||||
- **vpn**: local config_file for self-hosted/personal VPN testing
|
||||
- **vpn**: split-tunnel torrent traffic through managed WireGuard
|
||||
|
||||
### Documentation
|
||||
|
||||
- **docker**: refresh Docker Hub README + sync description in CI
|
||||
|
||||
### Fixed
|
||||
|
||||
- **security**: CORS allowlist, URL scheme guard, state perms, ZIP slip, mirror docs
|
||||
- **security**: UPnP opt-in, bounded SSE reader, signed self-update
|
||||
- **security**: harden HLS session IDs, /health disclosure, archive password handling
|
||||
- **upgrade**: fetch releases from TorrentClaw app, not GitHub
|
||||
|
||||
### Other
|
||||
|
||||
- **release**: 0.9.0
|
||||
## [0.8.1] - 2026-05-08
|
||||
|
||||
|
||||
|
|
@ -25,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Other
|
||||
|
||||
- **gitignore**: add dist-ffbinaries to ignored files
|
||||
- **release**: 0.8.1
|
||||
## [0.8.0] - 2026-05-08
|
||||
|
||||
|
||||
|
|
@ -238,16 +272,117 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
## [0.4.1] - 2026-04-01
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- **cli**: add login command and refactor shared helpers
|
||||
- **stream**: report watch progress to API via HTTP Range tracking
|
||||
|
||||
### Fixed
|
||||
|
||||
- **ci**: fix lint errors and pin CI to Go 1.25
|
||||
- **lint**: remove unused newStubCmd function
|
||||
|
||||
### Other
|
||||
|
||||
- **cli**: remove moreseed stub command
|
||||
- **cli**: remove redundant stub commands (monitor, open, add, compare)
|
||||
## [0.4.0] - 2026-03-31
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- **cli**: upgrade command, rich status, and version cache
|
||||
|
||||
### Fixed
|
||||
|
||||
- **progress**: always report status transitions and poll for control signals
|
||||
## [0.3.7] - 2026-03-31
|
||||
|
||||
|
||||
### CI/CD
|
||||
|
||||
- **docker**: remove dockerhub-description sync step
|
||||
## [0.3.6] - 2026-03-31
|
||||
|
||||
|
||||
### CI/CD
|
||||
|
||||
- **deps**: bump docker/metadata-action from 5 to 6
|
||||
- **deps**: bump docker/setup-qemu-action from 3 to 4
|
||||
- **deps**: bump docker/login-action from 3 to 4
|
||||
- **deps**: bump docker/build-push-action from 6 to 7
|
||||
- **deps**: bump codecov/codecov-action from 5 to 6
|
||||
- **docker**: add Docker Hub description sync and DOCKERHUB.md
|
||||
|
||||
### Fixed
|
||||
|
||||
- **ci**: upgrade golangci-lint to v2.11.3 for Go 1.25 support
|
||||
- **docker**: upgrade alpine packages to patch CVE-2025-60876 and CVE-2026-27171
|
||||
- **lint**: use default:none to disable errcheck, fix all gofmt and exhaustive
|
||||
- **lint**: disable errcheck, tune gosec/exclusions for codebase state
|
||||
- **lint**: configure linters for codebase maturity, fix gofmt and ineffassign
|
||||
- **lint**: exclude common fire-and-forget patterns from errcheck
|
||||
- **lint**: resolve errcheck and bodyclose warnings for golangci-lint v2
|
||||
## [0.3.5] - 2026-03-30
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- migrate lint config to v2, remove daemon auto-upgrade, add trust badges
|
||||
## [0.3.3] - 2026-03-30
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- **ci**: remove go-client checkout steps
|
||||
## [0.3.2] - 2026-03-30
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- **init**: add 60s countdown, skip key, and cancel detection to browser auth
|
||||
|
||||
### CI/CD
|
||||
|
||||
- **release**: add Docker Hub publish and VirusTotal scan jobs
|
||||
|
||||
### Documentation
|
||||
|
||||
- add beta notice, fix install URLs to get.torrentclaw.com
|
||||
|
||||
### Fixed
|
||||
|
||||
- **ci**: fix virustotal job condition syntax
|
||||
- **docker**: simplify Dockerfile for CI builds (no local go-client)
|
||||
- **release**: disable homebrew tap (needs PAT, not GITHUB_TOKEN)
|
||||
|
||||
### Other
|
||||
|
||||
- re-enable homebrew tap in goreleaser
|
||||
## [0.3.1] - 2026-03-30
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- **build**: unused variable in Windows process check
|
||||
- **release**: disable homebrew tap until repo is created
|
||||
|
||||
### Other
|
||||
|
||||
- rename module from torrentclaw-cli to unarr
|
||||
|
||||
### Build
|
||||
|
||||
- remove UPX compression (antivirus false positives, startup penalty)
|
||||
## [0.3.0] - 2026-03-29
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- **agent**: add WebSocket transport with HTTP fallback
|
||||
- **auth**: browser-based CLI authentication (like Claude Code)
|
||||
- **cli**: add login command and refactor shared helpers
|
||||
- **cli**: upgrade command, rich status, and version cache
|
||||
- **daemon**: add auto-scan, force start, and stall timeout default
|
||||
- **debrid**: add HTTPS downloader for debrid direct URLs
|
||||
- **init**: add 60s countdown, skip key, and cancel detection to browser auth
|
||||
- **stream**: report watch progress to API via HTTP Range tracking
|
||||
- **stream**: UPnP port forwarding for remote video playback
|
||||
- **usenet**: implement full NNTP download pipeline
|
||||
- add migrate command, media server detection, and debrid auto-config
|
||||
|
|
@ -257,61 +392,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- improve daemon resilience, streaming, and usenet downloads
|
||||
- initial commit — unarr CLI
|
||||
|
||||
### CI/CD
|
||||
|
||||
- **deps**: bump docker/metadata-action from 5 to 6
|
||||
- **deps**: bump docker/setup-qemu-action from 3 to 4
|
||||
- **deps**: bump docker/login-action from 3 to 4
|
||||
- **deps**: bump docker/build-push-action from 6 to 7
|
||||
- **deps**: bump codecov/codecov-action from 5 to 6
|
||||
- **docker**: remove dockerhub-description sync step
|
||||
- **docker**: add Docker Hub description sync and DOCKERHUB.md
|
||||
- **release**: add Docker Hub publish and VirusTotal scan jobs
|
||||
|
||||
### Changed
|
||||
|
||||
- migrate lint config to v2, remove daemon auto-upgrade, add trust badges
|
||||
- extract BuildSyncItems to library package, remove duplication
|
||||
|
||||
### Documentation
|
||||
|
||||
- add beta notice, fix install URLs to get.torrentclaw.com
|
||||
- improve CLI help, shell completion, and README
|
||||
|
||||
### Fixed
|
||||
|
||||
- **build**: unused variable in Windows process check
|
||||
- **ci**: fix lint errors and pin CI to Go 1.25
|
||||
- **ci**: upgrade golangci-lint to v2.11.3 for Go 1.25 support
|
||||
- **ci**: remove go-client checkout steps
|
||||
- **ci**: fix virustotal job condition syntax
|
||||
- **docker**: upgrade alpine packages to patch CVE-2025-60876 and CVE-2026-27171
|
||||
- **docker**: simplify Dockerfile for CI builds (no local go-client)
|
||||
- **lint**: remove unused newStubCmd function
|
||||
- **lint**: use default:none to disable errcheck, fix all gofmt and exhaustive
|
||||
- **lint**: disable errcheck, tune gosec/exclusions for codebase state
|
||||
- **lint**: configure linters for codebase maturity, fix gofmt and ineffassign
|
||||
- **lint**: exclude common fire-and-forget patterns from errcheck
|
||||
- **lint**: resolve errcheck and bodyclose warnings for golangci-lint v2
|
||||
- **progress**: always report status transitions and poll for control signals
|
||||
- **release**: disable homebrew tap (needs PAT, not GITHUB_TOKEN)
|
||||
- **release**: disable homebrew tap until repo is created
|
||||
- **torrent**: expand tracker list, add DHT persistence and configurable timeouts
|
||||
- force-start tasks bypass HasCapacity check in dispatch loop
|
||||
- add panic recovery to auto-scan, cap DHT nodes at 200
|
||||
- harden usenet/debrid downloaders from critico review
|
||||
|
||||
### Other
|
||||
|
||||
- **cli**: remove moreseed stub command
|
||||
- **cli**: remove redundant stub commands (monitor, open, add, compare)
|
||||
- re-enable homebrew tap in goreleaser
|
||||
- rename module from torrentclaw-cli to unarr
|
||||
|
||||
### Build
|
||||
|
||||
- remove UPX compression (antivirus false positives, startup penalty)
|
||||
- add -s -w -trimpath to Makefile, add build-small target with UPX
|
||||
[0.9.1]: https://github.com/torrentclaw/unarr/compare/v0.9.0...v0.9.1
|
||||
[0.9.0]: https://github.com/torrentclaw/unarr/compare/v0.8.1...v0.9.0
|
||||
[0.8.1]: https://github.com/torrentclaw/unarr/compare/v0.8.0...v0.8.1
|
||||
[0.8.0]: https://github.com/torrentclaw/unarr/compare/v0.7.0...v0.8.0
|
||||
[0.7.0]: https://github.com/torrentclaw/unarr/compare/v0.6.8...v0.7.0
|
||||
|
|
@ -331,4 +431,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
[0.5.1]: https://github.com/torrentclaw/unarr/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/torrentclaw/unarr/compare/v0.4.1...v0.5.0
|
||||
[0.4.1]: https://github.com/torrentclaw/unarr/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/torrentclaw/unarr/compare/v0.3.7...v0.4.0
|
||||
[0.3.7]: https://github.com/torrentclaw/unarr/compare/v0.3.6...v0.3.7
|
||||
[0.3.6]: https://github.com/torrentclaw/unarr/compare/v0.3.5...v0.3.6
|
||||
[0.3.5]: https://github.com/torrentclaw/unarr/compare/v0.3.3...v0.3.5
|
||||
[0.3.3]: https://github.com/torrentclaw/unarr/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/torrentclaw/unarr/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/torrentclaw/unarr/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/torrentclaw/unarr/releases/tag/v0.3.0
|
||||
|
||||
|
|
|
|||
125
DOCKERHUB.md
125
DOCKERHUB.md
|
|
@ -1,12 +1,20 @@
|
|||
# unarr
|
||||
|
||||
Powerful terminal tool for torrent search and management. Search 30+ sources, inspect quality, discover popular content, find streaming providers, and manage downloads — all from your terminal.
|
||||
**The single binary that replaces your whole *arr stack.** Search 30+ torrent
|
||||
sources, inspect real quality before you download, grab subtitles, and manage
|
||||
your media library — all from one terminal tool or a headless daemon.
|
||||
|
||||
**[GitHub](https://github.com/torrentclaw/unarr)** | **[Documentation](https://github.com/torrentclaw/unarr#readme)** | **[Releases](https://github.com/torrentclaw/unarr/releases)**
|
||||
**[Website & docs](https://torrentclaw.com/unarr)** · **[Install guide](https://torrentclaw.com/cli)** · **[Get an API key](https://torrentclaw.com)**
|
||||
|
||||
## Quick Start
|
||||
> Powered by [TorrentClaw](https://torrentclaw.com) — an aggregator that unifies
|
||||
> YTS, EZTV, Knaben, Torrentio, Bitmagnet and more, enriched with TMDB metadata
|
||||
> and a 0–100 quality score per release.
|
||||
|
||||
### 1. Setup (interactive wizard)
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### 1. First-time setup (interactive wizard)
|
||||
|
||||
```bash
|
||||
docker run -it --rm \
|
||||
|
|
@ -14,6 +22,9 @@ docker run -it --rm \
|
|||
torrentclaw/unarr setup
|
||||
```
|
||||
|
||||
The wizard asks for your TorrentClaw API key (free at
|
||||
[torrentclaw.com](https://torrentclaw.com)) and your download directory.
|
||||
|
||||
### 2. Run the daemon
|
||||
|
||||
```bash
|
||||
|
|
@ -26,6 +37,10 @@ docker run -d --name unarr \
|
|||
torrentclaw/unarr
|
||||
```
|
||||
|
||||
That's it — `unarr` now runs headless, watching for jobs and managing downloads.
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```yaml
|
||||
|
|
@ -45,45 +60,54 @@ services:
|
|||
environment:
|
||||
- TZ=UTC
|
||||
# - UNARR_API_KEY=tc_your_key_here
|
||||
network_mode: host # recommended for full P2P performance
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "2.0"
|
||||
network_mode: host
|
||||
|
||||
volumes:
|
||||
unarr-data:
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose run --rm unarr setup # one-time wizard
|
||||
docker compose up -d # start the daemon
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Volumes
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
|--------------|--------------------------------------------------|
|
||||
| `/config` | Configuration file (`config.toml`) |
|
||||
| `/downloads` | Finished media downloads |
|
||||
| `/data` | Internal state: torrent metadata, cache |
|
||||
|
||||
## Environment Variables
|
||||
## Environment variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `TZ` | Timezone | `UTC` |
|
||||
|------------------------|--------------------------------------|---------------------------|
|
||||
| `UNARR_API_KEY` | TorrentClaw API key | from config |
|
||||
| `UNARR_API_URL` | API endpoint | `https://torrentclaw.com` |
|
||||
| `UNARR_DOWNLOAD_DIR` | Download directory | `/downloads` |
|
||||
| `UNARR_CONFIG_DIR` | Config directory | `/config` |
|
||||
| `UNARR_COUNTRY` | Country code (ISO 3166) | `US` |
|
||||
| `TZ` | Timezone | `UTC` |
|
||||
|
||||
Any config value can be overridden by its matching `UNARR_*` environment variable.
|
||||
|
||||
## Networking
|
||||
|
||||
**Host mode** (recommended) gives full P2P performance with no port management:
|
||||
**Host mode (recommended)** — full P2P performance, no port mapping:
|
||||
|
||||
```yaml
|
||||
network_mode: host
|
||||
```
|
||||
|
||||
**Bridge mode** — more isolated, but requires explicit ports:
|
||||
**Bridge mode** — more isolated, but you must expose the BitTorrent ports:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
|
|
@ -91,7 +115,7 @@ ports:
|
|||
- "6881-6889:6881-6889/udp"
|
||||
```
|
||||
|
||||
## Running Commands
|
||||
## Running commands
|
||||
|
||||
Use `docker exec` for one-off commands while the daemon is running:
|
||||
|
||||
|
|
@ -99,32 +123,77 @@ Use `docker exec` for one-off commands while the daemon is running:
|
|||
docker exec unarr unarr search "inception" --quality 1080p
|
||||
docker exec unarr unarr popular --limit 10
|
||||
docker exec unarr unarr status
|
||||
docker exec unarr unarr doctor
|
||||
docker exec unarr unarr doctor # diagnose config / connectivity
|
||||
```
|
||||
|
||||
## Supported Architectures
|
||||
|
||||
| Architecture | Tag |
|
||||
|-------------|-----|
|
||||
| `linux/amd64` | `latest`, `0.3`, `0.3.5` |
|
||||
| `linux/arm64` | `latest`, `0.3`, `0.3.5` |
|
||||
---
|
||||
|
||||
## Tags
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
|----------|--------------------------------------------------|
|
||||
| `latest` | Latest stable release |
|
||||
| `X.Y.Z` | Specific version (e.g. `0.3.5`) |
|
||||
| `X.Y` | Latest patch for minor version (e.g. `0.3`) |
|
||||
| `X.Y.Z` | Exact version (e.g. `0.9.0`) |
|
||||
| `X.Y` | Latest patch within a minor (e.g. `0.9`) |
|
||||
|
||||
## Image Details
|
||||
Pin a tag in production (`torrentclaw/unarr:0.9.0`) for reproducible deploys.
|
||||
|
||||
- **Base image:** Alpine 3.22
|
||||
- **User:** `unarr` (UID 1000, GID 1000)
|
||||
## Supported architectures
|
||||
|
||||
Multi-arch image — Docker pulls the right one automatically:
|
||||
|
||||
- `linux/amd64`
|
||||
- `linux/arm64` (Apple Silicon, Raspberry Pi 4/5, ARM servers)
|
||||
|
||||
## Image details
|
||||
|
||||
- **Base:** Alpine 3.22 (minimal, regularly patched)
|
||||
- **User:** `unarr` (UID 1000, GID 1000) — runs as **non-root**
|
||||
- **Entrypoint:** `unarr start` (daemon mode)
|
||||
- **Read-only filesystem** — only mounted volumes are writable
|
||||
- **No root required** — runs as non-root by default
|
||||
- **Read-only rootfs** — only mounted volumes are writable
|
||||
- **Bundled `ffmpeg` / `ffprobe`** for media inspection — nothing else to install
|
||||
- **Self-contained updates** — binaries are served from TorrentClaw's own
|
||||
infrastructure, no third-party registry dependency
|
||||
|
||||
---
|
||||
|
||||
## Other install methods
|
||||
|
||||
Not using Docker? Install the native binary instead:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
curl -fsSL https://torrentclaw.com/install.sh | sh
|
||||
|
||||
# Windows (PowerShell)
|
||||
irm https://torrentclaw.com/install.ps1 | iex
|
||||
|
||||
# Go toolchain
|
||||
go install github.com/torrentclaw/unarr/cmd/unarr@latest
|
||||
```
|
||||
|
||||
## Mirrors
|
||||
|
||||
The installer and release binaries are served from every TorrentClaw mirror, so
|
||||
you can install even if one domain is blocked in your region. Each mirror is
|
||||
self-contained (it serves its own binaries — no cross-domain dependency):
|
||||
|
||||
| Mirror | Install command |
|
||||
|--------|-----------------|
|
||||
| `torrentclaw.com` (primary) | `curl -fsSL https://torrentclaw.com/install.sh \| sh` |
|
||||
| `torrentclaw.to` | `curl -fsSL https://torrentclaw.to/install.sh \| sh` |
|
||||
| Tor (`.onion`) | `torsocks sh -c "$(curl http://torrentf3aifidcsaaanmnmuhv2s53r6hqsl3zkmfidiaxainkeqk5id.onion/install.sh)"` |
|
||||
|
||||
The Tor address routes everything (install script + binaries) through the hidden
|
||||
service, so no clearnet exit is needed.
|
||||
|
||||
## Links
|
||||
|
||||
- **Website & docs:** https://torrentclaw.com/unarr
|
||||
- **CLI install guide:** https://torrentclaw.com/cli
|
||||
- **API & account:** https://torrentclaw.com
|
||||
- **Mirror status:** https://torrentclaw.com/mirrors
|
||||
|
||||
## License
|
||||
|
||||
MIT License — see [LICENSE](https://github.com/torrentclaw/unarr/blob/main/LICENSE) for details.
|
||||
MIT.
|
||||
|
|
|
|||
131
Docs/plans/security-stream-token.md
Normal file
131
Docs/plans/security-stream-token.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# Phase 2.2 — Per-task stream token (deferred)
|
||||
|
||||
Status: deferred. Requires coordinated change in the web app
|
||||
(`torrentclaw-web`) and the CLI daemon. Pulled out of the Phase 2
|
||||
security pass because the CLI-only fixes (UPnP opt-in, SSE caps,
|
||||
signed self-update) ship without web-side work; the stream-token
|
||||
work cannot.
|
||||
|
||||
## Problem
|
||||
|
||||
`/stream`, `/playlist.m3u` and `/hls/<sessionID>/...` on the daemon
|
||||
HTTP server have no authentication. Today, anyone who can reach the
|
||||
listener and guesses (or learns) the `taskID` (for `/stream`) or
|
||||
`sessionID` (for `/hls`) can fetch the active file.
|
||||
|
||||
Mitigations already in place after Phase 1+2:
|
||||
|
||||
- `sessionID` is restricted to a safe regex and is a server-issued
|
||||
UUID v4 (122-bit entropy, not enumerable in practice).
|
||||
- `/health` no longer leaks the active filename, taskID prefix or
|
||||
client IP to remote callers (loopback diagnostics preserved).
|
||||
- UPnP is opt-in, so by default the daemon is not exposed to the
|
||||
public internet.
|
||||
- The web client probes `/health` to pick LAN vs Tailscale.
|
||||
|
||||
Residual risk:
|
||||
|
||||
- On a shared LAN (open Wi-Fi, office network, dorm) any device can
|
||||
reach the listener and brute-force `?id=<taskID>` against
|
||||
`/stream`. taskIDs are also UUIDs, so this is high entropy, but
|
||||
the URL may leak through browser history, sharing, screen capture
|
||||
or a passive logger and there is no second factor.
|
||||
- A user who explicitly opts into UPnP exposes the same surface to
|
||||
the entire internet.
|
||||
|
||||
A per-task secret carried in the URL closes this without breaking
|
||||
the `<video src>` flow (the browser cannot attach `Authorization`
|
||||
headers to media elements, but it can append a query parameter).
|
||||
|
||||
## Design
|
||||
|
||||
Both ends agree on a per-task secret token. The web generates it
|
||||
when the user requests streaming; the daemon receives the
|
||||
`(taskID, token)` pair and validates the token on every `/stream`
|
||||
and `/hls/...` request.
|
||||
|
||||
### Web side (`torrentclaw-web`)
|
||||
|
||||
When the user clicks "Stream":
|
||||
|
||||
1. Generate `streamToken = crypto.randomBytes(32).toString("hex")`
|
||||
server-side (NOT browser, so it never lives in client storage
|
||||
longer than the page lifetime).
|
||||
2. Persist `(taskID, streamToken, expiresAt)` in `download_task`
|
||||
(new columns or a sibling table). Token expires e.g. 6 h after
|
||||
issue or on explicit revoke.
|
||||
3. Push the token to the daemon over the existing heartbeat / sync
|
||||
channel that already carries `streamRequested`. Add a
|
||||
`streamToken` field next to it. The daemon trusts that channel
|
||||
(it is authenticated agent ↔ origin).
|
||||
4. Include the token in the stream URLs the API returns to the
|
||||
browser:
|
||||
`http://<host>:<port>/stream?id=<taskID>&t=<streamToken>` and
|
||||
the `/hls/<sessionID>` URLs gain `?t=<streamToken>` too.
|
||||
|
||||
Files that will need to change:
|
||||
|
||||
- `src/lib/services/agent.ts` — extend the stream-request payload
|
||||
with `streamToken`.
|
||||
- `src/lib/db/schema.ts` — column / table for the token.
|
||||
- `src/lib/services/stream-resolve.ts` — append `&t=` to the URLs
|
||||
it builds.
|
||||
- `src/lib/stream-probe.ts` — keep probing `/health` (no token),
|
||||
then append `&t=` to the winning stream URL before returning.
|
||||
- `src/middleware.ts` — no CORS change required (browser still hits
|
||||
daemon directly).
|
||||
|
||||
### CLI side
|
||||
|
||||
- `internal/agent/types.go` / `internal/agent/sync.go` — accept and
|
||||
store `streamToken` next to `streamRequested`.
|
||||
- `internal/agent/daemon.go` — when the heartbeat reports a new
|
||||
active stream task, push the token into the stream server via a
|
||||
setter: `streamSrv.SetTaskToken(taskID, token)`.
|
||||
- `internal/engine/stream_server.go`:
|
||||
- New field `tokens map[string]string` guarded by mutex.
|
||||
- `SetTaskToken(taskID, token)` and `ClearTaskToken(taskID)`.
|
||||
- `handler` (`/stream`) extracts `?id=` and `?t=`, checks the
|
||||
token with `subtle.ConstantTimeCompare`; 404 on mismatch.
|
||||
- `hlsHandler` (`/hls/<sessionID>/...`) needs an HLS-session
|
||||
→ token mapping, since the path carries `sessionID` not
|
||||
`taskID`. Store the token on the `HLSSession` at start and
|
||||
validate per request.
|
||||
|
||||
### Backwards compatibility
|
||||
|
||||
- The daemon must accept token-less requests for one minor version
|
||||
so a newer daemon can still serve an older web (and vice-versa).
|
||||
Gate the check on a config flag (`require_stream_token`,
|
||||
default false in the first release, default true in the next).
|
||||
- The `<video src>` form supports query parameters, so the only
|
||||
user-visible change is the URL string.
|
||||
|
||||
## Open questions to resolve before implementing
|
||||
|
||||
1. Token TTL. 6 h gives plenty of room for a movie + pause +
|
||||
resume; longer means the post-leak window is wider.
|
||||
2. Where to store the token in `download_task` — same row, or a
|
||||
sibling `download_stream_token` table that we can rotate
|
||||
without writing to the task row.
|
||||
3. Should `/playlist.m3u` (VLC) embed the token directly, or use
|
||||
a one-shot redeem URL? VLC URL ends up in history.
|
||||
4. Token reuse across HLS reconnects — yes, scoped to the
|
||||
`HLSSession`, invalidated on `Close()`.
|
||||
5. Do we want a daemon flag `--require-stream-token` independent
|
||||
of config, for users to flip on quickly without editing TOML?
|
||||
|
||||
## Effort estimate
|
||||
|
||||
- CLI: ~3 h
|
||||
- Web: ~3 h
|
||||
- Migration + rollout (config flag flip): 1 release cycle of soak.
|
||||
|
||||
## Why not now
|
||||
|
||||
- Cross-repo coordination raises commit blast radius beyond what
|
||||
the Phase 2 security pass should carry.
|
||||
- Web work needs DB migration + UI surfaces (the "stream link
|
||||
expired" path).
|
||||
- Phase 2 hardenings ship value today without it; this is the
|
||||
defense-in-depth layer on top.
|
||||
44
SECURITY.md
44
SECURITY.md
|
|
@ -59,6 +59,50 @@ This project follows these security practices:
|
|||
- **Non-root Docker** — Container runs as unprivileged user (UID 1000)
|
||||
- **Dependency scanning** — Automated via Dependabot
|
||||
|
||||
## Container Image Vulnerability Scanning
|
||||
|
||||
The Docker image (`torrentclaw/unarr`) is scanned by Docker Scout on Docker Hub and
|
||||
by a CVE gate in CI (see `.github/workflows/`). Two things matter when reading the
|
||||
Docker Hub vulnerability count:
|
||||
|
||||
- **Scanner database differs.** Docker Hub (Scout) matches `package@version` against
|
||||
NVD/GHSA. Trivy/Alpine `secdb` only lists CVEs Alpine has acknowledged and patched.
|
||||
A high Scout count with a clean Trivy report is expected, not a contradiction.
|
||||
- **The bulk comes from the bundled `ffmpeg` codec stack.** Alpine's `ffmpeg`
|
||||
package pulls ~40 codec/parser libraries (`x264`, `x265`, `libvpx`, `aom`,
|
||||
`dav1d`, `libtheora`, `libvorbis`, `libwebp`, `libbluray`, `libopenmpt`, …).
|
||||
Each carries a long NVD history that Alpine does not backport. ffmpeg is a
|
||||
**functional dependency** — the WebRTC/HLS transcode pipeline shells out to
|
||||
`ffmpeg`/`ffprobe` to decode untrusted media and re-encode to H.264 + AAC.
|
||||
|
||||
### Accepted risk and policy
|
||||
|
||||
- **Fixable** CRITICAL/HIGH findings **block** a release (CI CVE gate, `only-fixed`).
|
||||
- **Unfixed-upstream** codec CVEs are tracked but **accepted**: there is no patched
|
||||
Alpine package to move to, and dropping codecs would break playback of common
|
||||
formats. They are mitigated by the hardening below rather than eliminated.
|
||||
- Images are **rebuilt and re-pushed weekly** (scheduled workflow) so any newly
|
||||
*fixed* base/ffmpeg/Go patch lands between tagged releases.
|
||||
|
||||
### Mitigations (run the container hardened)
|
||||
|
||||
Crafted media (torrents are untrusted input) is the realistic attack vector against
|
||||
ffmpeg's parsers. The shipped `docker-compose.yml` already applies:
|
||||
|
||||
- **Non-root** user (UID 1000), **read-only** root filesystem, writable `tmpfs` only.
|
||||
- **Resource limits** (memory/CPU) to bound a runaway decode.
|
||||
|
||||
Recommended additions for exposed deployments:
|
||||
|
||||
```yaml
|
||||
cap_drop: ["ALL"]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
```
|
||||
|
||||
If you do not need WebRTC/HLS transcoding, you can run with transcoding disabled to
|
||||
avoid feeding untrusted media to ffmpeg at all.
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
We follow coordinated disclosure. We will credit reporters in the release notes unless they prefer to remain anonymous.
|
||||
|
|
|
|||
13
go.mod
13
go.mod
|
|
@ -16,8 +16,9 @@ require (
|
|||
github.com/pion/webrtc/v4 v4.2.11
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/torrentclaw/go-client v0.2.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -121,12 +122,14 @@ require (
|
|||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
|
|
|||
34
go.sum
34
go.sum
|
|
@ -473,8 +473,8 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
|
|||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
|
|
@ -485,8 +485,8 @@ golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTk
|
|||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
|
|
@ -500,8 +500,8 @@ golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLL
|
|||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
|
@ -532,18 +532,18 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
|
@ -554,12 +554,16 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
|
|||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
|
|
@ -587,6 +591,8 @@ gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
|
||||
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
||||
|
|
|
|||
|
|
@ -37,9 +37,28 @@ type MirrorsResponse struct {
|
|||
// Hard-coded here (not loaded from config) because the whole point is to
|
||||
// have something to consult when config-driven URLs all fail.
|
||||
//
|
||||
// Keep in sync with src/lib/mirrors-config.ts → STATIC_FALLBACKS on the web.
|
||||
// Hosted on IPFS (content-addressed, re-pinnable, no host can take it down
|
||||
// permanently — same bytes re-pinned anywhere keep the same CID). Multiple
|
||||
// public gateways are listed so a single gateway being blocked doesn't kill
|
||||
// the fallback; the /ipfs/<CID>/ path is identical across all gateways.
|
||||
//
|
||||
// GitHub Pages was removed 2026-05-17: the whole torrentclaw org is
|
||||
// shadow-banned (public repos 404 to anonymous users). Do NOT re-add any
|
||||
// github.io URL. Keep this slice in sync with `STATIC_FALLBACKS` in
|
||||
// `torrentclaw-web/src/lib/mirrors-config.ts` — when the IPFS CID changes
|
||||
// (scripts/publish-mirrors-ipfs.sh), update both.
|
||||
//
|
||||
// Future hardening: sign mirrors.json with the same ed25519 release key
|
||||
// (or a sibling) so a hijack of any single static host cannot serve a
|
||||
// malicious mirror list. Today the only signal is "agreement between
|
||||
// independent providers" via cross-checking, which we leave to the
|
||||
// operator.
|
||||
const mirrorsIPFSCID = "bafybeigwux74fek7uky7nct47z5eqwwnpylakfxppqqnzbuxdw7p3ikfdy"
|
||||
|
||||
var DefaultStaticFallbackURLs = []string{
|
||||
"https://torrentclaw.github.io/mirrors/mirrors.json",
|
||||
"https://ipfs.io/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
|
||||
"https://dweb.link/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
|
||||
"https://gateway.pinata.cloud/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
|
||||
}
|
||||
|
||||
// FetchMirrorsWithFallback pulls the mirror list using FetchMirrors against
|
||||
|
|
@ -68,8 +87,8 @@ func FetchMirrorsWithFallback(ctx context.Context, candidates []string, userAgen
|
|||
}
|
||||
|
||||
// fetchMirrorsJSON pulls a MirrorsResponse from already-fully-qualified URLs
|
||||
// (e.g. https://torrentclaw.github.io/mirrors/mirrors.json). Each candidate
|
||||
// is tried in order; the first success wins.
|
||||
// (e.g. https://ipfs.io/ipfs/<CID>/mirrors.json). Each candidate is tried
|
||||
// in order; the first success wins.
|
||||
func fetchMirrorsJSON(ctx context.Context, urls []string, userAgent string) (*MirrorsResponse, error) {
|
||||
if len(urls) == 0 {
|
||||
return nil, fmt.Errorf("no static fallback URLs configured")
|
||||
|
|
|
|||
|
|
@ -140,26 +140,29 @@ func (c *Client) OpenSignalStream(ctx context.Context, sessionID string) (*Signa
|
|||
return stream, nil
|
||||
}
|
||||
|
||||
// sseMaxLineBytes caps the size of a single SSE line. Real signalling lines
|
||||
// are JSON payloads of a few hundred bytes; 256 KiB is generous enough to
|
||||
// survive a future schema bump but small enough that a hostile or buggy
|
||||
// server cannot grow daemon memory by streaming a single line forever.
|
||||
const sseMaxLineBytes = 256 * 1024
|
||||
|
||||
// sseMaxEventBytes caps the total bytes buffered across the lines of one
|
||||
// SSE event. Without a cap, a peer could send unbounded `data:` continuation
|
||||
// lines and OOM the daemon between blank-line dispatches.
|
||||
const sseMaxEventBytes = 1024 * 1024
|
||||
|
||||
func (s *SignalEventStream) read() {
|
||||
defer close(s.done)
|
||||
defer close(s.events)
|
||||
|
||||
reader := bufio.NewReaderSize(s.resp.Body, 16*1024)
|
||||
scanner := bufio.NewScanner(s.resp.Body)
|
||||
scanner.Buffer(make([]byte, 16*1024), sseMaxLineBytes)
|
||||
|
||||
var dataBuf bytes.Buffer
|
||||
var eventName string
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
select {
|
||||
case s.errs <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimRight(scanner.Text(), "\r")
|
||||
if line == "" {
|
||||
// End of an event — dispatch if we have data.
|
||||
if dataBuf.Len() == 0 {
|
||||
|
|
@ -190,6 +193,18 @@ func (s *SignalEventStream) read() {
|
|||
}
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
payload := strings.TrimSpace(line[len("data:"):])
|
||||
// Refuse to grow the event buffer past the cap. Reset so a
|
||||
// well-formed event after the offender can still be parsed,
|
||||
// and surface an error so SignalLoop reconnects.
|
||||
if dataBuf.Len()+len(payload)+1 > sseMaxEventBytes {
|
||||
dataBuf.Reset()
|
||||
eventName = ""
|
||||
select {
|
||||
case s.errs <- fmt.Errorf("sse: event exceeded %d bytes", sseMaxEventBytes):
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
if dataBuf.Len() > 0 {
|
||||
dataBuf.WriteByte('\n')
|
||||
}
|
||||
|
|
@ -198,6 +213,12 @@ func (s *SignalEventStream) read() {
|
|||
}
|
||||
// id:, retry:, anything else — ignore for now.
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
select {
|
||||
case s.errs <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SignalLoop runs an SSE consumer that reconnects automatically on disconnect.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -120,6 +121,48 @@ func TestSignalStreamCloseCancelsRead(t *testing.T) {
|
|||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestSignalStreamRejectsOversizedEvent verifies that a hostile or buggy
|
||||
// server sending an unbounded `data:` event surfaces an error and stops
|
||||
// the reader instead of growing daemon memory forever.
|
||||
func TestSignalStreamRejectsOversizedEvent(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||
http.Error(w, "auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
flusher := w.(http.Flusher)
|
||||
// Send many data: continuation lines until we blow past the
|
||||
// per-event cap. Each chunk is a short legitimate-looking line.
|
||||
chunk := "data: " + strings.Repeat("x", 4096) + "\n"
|
||||
fmt.Fprint(w, "event: signal\n")
|
||||
for i := 0; i < (sseMaxEventBytes/4096)+8; i++ {
|
||||
fmt.Fprint(w, chunk)
|
||||
}
|
||||
flusher.Flush()
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "test-key", "test-ua")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := c.OpenSignalStream(ctx, "session-overflow")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
for range stream.Events() {
|
||||
// Should never receive a parsed event — the over-sized buffer must
|
||||
// be rejected before dispatch.
|
||||
}
|
||||
if err := stream.Err(); err == nil {
|
||||
t.Fatal("expected error from oversized event, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSignalSendsCorrectBody(t *testing.T) {
|
||||
var bodySeen map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -45,9 +45,13 @@ func WriteState(state *DaemonState) {
|
|||
return
|
||||
}
|
||||
|
||||
// Write to temp file then rename for atomicity
|
||||
// Write to temp file then rename for atomicity. 0o600 keeps the file
|
||||
// readable only by the owning user — the state contains agentID, PID
|
||||
// and counters which are useful to a co-tenant on a shared host for
|
||||
// fingerprinting the daemon, and we already use 0o600 for the config
|
||||
// file. No need for cross-user readability here.
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return
|
||||
}
|
||||
os.Rename(tmp, path)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package cmd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
|
@ -19,6 +20,7 @@ import (
|
|||
"github.com/torrentclaw/unarr/internal/library"
|
||||
"github.com/torrentclaw/unarr/internal/library/mediainfo"
|
||||
"github.com/torrentclaw/unarr/internal/usenet/download"
|
||||
"github.com/torrentclaw/unarr/internal/vpn"
|
||||
)
|
||||
|
||||
// newStartCmd creates the top-level `unarr start` command.
|
||||
|
|
@ -193,6 +195,47 @@ func runDaemonStart() error {
|
|||
reporter := engine.NewProgressReporter(agentClient, statusInterval)
|
||||
reporter.SetWatchingFunc(func() bool { return d.Watching.Load() })
|
||||
|
||||
// Managed-VPN add-on: bring up the in-process WireGuard split-tunnel before
|
||||
// the torrent client so peer + tracker traffic routes through it. Failure is
|
||||
// non-fatal — log and download in the clear (better than refusing to run).
|
||||
var vpnTunnel *vpn.Tunnel
|
||||
if cfg.Download.VPN.ConfigFile != "" {
|
||||
// Self-hosted / personal-VPN mode: read a local .conf directly.
|
||||
raw, rerr := os.ReadFile(cfg.Download.VPN.ConfigFile)
|
||||
if rerr != nil {
|
||||
log.Printf("[vpn] could not read config_file %q (%v) — downloading in the clear", cfg.Download.VPN.ConfigFile, rerr)
|
||||
} else if t, uerr := vpn.Up(string(raw)); uerr != nil {
|
||||
log.Printf("[vpn] tunnel failed to start from config_file (%v) — downloading in the clear", uerr)
|
||||
} else {
|
||||
vpnTunnel = t
|
||||
defer vpnTunnel.Close()
|
||||
log.Printf("[vpn] managed VPN active (local config_file) — torrent traffic split-tunnelled through WireGuard")
|
||||
}
|
||||
} else if cfg.Download.VPN.Enabled {
|
||||
apiURL := cfg.Auth.APIURL
|
||||
if apiURL == "" {
|
||||
apiURL = "https://torrentclaw.com"
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
conf, ferr := vpn.FetchConfig(fetchCtx, apiURL, cfg.Auth.APIKey, "unarr/"+Version)
|
||||
cancel()
|
||||
var fe *vpn.FetchError
|
||||
switch {
|
||||
case ferr != nil && errors.As(ferr, &fe) && fe.Code == vpn.ErrSlotOnDevice:
|
||||
log.Printf("[vpn] slot is active on one of your devices — downloads will NOT use the VPN. Switch the slot to unarr in your profile to protect downloads.")
|
||||
case ferr != nil:
|
||||
log.Printf("[vpn] could not enable VPN (%v) — downloading in the clear", ferr)
|
||||
default:
|
||||
if t, uerr := vpn.Up(conf); uerr != nil {
|
||||
log.Printf("[vpn] tunnel failed to start (%v) — downloading in the clear", uerr)
|
||||
} else {
|
||||
vpnTunnel = t
|
||||
defer vpnTunnel.Close()
|
||||
log.Printf("[vpn] managed VPN active — torrent traffic split-tunnelled through WireGuard")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create torrent downloader
|
||||
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
|
||||
DataDir: cfg.Download.Dir,
|
||||
|
|
@ -206,6 +249,7 @@ func runDaemonStart() error {
|
|||
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
|
||||
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
|
||||
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
|
||||
VPNTunnel: vpnTunnel,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create torrent downloader: %w", err)
|
||||
|
|
@ -240,6 +284,8 @@ func runDaemonStart() error {
|
|||
|
||||
// Create persistent stream server
|
||||
streamSrv := engine.NewStreamServer(cfg.Download.StreamPort)
|
||||
streamSrv.SetUPnPEnabled(cfg.Download.EnableUPnP)
|
||||
streamSrv.SetCORSAllowedOrigins(cfg.Download.CORSExtraOrigins)
|
||||
// Reap HLS tmpdirs left over from a previous daemon run before we start
|
||||
// accepting new sessions. The in-memory registry doesn't survive a
|
||||
// restart, so without this disk usage grows unbounded across restarts.
|
||||
|
|
|
|||
|
|
@ -9,12 +9,21 @@ import (
|
|||
)
|
||||
|
||||
// openBrowser opens a URL in the default browser.
|
||||
//
|
||||
// The URL is restricted to http(s) so that a hostile caller cannot trick
|
||||
// xdg-open/open into interpreting it as a flag (a leading "-" would otherwise
|
||||
// match a switch on every helper we shell out to). Where the helper supports
|
||||
// it we also append "--" to terminate switch parsing as belt-and-braces.
|
||||
func openBrowser(url string) {
|
||||
if !isSafeBrowserURL(url) {
|
||||
return
|
||||
}
|
||||
var c *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
c = exec.Command("open", url)
|
||||
c = exec.Command("open", "--", url)
|
||||
case "windows":
|
||||
// rundll32 does not parse switches from positional args.
|
||||
c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||
default: // linux, freebsd
|
||||
c = exec.Command("xdg-open", url)
|
||||
|
|
@ -22,6 +31,12 @@ func openBrowser(url string) {
|
|||
_ = c.Start() // fire and forget; best-effort
|
||||
}
|
||||
|
||||
// isSafeBrowserURL accepts only http(s) URLs. Other schemes (file://, javascript:,
|
||||
// data:, ...) and flag-shaped strings ("--help") are rejected.
|
||||
func isSafeBrowserURL(url string) bool {
|
||||
return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
|
||||
}
|
||||
|
||||
// defaultDownloadDir returns a sensible default download directory.
|
||||
func defaultDownloadDir() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
|
|
|||
|
|
@ -31,6 +31,32 @@ func TestExpandHome(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIsSafeBrowserURL(t *testing.T) {
|
||||
good := []string{
|
||||
"http://localhost:3000",
|
||||
"https://torrentclaw.com/some/path?q=1",
|
||||
}
|
||||
bad := []string{
|
||||
"--help",
|
||||
"-version",
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,foo",
|
||||
"ftp://example.com",
|
||||
"",
|
||||
}
|
||||
for _, u := range good {
|
||||
if !isSafeBrowserURL(u) {
|
||||
t.Errorf("isSafeBrowserURL(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
for _, u := range bad {
|
||||
if isSafeBrowserURL(u) {
|
||||
t.Errorf("isSafeBrowserURL(%q) = true, want false", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultDownloadDir(t *testing.T) {
|
||||
dir := defaultDownloadDir()
|
||||
if dir == "" {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
tc "github.com/torrentclaw/go-client"
|
||||
"github.com/torrentclaw/unarr/internal/config"
|
||||
"github.com/torrentclaw/unarr/internal/sentry"
|
||||
"github.com/torrentclaw/unarr/internal/upgrade"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -42,6 +43,10 @@ Source: https://github.com/torrentclaw/unarr`,
|
|||
if noColor || os.Getenv("NO_COLOR") != "" {
|
||||
color.NoColor = true
|
||||
}
|
||||
// Self-updater fetches releases from the configured host (default
|
||||
// torrentclaw.com), not GitHub — so mirrors / onion / staging /
|
||||
// UNARR_API_URL all route updates correctly.
|
||||
upgrade.SetBaseURL(loadConfig().Auth.APIURL)
|
||||
},
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
|
||||
func newSelfUpdateCmd() *cobra.Command {
|
||||
var force bool
|
||||
var allowUnsigned bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "self-update",
|
||||
|
|
@ -26,18 +27,20 @@ If the daemon is running, it is automatically restarted so the new
|
|||
version is loaded into memory (otherwise heartbeat would keep
|
||||
reporting the old version until a manual restart).`,
|
||||
Example: ` unarr self-update
|
||||
unarr self-update --force`,
|
||||
unarr self-update --force
|
||||
unarr self-update --allow-unsigned # accept releases missing checksums.txt.sig`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runSelfUpdate(force)
|
||||
return runSelfUpdate(force, allowUnsigned)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
|
||||
cmd.Flags().BoolVar(&allowUnsigned, "allow-unsigned", false, "continue with SHA256-only verification when checksums.txt.sig is missing")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runSelfUpdate(force bool) error {
|
||||
func runSelfUpdate(force, allowUnsigned bool) error {
|
||||
bold := color.New(color.Bold)
|
||||
green := color.New(color.FgGreen)
|
||||
yellow := color.New(color.FgYellow)
|
||||
|
|
@ -74,6 +77,7 @@ func runSelfUpdate(force bool) error {
|
|||
|
||||
upgrader := &upgrade.Upgrader{
|
||||
CurrentVersion: currentClean,
|
||||
AllowUnsigned: allowUnsigned,
|
||||
OnProgress: func(msg string) {
|
||||
fmt.Printf(" %s\n", msg)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
// newUpgradeCmd creates the `unarr upgrade` command as an alias for `self-update`.
|
||||
func newUpgradeCmd() *cobra.Command {
|
||||
var force bool
|
||||
var allowUnsigned bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "upgrade",
|
||||
|
|
@ -18,13 +19,15 @@ This is an alias for 'unarr self-update'. Checks GitHub for the latest
|
|||
release, verifies the checksum, and replaces the current binary.
|
||||
A backup is kept at <binary>.backup.`,
|
||||
Example: ` unarr upgrade
|
||||
unarr upgrade --force`,
|
||||
unarr upgrade --force
|
||||
unarr upgrade --allow-unsigned`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runSelfUpdate(force)
|
||||
return runSelfUpdate(force, allowUnsigned)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
|
||||
cmd.Flags().BoolVar(&allowUnsigned, "allow-unsigned", false, "continue with SHA256-only verification when checksums.txt.sig is missing")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package cmd
|
||||
|
||||
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
|
||||
var Version = "0.8.1"
|
||||
var Version = "0.9.1"
|
||||
|
|
|
|||
|
|
@ -49,8 +49,25 @@ type DownloadConfig struct {
|
|||
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
|
||||
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
|
||||
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
|
||||
EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in because it exposes the unauthenticated /stream + /hls endpoints to the public internet)
|
||||
CORSExtraOrigins []string `toml:"cors_extra_origins"` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030)
|
||||
WebRTC WebRTCConfig `toml:"webrtc"`
|
||||
Transcode TranscodeConfig `toml:"transcode"`
|
||||
VPN VPNConfig `toml:"vpn"`
|
||||
}
|
||||
|
||||
// VPNConfig gates the managed-VPN add-on split-tunnel. When enabled, the daemon
|
||||
// fetches a WireGuard config from the web (/api/internal/agent/vpn-config) and
|
||||
// routes only the torrent client's peer/tracker traffic through an in-process
|
||||
// userspace tunnel (no root, no OS routing changes). Requires an active VPN
|
||||
// add-on on the account; otherwise the daemon logs and downloads in the clear.
|
||||
type VPNConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// ConfigFile, when set, makes the daemon read a local WireGuard .conf instead
|
||||
// of fetching one from the web API. For self-hosted / personal-VPN testing:
|
||||
// point it at a peer .conf from your own WireGuard server and the torrent
|
||||
// client split-tunnels through it with no web/provider plumbing.
|
||||
ConfigFile string `toml:"config_file"`
|
||||
}
|
||||
|
||||
// TranscodeConfig controls real-time transcoding for the in-browser player
|
||||
|
|
|
|||
|
|
@ -241,6 +241,9 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
|||
if cfg.SessionID == "" {
|
||||
return nil, errors.New("hls: empty session id")
|
||||
}
|
||||
if !validSessionID.MatchString(cfg.SessionID) {
|
||||
return nil, errors.New("hls: invalid session id")
|
||||
}
|
||||
if cfg.SourcePath == "" {
|
||||
return nil, errors.New("hls: empty source path")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -261,3 +261,33 @@ func TestCleanupHLSOrphanDirsMissingRoot(t *testing.T) {
|
|||
t.Errorf("CleanupHLSOrphanDirs on missing root = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidSessionID(t *testing.T) {
|
||||
good := []string{
|
||||
"abc",
|
||||
"7b8c4f12-9d3e-4a1b-9c2f-aabbccddeeff",
|
||||
"ABC_123-xyz",
|
||||
strings.Repeat("a", 128),
|
||||
}
|
||||
bad := []string{
|
||||
"",
|
||||
"../etc/passwd",
|
||||
"foo/bar",
|
||||
"foo\\bar",
|
||||
"foo.bar",
|
||||
"with spaces",
|
||||
"with\nnewline",
|
||||
strings.Repeat("a", 129),
|
||||
"héctor", // non-ascii
|
||||
}
|
||||
for _, id := range good {
|
||||
if !validSessionID.MatchString(id) {
|
||||
t.Errorf("validSessionID rejected good id %q", id)
|
||||
}
|
||||
}
|
||||
for _, id := range bad {
|
||||
if validSessionID.MatchString(id) {
|
||||
t.Errorf("validSessionID accepted bad id %q", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,23 @@ import (
|
|||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpenPlayer attempts to open a media player with the given stream URL.
|
||||
// Returns the player name and the running command.
|
||||
// If override is set, it uses that command directly.
|
||||
//
|
||||
// The URL is required to be http(s) so a hostile-looking value (e.g. starting
|
||||
// with `--`) is not interpreted as a switch by mpv/vlc/xdg-open/open. The
|
||||
// `--` separator is also appended before the URL where the helper supports
|
||||
// it.
|
||||
func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
|
||||
if !isSafePlayerURL(url) {
|
||||
return "", nil, fmt.Errorf("refusing to open non-http(s) URL")
|
||||
}
|
||||
if override != "" {
|
||||
cmd := exec.Command(override, url)
|
||||
cmd := exec.Command(override, "--", url)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return override, nil, fmt.Errorf("start %s: %w", override, err)
|
||||
}
|
||||
|
|
@ -20,7 +29,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
|
|||
|
||||
// Try mpv first (best streaming support)
|
||||
if path, err := exec.LookPath("mpv"); err == nil {
|
||||
cmd := exec.Command(path, "--no-terminal", url)
|
||||
cmd := exec.Command(path, "--no-terminal", "--", url)
|
||||
if err := cmd.Start(); err == nil {
|
||||
return "mpv", cmd, nil
|
||||
}
|
||||
|
|
@ -28,7 +37,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
|
|||
|
||||
// Try VLC
|
||||
if path, err := exec.LookPath("vlc"); err == nil {
|
||||
cmd := exec.Command(path, url)
|
||||
cmd := exec.Command(path, "--", url)
|
||||
if err := cmd.Start(); err == nil {
|
||||
return "vlc", cmd, nil
|
||||
}
|
||||
|
|
@ -36,7 +45,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
|
|||
|
||||
// Try cvlc (VLC headless)
|
||||
if path, err := exec.LookPath("cvlc"); err == nil {
|
||||
cmd := exec.Command(path, url)
|
||||
cmd := exec.Command(path, "--", url)
|
||||
if err := cmd.Start(); err == nil {
|
||||
return "vlc (headless)", cmd, nil
|
||||
}
|
||||
|
|
@ -51,6 +60,9 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
|
|||
}
|
||||
|
||||
func openBrowser(url string) (string, *exec.Cmd, error) {
|
||||
if !isSafePlayerURL(url) {
|
||||
return "", nil, fmt.Errorf("refusing to open non-http(s) URL")
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
if path, err := exec.LookPath("xdg-open"); err == nil {
|
||||
|
|
@ -60,7 +72,7 @@ func openBrowser(url string) (string, *exec.Cmd, error) {
|
|||
}
|
||||
}
|
||||
case "darwin":
|
||||
cmd := exec.Command("/usr/bin/open", url)
|
||||
cmd := exec.Command("/usr/bin/open", "--", url)
|
||||
if err := cmd.Start(); err == nil {
|
||||
return "browser", cmd, nil
|
||||
}
|
||||
|
|
@ -72,3 +84,9 @@ func openBrowser(url string) (string, *exec.Cmd, error) {
|
|||
}
|
||||
return "", nil, fmt.Errorf("no browser opener found")
|
||||
}
|
||||
|
||||
// isSafePlayerURL guards the helpers above against URLs that could be
|
||||
// interpreted as command-line switches by the launched player.
|
||||
func isSafePlayerURL(url string) bool {
|
||||
return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,18 @@ type StreamServer struct {
|
|||
url string // best single URL (backward compat)
|
||||
urls StreamURLs // all available URLs by network type
|
||||
upnpMapping *UPnPMapping
|
||||
disableUPnP bool
|
||||
// enableUPnP gates whether Listen() asks the gateway to publish the
|
||||
// stream port to the WAN. UPnP is opt-in (false by default) because
|
||||
// /stream and /hls have no auth — exposing them on the public internet
|
||||
// would let any scanner enumerate active downloads. LAN and Tailscale
|
||||
// access keep working without UPnP.
|
||||
enableUPnP bool
|
||||
// corsExtraOrigins are operator-configured origins added to the default
|
||||
// allowlist defined in validate.go. Set before Listen().
|
||||
corsExtraOrigins []string
|
||||
// corsAllowlist is computed at Listen() time and treated as read-only
|
||||
// thereafter so per-request reads need no locking.
|
||||
corsAllowlist map[string]struct{}
|
||||
|
||||
hls *HLSSessionRegistry // HLS sessions served on /hls/<id>/...
|
||||
|
||||
|
|
@ -65,16 +76,73 @@ type StreamServer struct {
|
|||
|
||||
// NewStreamServer creates a stream server bound to the given port.
|
||||
// Call Listen() to start accepting connections, then SetFile() to serve content.
|
||||
//
|
||||
// UPnP is opt-in: call SetUPnPEnabled(true) before Listen() to publish the
|
||||
// stream port on the WAN. Without it, only LAN and Tailscale clients can
|
||||
// reach the server. This matches the security default — /stream and /hls
|
||||
// have no auth, so exposing them to the public internet is something the
|
||||
// operator must explicitly request.
|
||||
func NewStreamServer(port int) *StreamServer {
|
||||
return &StreamServer{port: port, hls: NewHLSSessionRegistry()}
|
||||
}
|
||||
|
||||
// SetUPnPEnabled toggles WAN publishing of the stream port. Call before
|
||||
// Listen(); changes after Listen() are ignored for the active server.
|
||||
func (ss *StreamServer) SetUPnPEnabled(enabled bool) {
|
||||
ss.enableUPnP = enabled
|
||||
}
|
||||
|
||||
// SetCORSAllowedOrigins replaces the operator-supplied extra origins. The
|
||||
// default allowlist (torrentclaw.com / app.torrentclaw.com / localhost dev
|
||||
// ports) is always merged in. Call before Listen().
|
||||
func (ss *StreamServer) SetCORSAllowedOrigins(origins []string) {
|
||||
ss.corsExtraOrigins = origins
|
||||
}
|
||||
|
||||
// writeCORSHeaders writes the per-origin CORS response headers when the
|
||||
// request carries an Origin header that matches the allowlist. Returns true
|
||||
// if the handler must short-circuit (preflight OPTIONS). Media-tag requests
|
||||
// (no Origin header) bypass this entirely.
|
||||
//
|
||||
// `Vary: Origin` is emitted whenever an Origin header is present (matched
|
||||
// or not) so any intermediate cache keys the response per-origin and a
|
||||
// later request with a different origin cannot be served a stale ACAO.
|
||||
func (ss *StreamServer) writeCORSHeaders(w http.ResponseWriter, r *http.Request, expose string) (preflight bool) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
w.Header().Add("Vary", "Origin")
|
||||
if _, ok := ss.corsAllowlist[origin]; !ok {
|
||||
// Unknown origin — do not emit CORS headers so the browser blocks
|
||||
// the response. Still return without short-circuiting so a non-CORS
|
||||
// caller (e.g. curl) keeps working.
|
||||
return false
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
||||
if expose != "" {
|
||||
w.Header().Set("Access-Control-Expose-Headers", expose)
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HLS returns the HLS session registry for this server. Daemon code uses it
|
||||
// to register a session when the backend asks for HLS playback.
|
||||
func (ss *StreamServer) HLS() *HLSSessionRegistry { return ss.hls }
|
||||
|
||||
// Listen starts the HTTP server on the configured port. Call once at daemon startup.
|
||||
func (ss *StreamServer) Listen(ctx context.Context) error {
|
||||
// Freeze the CORS allowlist before the first request can land. After
|
||||
// this point the map is treated as read-only so handlers can probe it
|
||||
// without locking.
|
||||
ss.corsAllowlist = buildCORSAllowlist(ss.corsExtraOrigins)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/stream", ss.handler)
|
||||
mux.HandleFunc("/health", ss.healthHandler)
|
||||
|
|
@ -122,11 +190,16 @@ func (ss *StreamServer) Listen(ctx context.Context) error {
|
|||
if tsIP := TailscaleIP(); tsIP != "" {
|
||||
ss.urls.Tailscale = fmt.Sprintf("http://%s:%d/stream", tsIP, ss.port)
|
||||
}
|
||||
if !ss.disableUPnP {
|
||||
if mapping, err := SetupUPnP(ss.port); err == nil {
|
||||
if ss.enableUPnP {
|
||||
mapping, err := SetupUPnP(ss.port)
|
||||
if err != nil {
|
||||
log.Printf("[stream] UPnP setup failed: %v (only LAN/Tailscale clients will reach port %d)", err, ss.port)
|
||||
} else {
|
||||
ss.upnpMapping = mapping
|
||||
ss.urls.Public = fmt.Sprintf("http://%s:%d/stream", mapping.ExternalIP, mapping.ExternalPort)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[stream] UPnP disabled — port %d not published to WAN (set downloads.enable_upnp = true to opt in)", ss.port)
|
||||
}
|
||||
|
||||
// Best single URL for backward compat: Tailscale > LAN > Public > localhost
|
||||
|
|
@ -284,17 +357,9 @@ func (ss *StreamServer) HLSURLsJSON(sessionID string) string {
|
|||
func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ss.lastActivity.Store(time.Now().UnixNano())
|
||||
|
||||
// CORS for app.torrentclaw.com → 127.0.0.1/Tailscale daemon.
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
if ss.writeCORSHeaders(w, r, "Content-Length, Content-Range, Accept-Ranges") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/hls/")
|
||||
parts := strings.SplitN(rest, "/", 2)
|
||||
|
|
@ -303,6 +368,12 @@ func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
sessionID := parts[0]
|
||||
// Reject malformed IDs with the same 404 we return for unknown sessions —
|
||||
// no oracle for the accepted format.
|
||||
if !validSessionID.MatchString(sessionID) {
|
||||
http.Error(w, "hls session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
session := ss.hls.Get(sessionID)
|
||||
if session == nil {
|
||||
http.Error(w, "hls session not found", http.StatusNotFound)
|
||||
|
|
@ -386,12 +457,26 @@ func (ss *StreamServer) serveSubtitlePlaylist(w http.ResponseWriter, r *http.Req
|
|||
//
|
||||
// curl http://<tailscale-ip>:<port>/health
|
||||
func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if ss.writeCORSHeaders(w, r, "") {
|
||||
return
|
||||
}
|
||||
ss.mu.RLock()
|
||||
provider := ss.provider
|
||||
taskID := ss.taskID
|
||||
ss.mu.RUnlock()
|
||||
|
||||
clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
// Only expose filename/taskID/client to loopback callers (local diagnostics).
|
||||
// Remote callers (LAN, Tailscale, UPnP public) get a minimal probe response
|
||||
// so that scanners and unauthenticated peers cannot fingerprint the active
|
||||
// download. The web stream-probe only checks HTTP 200 + Content-Type.
|
||||
//
|
||||
// Use net.IP.IsLoopback so we also accept ::ffff:127.0.0.1 (Linux dual-stack
|
||||
// IPv4-mapped form) and reject the empty-string fallthrough when
|
||||
// SplitHostPort fails on a malformed RemoteAddr — both would otherwise
|
||||
// silently bypass the disclosure boundary.
|
||||
parsedIP := net.ParseIP(clientIP)
|
||||
isLocal := parsedIP != nil && parsedIP.IsLoopback()
|
||||
|
||||
type healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
|
|
@ -399,21 +484,25 @@ func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
|
|||
File string `json:"file,omitempty"`
|
||||
Task string `json:"task,omitempty"`
|
||||
Port int `json:"port"`
|
||||
Client string `json:"client"`
|
||||
Client string `json:"client,omitempty"`
|
||||
}
|
||||
resp := healthResponse{
|
||||
Status: "ok",
|
||||
Port: ss.port,
|
||||
Client: clientIP,
|
||||
}
|
||||
if provider != nil {
|
||||
resp.Streaming = true
|
||||
}
|
||||
if isLocal {
|
||||
resp.Client = clientIP
|
||||
if provider != nil {
|
||||
resp.File = provider.FileName()
|
||||
resp.Task = taskID
|
||||
if len(resp.Task) > 8 {
|
||||
resp.Task = resp.Task[:8]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
|
|
@ -427,16 +516,9 @@ func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
|
|||
// VLC fetches this playlist and applies the EXTVLCOPT directives automatically,
|
||||
// enabling automatic audio/subtitle track selection on all VLC platforms (desktop + mobile).
|
||||
func (ss *StreamServer) playlistHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// CORS — handle preflight before doing any work (consistent with handler)
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
if ss.writeCORSHeaders(w, r, "") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
|
|
@ -505,18 +587,9 @@ func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// CORS headers — only when browser sends Origin (HTTPS site → localhost)
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
if ss.writeCORSHeaders(w, r, "Content-Length, Content-Range, Accept-Ranges") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rawReader := provider.NewFileReader(r.Context())
|
||||
if rawReader == nil {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
|
@ -379,6 +380,149 @@ func TestStreamServer_Health_WithFile(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestStreamServer_Health_NonLoopback_NoLeak verifica que /health no revela
|
||||
// nombre de fichero, taskID ni client IP cuando el caller no es loopback.
|
||||
// Protección contra reconnaissance vía LAN / UPnP / Tailscale.
|
||||
func TestStreamServer_Health_NonLoopback_NoLeak(t *testing.T) {
|
||||
srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
|
||||
ctx := context.Background()
|
||||
if err := srv.Listen(ctx); err != nil {
|
||||
t.Fatalf("Listen() error: %v", err)
|
||||
}
|
||||
defer srv.Shutdown(ctx)
|
||||
|
||||
provider := newFakeProvider("secret.mkv", []byte("data"))
|
||||
srv.SetFile(provider, "secret-task-id")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
}{
|
||||
{"lan_ipv4", "192.168.1.50:54321"},
|
||||
{"empty_host_no_bypass", ":54321"},
|
||||
{"public_ipv4", "203.0.113.10:443"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
req.RemoteAddr = tc.remoteAddr
|
||||
srv.healthHandler(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `"status":"ok"`) {
|
||||
t.Errorf("body missing status:ok: %q", body)
|
||||
}
|
||||
if !strings.Contains(body, `"streaming":true`) {
|
||||
t.Errorf("body should report streaming bool: %q", body)
|
||||
}
|
||||
if strings.Contains(body, "secret.mkv") {
|
||||
t.Errorf("body leaked filename: %q", body)
|
||||
}
|
||||
if strings.Contains(body, "secret-t") {
|
||||
t.Errorf("body leaked task id: %q", body)
|
||||
}
|
||||
if strings.Contains(body, "192.168.1.50") || strings.Contains(body, "203.0.113.10") {
|
||||
t.Errorf("body leaked client ip: %q", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamServer_CORS_Allowlist verifica que sólo los origenes en la
|
||||
// allowlist reciben Access-Control-Allow-Origin y que ningún otro origen
|
||||
// es eco-reflejado.
|
||||
func TestStreamServer_CORS_Allowlist(t *testing.T) {
|
||||
srv := NewStreamServer(0)
|
||||
ctx := context.Background()
|
||||
if err := srv.Listen(ctx); err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
defer srv.Shutdown(ctx)
|
||||
|
||||
cases := []struct {
|
||||
origin string
|
||||
wantAllow bool
|
||||
}{
|
||||
{"https://app.torrentclaw.com", true},
|
||||
{"https://torrentclaw.com", true},
|
||||
{"http://localhost:3030", true},
|
||||
{"http://127.0.0.1:3030", true},
|
||||
{"https://evil.example", false},
|
||||
{"null", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.origin, func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodOptions, "/health", nil)
|
||||
if tc.origin != "" {
|
||||
req.Header.Set("Origin", tc.origin)
|
||||
}
|
||||
srv.healthHandler(rr, req)
|
||||
got := rr.Header().Get("Access-Control-Allow-Origin")
|
||||
if tc.wantAllow {
|
||||
if got != tc.origin {
|
||||
t.Errorf("origin %q: ACAO = %q, want %q", tc.origin, got, tc.origin)
|
||||
}
|
||||
} else if got != "" {
|
||||
t.Errorf("origin %q: ACAO leaked as %q, expected empty", tc.origin, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamServer_CORS_ExtraOrigin verifica que SetCORSAllowedOrigins añade
|
||||
// origins al baseline sin removerlos.
|
||||
func TestStreamServer_CORS_ExtraOrigin(t *testing.T) {
|
||||
srv := NewStreamServer(0)
|
||||
srv.SetCORSAllowedOrigins([]string{"https://custom.example"})
|
||||
ctx := context.Background()
|
||||
if err := srv.Listen(ctx); err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
defer srv.Shutdown(ctx)
|
||||
|
||||
for _, origin := range []string{"https://custom.example", "https://torrentclaw.com"} {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
req.Header.Set("Origin", origin)
|
||||
srv.healthHandler(rr, req)
|
||||
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != origin {
|
||||
t.Errorf("origin %q: ACAO = %q", origin, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamServer_HLS_InvalidSessionID verifica que el hlsHandler rechaza
|
||||
// session IDs con caracteres ilegales devolviendo 404 (uniforme con sesión
|
||||
// inexistente) para no filtrar el formato aceptado a un attacker.
|
||||
func TestStreamServer_HLS_InvalidSessionID(t *testing.T) {
|
||||
srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
|
||||
ctx := context.Background()
|
||||
if err := srv.Listen(ctx); err != nil {
|
||||
t.Fatalf("Listen() error: %v", err)
|
||||
}
|
||||
defer srv.Shutdown(ctx)
|
||||
|
||||
bad := []string{
|
||||
"/hls/..%2Fetc%2Fpasswd/master.m3u8",
|
||||
"/hls/foo.bar/master.m3u8",
|
||||
"/hls/foo%20bar/master.m3u8",
|
||||
"/hls/foo%2Fbar/master.m3u8",
|
||||
}
|
||||
for _, path := range bad {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
srv.hlsHandler(rr, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("path %q: status = %d, want 404", path, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamServer_MKV_ContentType verifica que el Content-Type para .mkv
|
||||
// es el correcto.
|
||||
func TestStreamServer_MKV_ContentType(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/anacrolix/torrent/storage"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/torrentclaw/unarr/internal/config"
|
||||
"github.com/torrentclaw/unarr/internal/vpn"
|
||||
"golang.org/x/term"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
|
@ -79,6 +80,11 @@ type TorrentConfig struct {
|
|||
WebRTCEnabled bool
|
||||
WebRTCTrackers []string // wss://… signaling trackers added to every magnet
|
||||
ICEServers []webrtc.ICEServer // STUN + TURN servers for NAT traversal
|
||||
|
||||
// VPNTunnel, when set, split-tunnels the torrent client's peer + tracker
|
||||
// traffic through an in-process userspace WireGuard tunnel (managed-VPN
|
||||
// add-on). nil = downloads in the clear. Brought up by the daemon.
|
||||
VPNTunnel *vpn.Tunnel
|
||||
}
|
||||
|
||||
// TorrentDownloader downloads torrents via BitTorrent P2P.
|
||||
|
|
@ -218,6 +224,20 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
|
|||
// Re-announce active torrents to DHT periodically (keeps routing table healthy).
|
||||
tcfg.PeriodicallyAnnounceTorrentsToDht = true
|
||||
|
||||
// --- Managed-VPN split-tunnel ---
|
||||
// Route the torrent client's outbound peer + tracker traffic through the
|
||||
// in-process WireGuard tunnel so the swarm + trackers see the VPN IP, not
|
||||
// the user's. unarr's control plane keeps using the normal net. uTP (UDP
|
||||
// peers) is disabled — TCP peers + HTTP/UDP tracker announces are tunnelled;
|
||||
// inbound peers don't apply (leech-only, no port forward).
|
||||
if cfg.VPNTunnel != nil {
|
||||
tcfg.DisableUTP = true
|
||||
tcfg.TrackerDialContext = cfg.VPNTunnel.Net.DialContext
|
||||
tcfg.HTTPDialContext = cfg.VPNTunnel.Net.DialContext
|
||||
tcfg.TrackerListenPacket = cfg.VPNTunnel.ListenPacket
|
||||
log.Printf("[torrent] VPN split-tunnel enabled (peer + tracker traffic routed through WireGuard)")
|
||||
}
|
||||
|
||||
// Try to create client; if the port is in use, try the next few ports.
|
||||
var client *torrent.Client
|
||||
var err error
|
||||
|
|
@ -239,6 +259,12 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
|
|||
log.Printf("[torrent] listening on port %d (configured: %d was busy)", tcfg.ListenPort, listenPort)
|
||||
}
|
||||
|
||||
// Route outgoing peer dials through the VPN tunnel (TCP). Added after client
|
||||
// creation; DialForPeerConns defaults to true so this is used for peers.
|
||||
if cfg.VPNTunnel != nil {
|
||||
client.AddDialer(torrent.NetworkDialer{Network: "tcp", Dialer: cfg.VPNTunnel.Net})
|
||||
}
|
||||
|
||||
// Restore DHT nodes with full node IDs (direct routing table insertion, no async pings).
|
||||
for _, s := range client.DhtServers() {
|
||||
if w, ok := s.(torrent.AnacrolixDhtServerWrapper); ok {
|
||||
|
|
|
|||
48
internal/engine/validate.go
Normal file
48
internal/engine/validate.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Package engine — validate.go centralises input validators used by the
|
||||
// stream/HLS HTTP handlers and the daemon glue. Keep new validators in this
|
||||
// file so a future reviewer can audit the trust boundary in one place.
|
||||
package engine
|
||||
|
||||
import "regexp"
|
||||
|
||||
// validSessionID restricts session IDs to characters safe for use as a single
|
||||
// filesystem path component. Server-issued UUIDs and hex strings match this;
|
||||
// anything containing slashes, dots, or path separators is rejected so a
|
||||
// compromised or buggy server cannot escape hlsTmpDirRoot via os.MkdirAll.
|
||||
var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}$`)
|
||||
|
||||
// defaultCORSAllowedOrigins is the baseline of browser origins that may
|
||||
// XHR-probe `/health` and friends on the local daemon. Production hosts are
|
||||
// hardcoded; localhost on the dev port used by torrentclaw-web is included
|
||||
// so dev builds work without extra configuration. Operators may add more
|
||||
// origins via the [downloads] cors_extra_origins TOML key.
|
||||
//
|
||||
// The dev port matches `next dev -p 3030` in torrentclaw-web/package.json.
|
||||
// 127.0.0.1 is listed in addition to localhost because some browsers treat
|
||||
// them as distinct origins for CORS.
|
||||
//
|
||||
// Note: media tags (<video src>, <audio src>) do not send the Origin
|
||||
// header so they are not gated by CORS at all; this allowlist only
|
||||
// affects fetch()/XHR.
|
||||
var defaultCORSAllowedOrigins = []string{
|
||||
"https://torrentclaw.com",
|
||||
"https://app.torrentclaw.com",
|
||||
"http://localhost:3030",
|
||||
"http://127.0.0.1:3030",
|
||||
}
|
||||
|
||||
// buildCORSAllowlist merges the default origins with any extras supplied by
|
||||
// the operator. Returned map is intended to be installed once at Listen()
|
||||
// and treated as read-only afterwards.
|
||||
func buildCORSAllowlist(extra []string) map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(defaultCORSAllowedOrigins)+len(extra))
|
||||
for _, o := range defaultCORSAllowedOrigins {
|
||||
out[o] = struct{}{}
|
||||
}
|
||||
for _, o := range extra {
|
||||
if o != "" {
|
||||
out[o] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -185,8 +185,7 @@ func TestStreamServerByteTracking(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
srv := NewStreamServer(0)
|
||||
srv.disableUPnP = true
|
||||
srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
|
||||
ctx := context.Background()
|
||||
if err := srv.Listen(ctx); err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ package upgrade
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -88,7 +88,23 @@ func download(ctx context.Context, version string) (string, error) {
|
|||
}
|
||||
|
||||
// verifyChecksum downloads checksums.txt and verifies the archive's SHA256.
|
||||
// When a release public key is embedded at build time (releasePubKeyBase64),
|
||||
// the function also verifies an ed25519 signature over checksums.txt before
|
||||
// trusting any hash inside it — this turns the checksum file from a passive
|
||||
// integrity check into an authenticated artifact that a maintainer or CI key
|
||||
// compromise cannot trivially forge.
|
||||
func verifyChecksum(ctx context.Context, version, archivePath string) error {
|
||||
return verifyChecksumWithOptions(ctx, version, archivePath, true)
|
||||
}
|
||||
|
||||
// verifyChecksumOnly skips the ed25519 signature step. Used by Upgrader
|
||||
// when --allow-unsigned is set and the release is known to predate signing
|
||||
// (or when a release accidentally shipped without a .sig file).
|
||||
func verifyChecksumOnly(ctx context.Context, version, archivePath string) error {
|
||||
return verifyChecksumWithOptions(ctx, version, archivePath, false)
|
||||
}
|
||||
|
||||
func verifyChecksumWithOptions(ctx context.Context, version, archivePath string, verifySignature bool) error {
|
||||
// Download checksums.txt
|
||||
url := releaseURL(version, "checksums.txt")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
|
@ -107,11 +123,28 @@ func verifyChecksum(ctx context.Context, version, archivePath string) error {
|
|||
return fmt.Errorf("fetch checksums: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read the entire checksums.txt content first so we can both parse and
|
||||
// verify the signature over the same bytes.
|
||||
checksumsContent, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read checksums: %w", err)
|
||||
}
|
||||
|
||||
// Verify ed25519 signature over checksums.txt before trusting its
|
||||
// contents. Skipped silently when no key is embedded (handled by the
|
||||
// caller via SignatureVerificationConfigured) or when the caller
|
||||
// explicitly opts out via --allow-unsigned.
|
||||
if verifySignature {
|
||||
if err := verifyChecksumsSignature(ctx, version, checksumsContent); err != nil {
|
||||
return fmt.Errorf("verify signature: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse checksums.txt — format: "<sha256> <filename>"
|
||||
expectedName := archiveName(version)
|
||||
var expectedHash string
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner := bufio.NewScanner(bytes.NewReader(checksumsContent))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.Fields(line)
|
||||
|
|
@ -148,36 +181,35 @@ func verifyChecksum(ctx context.Context, version, archivePath string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// fetchLatestVersion queries GitHub API for the latest release tag.
|
||||
// fetchLatestVersion queries the TorrentClaw release endpoint (/version) for the
|
||||
// latest version string (e.g. "0.8.1"). No GitHub dependency.
|
||||
func fetchLatestVersion(ctx context.Context) (string, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo)
|
||||
url := updateBaseURL + "/version"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "unarr-updater")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch latest release: %w", err)
|
||||
return "", fmt.Errorf("fetch latest version: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GitHub API: HTTP %d", resp.StatusCode)
|
||||
return "", fmt.Errorf("version endpoint: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return "", fmt.Errorf("decode response: %w", err)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read version: %w", err)
|
||||
}
|
||||
|
||||
if release.TagName == "" {
|
||||
return "", fmt.Errorf("empty tag_name in release")
|
||||
version := strings.TrimPrefix(strings.TrimSpace(string(body)), "v")
|
||||
if version == "" {
|
||||
return "", fmt.Errorf("empty version from %s", url)
|
||||
}
|
||||
|
||||
return strings.TrimPrefix(release.TagName, "v"), nil
|
||||
return version, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
|
@ -85,15 +86,22 @@ func extractZip(archivePath, destDir string) (string, error) {
|
|||
|
||||
target := binaryName + ".exe"
|
||||
|
||||
for _, f := range r.File {
|
||||
name := filepath.Base(f.Name)
|
||||
|
||||
// Guard against path traversal
|
||||
if strings.Contains(f.Name, "..") {
|
||||
continue
|
||||
// Resolve destDir to its absolute form once so the ZIP-slip check below
|
||||
// can compare canonical paths instead of fragile substring matches.
|
||||
absDest, err := filepath.Abs(destDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve dest: %w", err)
|
||||
}
|
||||
|
||||
if name != target {
|
||||
for _, f := range r.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if filepath.Base(f.Name) != target {
|
||||
continue
|
||||
}
|
||||
absDst, ok := safeZipPath(f.Name, target, absDest)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -102,8 +110,7 @@ func extractZip(archivePath, destDir string) (string, error) {
|
|||
return "", err
|
||||
}
|
||||
|
||||
dst := filepath.Join(destDir, target)
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
out, err := os.OpenFile(absDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return "", err
|
||||
|
|
@ -116,8 +123,41 @@ func extractZip(archivePath, destDir string) (string, error) {
|
|||
}
|
||||
out.Close()
|
||||
rc.Close()
|
||||
return dst, nil
|
||||
return absDst, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("binary %q not found in archive", target)
|
||||
}
|
||||
|
||||
// safeZipPath validates that a ZIP entry name is safe to extract under
|
||||
// absDest, then returns the absolute destination path (always
|
||||
// absDest/target, never the raw entry name — we still only extract files
|
||||
// matched by Base name).
|
||||
//
|
||||
// Rejected: absolute paths, paths that resolve to "..", paths containing
|
||||
// a "../" or "..\\" component, and any entry whose final destination
|
||||
// would land outside absDest. The check uses path.Clean on the entry's
|
||||
// native separator (ZIP uses forward slashes by spec, but some authors
|
||||
// emit backslashes — we treat both as separators here so a hostile entry
|
||||
// on Linux can't bypass the substring scan).
|
||||
func safeZipPath(entryName, target, absDest string) (string, bool) {
|
||||
// Normalise both separators to "/" so the check works on Linux too,
|
||||
// where filepath.Separator is "/" and a hostile "..\\foo" string is
|
||||
// otherwise treated as a single filename component by filepath.Clean.
|
||||
normalised := strings.ReplaceAll(entryName, `\`, "/")
|
||||
cleaned := path.Clean(normalised)
|
||||
if cleaned == ".." ||
|
||||
strings.HasPrefix(cleaned, "../") ||
|
||||
strings.Contains(cleaned, "/../") ||
|
||||
path.IsAbs(cleaned) {
|
||||
return "", false
|
||||
}
|
||||
absDst, err := filepath.Abs(filepath.Join(absDest, target))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !strings.HasPrefix(absDst+string(filepath.Separator), absDest+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
return absDst, true
|
||||
}
|
||||
|
|
|
|||
112
internal/upgrade/signature.go
Normal file
112
internal/upgrade/signature.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package upgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// releasePubKeyBase64 is the base64-encoded ed25519 public key used to verify
|
||||
// `checksums.txt.sig` against `checksums.txt` during self-update.
|
||||
//
|
||||
// It is overridable at link time via ldflags so the same source compiles for
|
||||
// users who do not yet have a release-signing keypair in their CI:
|
||||
//
|
||||
// -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64=<base64-pubkey>
|
||||
//
|
||||
// When the variable is empty, signature verification is skipped and a warning
|
||||
// is logged — checksum-only verification remains in force. This is the
|
||||
// transitional default until the keypair is provisioned; flip to a non-empty
|
||||
// value (and enable the corresponding CI signing step) to make signature
|
||||
// verification mandatory.
|
||||
var releasePubKeyBase64 = ""
|
||||
|
||||
// ErrMissingSignature indicates the release does not ship a `.sig` file even
|
||||
// though signature verification is required by an embedded public key.
|
||||
var ErrMissingSignature = errors.New("release signature file is missing")
|
||||
|
||||
// verifyChecksumsSignature downloads `checksums.txt.sig` (raw 64-byte ed25519
|
||||
// signature over the checksums.txt content) and verifies it with the embedded
|
||||
// public key. Returns nil if verification succeeds or if no public key has
|
||||
// been embedded yet (caller is expected to surface a warning in that case).
|
||||
func verifyChecksumsSignature(ctx context.Context, version string, checksumsContent []byte) error {
|
||||
pubKey, err := loadReleasePubKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load release pubkey: %w", err)
|
||||
}
|
||||
if pubKey == nil {
|
||||
// Signature verification not configured; caller decides what to do.
|
||||
return nil
|
||||
}
|
||||
|
||||
url := releaseURL(version, "checksums.txt.sig")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "unarr-updater")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch signature: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return ErrMissingSignature
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("fetch signature: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Signature file is base64(signature)\n — small and bounded.
|
||||
rawSig, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read signature: %w", err)
|
||||
}
|
||||
sig, err := decodeSignature(rawSig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode signature: %w", err)
|
||||
}
|
||||
if len(sig) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("signature size %d, expected %d", len(sig), ed25519.SignatureSize)
|
||||
}
|
||||
if !ed25519.Verify(pubKey, checksumsContent, sig) {
|
||||
return errors.New("ed25519 signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SignatureVerificationConfigured reports whether the build has a release
|
||||
// public key embedded. The CLI surfaces this so users running a non-signed
|
||||
// build get a clear warning rather than silent trust.
|
||||
func SignatureVerificationConfigured() bool {
|
||||
pubKey, err := loadReleasePubKey()
|
||||
return err == nil && pubKey != nil
|
||||
}
|
||||
|
||||
func loadReleasePubKey() (ed25519.PublicKey, error) {
|
||||
v := strings.TrimSpace(releasePubKeyBase64)
|
||||
if v == "" {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode: %w", err)
|
||||
}
|
||||
if len(raw) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("pubkey size %d, expected %d", len(raw), ed25519.PublicKeySize)
|
||||
}
|
||||
return ed25519.PublicKey(raw), nil
|
||||
}
|
||||
|
||||
// decodeSignature parses the base64-encoded signature emitted by
|
||||
// scripts/sign-checksums (always base64 + trailing newline). A single
|
||||
// expected format keeps the surface area minimal — a stricter parser is
|
||||
// less likely to accept a hostile mirror's coincidentally-sized payload.
|
||||
func decodeSignature(raw []byte) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw)))
|
||||
}
|
||||
134
internal/upgrade/signature_test.go
Normal file
134
internal/upgrade/signature_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package upgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withReleasePubKey temporarily swaps the embedded release public key and
|
||||
// restores the previous value on test exit.
|
||||
func withReleasePubKey(t *testing.T, encoded string) {
|
||||
t.Helper()
|
||||
prev := releasePubKeyBase64
|
||||
releasePubKeyBase64 = encoded
|
||||
t.Cleanup(func() { releasePubKeyBase64 = prev })
|
||||
}
|
||||
|
||||
func TestSignatureVerificationDisabledByDefault(t *testing.T) {
|
||||
withReleasePubKey(t, "")
|
||||
if SignatureVerificationConfigured() {
|
||||
t.Fatal("expected SignatureVerificationConfigured() to be false when pubkey is empty")
|
||||
}
|
||||
// verifyChecksumsSignature should be a no-op when no key is embedded.
|
||||
if err := verifyChecksumsSignature(context.Background(), "0.0.0", []byte("anything")); err != nil {
|
||||
t.Fatalf("expected nil when pubkey is empty, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureRejectsMalformedPubKey(t *testing.T) {
|
||||
withReleasePubKey(t, "not-base64!!")
|
||||
if _, err := loadReleasePubKey(); err == nil {
|
||||
t.Fatal("expected error from malformed base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureRejectsWrongSizePubKey(t *testing.T) {
|
||||
withReleasePubKey(t, base64.StdEncoding.EncodeToString([]byte("too-short")))
|
||||
if _, err := loadReleasePubKey(); err == nil {
|
||||
t.Fatal("expected error from wrong-size pubkey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureVerifiesGoodSignature(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate keypair: %v", err)
|
||||
}
|
||||
withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
|
||||
|
||||
checksumsBody := []byte("deadbeef unarr_0.0.0_linux_amd64.tar.gz\n")
|
||||
signature := ed25519.Sign(priv, checksumsBody)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "checksums.txt.sig") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, base64.StdEncoding.EncodeToString(signature))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
prevHost := updateBaseURL
|
||||
updateBaseURL = srv.URL
|
||||
t.Cleanup(func() { updateBaseURL = prevHost })
|
||||
|
||||
if err := verifyChecksumsSignature(context.Background(), "0.0.0", checksumsBody); err != nil {
|
||||
t.Fatalf("verifyChecksumsSignature(good) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureRejectsBadSignature(t *testing.T) {
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate keypair: %v", err)
|
||||
}
|
||||
withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
|
||||
|
||||
// Sign with a DIFFERENT private key — should be rejected.
|
||||
_, other, _ := ed25519.GenerateKey(rand.Reader)
|
||||
body := []byte("checksum-line\n")
|
||||
badSig := ed25519.Sign(other, body)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, base64.StdEncoding.EncodeToString(badSig))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
prevHost := updateBaseURL
|
||||
updateBaseURL = srv.URL
|
||||
t.Cleanup(func() { updateBaseURL = prevHost })
|
||||
|
||||
err = verifyChecksumsSignature(context.Background(), "0.0.0", body)
|
||||
if err == nil || !strings.Contains(err.Error(), "verification failed") {
|
||||
t.Fatalf("expected verification failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureMissingFile(t *testing.T) {
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
prevHost := updateBaseURL
|
||||
updateBaseURL = srv.URL
|
||||
t.Cleanup(func() { updateBaseURL = prevHost })
|
||||
|
||||
err := verifyChecksumsSignature(context.Background(), "0.0.0", []byte("body"))
|
||||
if !errors.Is(err, ErrMissingSignature) {
|
||||
t.Fatalf("expected ErrMissingSignature, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSignatureRejectsRaw(t *testing.T) {
|
||||
// 64-byte payload that happens NOT to be valid base64 must error rather
|
||||
// than be silently accepted as a raw signature — the only legitimate
|
||||
// shape is base64-encoded text.
|
||||
raw := make([]byte, ed25519.SignatureSize)
|
||||
for i := range raw {
|
||||
raw[i] = 0xff
|
||||
}
|
||||
if _, err := decodeSignature(raw); err == nil {
|
||||
t.Fatal("expected error from non-base64 64-byte payload")
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ package upgrade
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
|
@ -24,7 +25,6 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
githubRepo = "torrentclaw/unarr"
|
||||
binaryName = "unarr"
|
||||
smokeTestTO = 5 * time.Second
|
||||
)
|
||||
|
|
@ -43,6 +43,13 @@ type Upgrader struct {
|
|||
CurrentVersion string
|
||||
// OnProgress is called with status messages during the upgrade process.
|
||||
OnProgress func(msg string)
|
||||
// AllowUnsigned downgrades a missing checksums.txt.sig to a warning and
|
||||
// continues with SHA256-only verification. Required to downgrade to a
|
||||
// release published before signing was introduced, or to recover from
|
||||
// an accidental release where the workflow's signing step was skipped.
|
||||
// Default false — signature missing is a hard failure when a public
|
||||
// key is embedded.
|
||||
AllowUnsigned bool
|
||||
}
|
||||
|
||||
func (u *Upgrader) log(msg string) {
|
||||
|
|
@ -89,11 +96,22 @@ func (u *Upgrader) Execute(ctx context.Context, targetVersion string) Result {
|
|||
}
|
||||
defer os.Remove(archivePath)
|
||||
|
||||
// 5. Verify checksum
|
||||
u.log("Verifying checksum...")
|
||||
// 5. Verify checksum (and signature, if configured)
|
||||
if SignatureVerificationConfigured() {
|
||||
u.log("Verifying checksum + ed25519 signature...")
|
||||
} else {
|
||||
u.log("Verifying checksum (release signature verification not configured for this build)...")
|
||||
}
|
||||
if err := verifyChecksum(ctx, targetVersion, archivePath); err != nil {
|
||||
if errors.Is(err, ErrMissingSignature) && u.AllowUnsigned {
|
||||
u.log("WARNING: release is unsigned and --allow-unsigned was passed; continuing with SHA256-only verification")
|
||||
if err := verifyChecksumOnly(ctx, targetVersion, archivePath); err != nil {
|
||||
return u.fail("checksum: %v", err)
|
||||
}
|
||||
} else {
|
||||
return u.fail("checksum: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Extract binary
|
||||
u.log("Extracting...")
|
||||
|
|
@ -224,7 +242,26 @@ func archiveName(version string) string {
|
|||
return fmt.Sprintf("%s_%s_%s_%s.%s", binaryName, version, runtime.GOOS, runtime.GOARCH, ext)
|
||||
}
|
||||
|
||||
// releaseURL returns the download URL for a release asset.
|
||||
func releaseURL(version, filename string) string {
|
||||
return fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", githubRepo, version, filename)
|
||||
// updateBaseURL is the base URL the self-updater fetches releases from —
|
||||
// TorrentClaw's own app, no GitHub dependency (the org is shadow-banned, so
|
||||
// GitHub releases/raw/API all 404 to anonymous clients). Defaults to the
|
||||
// production apex; SetBaseURL points it at the configured host (cfg.Auth.APIURL)
|
||||
// so mirrors / onion / staging work, and tests can point it at an httptest.Server.
|
||||
var updateBaseURL = "https://torrentclaw.com"
|
||||
|
||||
// SetBaseURL overrides the release endpoint base (trailing slash trimmed).
|
||||
// No-op for empty input so a blank config can't break the default.
|
||||
func SetBaseURL(base string) {
|
||||
if base != "" {
|
||||
updateBaseURL = strings.TrimRight(base, "/")
|
||||
}
|
||||
}
|
||||
|
||||
// releaseURL returns the download URL for a release asset:
|
||||
//
|
||||
// {base}/releases/download/v{version}/{filename}
|
||||
//
|
||||
// served by the app's src/app/releases/download/[...seg] route handler.
|
||||
func releaseURL(version, filename string) string {
|
||||
return fmt.Sprintf("%s/releases/download/v%s/%s", updateBaseURL, version, filename)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ func TestArchiveName(t *testing.T) {
|
|||
|
||||
func TestReleaseURL(t *testing.T) {
|
||||
url := releaseURL("0.3.0", "unarr_0.3.0_linux_amd64.tar.gz")
|
||||
want := "https://github.com/torrentclaw/unarr/releases/download/v0.3.0/unarr_0.3.0_linux_amd64.tar.gz"
|
||||
want := "https://torrentclaw.com/releases/download/v0.3.0/unarr_0.3.0_linux_amd64.tar.gz"
|
||||
if url != want {
|
||||
t.Errorf("releaseURL = %q, want %q", url, want)
|
||||
}
|
||||
|
|
@ -289,21 +289,24 @@ func TestUpgraderSameVersionWithPrefix(t *testing.T) {
|
|||
|
||||
func TestFetchLatestVersionMockServer(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"tag_name":"v2.5.1","published_at":"2025-01-01T00:00:00Z"}`)
|
||||
if r.URL.Path != "/version" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "v2.5.1")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// We can't directly test fetchLatestVersion because it uses a hardcoded URL.
|
||||
// But we can test the JSON parsing logic by calling the endpoint ourselves.
|
||||
resp, err := http.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
prev := updateBaseURL
|
||||
updateBaseURL = srv.URL
|
||||
t.Cleanup(func() { updateBaseURL = prev })
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
ver, err := fetchLatestVersion(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("fetchLatestVersion() = %v", err)
|
||||
}
|
||||
if ver != "2.5.1" {
|
||||
t.Errorf("fetchLatestVersion() = %q, want %q", ver, "2.5.1")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -403,19 +406,19 @@ func TestReleaseURLEdgeCases(t *testing.T) {
|
|||
name: "pre-release version",
|
||||
version: "2.0.0-beta.1",
|
||||
filename: "unarr_2.0.0-beta.1_darwin_arm64.tar.gz",
|
||||
wantURL: "https://github.com/torrentclaw/unarr/releases/download/v2.0.0-beta.1/unarr_2.0.0-beta.1_darwin_arm64.tar.gz",
|
||||
wantURL: "https://torrentclaw.com/releases/download/v2.0.0-beta.1/unarr_2.0.0-beta.1_darwin_arm64.tar.gz",
|
||||
},
|
||||
{
|
||||
name: "checksums file",
|
||||
version: "3.0.0",
|
||||
filename: "checksums.txt",
|
||||
wantURL: "https://github.com/torrentclaw/unarr/releases/download/v3.0.0/checksums.txt",
|
||||
wantURL: "https://torrentclaw.com/releases/download/v3.0.0/checksums.txt",
|
||||
},
|
||||
{
|
||||
name: "windows zip",
|
||||
version: "1.2.3",
|
||||
filename: "unarr_1.2.3_windows_amd64.zip",
|
||||
wantURL: "https://github.com/torrentclaw/unarr/releases/download/v1.2.3/unarr_1.2.3_windows_amd64.zip",
|
||||
wantURL: "https://torrentclaw.com/releases/download/v1.2.3/unarr_1.2.3_windows_amd64.zip",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
|
@ -530,19 +533,19 @@ func TestFetchLatestVersionWithHTTPTest(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
name: "valid response",
|
||||
body: `{"tag_name":"v3.1.4"}`,
|
||||
body: "v3.1.4\n",
|
||||
statusCode: 200,
|
||||
wantVer: "3.1.4",
|
||||
},
|
||||
{
|
||||
name: "valid response without v prefix",
|
||||
body: `{"tag_name":"2.0.0"}`,
|
||||
body: "2.0.0",
|
||||
statusCode: 200,
|
||||
wantVer: "2.0.0",
|
||||
},
|
||||
{
|
||||
name: "empty tag_name",
|
||||
body: `{"tag_name":""}`,
|
||||
name: "empty body",
|
||||
body: "",
|
||||
statusCode: 200,
|
||||
wantErr: true,
|
||||
},
|
||||
|
|
@ -553,8 +556,8 @@ func TestFetchLatestVersionWithHTTPTest(t *testing.T) {
|
|||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
body: `{invalid`,
|
||||
name: "whitespace only",
|
||||
body: " \n",
|
||||
statusCode: 200,
|
||||
wantErr: true,
|
||||
},
|
||||
|
|
@ -1085,3 +1088,40 @@ func TestDownloadSetsUserAgent(t *testing.T) {
|
|||
t.Errorf("User-Agent = %q, want 'unarr-updater'", gotUA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeZipPath(t *testing.T) {
|
||||
dest := t.TempDir()
|
||||
absDest, err := filepath.Abs(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("abs dest: %v", err)
|
||||
}
|
||||
|
||||
// Names that must extract successfully.
|
||||
good := []string{
|
||||
"unarr.exe",
|
||||
"bin/unarr.exe",
|
||||
"./unarr.exe",
|
||||
"folder/sub/unarr.exe",
|
||||
}
|
||||
for _, name := range good {
|
||||
if _, ok := safeZipPath(name, "unarr.exe", absDest); !ok {
|
||||
t.Errorf("safeZipPath(%q) = ok:false, want ok:true", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Names that must be rejected for path-traversal reasons.
|
||||
bad := []string{
|
||||
"../unarr.exe",
|
||||
"..",
|
||||
"foo/../../unarr.exe",
|
||||
"/etc/passwd",
|
||||
"/abs/unarr.exe",
|
||||
`..\..\windows\system32\unarr.exe`, // backslash entries that escape
|
||||
"../../bin/unarr.exe",
|
||||
}
|
||||
for _, name := range bad {
|
||||
if _, ok := safeZipPath(name, "unarr.exe", absDest); ok {
|
||||
t.Errorf("safeZipPath(%q) = ok:true, want ok:false", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package postprocess
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
|
@ -8,8 +9,25 @@ import (
|
|||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// extractTimeout caps how long a single extractor invocation may run. Without
|
||||
// a cap, an encrypted archive that triggers a TTY-only prompt (or a corrupt
|
||||
// archive that confuses the tool) hangs the post-process pipeline forever.
|
||||
const extractTimeout = 30 * time.Minute
|
||||
|
||||
// validatePassword rejects passwords containing control characters that could
|
||||
// inject extra answers into unrar/7z prompts via stdin (e.g. a newline lets an
|
||||
// attacker-controlled NZB password feed a second response to overwrite or
|
||||
// rename prompts).
|
||||
func validatePassword(password string) error {
|
||||
if strings.ContainsAny(password, "\r\n\x00") {
|
||||
return fmt.Errorf("invalid password: contains control characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtractorType identifies which extraction tool is available.
|
||||
type ExtractorType string
|
||||
|
||||
|
|
@ -50,18 +68,35 @@ func Extract(archivePath string, outputDir string, password string) ([]string, e
|
|||
}
|
||||
|
||||
// extractUnrar extracts using unrar.
|
||||
//
|
||||
// Security: when a password is supplied it is sent via stdin rather than via
|
||||
// the `-p<password>` switch so it does not appear in `/proc/<pid>/cmdline`
|
||||
// (visible to any other process on the host). unrar prompts for the password
|
||||
// when no `-p` switch is given, and reads the prompt response from stdin when
|
||||
// no controlling TTY is attached (the usual case for a daemon-spawned child).
|
||||
func extractUnrar(unrarPath, archivePath, outputDir, password string) ([]string, error) {
|
||||
if err := validatePassword(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := []string{"x", "-o+", "-y"}
|
||||
if password != "" {
|
||||
args = append(args, "-p"+password)
|
||||
} else {
|
||||
args = append(args, "-p-") // no password, skip asking
|
||||
if password == "" {
|
||||
// Tell unrar there is no password so it skips the prompt and fails
|
||||
// fast on encrypted archives instead of hanging.
|
||||
args = append(args, "-p-")
|
||||
}
|
||||
args = append(args, archivePath, outputDir+"/")
|
||||
|
||||
cmd := exec.Command(unrarPath, args...)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), extractTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, unrarPath, args...)
|
||||
cmd.Dir = outputDir
|
||||
if password != "" {
|
||||
cmd.Stdin = strings.NewReader(password + "\n")
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("unrar: timed out after %s", extractTimeout)
|
||||
}
|
||||
if err != nil {
|
||||
// Check for password error
|
||||
outStr := string(output)
|
||||
|
|
@ -75,18 +110,33 @@ func extractUnrar(unrarPath, archivePath, outputDir, password string) ([]string,
|
|||
}
|
||||
|
||||
// extract7z extracts using 7z.
|
||||
//
|
||||
// Security: same rationale as extractUnrar — passwords go through stdin to
|
||||
// avoid `/proc/<pid>/cmdline` exposure. 7z reads the password from stdin when
|
||||
// no `-p` switch is given and the archive is encrypted.
|
||||
func extract7z(szPath, archivePath, outputDir, password string) ([]string, error) {
|
||||
if err := validatePassword(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := []string{"x", "-y", "-o" + outputDir}
|
||||
if password != "" {
|
||||
args = append(args, "-p"+password)
|
||||
} else {
|
||||
args = append(args, "-p") // empty password
|
||||
if password == "" {
|
||||
// `-p` with no value tells 7z the password is empty so encrypted
|
||||
// archives fail fast instead of waiting for a prompt.
|
||||
args = append(args, "-p")
|
||||
}
|
||||
args = append(args, archivePath)
|
||||
|
||||
cmd := exec.Command(szPath, args...)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), extractTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, szPath, args...)
|
||||
cmd.Dir = outputDir
|
||||
if password != "" {
|
||||
cmd.Stdin = strings.NewReader(password + "\n")
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("7z: timed out after %s", extractTimeout)
|
||||
}
|
||||
if err != nil {
|
||||
outStr := string(output)
|
||||
if strings.Contains(outStr, "Wrong password") || strings.Contains(outStr, "incorrect password") {
|
||||
|
|
|
|||
315
internal/vpn/vpn.go
Normal file
315
internal/vpn/vpn.go
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
// Package vpn brings up an in-process WireGuard tunnel (userspace, via
|
||||
// wireguard-go + gVisor netstack) and exposes it as a dialer so the BitTorrent
|
||||
// client's peer/tracker traffic can be split-tunnelled through it — without
|
||||
// touching the OS routing table or requiring root.
|
||||
//
|
||||
// The config is a standard WireGuard .conf fetched from the web
|
||||
// (/api/internal/agent/vpn-config). Only the torrent client uses this tunnel;
|
||||
// unarr's control-plane traffic (API, heartbeats) keeps using the normal net.
|
||||
package vpn
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.zx2c4.com/wireguard/conn"
|
||||
"golang.zx2c4.com/wireguard/device"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
// ErrCode classifies fetch failures the agent should react to differently.
|
||||
type ErrCode string
|
||||
|
||||
const (
|
||||
ErrDisabled ErrCode = "disabled" // 503 — VPN feature off server-side
|
||||
ErrNotProvisioned ErrCode = "not_provisioned" // 403 — user has no active VPN
|
||||
ErrSlotOnDevice ErrCode = "slot_on_device" // 409 — slot claimed by a device
|
||||
ErrUpstream ErrCode = "upstream" // network / 5xx / parse
|
||||
)
|
||||
|
||||
// FetchError carries an ErrCode so callers can decide whether to retry, warn, or
|
||||
// fall back to a clear (non-VPN) download.
|
||||
type FetchError struct {
|
||||
Code ErrCode
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *FetchError) Error() string { return fmt.Sprintf("vpn fetch: %s (%s)", e.Msg, e.Code) }
|
||||
|
||||
type fetchResponse struct {
|
||||
Content string `json:"content"`
|
||||
Filename string `json:"filename"`
|
||||
ServerID int `json:"serverId"`
|
||||
Mode string `json:"mode"`
|
||||
Error string `json:"error"`
|
||||
CodeStr string `json:"code"`
|
||||
}
|
||||
|
||||
// FetchConfig retrieves the agent's WireGuard .conf from the web API. Auth is
|
||||
// `Authorization: Bearer <apiKey>` (the agent-auth scheme).
|
||||
func FetchConfig(ctx context.Context, apiURL, apiKey, userAgent string) (string, error) {
|
||||
url := strings.TrimSuffix(apiURL, "/") + "/api/internal/agent/vpn-config"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", &FetchError{ErrUpstream, err.Error()}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", &FetchError{ErrUpstream, err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var body fetchResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
if body.Content == "" {
|
||||
return "", &FetchError{ErrUpstream, "empty config"}
|
||||
}
|
||||
return body.Content, nil
|
||||
case http.StatusServiceUnavailable:
|
||||
return "", &FetchError{ErrDisabled, "VPN disabled server-side"}
|
||||
case http.StatusForbidden:
|
||||
return "", &FetchError{ErrNotProvisioned, "no active VPN for this account"}
|
||||
case http.StatusConflict:
|
||||
return "", &FetchError{ErrSlotOnDevice, "VPN slot is active on one of your devices"}
|
||||
default:
|
||||
msg := body.Error
|
||||
if msg == "" {
|
||||
msg = "unexpected status " + strconv.Itoa(resp.StatusCode)
|
||||
}
|
||||
return "", &FetchError{ErrUpstream, msg}
|
||||
}
|
||||
}
|
||||
|
||||
// Tunnel is a live userspace WireGuard tunnel. Net exposes a DialContext +
|
||||
// ListenUDP backed by the tunnel; wire these into the torrent client.
|
||||
type Tunnel struct {
|
||||
dev *device.Device
|
||||
Net *netstack.Net
|
||||
}
|
||||
|
||||
// Up parses a WireGuard .conf and brings up the tunnel in userspace.
|
||||
func Up(confText string) (*Tunnel, error) {
|
||||
wc, err := parseConf(confText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mtu := wc.mtu
|
||||
if mtu == 0 {
|
||||
mtu = 1420
|
||||
}
|
||||
|
||||
tunDev, tnet, err := netstack.CreateNetTUN(wc.addresses, wc.dns, mtu)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create netstack tun: %w", err)
|
||||
}
|
||||
|
||||
dev := device.NewDevice(tunDev, conn.NewDefaultBind(), device.NewLogger(device.LogLevelError, "wg-unarr "))
|
||||
if err := dev.IpcSet(wc.uapi()); err != nil {
|
||||
dev.Close()
|
||||
return nil, fmt.Errorf("wireguard ipc set: %w", err)
|
||||
}
|
||||
if err := dev.Up(); err != nil {
|
||||
dev.Close()
|
||||
return nil, fmt.Errorf("wireguard up: %w", err)
|
||||
}
|
||||
|
||||
return &Tunnel{dev: dev, Net: tnet}, nil
|
||||
}
|
||||
|
||||
// Close tears the tunnel down.
|
||||
func (t *Tunnel) Close() {
|
||||
if t != nil && t.dev != nil {
|
||||
t.dev.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ListenPacket adapts the tunnel's UDP for anacrolix TrackerListenPacket so UDP
|
||||
// tracker announces also go through the VPN (no IP leak to trackers).
|
||||
func (t *Tunnel) ListenPacket(_ string, _ string) (net.PacketConn, error) {
|
||||
return t.Net.ListenUDP(&net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
||||
}
|
||||
|
||||
// --- .conf parsing ----------------------------------------------------------
|
||||
|
||||
type wgConf struct {
|
||||
privateKey string // hex
|
||||
addresses []netip.Addr
|
||||
dns []netip.Addr
|
||||
mtu int
|
||||
|
||||
peerPublicKey string // hex
|
||||
presharedKey string // hex (optional)
|
||||
endpoint string // resolved ip:port
|
||||
allowedIPs []string
|
||||
keepalive int
|
||||
}
|
||||
|
||||
func (w *wgConf) uapi() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "private_key=%s\n", w.privateKey)
|
||||
fmt.Fprintf(&b, "public_key=%s\n", w.peerPublicKey)
|
||||
if w.presharedKey != "" {
|
||||
fmt.Fprintf(&b, "preshared_key=%s\n", w.presharedKey)
|
||||
}
|
||||
if w.endpoint != "" {
|
||||
fmt.Fprintf(&b, "endpoint=%s\n", w.endpoint)
|
||||
}
|
||||
if w.keepalive > 0 {
|
||||
fmt.Fprintf(&b, "persistent_keepalive_interval=%d\n", w.keepalive)
|
||||
}
|
||||
for _, a := range w.allowedIPs {
|
||||
fmt.Fprintf(&b, "allowed_ip=%s\n", a)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func b64ToHex(s string) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid base64 key: %w", err)
|
||||
}
|
||||
if len(raw) != 32 {
|
||||
return "", fmt.Errorf("key must be 32 bytes, got %d", len(raw))
|
||||
}
|
||||
return hex.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func parseConf(text string) (*wgConf, error) {
|
||||
w := &wgConf{keepalive: 25}
|
||||
section := ""
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") {
|
||||
section = strings.ToLower(strings.Trim(line, "[]"))
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
val = strings.TrimSpace(val)
|
||||
|
||||
switch section {
|
||||
case "interface":
|
||||
switch key {
|
||||
case "privatekey":
|
||||
hexKey, err := b64ToHex(val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.privateKey = hexKey
|
||||
case "address":
|
||||
for _, part := range strings.Split(val, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
pfx, err := netip.ParsePrefix(part)
|
||||
if err != nil {
|
||||
// allow bare address
|
||||
if a, e2 := netip.ParseAddr(part); e2 == nil {
|
||||
w.addresses = append(w.addresses, a)
|
||||
}
|
||||
continue
|
||||
}
|
||||
w.addresses = append(w.addresses, pfx.Addr())
|
||||
}
|
||||
case "dns":
|
||||
for _, part := range strings.Split(val, ",") {
|
||||
if a, err := netip.ParseAddr(strings.TrimSpace(part)); err == nil {
|
||||
w.dns = append(w.dns, a)
|
||||
}
|
||||
}
|
||||
case "mtu":
|
||||
w.mtu, _ = strconv.Atoi(val)
|
||||
}
|
||||
case "peer":
|
||||
switch key {
|
||||
case "publickey":
|
||||
hexKey, err := b64ToHex(val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.peerPublicKey = hexKey
|
||||
case "presharedkey":
|
||||
if hexKey, err := b64ToHex(val); err == nil {
|
||||
w.presharedKey = hexKey
|
||||
}
|
||||
case "endpoint":
|
||||
ep, err := resolveEndpoint(val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.endpoint = ep
|
||||
case "allowedips":
|
||||
for _, part := range strings.Split(val, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
w.allowedIPs = append(w.allowedIPs, part)
|
||||
}
|
||||
}
|
||||
case "persistentkeepalive":
|
||||
if k, err := strconv.Atoi(val); err == nil {
|
||||
w.keepalive = k
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if w.privateKey == "" || w.peerPublicKey == "" {
|
||||
return nil, fmt.Errorf("config missing keys")
|
||||
}
|
||||
if len(w.addresses) == 0 {
|
||||
return nil, fmt.Errorf("config missing interface address")
|
||||
}
|
||||
if len(w.dns) == 0 {
|
||||
// Resolve tracker hostnames through the tunnel rather than leaking to the
|
||||
// local resolver. Fall back to Cloudflare.
|
||||
w.dns = []netip.Addr{netip.MustParseAddr("1.1.1.1")}
|
||||
}
|
||||
if len(w.allowedIPs) == 0 {
|
||||
w.allowedIPs = []string{"0.0.0.0/0", "::/0"}
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// resolveEndpoint turns host:port into ip:port — wireguard-go's IpcSet endpoint
|
||||
// expects a literal IP (it does not resolve DNS). Resolution uses the real net.
|
||||
func resolveEndpoint(hostport string) (string, error) {
|
||||
host, port, err := net.SplitHostPort(hostport)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid endpoint %q: %w", hostport, err)
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return hostport, nil
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return "", fmt.Errorf("resolve endpoint %q: %w", host, err)
|
||||
}
|
||||
return net.JoinHostPort(ips[0].String(), port), nil
|
||||
}
|
||||
37
scripts/gen-release-key/main.go
Normal file
37
scripts/gen-release-key/main.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// gen-release-key generates an ed25519 keypair for signing release artifacts.
|
||||
// Run once per repository, then store the printed values:
|
||||
//
|
||||
// RELEASE_SIGNING_KEY → GitHub Actions secret (private key, base64)
|
||||
// RELEASE_SIGNING_PUBKEY → GitHub Actions variable (public key, base64)
|
||||
//
|
||||
// The public key is injected into the binary at build time via the
|
||||
// goreleaser ldflags entry that resolves
|
||||
// `github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64`.
|
||||
// The private key is used by the workflow's "Sign checksums.txt" step.
|
||||
//
|
||||
// Build and run:
|
||||
//
|
||||
// go run ./scripts/gen-release-key
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("# Add the following to your GitHub repository:")
|
||||
fmt.Println("# - Settings → Secrets and variables → Actions → New repository secret")
|
||||
fmt.Println("# RELEASE_SIGNING_KEY = <PRIVATE_KEY_BASE64 below>")
|
||||
fmt.Println("# - Settings → Secrets and variables → Actions → New repository variable")
|
||||
fmt.Println("# RELEASE_SIGNING_PUBKEY = <PUBLIC_KEY_BASE64 below>")
|
||||
fmt.Println()
|
||||
fmt.Printf("PUBLIC_KEY_BASE64=%s\n", base64.StdEncoding.EncodeToString(pub))
|
||||
fmt.Printf("PRIVATE_KEY_BASE64=%s\n", base64.StdEncoding.EncodeToString(priv))
|
||||
}
|
||||
60
scripts/sign-checksums/main.go
Normal file
60
scripts/sign-checksums/main.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// sign-checksums signs the dist/checksums.txt file with an ed25519 private
|
||||
// key and writes the base64-encoded signature to the path given by -out.
|
||||
//
|
||||
// Usage (from release workflow):
|
||||
//
|
||||
// go run ./scripts/sign-checksums \
|
||||
// -key "$RELEASE_SIGNING_KEY" \
|
||||
// -in dist/checksums.txt \
|
||||
// -out dist/checksums.txt.sig
|
||||
//
|
||||
// The companion CLI verifier (internal/upgrade/signature.go) requires the
|
||||
// signature to be base64 text, so emitting base64 + trailing newline makes
|
||||
// the artifact safe to inspect with `cat` / the GitHub release UI.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
keyB64 := flag.String("key", "", "base64-encoded ed25519 private key (PrivateKeySize = 64 bytes)")
|
||||
in := flag.String("in", "", "path to file to sign")
|
||||
out := flag.String("out", "", "path to write the base64-encoded signature")
|
||||
flag.Parse()
|
||||
|
||||
if *keyB64 == "" || *in == "" || *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: sign-checksums -key <base64> -in <path> -out <path>")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(*keyB64)
|
||||
if err != nil {
|
||||
fail("decode key: %v", err)
|
||||
}
|
||||
if len(keyBytes) != ed25519.PrivateKeySize {
|
||||
fail("private key size %d, expected %d", len(keyBytes), ed25519.PrivateKeySize)
|
||||
}
|
||||
priv := ed25519.PrivateKey(keyBytes)
|
||||
|
||||
content, err := os.ReadFile(*in)
|
||||
if err != nil {
|
||||
fail("read input: %v", err)
|
||||
}
|
||||
|
||||
sig := ed25519.Sign(priv, content)
|
||||
encoded := base64.StdEncoding.EncodeToString(sig) + "\n"
|
||||
if err := os.WriteFile(*out, []byte(encoded), 0o644); err != nil {
|
||||
fail("write signature: %v", err)
|
||||
}
|
||||
fmt.Printf("Signed %s (%d bytes) → %s\n", *in, len(content), *out)
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue