commit f6b271a926d02a3d7e13013a98f13e107cd82d16 Author: Konstantin Passig PC <2002erdi@gmail.com> Date: Tue Aug 25 15:09:18 2026 +0200 Add yt-downloader: single-URL YouTube downloads through an isolated Mullvad WireGuard tunnel diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06b4d91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..981f271 --- /dev/null +++ b/README.md @@ -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 # 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 -q 720 # quality preset +yt-downloader download -q best # best video+audio (default) +yt-downloader download -q audio # audio only (no muxing needed) +yt-downloader download -q 1080 -o ~/Videos # custom output dir +yt-downloader download -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" +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a705e35 --- /dev/null +++ b/pyproject.toml @@ -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*"] \ No newline at end of file diff --git a/yt_downloader/__init__.py b/yt_downloader/__init__.py new file mode 100644 index 0000000..bd1b74c --- /dev/null +++ b/yt_downloader/__init__.py @@ -0,0 +1,3 @@ +"""yt_downloader - download YouTube videos through an isolated Mullvad tunnel.""" + +__version__ = "0.1.0" \ No newline at end of file diff --git a/yt_downloader/__main__.py b/yt_downloader/__main__.py new file mode 100644 index 0000000..187e7c5 --- /dev/null +++ b/yt_downloader/__main__.py @@ -0,0 +1,6 @@ +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/yt_downloader/cli.py b/yt_downloader/cli.py new file mode 100644 index 0000000..a800bae --- /dev/null +++ b/yt_downloader/cli.py @@ -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 ") + 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()) \ No newline at end of file diff --git a/yt_downloader/config.py b/yt_downloader/config.py new file mode 100644 index 0000000..a8cd553 --- /dev/null +++ b/yt_downloader/config.py @@ -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) \ No newline at end of file diff --git a/yt_downloader/downloader.py b/yt_downloader/downloader.py new file mode 100644 index 0000000..60c0e78 --- /dev/null +++ b/yt_downloader/downloader.py @@ -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 \ No newline at end of file diff --git a/yt_downloader/vpn.py b/yt_downloader/vpn.py new file mode 100644 index 0000000..e99faeb --- /dev/null +++ b/yt_downloader/vpn.py @@ -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 `` 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 \ No newline at end of file