Add yt-downloader: single-URL YouTube downloads through an isolated Mullvad WireGuard tunnel
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# yt-downloader
|
||||||
|
|
||||||
|
Download a single YouTube video through an **isolated Mullvad WireGuard tunnel**.
|
||||||
|
|
||||||
|
Built as the video equivalent of [music-gatherer](https://github.com/anomalyco/music_gatherer)'s
|
||||||
|
download phase: the only thing that talks to YouTube is the `download` command, and when a
|
||||||
|
WireGuard config is configured it re-runs itself inside a dedicated network namespace whose
|
||||||
|
only egress is the tunnel — so YouTube never sees your home IP. Everything else on your
|
||||||
|
machine stays on the normal network.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `yt-dlp` (pulled in automatically) and `ffmpeg` (used to mux separate
|
||||||
|
video + audio streams into a single file).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yt-downloader init # write a starter config
|
||||||
|
yt-downloader download <url> # download one video (auto-wraps into the VPN)
|
||||||
|
yt-downloader vpn up # create the namespace + bring the tunnel up (sudo)
|
||||||
|
yt-downloader vpn status # handshake time, routes, DNS
|
||||||
|
yt-downloader vpn down # tear it all down
|
||||||
|
```
|
||||||
|
|
||||||
|
Download options:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yt-downloader download <url> -q 720 # quality preset
|
||||||
|
yt-downloader download <url> -q best # best video+audio (default)
|
||||||
|
yt-downloader download <url> -q audio # audio only (no muxing needed)
|
||||||
|
yt-downloader download <url> -q 1080 -o ~/Videos # custom output dir
|
||||||
|
yt-downloader download <url> -f 'bestvideo[height<=480]+bestaudio' # raw yt-dlp format
|
||||||
|
```
|
||||||
|
|
||||||
|
Quality presets: `best`, `1080`, `720`, `480`, `audio`. The `-f/--format` flag takes any
|
||||||
|
yt-dlp format string and overrides the preset.
|
||||||
|
|
||||||
|
## Downloads through a Mullvad VPN
|
||||||
|
|
||||||
|
By default downloads go through your normal connection. To route **only the yt-dlp
|
||||||
|
traffic** through a WireGuard (Mullvad) tunnel, give the tool a WireGuard config:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[vpn]
|
||||||
|
wireguard = "~/.config/yt-downloader/mullvad.conf" # Mullvad .conf
|
||||||
|
# namespace = "mullvad" # optional: network namespace name
|
||||||
|
# interface = "mv0" # optional: WireGuard interface name
|
||||||
|
```
|
||||||
|
|
||||||
|
When set, `yt-downloader download` re-runs itself inside a dedicated **network namespace**
|
||||||
|
whose only egress is the WireGuard tunnel. The namespace has no fallback route, so a dead
|
||||||
|
tunnel means a failed download, never a leak. Bring-up is automatic (`ensure_up`); use
|
||||||
|
`yt-downloader vpn up/down/status` to manage it by hand.
|
||||||
|
|
||||||
|
It uses a Mullvad `.conf` (`[Interface]` + `[Peer]`), which you get from mullvad.net; the
|
||||||
|
private key is copied into `/etc/wireguard/mv0.conf`.
|
||||||
|
|
||||||
|
Requirements: Linux, `wireguard-tools` + `iproute2`, and `sudo`. Interactive runs prompt for
|
||||||
|
the sudo password once; for unattended runs add passwordless sudo entries:
|
||||||
|
|
||||||
|
```
|
||||||
|
# /etc/sudoers.d/yt-downloader (run: sudo visudo -f /etc/sudoers.d/yt-downloader)
|
||||||
|
magerbeton ALL=(root) NOPASSWD: /usr/bin/ip, /usr/bin/wg, /usr/bin/wg-quick, \
|
||||||
|
/usr/sbin/iptables, /usr/sbin/sysctl, /bin/mkdir, /bin/rm, /bin/chmod, /bin/sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The download process drops back to your user inside the namespace, so downloaded files stay
|
||||||
|
owned by you.
|
||||||
|
|
||||||
|
## Bot-check mitigation
|
||||||
|
|
||||||
|
YouTube sometimes challenges downloads. Like music-gatherer, you can pass your browser
|
||||||
|
cookies to yt-dlp:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[download]
|
||||||
|
# cookies_file = "/path/to/cookies.txt"
|
||||||
|
# cookies_from_browser = "firefox" # e.g. chromium, firefox, safari
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
See `yt_downloader.toml` after `init` for the full template (`~/.config/yt-downloader/`).
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[download]
|
||||||
|
out = "~/Videos/yt-downloader" # where videos land
|
||||||
|
quality = "best" # best | 1080 | 720 | 480 | audio
|
||||||
|
# format = "" # raw yt-dlp -f string, overrides quality
|
||||||
|
|
||||||
|
[vpn]
|
||||||
|
# wireguard = "~/.config/yt-downloader/mullvad.conf"
|
||||||
|
# namespace = "mullvad"
|
||||||
|
# interface = "mv0"
|
||||||
|
```
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "yt-downloader"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Download YouTube videos through an isolated Mullvad WireGuard tunnel."
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"yt-dlp>=2024.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
yt-downloader = "yt_downloader.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["yt_downloader*"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""yt_downloader - download YouTube videos through an isolated Mullvad tunnel."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Command-line interface for yt-downloader."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import __version__, vpn
|
||||||
|
from .config import Config, QUALITY_PRESETS, default_config_path, load_config, write_default_config
|
||||||
|
from .downloader import cookie_opts, download_video
|
||||||
|
|
||||||
|
|
||||||
|
def log_setup(verbose: bool) -> None:
|
||||||
|
level = logging.DEBUG if verbose else logging.INFO
|
||||||
|
logging.basicConfig(
|
||||||
|
level=level,
|
||||||
|
format="%(levelname)-7s %(message)s",
|
||||||
|
stream=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- commands ------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_init(args: argparse.Namespace) -> int:
|
||||||
|
path = Path(args.config)
|
||||||
|
if path.exists():
|
||||||
|
logging.error("Config already exists: %s", path)
|
||||||
|
return 1
|
||||||
|
write_default_config(path)
|
||||||
|
print(f"Wrote config to {path}")
|
||||||
|
print("Edit it, then run: yt-downloader download <url>")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _download_argv(args: argparse.Namespace) -> list[str]:
|
||||||
|
"""Rebuild the `download` argv for the VPN-wrapped subprocess."""
|
||||||
|
argv = ["download"]
|
||||||
|
if getattr(args, "config", None):
|
||||||
|
argv += ["--config", str(args.config)]
|
||||||
|
argv += [args.url]
|
||||||
|
if getattr(args, "quality", None):
|
||||||
|
argv += ["--quality", args.quality]
|
||||||
|
if getattr(args, "format", None):
|
||||||
|
argv += ["--format", args.format]
|
||||||
|
if getattr(args, "output", None):
|
||||||
|
argv += ["--output", str(args.output)]
|
||||||
|
return argv
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_download(args: argparse.Namespace) -> int:
|
||||||
|
cfg = load_config(args.config)
|
||||||
|
if (
|
||||||
|
not args.dry_run
|
||||||
|
and vpn.configured(cfg)
|
||||||
|
and not os.environ.get(vpn.INNER_ENV)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return vpn.run_download_in_ns(cfg, _download_argv(args))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logging.error("%s", exc)
|
||||||
|
return 1
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"[dry] would download {args.url}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
quality = args.quality or cfg.quality
|
||||||
|
format_override = args.format or cfg.format_override
|
||||||
|
out_dir = Path(args.output) if args.output else cfg.out_dir
|
||||||
|
extra = cookie_opts(cfg)
|
||||||
|
|
||||||
|
print(f"downloading: {args.url} (quality={quality}, out={out_dir.expanduser()})")
|
||||||
|
path = download_video(args.url, out_dir, quality, format_override, extra)
|
||||||
|
if not path:
|
||||||
|
logging.error("download failed: %s", args.url)
|
||||||
|
return 1
|
||||||
|
print(f"saved: {path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_vpn(args: argparse.Namespace) -> int:
|
||||||
|
cfg = load_config(args.config)
|
||||||
|
try:
|
||||||
|
if args.vpn_command == "up":
|
||||||
|
if vpn.is_up(cfg):
|
||||||
|
print("tunnel already up")
|
||||||
|
else:
|
||||||
|
vpn.up(cfg)
|
||||||
|
print("tunnel up")
|
||||||
|
elif args.vpn_command == "down":
|
||||||
|
vpn.down(cfg)
|
||||||
|
print("tunnel down")
|
||||||
|
elif args.vpn_command == "status":
|
||||||
|
print(vpn.status_text(cfg))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logging.error("%s", exc)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# -- entry point ---------------------------------------------------------
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="yt-downloader",
|
||||||
|
description="Download a single YouTube video through an isolated "
|
||||||
|
"Mullvad WireGuard tunnel (network namespace).",
|
||||||
|
)
|
||||||
|
parser.add_argument("--version", action="version", version=__version__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config", "-c", type=str, default=str(default_config_path()),
|
||||||
|
help="path to config TOML",
|
||||||
|
)
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true", help="debug logging")
|
||||||
|
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("init", help="write a starter config file")
|
||||||
|
|
||||||
|
p_dl = sub.add_parser("download", help="download a single YouTube video")
|
||||||
|
p_dl.add_argument("url", help="YouTube video URL")
|
||||||
|
p_dl.add_argument(
|
||||||
|
"-q", "--quality", choices=QUALITY_PRESETS, default=None,
|
||||||
|
help="video quality preset (default from config)",
|
||||||
|
)
|
||||||
|
p_dl.add_argument(
|
||||||
|
"-f", "--format", default=None,
|
||||||
|
help="raw yt-dlp format string, overrides --quality",
|
||||||
|
)
|
||||||
|
p_dl.add_argument(
|
||||||
|
"-o", "--output", type=Path, default=None,
|
||||||
|
help="output directory (default from config)",
|
||||||
|
)
|
||||||
|
p_dl.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
help="plan only: don't set up the tunnel and don't download",
|
||||||
|
)
|
||||||
|
|
||||||
|
p_vpn = sub.add_parser("vpn", help="manage the isolated Mullvad tunnel used by downloads")
|
||||||
|
vsub = p_vpn.add_subparsers(dest="vpn_command", required=True)
|
||||||
|
vsub.add_parser("up", help="create the tunnel namespace and bring WireGuard up")
|
||||||
|
vsub.add_parser("down", help="tear the tunnel namespace down")
|
||||||
|
vsub.add_parser("status", help="show tunnel state")
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
log_setup(args.verbose)
|
||||||
|
if args.command == "init":
|
||||||
|
args.config = args.config if args.config else str(default_config_path())
|
||||||
|
return cmd_init(args)
|
||||||
|
try:
|
||||||
|
if args.command == "download":
|
||||||
|
return cmd_download(args)
|
||||||
|
if args.command == "vpn":
|
||||||
|
return cmd_vpn(args)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
logging.error("%s", exc)
|
||||||
|
return 1
|
||||||
|
parser.error(f"unknown command {args.command!r}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Configuration loading and defaults."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_NAME = "yt_downloader.toml"
|
||||||
|
QUALITY_PRESETS = ("best", "1080", "720", "480", "audio")
|
||||||
|
|
||||||
|
|
||||||
|
def default_config_path() -> Path:
|
||||||
|
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
base = Path(xdg) if xdg else Path.home() / ".config"
|
||||||
|
return base / "yt-downloader" / DEFAULT_CONFIG_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(value: Any, default: bool) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Thin wrapper around the parsed TOML config."""
|
||||||
|
|
||||||
|
def __init__(self, data: dict, path: Path):
|
||||||
|
self._data = data
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
# -- download --------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def out_dir(self) -> Path:
|
||||||
|
return Path(self._data.get("download", {}).get("out", "./videos"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quality(self) -> str:
|
||||||
|
q = str(self._data.get("download", {}).get("quality", "best")).lower()
|
||||||
|
return q if q in QUALITY_PRESETS else "best"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format_override(self) -> Optional[str]:
|
||||||
|
return self._data.get("download", {}).get("format")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cookies_file(self) -> Optional[str]:
|
||||||
|
return self._data.get("download", {}).get("cookies_file")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cookies_from_browser(self) -> Optional[str]:
|
||||||
|
return self._data.get("download", {}).get("cookies_from_browser")
|
||||||
|
|
||||||
|
# -- vpn ------------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def vpn_wireguard(self) -> Optional[str]:
|
||||||
|
return self._data.get("vpn", {}).get("wireguard")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vpn_namespace(self) -> Optional[str]:
|
||||||
|
return self._data.get("vpn", {}).get("namespace")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vpn_interface(self) -> Optional[str]:
|
||||||
|
return self._data.get("vpn", {}).get("interface")
|
||||||
|
|
||||||
|
# -- misc -----------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def dry_run(self) -> bool:
|
||||||
|
return _as_bool(self._data.get("global", {}).get("dry_run", False), False)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Optional[Path | str] = None) -> Config:
|
||||||
|
if path is None:
|
||||||
|
path = default_config_path()
|
||||||
|
path = Path(path)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Config not found at {path}. Run 'yt-downloader init' first."
|
||||||
|
)
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
data = tomllib.load(fh)
|
||||||
|
return Config(data, path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_default_config(path: Path) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
template = """\
|
||||||
|
# yt-downloader configuration
|
||||||
|
|
||||||
|
[global]
|
||||||
|
dry_run = false # when true: plan only, touch nothing
|
||||||
|
|
||||||
|
[download]
|
||||||
|
out = "~/Videos/yt-downloader" # where videos land
|
||||||
|
quality = "best" # best | 1080 | 720 | 480 | audio
|
||||||
|
# format = "" # raw yt-dlp -f string, overrides quality
|
||||||
|
# cookies_file = "/path/to/cookies.txt" # pass your browser cookies (best
|
||||||
|
# cookies_from_browser = "firefox" # protection against bot checks;
|
||||||
|
# # e.g. chromium, firefox, safari)
|
||||||
|
|
||||||
|
[vpn]
|
||||||
|
# wireguard = "~/.config/yt-downloader/mullvad.conf"
|
||||||
|
# # optional: path to a WireGuard (Mullvad) .conf.
|
||||||
|
# # When set, `download` runs inside a dedicated
|
||||||
|
# # network namespace so ONLY yt-dlp traffic goes
|
||||||
|
# # through the VPN. Requires sudo + wireguard-tools.
|
||||||
|
# namespace = "mullvad" # optional: network namespace name
|
||||||
|
# interface = "mv0" # optional: WireGuard interface name
|
||||||
|
"""
|
||||||
|
path.write_text(template)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""yt-dlp integration: download a single YouTube video (or its audio)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import yt_dlp
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BASE_OPTS = {
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
"noplaylist": True,
|
||||||
|
"ignoreerrors": True,
|
||||||
|
"no_color": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# named presets -> yt-dlp format strings. bestvideo+bestaudio requires ffmpeg
|
||||||
|
# to mux; the trailing /best fallbacks cover formats without a separate stream.
|
||||||
|
QUALITY_FORMATS = {
|
||||||
|
"best": "bestvideo+bestaudio/best",
|
||||||
|
"1080": "bestvideo[height<=1080]+bestaudio/best[height<=1080]",
|
||||||
|
"720": "bestvideo[height<=720]+bestaudio/best[height<=720]",
|
||||||
|
"480": "bestvideo[height<=480]+bestaudio/best[height<=480]",
|
||||||
|
"audio": "bestaudio/best",
|
||||||
|
}
|
||||||
|
|
||||||
|
# suffixes yt-dlp leaves behind while downloading / on interrupted runs
|
||||||
|
_INCOMPLETE = (".part", ".ytdl", ".tmp")
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_opts(cfg) -> dict:
|
||||||
|
"""yt-dlp options for browser cookies (bot-check mitigation)."""
|
||||||
|
opts: dict = {}
|
||||||
|
if cfg.cookies_from_browser:
|
||||||
|
opts["cookiesfrombrowser"] = (cfg.cookies_from_browser,)
|
||||||
|
elif cfg.cookies_file:
|
||||||
|
opts["cookiefile"] = cfg.cookies_file
|
||||||
|
return opts
|
||||||
|
|
||||||
|
|
||||||
|
def _select_format(quality: str, format_override: Optional[str]) -> str:
|
||||||
|
if format_override:
|
||||||
|
return format_override
|
||||||
|
return QUALITY_FORMATS.get(quality, QUALITY_FORMATS["best"])
|
||||||
|
|
||||||
|
|
||||||
|
def download_video(
|
||||||
|
url: str,
|
||||||
|
out_dir: Path,
|
||||||
|
quality: str = "best",
|
||||||
|
format_override: Optional[str] = None,
|
||||||
|
extra_opts: dict | None = None,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""Download a single video into out_dir. Returns the resulting file path."""
|
||||||
|
out_dir = out_dir.expanduser()
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
fmt = _select_format(quality, format_override)
|
||||||
|
opts = {
|
||||||
|
**_BASE_OPTS,
|
||||||
|
**(extra_opts or {}),
|
||||||
|
"format": fmt,
|
||||||
|
"outtmpl": str(out_dir / "%(title).150B [%(id)s].%(ext)s"),
|
||||||
|
"nocheckcertificate": True,
|
||||||
|
}
|
||||||
|
if quality != "audio" and not format_override:
|
||||||
|
opts["merge_output_format"] = "mp4"
|
||||||
|
try:
|
||||||
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||||
|
info = ydl.extract_info(url, download=True)
|
||||||
|
if info is None:
|
||||||
|
return None
|
||||||
|
actual_id = info.get("id")
|
||||||
|
if not actual_id:
|
||||||
|
return None
|
||||||
|
marker = f" [{actual_id}]."
|
||||||
|
for f in out_dir.iterdir():
|
||||||
|
if f.is_file() and marker in f.name and f.suffix.lower() not in _INCOMPLETE:
|
||||||
|
return f
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("Download failed for %s: %s", url, exc)
|
||||||
|
return None
|
||||||
|
return None
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""Isolated Mullvad WireGuard tunnel for the yt-dlp download phase.
|
||||||
|
|
||||||
|
The download phase is the only place yt-downloader talks to YouTube. When a
|
||||||
|
WireGuard (Mullvad) config is configured, ``yt-downloader download`` re-runs
|
||||||
|
itself inside a dedicated network namespace whose only egress is the WireGuard
|
||||||
|
tunnel, so YouTube traffic never leaves through the normal network.
|
||||||
|
|
||||||
|
Layout inside the namespace:
|
||||||
|
|
||||||
|
+-----------+ veth +--------------------------------------+
|
||||||
|
| host | eth0 | netns "mullvad" |
|
||||||
|
| veth-mv0 | 10.66..2 | mv0 (WireGuard) <- default route |
|
||||||
|
| 10.66..1 +----------+ DNS -> Mullvad (through tunnel) |
|
||||||
|
+-----------+ +--------------------------------------+
|
||||||
|
|
||||||
|
The namespace has no fallback egress: if the tunnel is down the default route
|
||||||
|
(via mv0) is dead, so traffic is dropped rather than leaked onto the host's
|
||||||
|
network.
|
||||||
|
|
||||||
|
Privileged steps are delegated to ``sudo``. sudo caches its credential, so an
|
||||||
|
interactive run prompts once; unattended runs need the passwordless sudoers
|
||||||
|
entries described in the README.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import configparser
|
||||||
|
import getpass
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# host <-> namespace point-to-point veth link (a /30)
|
||||||
|
_LINK_GUEST = "eth0"
|
||||||
|
_HOST_ADDR = "10.66.66.1/30"
|
||||||
|
_GUEST_ADDR = "10.66.66.2/30"
|
||||||
|
_GUEST_NET = "10.66.66.0/30"
|
||||||
|
_GUEST_GW = "10.66.66.1"
|
||||||
|
FALLBACK_DNS = "10.64.0.1" # Mullvad DNS
|
||||||
|
|
||||||
|
# env var set on the inner re-run so it doesn't wrap itself again
|
||||||
|
INNER_ENV = "YTDL_VPN"
|
||||||
|
|
||||||
|
|
||||||
|
# -- configuration helpers ------------------------------------------------
|
||||||
|
|
||||||
|
def configured(cfg) -> bool:
|
||||||
|
return bool(getattr(cfg, "vpn_wireguard", None))
|
||||||
|
|
||||||
|
|
||||||
|
def netns_name(cfg) -> str:
|
||||||
|
return getattr(cfg, "vpn_namespace", None) or "mullvad"
|
||||||
|
|
||||||
|
|
||||||
|
def iface_name(cfg) -> str:
|
||||||
|
return getattr(cfg, "vpn_interface", None) or "mv0"
|
||||||
|
|
||||||
|
|
||||||
|
def conf_path(cfg) -> Path:
|
||||||
|
return Path(cfg.vpn_wireguard).expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
def _host_link(iface: str) -> str:
|
||||||
|
return f"veth-{iface}"[:15]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_conf(cfg) -> dict:
|
||||||
|
"""Read [Interface] / [Peer] from the WireGuard config."""
|
||||||
|
path = conf_path(cfg)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"WireGuard config not found: {path} "
|
||||||
|
"(set [vpn] wireguard or place the Mullvad .conf there)"
|
||||||
|
)
|
||||||
|
parser = configparser.ConfigParser(interpolation=None)
|
||||||
|
try:
|
||||||
|
parser.read(path)
|
||||||
|
except configparser.Error as exc:
|
||||||
|
raise ValueError(f"{path}: cannot parse WireGuard config: {exc}") from exc
|
||||||
|
if not parser.has_section("Interface") or not parser.has_section("Peer"):
|
||||||
|
raise ValueError(f"{path}: expected [Interface] and [Peer] sections")
|
||||||
|
interface = dict(parser.items("Interface"))
|
||||||
|
peer = dict(parser.items("Peer"))
|
||||||
|
dns = [
|
||||||
|
part.strip()
|
||||||
|
for part in re.split(r"[\s,]+", interface.get("dns", "").strip())
|
||||||
|
if part.strip()
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"interface": interface,
|
||||||
|
"peer": peer,
|
||||||
|
"dns": dns,
|
||||||
|
"endpoint": peer.get("endpoint", "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_dns(path: Path) -> str:
|
||||||
|
lines = [ln for ln in path.read_text(encoding="utf-8").splitlines()
|
||||||
|
if not re.match(r"\s*DNS\s*=", ln)]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# -- low-level sudo plumbing ---------------------------------------------
|
||||||
|
|
||||||
|
def _run(cmd: list[str], check: bool = True, input: bytes | None = None) -> subprocess.CompletedProcess:
|
||||||
|
proc = subprocess.run(cmd, input=input, check=False)
|
||||||
|
if check and proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"command failed (exit {proc.returncode}): {' '.join(cmd)}")
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
def _sudo(args: list[str], check: bool = True, input: bytes | None = None) -> subprocess.CompletedProcess:
|
||||||
|
return _run(["sudo", *args], check=check, input=input)
|
||||||
|
|
||||||
|
|
||||||
|
def _sudo_out(args: list[str]) -> tuple[int, str]:
|
||||||
|
proc = subprocess.run(["sudo", *args], capture_output=True, text=True)
|
||||||
|
return proc.returncode, (proc.stdout + proc.stderr).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# -- lifecycle -------------------------------------------------------------
|
||||||
|
|
||||||
|
def is_up(cfg) -> bool:
|
||||||
|
ns, iface = netns_name(cfg), iface_name(cfg)
|
||||||
|
rc, out = _sudo_out(["ip", "netns", "exec", ns, "wg", "show", iface])
|
||||||
|
return rc == 0 and "no such device" not in out.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def up(cfg) -> None:
|
||||||
|
ns, iface = netns_name(cfg), iface_name(cfg)
|
||||||
|
host_link = _host_link(iface)
|
||||||
|
for tool in ("ip", "wg", "wg-quick"):
|
||||||
|
if not shutil.which(tool):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"required tool not found: {tool!r} (install wireguard-tools / iproute2)"
|
||||||
|
)
|
||||||
|
info = parse_conf(cfg)
|
||||||
|
dns_servers = info["dns"] or [FALLBACK_DNS]
|
||||||
|
|
||||||
|
# 1. namespace DNS — `ip netns exec` binds this over /etc/resolv.conf
|
||||||
|
_sudo(["mkdir", "-p", f"/etc/netns/{ns}"])
|
||||||
|
resolv = "".join(f"nameserver {d}\n" for d in dns_servers)
|
||||||
|
_sudo(["sh", "-c", f"cat > /etc/netns/{ns}/resolv.conf"], input=resolv.encode())
|
||||||
|
|
||||||
|
# 2. namespace + host <-> namespace veth link
|
||||||
|
_sudo(["ip", "netns", "add", ns], check=False)
|
||||||
|
_sudo(["ip", "link", "add", host_link, "type", "veth",
|
||||||
|
"peer", "name", _LINK_GUEST, "netns", ns], check=False)
|
||||||
|
_sudo(["ip", "link", "set", host_link, "up"])
|
||||||
|
_sudo(["ip", "addr", "add", _HOST_ADDR, "dev", host_link], check=False)
|
||||||
|
_sudo(["ip", "netns", "exec", ns, "ip", "link", "set", "lo", "up"])
|
||||||
|
_sudo(["ip", "netns", "exec", ns, "ip", "addr", "add", _GUEST_ADDR, "dev", _LINK_GUEST], check=False)
|
||||||
|
_sudo(["ip", "netns", "exec", ns, "ip", "link", "set", _LINK_GUEST, "up"])
|
||||||
|
# pre-tunnel default route so the WireGuard endpoint is reachable while the
|
||||||
|
# tunnel is still down; wg-quick replaces it with the tunnel default below
|
||||||
|
_sudo(["ip", "netns", "exec", ns, "ip", "route", "add", "default", "via", _GUEST_GW], check=False)
|
||||||
|
|
||||||
|
# 3. host forwards + NATs the namespace so it can reach the wg endpoint
|
||||||
|
_sudo(["sysctl", "-w", "net.ipv4.ip_forward=1"])
|
||||||
|
_sudo(["iptables", "-t", "nat", "-C", "POSTROUTING",
|
||||||
|
"-s", _GUEST_NET, "-j", "MASQUERADE"], check=False)
|
||||||
|
_sudo(["iptables", "-t", "nat", "-A", "POSTROUTING",
|
||||||
|
"-s", _GUEST_NET, "-j", "MASQUERADE"])
|
||||||
|
|
||||||
|
# 4. normalized wg config (DNS handled above, not by wg-quick) + tunnel up
|
||||||
|
cleaned = _strip_dns(conf_path(cfg))
|
||||||
|
_sudo(["sh", "-c", f"cat > /etc/wireguard/{iface}.conf"], input=cleaned.encode())
|
||||||
|
_sudo(["chmod", "600", f"/etc/wireguard/{iface}.conf"])
|
||||||
|
_sudo(["ip", "netns", "exec", ns, "wg-quick", "up", iface])
|
||||||
|
|
||||||
|
log.info("Mullvad tunnel up: namespace=%s interface=%s dns=%s",
|
||||||
|
ns, iface, ",".join(dns_servers))
|
||||||
|
|
||||||
|
|
||||||
|
def down(cfg) -> None:
|
||||||
|
ns, iface = netns_name(cfg), iface_name(cfg)
|
||||||
|
host_link = _host_link(iface)
|
||||||
|
_sudo(["ip", "netns", "exec", ns, "wg-quick", "down", iface], check=False)
|
||||||
|
_sudo(["ip", "link", "del", host_link], check=False)
|
||||||
|
_sudo(["ip", "netns", "del", ns], check=False)
|
||||||
|
_sudo(["iptables", "-t", "nat", "-D", "POSTROUTING",
|
||||||
|
"-s", _GUEST_NET, "-j", "MASQUERADE"], check=False)
|
||||||
|
_sudo(["rm", "-f", f"/etc/wireguard/{iface}.conf"])
|
||||||
|
_sudo(["rm", "-rf", f"/etc/netns/{ns}"])
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_up(cfg) -> None:
|
||||||
|
if is_up(cfg):
|
||||||
|
return
|
||||||
|
log.info("Mullvad tunnel is down — bringing it up")
|
||||||
|
down(cfg) # converge from any stale half-configured state
|
||||||
|
up(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def status_text(cfg) -> str:
|
||||||
|
ns, iface = netns_name(cfg), iface_name(cfg)
|
||||||
|
rc, out = _sudo_out(["ip", "netns", "exec", ns, "wg", "show", iface])
|
||||||
|
if rc != 0:
|
||||||
|
return "tunnel: DOWN (namespace or WireGuard interface missing)"
|
||||||
|
lines = ["tunnel: UP", f"namespace : {ns}"]
|
||||||
|
lines.extend(line for line in out.splitlines())
|
||||||
|
_, route = _sudo_out(["ip", "netns", "exec", ns, "ip", "route", "show", "default"])
|
||||||
|
lines.append("default : " + (route or "(none)"))
|
||||||
|
_, dns = _sudo_out(["cat", f"/etc/netns/{ns}/resolv.conf"])
|
||||||
|
lines.append("dns : " + (dns.replace("\n", " ").strip() or "(none)"))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# -- running downloads inside the tunnel ----------------------------------
|
||||||
|
|
||||||
|
def run_download_in_ns(cfg, argv: list[str]) -> int:
|
||||||
|
"""Re-run ``yt-downloader download <argv>`` inside the tunnel namespace,
|
||||||
|
dropping back to the invoking user so downloaded files stay theirs."""
|
||||||
|
ensure_up(cfg)
|
||||||
|
ns = netns_name(cfg)
|
||||||
|
user = getpass.getuser()
|
||||||
|
cmd = [sys.executable, "-m", "yt_downloader", *argv]
|
||||||
|
wrapped = ["sudo", "ip", "netns", "exec", ns,
|
||||||
|
"sudo", "-u", user, "env", f"{INNER_ENV}=1", *cmd]
|
||||||
|
log.info("running download inside Mullvad namespace %r", ns)
|
||||||
|
return _run(wrapped, check=False).returncode
|
||||||
Reference in New Issue
Block a user