Compare commits

...

7 Commits

Author SHA1 Message Date
Egor 38853cdd5a Merge pull request #2790 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.38.0
2026-03-21 04:32:38 +03:00
github-actions[bot] f837c0c244 chore(main): release 3.38.0 2026-03-21 01:32:11 +00:00
Egor 8a7b9cc651 Merge pull request #2789 from BEDOLAGA-DEV/dev
Dev
2026-03-21 04:31:51 +03:00
Fringg 3bf31055e7 fix: sanitize proxy credentials in all nalogo error paths
- Apply sanitize_proxy_error() to all 8 error handlers in nalogo_service
- Remove exc_info=True from error paths that could expose proxy creds
- Fix regex backreference to preserve original SOCKS scheme
- Consolidate proxy utility imports to module level
- Add source indicator (NALOGO_PROXY_URL vs fallback) to startup log
2026-03-21 04:27:15 +03:00
Fringg 3c5bf4fa22 feat: add SOCKS proxy support for nalogo (tax service) module
Route all nalog.ru API traffic through SOCKS proxy. Uses NALOGO_PROXY_URL
env var (falls back to PROXY_URL if not set). Adds httpx[socks] dependency.

- Thread proxy_url through Client → AuthProviderImpl + AsyncHTTPClient
- Extract mask_proxy_url() and sanitize_proxy_error() utilities
- Add socks5h:// scheme support for remote DNS resolution
- Sanitize proxy credentials in error messages
- Log masked proxy URL at startup and service init
2026-03-21 04:21:11 +03:00
Fringg 4990ddf9e4 fix: add diagnostic payload logging in create_user error path
Consistent with update_user — log full payload before re-raising
non-A039 errors to aid debugging.
2026-03-21 04:08:18 +03:00
Fringg de00612965 fix: retry Remnawave API calls without externalSquadUuid on A039 FK violation
When a tariff has a stale external_squad_uuid that no longer exists in
the Remnawave panel, PATCH/POST /api/users fails with A039 (P2003 FK
constraint violation). This caused subscriptions to not sync with the
panel even though balance was already charged.

Now both update_user() and create_user() catch A039 errors and
automatically retry without externalSquadUuid, logging a warning about
the stale UUID. The subscription sync succeeds without the external
squad assignment rather than failing entirely.
2026-03-21 03:58:21 +03:00
14 changed files with 155 additions and 35 deletions
+1
View File
@@ -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)
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.37.0"
".": "3.38.0"
}
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [3.38.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.37.0...v3.38.0) (2026-03-21)
### New Features
* add SOCKS proxy support for nalogo (tax service) module ([3c5bf4f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3c5bf4fa22d1cdf144269f4e6ab32a4523c8f1f3))
### Bug Fixes
* add diagnostic payload logging in create_user error path ([4990ddf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4990ddf9e46495b65fc3638ea8d6bed0cbe6b857))
* retry Remnawave API calls without externalSquadUuid on A039 FK violation ([de00612](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de006129657ce3dac2b1f2fc0ab1b91e23e44241))
* sanitize proxy credentials in all nalogo error paths ([3bf3105](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3bf31055e71ff64e6a6d94486bb7f7775ac7dc91))
## [3.37.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.36.1...v3.37.0) (2026-03-21)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.37.0" # x-release-please-version
ARG VERSION="v3.38.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+9 -5
View File
@@ -101,12 +101,16 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
bot = create_bot()
proxy_url = settings.get_proxy_url()
if proxy_url:
from urllib.parse import urlparse
nalogo_proxy_url = settings.get_nalogo_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)
if proxy_url or nalogo_proxy_url:
from app.utils.proxy import mask_proxy_url
if proxy_url:
logger.info('Proxy configured', proxy_url=mask_proxy_url(proxy_url))
if nalogo_proxy_url:
source = 'NALOGO_PROXY_URL' if settings.NALOGO_PROXY_URL else 'PROXY_URL (fallback)'
logger.info('Nalogo proxy configured', proxy_url=mask_proxy_url(nalogo_proxy_url), source=source)
maintenance_service.set_bot(bot)
logger.info('Бот установлен в maintenance_service')
+13 -5
View File
@@ -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.
+30 -5
View File
@@ -469,7 +469,22 @@ class RemnaWaveAPI:
hwidDeviceLimit=data.get('hwidDeviceLimit'),
status=data.get('status'),
)
response = await self._make_request('POST', '/api/users', data)
try:
response = await self._make_request('POST', '/api/users', data)
except RemnaWaveAPIError as e:
# A039 = FK violation on externalSquadUuid — retry without it
error_code = (e.response_data or {}).get('errorCode', '')
if error_code == 'A039' and 'externalSquadUuid' in data:
stale_uuid = data.pop('externalSquadUuid')
logger.warning(
'A039 FK violation on externalSquadUuid, retrying without it',
stale_uuid=stale_uuid,
username=data.get('username'),
)
response = await self._make_request('POST', '/api/users', data)
else:
logger.error('POST /api/users FAILED — full payload', payload=data)
raise
user = self._parse_user(response['response'])
logger.info(
'POST /api/users response',
@@ -570,10 +585,20 @@ class RemnaWaveAPI:
try:
response = await self._make_request('PATCH', '/api/users', data)
except Exception:
# Логируем полный payload при ошибке для диагностики A039
logger.error('PATCH /api/users FAILED — full payload', payload=data)
raise
except RemnaWaveAPIError as e:
# A039 = FK violation on externalSquadUuid — retry without it
error_code = (e.response_data or {}).get('errorCode', '')
if error_code == 'A039' and 'externalSquadUuid' in data:
stale_uuid = data.pop('externalSquadUuid')
logger.warning(
'A039 FK violation on externalSquadUuid, retrying without it',
stale_uuid=stale_uuid,
uuid=uuid,
)
response = await self._make_request('PATCH', '/api/users', data)
else:
logger.error('PATCH /api/users FAILED — full payload', payload=data)
raise
user = self._parse_user(response['response'])
logger.info(
'PATCH /api/users response',
+3 -1
View File
@@ -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)
+6 -4
View File
@@ -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,
+4
View File
@@ -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)
+19 -11
View File
@@ -10,6 +10,7 @@ from app.config import settings
from app.lib.nalogo import Client
from app.lib.nalogo.dto.income import IncomeClient, IncomeType
from app.utils.cache import cache
from app.utils.proxy import mask_proxy_url, sanitize_proxy_error
logger = structlog.get_logger(__name__)
@@ -41,18 +42,25 @@ 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:
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)
logger.error('Ошибка инициализации NaloGO клиента', error=sanitize_proxy_error(error))
self.configured = False
@staticmethod
@@ -281,9 +289,9 @@ class NaloGoService:
return True
except Exception as error:
if self._is_service_unavailable(error):
logger.warning('NaloGO временно недоступен (техработы)', error=str(error)[:200])
logger.warning('NaloGO временно недоступен (техработы)', error=sanitize_proxy_error(error))
else:
logger.error('Ошибка аутентификации в NaloGO', error=error, exc_info=True)
logger.error('Ошибка аутентификации в NaloGO', error=sanitize_proxy_error(error))
return False
async def create_receipt(
@@ -355,7 +363,7 @@ class NaloGoService:
name, amount, quantity, client_info, payment_id, telegram_user_id, amount_kopeks
)
else:
logger.error('Ошибка аутентификации NaloGO', auth_error=auth_error, exc_info=True)
logger.error('Ошибка аутентификации NaloGO', auth_error=sanitize_proxy_error(auth_error))
return None
# ЭТАП 2: Создание чека
@@ -400,9 +408,9 @@ class NaloGoService:
# ВАЖНО: Аутентификация была успешной, запрос на создание чека УШЁЛ
# При таймауте чек МОГ быть создан на сервере — НЕ добавляем в очередь!
if self._is_service_unavailable(error):
error_msg = str(error)[:200]
error_msg = sanitize_proxy_error(error)[:200]
logger.error(
'⚠️ ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан! (payment_id=, сумма=₽). Сохраняем в очередь проверки. Проверьте lknpd.nalog.ru',
'ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан!',
payment_id=payment_id,
amount=amount,
)
@@ -418,7 +426,7 @@ class NaloGoService:
error_message=error_msg,
)
else:
logger.error('Ошибка создания чека в NaloGO', error=error, exc_info=True)
logger.error('Ошибка создания чека в NaloGO', error=sanitize_proxy_error(error))
return None
async def get_queue_length(self) -> int:
@@ -511,7 +519,7 @@ class NaloGoService:
return None
except Exception as error:
logger.warning('Ошибка проверки дубликата чека', error=error)
logger.warning('Ошибка проверки дубликата чека', error=sanitize_proxy_error(error))
return None
async def get_incomes(
@@ -555,7 +563,7 @@ class NaloGoService:
except Exception as error:
if self._is_service_unavailable(error):
logger.warning('NaloGO временно недоступен', error=error)
logger.warning('NaloGO временно недоступен', error=sanitize_proxy_error(error))
else:
logger.error('Ошибка получения списка доходов', error=error, exc_info=True)
logger.error('Ошибка получения списка доходов', error=sanitize_proxy_error(error))
return None # None = ошибка, [] = нет чеков
+35
View File
@@ -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 while preserving the original scheme.
"""
msg = str(error)
return _PROXY_CRED_RE.sub(r'\1***@', msg)
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.37.0"
version = "3.38.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
@@ -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]
Generated
+17 -1
View File
@@ -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"