diff --git a/app/config.py b/app/config.py index ad5d1c2..eb9c41f 100644 --- a/app/config.py +++ b/app/config.py @@ -29,6 +29,10 @@ token = "{token}" # Кто может обращаться. Пусто = разрешены все адреса. # Примеры: "192.168.1.0/24, 10.8.0.5, 127.0.0.1" allow_ips = "127.0.0.1, ::1" +# Адреса обратных прокси, которым можно верить в определении клиента. +# Пусто = сервис работает напрямую и заголовкам не доверяет. +# При работе через start-https.bat (Caddy на этой же машине) ставьте 127.0.0.1 +trust_proxy = "" # Открывать ли /docs и /openapi.json. Они не требуют токена и показывают # устройство API всем, кто знает адрес, поэтому по умолчанию выключены. docs = false @@ -81,6 +85,7 @@ class Settings: token: str = "" allow_ips: str = "" docs: bool = False + trust_proxy: str = "" threads: int = 0 workers: int = 0 speakers: int = 2 @@ -152,6 +157,7 @@ def load_settings(config_path: Path | None = None) -> Settings: token=str(security.get("token", "")), allow_ips=str(security.get("allow_ips", "")), docs=bool(security.get("docs", False)), + trust_proxy=str(security.get("trust_proxy", "")), threads=int(proc.get("threads", 0)), workers=int(proc.get("workers", 1)), speakers=int(proc.get("speakers", 2)), diff --git a/app/main.py b/app/main.py index 6bfa5ff..d9c232e 100644 --- a/app/main.py +++ b/app/main.py @@ -19,7 +19,7 @@ from app.config import Settings, load_settings from app.logbuffer import get as log_buffer from app.logbuffer import install as install_log_buffer from app.pipeline import ModelsMissing, Pipeline, to_wav16k -from app.security import check_token, ip_allowed, parse_allowlist +from app.security import check_token, client_address, ip_allowed, parse_allowlist from app.store import JobStatus, JobStore from app.webhook import deliver_async from app.version import __version__ @@ -28,6 +28,7 @@ log = logging.getLogger("talkscore-asr") settings: Settings = load_settings() allowlist = parse_allowlist(settings.allow_ips) +trusted_proxies = parse_allowlist(settings.trust_proxy) # Хранилище создаётся не здесь, а при старте сервиса. На Windows процессы # поднимаются через spawn и заново импортируют этот модуль: если бы очередь @@ -190,16 +191,22 @@ app = FastAPI( bearer_scheme = HTTPBearer(description="Токен из config.toml, раздел [security]") +def peer_address(request: Request) -> str | None: + """Адрес клиента с поправкой на обратный прокси.""" + peer = request.client.host if request.client else None + return client_address(peer, request.headers.get("x-forwarded-for"), trusted_proxies) + + def ip_guard(request: Request) -> None: """Только проверка адреса: нужна для страницы с описанием методов.""" - client_ip = request.client.host if request.client else None + client_ip = peer_address(request) if not ip_allowed(client_ip, allowlist): raise HTTPException(status_code=403, detail="адрес не в списке разрешённых") def guard(request: Request) -> None: """Проверяет адрес и токен. Порядок важен: сначала сеть, потом секрет.""" - client_ip = request.client.host if request.client else None + client_ip = peer_address(request) if not ip_allowed(client_ip, allowlist): log.warning("отказано по адресу: %s", client_ip) raise HTTPException(status_code=403, detail="адрес не в списке разрешённых") @@ -253,7 +260,7 @@ def health(request: Request) -> JSONResponse: отладка отказов превращается в гадание, а сам адрес клиенту и так известен. Список разрешённых адресов при этом не раскрывается. """ - client_ip = request.client.host if request.client else None + client_ip = peer_address(request) return JSONResponse({ "status": "ok" if _state["ready"] else "no_models", "version": __version__, diff --git a/app/security.py b/app/security.py index bad6b9b..2af77a8 100644 --- a/app/security.py +++ b/app/security.py @@ -3,7 +3,7 @@ import ipaddress import secrets from ipaddress import IPv4Network, IPv6Network -__all__ = ["check_token", "parse_allowlist", "ip_allowed"] +__all__ = ["check_token", "parse_allowlist", "ip_allowed", "client_address"] Network = IPv4Network | IPv6Network @@ -51,3 +51,21 @@ def ip_allowed(client_ip: str | None, allowlist: list[Network]) -> bool: except ValueError: return False return any(addr in net for net in allowlist) + + +def client_address(peer_ip: str | None, forwarded: str | None, + trusted_proxies: list[Network]) -> str | None: + """Возвращает адрес клиента с учётом обратного прокси. + + Заголовку X-Forwarded-For верим только тогда, когда сам запрос пришёл + от доверенного прокси. Иначе любой желающий подставил бы себе разрешённый + адрес и обошёл ограничение. + """ + if not forwarded or not trusted_proxies or not ip_allowed(peer_ip, trusted_proxies): + return peer_ip + first = forwarded.split(",")[0].strip() + try: + ipaddress.ip_address(first) + except ValueError: + return peer_ip + return first diff --git a/build/make_windows_zip.py b/build/make_windows_zip.py index d170a6e..cc38963 100644 --- a/build/make_windows_zip.py +++ b/build/make_windows_zip.py @@ -23,6 +23,10 @@ PY_URL = f"https://www.python.org/ftp/python/{PY_VERSION}/python-{PY_VERSION}-em # LGPL-сборка: нам нужен только декодер аудио, GPL-компоненты не требуются. FFMPEG_URL = ("https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/" "ffmpeg-master-latest-win64-lgpl.zip") +# Caddy сам получает и продлевает сертификаты Let's Encrypt - для машины без +# постоянного администратора это надёжнее ручной возни с сертификатами. +CADDY_URL = ("https://github.com/caddyserver/caddy/releases/download/v2.10.2/" + "caddy_2.10.2_windows_amd64.zip") PACKAGES = [ "fastapi", "uvicorn", "python-multipart", @@ -82,6 +86,16 @@ def add_ffmpeg(stage: Path) -> None: (bin_dir / "ffmpeg.exe").write_bytes(z.read(name)) +def add_caddy(stage: Path) -> None: + print("Caddy для HTTPS") + data = fetch(CADDY_URL) + bin_dir = stage / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(io.BytesIO(data)) as z: + name = next(n for n in z.namelist() if n.endswith("caddy.exe")) + (bin_dir / "caddy.exe").write_bytes(z.read(name)) + + def add_app(stage: Path) -> None: print("Код сервиса") shutil.copytree(ROOT / "app", stage / "app", @@ -92,6 +106,8 @@ def add_app(stage: Path) -> None: shutil.copy(src, stage / name) write_bat(stage / "start.bat", START_BAT) write_bat(stage / "download_models.bat", DOWNLOAD_BAT) + write_bat(stage / "start-https.bat", HTTPS_BAT) + (stage / "Caddyfile").write_text(CADDYFILE, encoding="utf-8", newline="\r\n") (stage / "download_models.py").write_text( (ROOT / "build" / "download_models.py").read_text(encoding="utf-8"), encoding="utf-8") @@ -119,6 +135,36 @@ set "PYTHONPATH=%~dp0" pause """ +# Caddy слушает 443 и проксирует на сервис, который при этом сидит на localhost. +HTTPS_BAT = """@echo off +chcp 65001 >nul +cd /d "%~dp0" +set "TALKSCORE_ASR_HOME=%~dp0" +set "PYTHONPATH=%~dp0" +start "talkscore-asr" /min "%~dp0python\\python.exe" -m app.main +timeout /t 5 /nobreak >nul +"%~dp0bin\\caddy.exe" run --config "%~dp0Caddyfile" +pause +""" + +CADDYFILE = """# Домены, на которых отвечает сервис. Caddy сам получит сертификаты +# Let's Encrypt и будет продлевать их без напоминаний. +# Требуется: порты 80 и 443 открыты снаружи, домены указывают на эту машину. + +asr.netranking.ru, asr.talkscore.ru { + reverse_proxy 127.0.0.1:8756 { + # Реальный адрес клиента - иначе список разрешённых адресов + # увидит вместо него localhost и пустит кого угодно. + header_up X-Forwarded-For {remote_host} + } + + # Записи бывают большими, стандартного предела мало. + request_body { + max_size 500MB + } +} +""" + DOWNLOAD_BAT = """@echo off chcp 65001 >nul cd /d "%~dp0" @@ -144,6 +190,7 @@ def main() -> int: add_wheels(stage) if not args.skip_ffmpeg: add_ffmpeg(stage) + add_caddy(stage) add_app(stage) archive = OUT / "talkscore-asr-windows.zip" diff --git a/docs/HTTPS.md b/docs/HTTPS.md new file mode 100644 index 0000000..a645fa4 --- /dev/null +++ b/docs/HTTPS.md @@ -0,0 +1,71 @@ +# HTTPS для сервиса + +Без него токен и расшифровки разговоров идут по сети открытым текстом. +Схема простая: Caddy держит сертификаты и HTTPS, сервис сидит на localhost +и наружу напрямую не смотрит. + +## Что нужно от вас + +1. **Открыть порты 80 и 443** на роутере и в брандмауэре Windows. + Порт 80 нужен Let's Encrypt для проверки владения доменом, 443 - для самой + работы. Сейчас оба закрыты. +2. **Проверить домены.** `asr.netranking.ru` уже указывает на 90.157.58.23. + `asr.talkscore.ru` пока не резолвится - добавьте A-запись на тот же адрес. +3. **Порт 8756 можно закрыть снаружи** - после перехода на HTTPS он больше + не нужен из интернета. + +## Настройка + +В `config.toml` поменяйте две строки: + +```toml +[server] +# Сервис больше не смотрит наружу: снаружи стоит Caddy +host = "127.0.0.1" + +[security] +# Доверяем заголовку с адресом клиента только от своего же прокси +trust_proxy = "127.0.0.1" +``` + +**Зачем `trust_proxy`.** За прокси все запросы приходят с localhost, и список +разрешённых адресов увидел бы вместо клиента саму машину - то есть пустил бы +кого угодно. Caddy передаёт настоящий адрес в заголовке, и сервис его читает, +но **только когда запрос пришёл от доверенного прокси**. Иначе заголовок +подделал бы любой желающий. + +Домены прописаны в файле `Caddyfile` рядом со `start.bat`. Если понадобится +изменить список - правьте его. + +## Запуск + +Вместо `start.bat` запускайте **`start-https.bat`**: он поднимает сервис на +localhost и Caddy перед ним. + +При первом запуске Caddy получит сертификаты - это занимает несколько секунд +и требует, чтобы порт 80 был доступен снаружи. Дальше он продлевает их сам, +напоминаний не нужно. + +Проверка: + +``` +curl https://asr.netranking.ru/health +``` + +## Если сертификат не выдаётся + +Caddy пишет причину в своё окно. Частые случаи: + +- **порт 80 закрыт** - Let's Encrypt не может проверить домен; +- **домен не указывает на эту машину** - проверьте A-запись; +- **порт 80 занят** другим приложением (IIS, Skype) - освободите его. + +Пока сертификата нет, сервис доступен только по `http://localhost:8756` +с самой машины. + +## Автозапуск + +``` +schtasks /create /tn "talkscore-asr" /tr "C:\talkscore-asr\start-https.bat" ^ + /sc onstart /ru SYSTEM /rl HIGHEST +``` diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..91b2688 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,44 @@ +"""Тесты определения адреса клиента за обратным прокси. + +Caddy держит HTTPS и проксирует запросы на localhost. Без учёта +X-Forwarded-For список разрешённых адресов увидит вместо клиента localhost +и пустит кого угодно. Но доверять заголовку можно только от самого прокси - +иначе его подделает любой желающий. +""" +import pytest + +from app.security import client_address, parse_allowlist + + +class TestClientAddress: + def test_direct_connection_uses_peer(self): + assert client_address("203.0.113.5", None, parse_allowlist("")) == "203.0.113.5" + + def test_forwarded_header_used_from_trusted_proxy(self): + trusted = parse_allowlist("127.0.0.1") + assert client_address("127.0.0.1", "203.0.113.5", trusted) == "203.0.113.5" + + def test_forwarded_header_ignored_from_untrusted_peer(self): + """Подделка заголовка не должна давать доступ.""" + trusted = parse_allowlist("127.0.0.1") + assert client_address("198.51.100.9", "10.0.0.1", trusted) == "198.51.100.9" + + def test_takes_first_address_from_chain(self): + trusted = parse_allowlist("127.0.0.1") + assert client_address("127.0.0.1", "203.0.113.5, 70.41.3.18", trusted) == "203.0.113.5" + + def test_empty_header_falls_back_to_peer(self): + trusted = parse_allowlist("127.0.0.1") + assert client_address("127.0.0.1", "", trusted) == "127.0.0.1" + + def test_no_trusted_proxies_ignores_header(self): + """Пока прокси не настроен, заголовку верить нельзя вообще.""" + assert client_address("127.0.0.1", "203.0.113.5", parse_allowlist("")) == "127.0.0.1" + + def test_malformed_header_falls_back_to_peer(self): + trusted = parse_allowlist("127.0.0.1") + assert client_address("127.0.0.1", "не-адрес", trusted) == "127.0.0.1" + + def test_ipv6_proxy_supported(self): + trusted = parse_allowlist("::1") + assert client_address("::1", "203.0.113.5", trusted) == "203.0.113.5"