diff --git a/.env.example b/.env.example index 682db9a7..67124ae5 100644 --- a/.env.example +++ b/.env.example @@ -522,6 +522,7 @@ NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токен NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды) NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды) NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека +# NALOGO_PROXY_URL=socks5://127.0.0.1:1080 # SOCKS прокси для nalog.ru (если не задан — используется PROXY_URL) # ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ ===== # Эти настройки позволяют изменить описания платежей, diff --git a/app/bot.py b/app/bot.py index 3d656be7..66594c38 100644 --- a/app/bot.py +++ b/app/bot.py @@ -102,11 +102,15 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: proxy_url = settings.get_proxy_url() if proxy_url: - from urllib.parse import urlparse + from app.utils.proxy import mask_proxy_url - parsed = urlparse(proxy_url) - masked = f'{parsed.scheme}://***@{parsed.hostname}:{parsed.port}' if parsed.username else proxy_url - logger.info('Proxy configured', proxy_url=masked) + logger.info('Proxy configured', proxy_url=mask_proxy_url(proxy_url)) + + nalogo_proxy_url = settings.get_nalogo_proxy_url() + if nalogo_proxy_url: + from app.utils.proxy import mask_proxy_url + + logger.info('Nalogo proxy configured', proxy_url=mask_proxy_url(nalogo_proxy_url)) maintenance_service.set_bot(bot) logger.info('Бот установлен в maintenance_service') diff --git a/app/config.py b/app/config.py index a50e286f..9afd1a2d 100644 --- a/app/config.py +++ b/app/config.py @@ -376,6 +376,7 @@ class Settings(BaseSettings): NALOGO_PASSWORD: str | None = None NALOGO_DEVICE_ID: str | None = None NALOGO_STORAGE_PATH: str = './nalogo_tokens.json' + NALOGO_PROXY_URL: str | None = None # SOCKS proxy for nalog.ru; falls back to PROXY_URL if not set AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False @@ -807,7 +808,7 @@ class Settings(BaseSettings): # Format: socks5://user:password@host:port or socks5://host:port PROXY_URL: str | None = None - @field_validator('PROXY_URL', mode='before') + @field_validator('PROXY_URL', 'NALOGO_PROXY_URL', mode='before') @classmethod def validate_proxy_url(cls, value: str | None) -> str | None: if not value: @@ -815,13 +816,13 @@ class Settings(BaseSettings): from urllib.parse import urlparse parsed = urlparse(value) - if parsed.scheme not in ('socks5', 'socks4'): + if parsed.scheme not in ('socks5', 'socks5h', 'socks4'): raise ValueError( - f'PROXY_URL must use socks5:// or socks4:// scheme, got: {parsed.scheme!r}. ' - 'HTTP proxies are not supported for security reasons (bot token would be exposed).' + f'Proxy URL must use socks5://, socks5h://, or socks4:// scheme, got: {parsed.scheme!r}. ' + 'HTTP proxies are not supported for security reasons.' ) if not parsed.hostname: - raise ValueError('PROXY_URL must contain a hostname') + raise ValueError('Proxy URL must contain a hostname') return value @field_validator('MAIN_MENU_MODE', mode='before') @@ -954,6 +955,13 @@ class Settings(BaseSettings): """Return SOCKS5 proxy URL or None.""" return self.PROXY_URL if self.PROXY_URL else None + def get_nalogo_proxy_url(self) -> str | None: + """Return SOCKS proxy URL for nalogo or None. + + Uses NALOGO_PROXY_URL if set, otherwise falls back to PROXY_URL. + """ + return self.NALOGO_PROXY_URL or self.PROXY_URL + def is_admin(self, telegram_id: int | None = None, email: str | None = None) -> bool: """ Check if user is admin by telegram_id or email. diff --git a/app/lib/nalogo/_http.py b/app/lib/nalogo/_http.py index 34062fcf..73716d76 100644 --- a/app/lib/nalogo/_http.py +++ b/app/lib/nalogo/_http.py @@ -41,11 +41,13 @@ class AsyncHTTPClient: auth_provider: AuthProvider, default_headers: dict[str, str] | None = None, timeout: float = 10.0, + proxy_url: str | None = None, ): self.base_url = base_url self.auth_provider = auth_provider self.default_headers = default_headers or {} self.timeout = timeout + self.proxy_url = proxy_url self._refresh_lock = asyncio.Lock() self.max_retries = 2 # Same as PHP AuthenticationPlugin::RETRY_LIMIT @@ -124,7 +126,7 @@ class AsyncHTTPClient: if json_data is not None: request_kwargs['json'] = json_data - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(proxy=self.proxy_url) as client: # Initial request response = await client.request(**request_kwargs) diff --git a/app/lib/nalogo/auth.py b/app/lib/nalogo/auth.py index ad6311ac..d07127a8 100644 --- a/app/lib/nalogo/auth.py +++ b/app/lib/nalogo/auth.py @@ -40,6 +40,7 @@ class AuthProviderImpl(AuthProvider): base_url: str = 'https://lknpd.nalog.ru/api', storage_path: str | None = None, device_id: str | None = None, + proxy_url: str | None = None, ): self.base_url_v1 = f'{base_url}/v1' self.base_url_v2 = f'{base_url}/v2' @@ -47,6 +48,7 @@ class AuthProviderImpl(AuthProvider): self.device_id = device_id or generate_device_id() self.device_info = DeviceInfo(sourceDeviceId=self.device_id) self._token_data: dict[str, Any] | None = None + self.proxy_url = proxy_url # Default headers similar to PHP Authenticator self.default_headers = { @@ -130,7 +132,7 @@ class AuthProviderImpl(AuthProvider): 'deviceInfo': self.device_info.model_dump(), } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(proxy=self.proxy_url) as client: response = await client.post( f'{self.base_url_v1}/auth/lkfl', json=request_data, @@ -165,7 +167,7 @@ class AuthProviderImpl(AuthProvider): 'requireTpToBeActive': True, } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(proxy=self.proxy_url) as client: response = await client.post( f'{self.base_url_v2}/auth/challenge/sms/start', json=request_data, @@ -200,7 +202,7 @@ class AuthProviderImpl(AuthProvider): 'deviceInfo': self.device_info.model_dump(), } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(proxy=self.proxy_url) as client: response = await client.post( f'{self.base_url_v1}/auth/challenge/sms/verify', json=request_data, @@ -233,7 +235,7 @@ class AuthProviderImpl(AuthProvider): } try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(proxy=self.proxy_url) as client: response = await client.post( f'{self.base_url_v1}/auth/token', json=request_data, diff --git a/app/lib/nalogo/client.py b/app/lib/nalogo/client.py index 648e2492..d6300d72 100644 --- a/app/lib/nalogo/client.py +++ b/app/lib/nalogo/client.py @@ -36,6 +36,7 @@ class Client: storage_path: str | None = None, device_id: str | None = None, timeout: float = 10.0, + proxy_url: str | None = None, ): """ Initialize Moy Nalog API client. @@ -45,6 +46,7 @@ class Client: storage_path: Optional file path for token storage device_id: Optional device ID (auto-generated if not provided) timeout: HTTP request timeout in seconds + proxy_url: Optional SOCKS proxy URL for routing traffic """ self.base_url = base_url self.timeout = timeout @@ -54,6 +56,7 @@ class Client: base_url=base_url, storage_path=storage_path, device_id=device_id, + proxy_url=proxy_url, ) # Initialize HTTP client with auth middleware @@ -67,6 +70,7 @@ class Client: 'Referrer': 'https://lknpd.nalog.ru/auth/login', }, timeout=timeout, + proxy_url=proxy_url, ) # User profile data (for receipt operations) diff --git a/app/services/nalogo_service.py b/app/services/nalogo_service.py index d937b816..e06adaaf 100644 --- a/app/services/nalogo_service.py +++ b/app/services/nalogo_service.py @@ -41,18 +41,29 @@ class NaloGoService: try: # Таймаут 30 секунд — nalog.ru иногда отвечает медленно timeout = getattr(settings, 'NALOGO_TIMEOUT', 30.0) + proxy_url = settings.get_nalogo_proxy_url() self.client = Client( base_url='https://lknpd.nalog.ru/api', storage_path=storage_path, device_id=device_id or 'bot-device-123', timeout=timeout, + proxy_url=proxy_url, ) self.inn = inn self.password = password self.configured = True - logger.info('NaloGO клиент инициализирован для ИНН: ...', inn=inn[:5]) + if proxy_url: + from app.utils.proxy import mask_proxy_url + + logger.info( + 'NaloGO клиент инициализирован с прокси', inn=inn[:5], proxy_url=mask_proxy_url(proxy_url) + ) + else: + logger.info('NaloGO клиент инициализирован для ИНН: ...', inn=inn[:5]) except Exception as error: - logger.error('Ошибка инициализации NaloGO клиента', error=error, exc_info=True) + from app.utils.proxy import sanitize_proxy_error + + logger.error('Ошибка инициализации NaloGO клиента', error=sanitize_proxy_error(error)) self.configured = False @staticmethod diff --git a/app/utils/proxy.py b/app/utils/proxy.py new file mode 100644 index 00000000..eb92c9d8 --- /dev/null +++ b/app/utils/proxy.py @@ -0,0 +1,35 @@ +"""Proxy URL utilities for safe logging and error handling.""" + +import re +from urllib.parse import urlparse + + +def mask_proxy_url(proxy_url: str) -> str: + """Mask credentials in a proxy URL for safe logging. + + Handles edge cases: + - No credentials: returns URL as-is + - Username + password: masks both with *** + - Password-only: masks as well + - No explicit port: omits :port part + """ + parsed = urlparse(proxy_url) + if not parsed.username and not parsed.password: + return proxy_url + host = parsed.hostname or 'unknown' + port_part = f':{parsed.port}' if parsed.port else '' + return f'{parsed.scheme}://***@{host}{port_part}' + + +_PROXY_CRED_RE = re.compile(r'socks[45h]*://[^@\s]+@', re.IGNORECASE) + + +def sanitize_proxy_error(error: Exception) -> str: + """Strip proxy credentials from exception messages. + + httpx/socksio may include the full proxy URL (with credentials) + in connection error messages and tracebacks. This function removes + credentials from the error string. + """ + msg = str(error) + return _PROXY_CRED_RE.sub('socks5://***@', msg) diff --git a/pyproject.toml b/pyproject.toml index e3f882d0..d77ecb43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ 'structlog>=25.1.0,<26', 'rich>=14.0', 'aiohttp-socks>=0.10.1', + 'httpx[socks]>=0.27.0', ] [dependency-groups] diff --git a/uv.lock b/uv.lock index b04db498..c85d0f8f 100644 --- a/uv.lock +++ b/uv.lock @@ -623,6 +623,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +socks = [ + { name = "socksio" }, +] + [[package]] name = "idna" version = "3.11" @@ -1137,7 +1142,7 @@ wheels = [ [[package]] name = "remnawave-bedolaga-telegram-bot" -version = "3.36.0" +version = "3.36.1" source = { virtual = "." } dependencies = [ { name = "aiogram" }, @@ -1148,6 +1153,7 @@ dependencies = [ { name = "bcrypt" }, { name = "cryptography" }, { name = "fastapi", extra = ["standard"] }, + { name = "httpx", extra = ["socks"] }, { name = "packaging" }, { name = "pyjwt" }, { name = "python-dateutil" }, @@ -1179,6 +1185,7 @@ requires-dist = [ { name = "bcrypt", specifier = ">=5.0.0" }, { name = "cryptography", specifier = ">=44.0.1" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" }, + { name = "httpx", extras = ["socks"], specifier = ">=0.27.0" }, { name = "packaging", specifier = ">=26.0" }, { name = "pyjwt", specifier = ">=2.11.0" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" }, @@ -1322,6 +1329,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.46"