mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
feat(proxy): mask proxy credentials in console and API output
Show user:pass as xxxxx:xxxxx in "Using ... Proxy" lines (full URI with -d), hide Basic provider hostnames, and redact proxy URIs in REST API error responses and remote session logs.
This commit is contained in:
@@ -97,6 +97,7 @@ from unshackle.core.utils.click_types import (
|
||||
)
|
||||
from unshackle.core.utils.collections import ci_get, merge_dict
|
||||
from unshackle.core.utils.post_scripts import build_context, dispatch, season_context
|
||||
from unshackle.core.utils.redact import mask_proxy
|
||||
from unshackle.core.utils.selector import select_multiple
|
||||
from unshackle.core.utils.subprocess import ffprobe
|
||||
from unshackle.core.vaults import Vaults
|
||||
@@ -1342,6 +1343,12 @@ class dl:
|
||||
self.log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}")
|
||||
|
||||
if proxy:
|
||||
|
||||
def log_proxy_used(provider: object, uri: str) -> None:
|
||||
self.log.info(
|
||||
f"Using {provider.__class__.__name__} Proxy: {mask_proxy(uri, isinstance(provider, Basic))}"
|
||||
)
|
||||
|
||||
requested_provider = None
|
||||
if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE):
|
||||
# requesting proxy from a specific proxy provider
|
||||
@@ -1386,7 +1393,7 @@ class dl:
|
||||
location = ", ".join(p for p in location_parts if p)
|
||||
self.log.info(f"VPN Connected: {conn_info['public_ip']} ({location})")
|
||||
else:
|
||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
log_proxy_used(proxy_provider, proxy)
|
||||
else:
|
||||
display = None
|
||||
if hasattr(proxy_provider, "last_connection_display"):
|
||||
@@ -1394,7 +1401,7 @@ class dl:
|
||||
if display:
|
||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy {display}")
|
||||
else:
|
||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
log_proxy_used(proxy_provider, proxy)
|
||||
else:
|
||||
for proxy_provider in self.proxy_providers:
|
||||
proxy_uri = proxy_provider.get_proxy(proxy)
|
||||
@@ -1408,7 +1415,7 @@ class dl:
|
||||
location = ", ".join(p for p in location_parts if p)
|
||||
self.log.info(f"VPN Connected: {conn_info['public_ip']} ({location})")
|
||||
else:
|
||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
log_proxy_used(proxy_provider, proxy)
|
||||
else:
|
||||
display = None
|
||||
if hasattr(proxy_provider, "last_connection_display"):
|
||||
@@ -1416,13 +1423,13 @@ class dl:
|
||||
if display:
|
||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy {display}")
|
||||
else:
|
||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
log_proxy_used(proxy_provider, proxy)
|
||||
break
|
||||
# Store proxy query info for service-specific overrides
|
||||
ctx.params["proxy_query"] = proxy_query
|
||||
ctx.params["proxy_provider"] = requested_provider
|
||||
else:
|
||||
self.log.info(f"Using explicit Proxy: {proxy}")
|
||||
self.log.info(f"Using explicit Proxy: {mask_proxy(ctx.params['proxy'])}")
|
||||
# For explicit proxies, store None for query/provider
|
||||
ctx.params["proxy_query"] = None
|
||||
ctx.params["proxy_provider"] = None
|
||||
|
||||
@@ -21,6 +21,7 @@ from unshackle.core.service import Service
|
||||
from unshackle.core.services import Services
|
||||
from unshackle.core.utils.click_types import ContextData
|
||||
from unshackle.core.utils.collections import merge_dict
|
||||
from unshackle.core.utils.redact import mask_proxy
|
||||
|
||||
|
||||
@click.command(
|
||||
@@ -113,7 +114,10 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr
|
||||
if display:
|
||||
log.info(f"Using {proxy_provider.__class__.__name__} Proxy {display}")
|
||||
else:
|
||||
log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
log.info(
|
||||
f"Using {proxy_provider.__class__.__name__} Proxy: "
|
||||
f"{mask_proxy(proxy, isinstance(proxy_provider, Basic))}"
|
||||
)
|
||||
else:
|
||||
for proxy_provider in proxy_providers:
|
||||
proxy_uri = proxy_provider.get_proxy(proxy)
|
||||
@@ -125,10 +129,13 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr
|
||||
if display:
|
||||
log.info(f"Using {proxy_provider.__class__.__name__} Proxy {display}")
|
||||
else:
|
||||
log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
log.info(
|
||||
f"Using {proxy_provider.__class__.__name__} Proxy: "
|
||||
f"{mask_proxy(proxy, isinstance(proxy_provider, Basic))}"
|
||||
)
|
||||
break
|
||||
else:
|
||||
log.info(f"Using explicit Proxy: {proxy}")
|
||||
log.info(f"Using explicit Proxy: {mask_proxy(ctx.params['proxy'])}")
|
||||
|
||||
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile)
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from unshackle.core.utils.redact import redact_text
|
||||
|
||||
|
||||
class APIErrorCode(str, Enum):
|
||||
"""Standard API error codes for programmatic error handling."""
|
||||
@@ -185,7 +187,7 @@ def categorize_exception(
|
||||
if "proxy" in str(root).lower():
|
||||
return APIError(
|
||||
error_code=APIErrorCode.INVALID_PROXY,
|
||||
message=f"The proxy for this session failed: {root}",
|
||||
message=f"The proxy for this session failed: {redact_text(str(root))}",
|
||||
details={**context, "reason": "proxy_error"},
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ from unshackle.core.services import Services
|
||||
from unshackle.core.titles import Episode, Movie, Song, Title_T
|
||||
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
|
||||
from unshackle.core.utils.collections import ci_get
|
||||
from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE
|
||||
from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE, redact_text
|
||||
|
||||
log = logging.getLogger("api")
|
||||
|
||||
@@ -2959,8 +2959,8 @@ def resolve_handler_proxy(
|
||||
if allowed:
|
||||
raise APIError(
|
||||
APIErrorCode.INVALID_PROXY,
|
||||
f"Proxy error: {e}",
|
||||
details={"proxy": data.get("proxy"), "service": normalized_service},
|
||||
f"Proxy error: {redact_text(str(e))}",
|
||||
details={"proxy": redact_text(data.get("proxy")), "service": normalized_service},
|
||||
)
|
||||
raise APIError(
|
||||
APIErrorCode.INVALID_PROXY,
|
||||
|
||||
@@ -6,6 +6,7 @@ from requests.utils import prepend_scheme_if_needed
|
||||
from urllib3.util import parse_url
|
||||
|
||||
from unshackle.core.proxies.proxy import Proxy
|
||||
from unshackle.core.utils.redact import mask_proxy
|
||||
|
||||
|
||||
class Basic(Proxy):
|
||||
@@ -49,6 +50,9 @@ class Basic(Proxy):
|
||||
proxy = prepend_scheme_if_needed(proxy, "http")
|
||||
parsed_proxy = parse_url(proxy)
|
||||
if not parsed_proxy.host:
|
||||
raise ValueError(f"The proxy '{proxy}' is not a valid proxy URI supported by Python-Requests.")
|
||||
raise ValueError(
|
||||
f"The proxy '{mask_proxy(proxy, mask_host=True, allow_debug=False)}' "
|
||||
"is not a valid proxy URI supported by Python-Requests."
|
||||
)
|
||||
|
||||
return proxy
|
||||
|
||||
@@ -9,6 +9,8 @@ import logging
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from unshackle.core.utils.redact import mask_proxy
|
||||
|
||||
log = logging.getLogger("proxies")
|
||||
|
||||
|
||||
@@ -85,13 +87,19 @@ def resolve_proxy(proxy: str, proxy_providers: List[Any]) -> Optional[str]:
|
||||
proxy_uri = provider.get_proxy(query)
|
||||
if not proxy_uri:
|
||||
raise ValueError(f"Proxy provider {requested_provider} had no proxy for {query}")
|
||||
log.info(f"Using {provider.__class__.__name__} Proxy: {proxy_uri}")
|
||||
log.info(
|
||||
f"Using {provider.__class__.__name__} Proxy: "
|
||||
f"{mask_proxy(proxy_uri, provider.__class__.__name__ == 'Basic', allow_debug=False)}"
|
||||
)
|
||||
return proxy_uri
|
||||
|
||||
for provider in proxy_providers:
|
||||
proxy_uri = provider.get_proxy(query)
|
||||
if proxy_uri:
|
||||
log.info(f"Using {provider.__class__.__name__} Proxy: {proxy_uri}")
|
||||
log.info(
|
||||
f"Using {provider.__class__.__name__} Proxy: "
|
||||
f"{mask_proxy(proxy_uri, provider.__class__.__name__ == 'Basic', allow_debug=False)}"
|
||||
)
|
||||
return proxy_uri
|
||||
|
||||
raise ValueError(f"No proxy provider had a proxy for {proxy}")
|
||||
|
||||
+12
-33
@@ -5,7 +5,6 @@ from dataclasses import dataclass, field
|
||||
from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Optional, Union
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from unshackle.core.api.input_bridge import InputBridge
|
||||
@@ -23,6 +22,7 @@ from unshackle.core.console import console, prompt_user
|
||||
from unshackle.core.constants import AnyTrack
|
||||
from unshackle.core.credential import Credential
|
||||
from unshackle.core.drm import DRM_T
|
||||
from unshackle.core.proxies.basic import Basic
|
||||
from unshackle.core.search_result import SearchResult
|
||||
from unshackle.core.session import (
|
||||
BACKOFF_FACTOR,
|
||||
@@ -39,6 +39,7 @@ from unshackle.core.titles import Title_T, Titles_T, remap_titles
|
||||
from unshackle.core.tracks import Chapters, Tracks
|
||||
from unshackle.core.tracks.video import Video
|
||||
from unshackle.core.utils.ip_info import get_ip_info
|
||||
from unshackle.core.utils.redact import mask_proxy
|
||||
|
||||
# Default (connect, read) timeout for the requests path, mirroring RnetSession's
|
||||
# connect_timeout / read_timeout construction defaults. A per-request timeout= wins.
|
||||
@@ -123,43 +124,19 @@ class TrackRequest:
|
||||
best_available: bool = False
|
||||
|
||||
|
||||
def sanitize_proxy_for_log(uri: Optional[str]) -> Optional[str]:
|
||||
def sanitize_proxy_for_log(uri: Optional[str], mask_host: bool = False) -> Optional[str]:
|
||||
"""
|
||||
Sanitise a proxy URI for logs by redacting any embedded userinfo (username/password).
|
||||
Sanitise a proxy URI for logs by masking any embedded userinfo (username/password).
|
||||
|
||||
Examples:
|
||||
- http://user:pass@host:8080 -> http://REDACTED@host:8080
|
||||
- socks5h://user@host:1080 -> socks5h://REDACTED@host:1080
|
||||
``serve`` sends these log lines to the client of a remote session, so the mask is
|
||||
unconditional and debug mode never lifts it. ``mask_host`` hides the hostname as
|
||||
well, for a proxy that came from the user-supplied ``Basic`` proxy provider.
|
||||
"""
|
||||
if uri is None:
|
||||
return None
|
||||
if not isinstance(uri, str):
|
||||
return str(uri)
|
||||
if not uri:
|
||||
return uri
|
||||
|
||||
try:
|
||||
parsed = urlparse(uri)
|
||||
|
||||
# Handle schemeless proxies like "user:pass@host:port"
|
||||
if not parsed.scheme and not parsed.netloc and "@" in uri and "://" not in uri:
|
||||
# Parse as netloc using a dummy scheme, then strip scheme back out.
|
||||
dummy = urlparse(f"http://{uri}")
|
||||
netloc = dummy.netloc
|
||||
if "@" in netloc:
|
||||
netloc = f"REDACTED@{netloc.split('@', 1)[1]}"
|
||||
# urlparse("http://...") sets path to "" for typical netloc-only strings; keep it just in case.
|
||||
return f"{netloc}{dummy.path}"
|
||||
|
||||
netloc = parsed.netloc
|
||||
if "@" in netloc:
|
||||
netloc = f"REDACTED@{netloc.split('@', 1)[1]}"
|
||||
|
||||
return urlunparse(parsed._replace(netloc=netloc))
|
||||
except ValueError:
|
||||
if "@" in uri:
|
||||
return f"REDACTED@{uri.split('@', 1)[1]}"
|
||||
return uri
|
||||
return mask_proxy(uri, mask_host=mask_host, allow_debug=False)
|
||||
|
||||
|
||||
class Service(metaclass=ABCMeta):
|
||||
@@ -244,7 +221,8 @@ class Service(metaclass=ABCMeta):
|
||||
if mapped_proxy_uri:
|
||||
proxy = mapped_proxy_uri
|
||||
self.log.info(
|
||||
f"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}"
|
||||
f"Using mapped proxy from {proxy_provider.__class__.__name__}: "
|
||||
f"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}"
|
||||
)
|
||||
else:
|
||||
self.log.warning(
|
||||
@@ -258,7 +236,8 @@ class Service(metaclass=ABCMeta):
|
||||
if mapped_proxy_uri:
|
||||
proxy = mapped_proxy_uri
|
||||
self.log.info(
|
||||
f"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}"
|
||||
f"Using mapped proxy from {proxy_provider.__class__.__name__}: "
|
||||
f"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}"
|
||||
)
|
||||
break
|
||||
else:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -32,6 +33,31 @@ def redact_text(text: Optional[str], secrets: Iterable[str] = ()) -> Optional[st
|
||||
return text
|
||||
|
||||
|
||||
PROXY_USERINFO_RE = re.compile(r"(^|://)[^:/@]+(?::[^/@]*)?@")
|
||||
PROXY_HOST_RE = re.compile(r"(://(?:[^/@]*@)?)(\[[^\]]+\]|[^:/?#]+)")
|
||||
|
||||
|
||||
def mask_proxy(uri: str, mask_host: bool = False, allow_debug: bool = True) -> str:
|
||||
"""Replace proxy userinfo with ``xxxxx:xxxxx@host`` for console display.
|
||||
|
||||
``mask_host`` also replaces the hostname and keeps the scheme and the port, for
|
||||
a user-supplied proxy whose hostname identifies the account on its own.
|
||||
The full URI passes through while DEBUG logging is active (``-d``), so a debug
|
||||
run keeps the complete proxy URI and a normal run never shows the credentials.
|
||||
Pass ``allow_debug=False`` where the output can reach more than the operator's
|
||||
own terminal, such as ``serve`` logs and the dashboard, so that debug mode does
|
||||
not widen the exposure.
|
||||
"""
|
||||
if not isinstance(uri, str) or not uri:
|
||||
return uri
|
||||
if allow_debug and logging.getLogger().getEffectiveLevel() <= logging.DEBUG:
|
||||
return uri
|
||||
uri = PROXY_USERINFO_RE.sub(r"\1xxxxx:xxxxx@", uri)
|
||||
if mask_host:
|
||||
uri = PROXY_HOST_RE.sub(r"\1xxxxx", uri)
|
||||
return uri
|
||||
|
||||
|
||||
def safe_display_url(url: str) -> str:
|
||||
"""Rebuild a URL from its scheme/host/port/path only, so unshackle can never log the userinfo."""
|
||||
parts = urlsplit(url)
|
||||
|
||||
Reference in New Issue
Block a user