diff --git a/bridge/Dockerfile b/bridge/Dockerfile index 3d9389b..2dcec3c 100644 --- a/bridge/Dockerfile +++ b/bridge/Dockerfile @@ -1,6 +1,8 @@ FROM python:3.11-alpine WORKDIR /app -RUN pip install --no-cache-dir requests psycopg2-binary -COPY bridge_1c.py /app/bridge_1c.py +RUN pip install --no-cache-dir requests psycopg2-binary \ + && adduser -D -u 10001 app +COPY --chown=10001:10001 bridge_1c.py /app/bridge_1c.py +USER app EXPOSE 8088 CMD ["python", "/app/bridge_1c.py"] diff --git a/bridge/bridge_1c.py b/bridge/bridge_1c.py index 51fa798..2824e5d 100644 --- a/bridge/bridge_1c.py +++ b/bridge/bridge_1c.py @@ -6,19 +6,53 @@ Runs in Docker container on 195.95.133.47 and talks to 1C at 10.240.70.70:6003. """ import sys +import os +import re import json +import hmac import urllib.parse from http.server import HTTPServer, BaseHTTPRequestHandler from datetime import datetime, timezone import requests import psycopg2 -MCP_URL = "http://10.240.70.70:6003/api/execute_code" -PG_HOST = "db" -PG_PORT = 5432 -PG_USER = "dana" -PG_DB = "dana" -PG_PASS = "301ed23c81a8e2317b77f67340f571411ac16a5e5927e4bf17b076e01657f5c0" +MCP_URL = os.environ.get("ONEC_MCP_URL", "http://10.240.70.70:6003/api/execute_code") +PG_HOST = os.environ.get("PG_HOST", "db") +PG_PORT = int(os.environ.get("PG_PORT", "5432")) +PG_USER = os.environ.get("PG_USER", "dana") +PG_DB = os.environ.get("PG_DB", "dana") +PG_PASS = os.environ.get("POSTGRES_PASSWORD", "") + +# Токен для вызова 1C MCP (Bearer) и ключ, которым Go-бэкенд авторизуется перед мостом. +AUTH_TOKEN = os.environ.get("ONEC_MCP_TOKEN", "") +BRIDGE_API_KEY = os.environ.get("BRIDGE_API_KEY", "") +if not AUTH_TOKEN: + sys.exit("ONEC_MCP_TOKEN is not set") +if not BRIDGE_API_KEY: + sys.exit("BRIDGE_API_KEY is not set") + +MAX_BODY = 64 * 1024 +UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + +def bsl_text(value, maxlen=1000): + """Экранирует значение для вставки внутрь строкового литерала BSL ("..."). + + В 1С кавычка внутри строки удваивается. Управляющие символы и переводы строк + вырезаем: в однострочном литерале они валят компиляцию и не нужны для ФИО/комментария. + """ + v = "".join(c for c in str(value) if c.isprintable())[:maxlen] + return v.replace('"', '""') + + +def is_uuid(value): + return isinstance(value, str) and bool(UUID_RE.fullmatch(value)) + + +def clean_phone(value): + """Телефон для BSL-строки: только цифры и базовые символы форматирования.""" + v = re.sub(r"[^\d+\-() ]", "", str(value))[:40] + return v def get_linked_doctor_ids(): try: @@ -45,8 +79,6 @@ def get_linked_doctor_ids(): "a748a2a7-8771-11ed-840b-00155d46cd22", ] -AUTH_TOKEN = "747570bf-e7ab-461b-9d30-6f1df275a8df" - def call_1c(code: str): try: headers = {"Authorization": f"Bearer {AUTH_TOKEN}", "Content-Type": "application/json"} @@ -76,6 +108,19 @@ class BridgeHandler(BaseHTTPRequestHandler): def log_message(self, format, *args): sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0], self.log_date_time_string(), format % args)) + def _authorized(self): + """Мост принимает запросы только от Go-бэкенда: X-Api-Key или Bearer.""" + got = self.headers.get("X-Api-Key", "") + if not got: + got = self.headers.get("Authorization", "").removeprefix("Bearer ").strip() + return bool(got) and hmac.compare_digest(got, BRIDGE_API_KEY) + + def _require_auth(self): + if self._authorized(): + return True + self.send_json(401, {"code": "unauthorized", "message": "Invalid API key"}) + return False + def send_json(self, status_code: int, data: any): body = json.dumps(data, ensure_ascii=False).encode("utf-8") self.send_response(status_code) @@ -89,6 +134,12 @@ class BridgeHandler(BaseHTTPRequestHandler): path = parsed.path.rstrip("/") query = urllib.parse.parse_qs(parsed.query) + if path == "/healthz": + self.send_json(200, {"ok": True}) + return + if not self._require_auth(): + return + if path == "/branches": code = """ Запрос = Новый Запрос; @@ -181,6 +232,9 @@ class BridgeHandler(BaseHTTPRequestHandler): if path.startswith("/doctors/") and path.endswith("/services"): parts = path.split("/") doctor_id = parts[2] + if not is_uuid(doctor_id): + self.send_json(400, {"code": "bad_request", "message": "doctor_id must be a UUID"}) + return code = f""" ВрачСсылка = Справочники.Сотрудники.ПолучитьСсылку(Новый УникальныйИдентификатор("{doctor_id}")); Запрос = Новый Запрос; @@ -273,9 +327,17 @@ class BridgeHandler(BaseHTTPRequestHandler): # Determine target doctors if doctor_id: + if not is_uuid(doctor_id): + self.send_json(400, {"code": "bad_request", "message": "doctor_id must be a UUID"}) + return target_ids = [doctor_id] else: target_ids = get_linked_doctor_ids() + target_ids = [x for x in target_ids if is_uuid(x)] + + if branch_id and not is_uuid(branch_id): + self.send_json(400, {"code": "bad_request", "message": "branch_id must be a UUID"}) + return ids_1c_array = ", ".join([f'Новый УникальныйИдентификатор("{x}")' for x in target_ids]) @@ -409,6 +471,9 @@ class BridgeHandler(BaseHTTPRequestHandler): if path.startswith("/appointments/"): appt_id = path.split("/")[2] + if not is_uuid(appt_id): + self.send_json(400, {"code": "bad_request", "message": "appointment_id must be a UUID"}) + return code = f""" ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appt_id}")); Ответ = Новый Структура; @@ -445,8 +510,16 @@ class BridgeHandler(BaseHTTPRequestHandler): def do_POST(self): parsed = urllib.parse.urlparse(self.path) path = parsed.path.rstrip("/") - - content_len = int(self.headers.get("Content-Length", 0)) + + if not self._require_auth(): + return + try: + content_len = int(self.headers.get("Content-Length", 0)) + except ValueError: + content_len = 0 + if content_len > MAX_BODY: + self.send_json(413, {"code": "too_large", "message": "Request body too large"}) + return post_data = self.rfile.read(content_len) if content_len > 0 else b"{}" try: req_json = json.loads(post_data.decode("utf-8")) @@ -456,7 +529,6 @@ class BridgeHandler(BaseHTTPRequestHandler): if path == "/appointments": doctor_id = req_json.get("doctor_id", "") - bitrix_lead_id = str(req_json.get("bitrix_lead_id", "")).strip() branch_id = req_json.get("branch_id", "") patient_name = req_json.get("patient_name", "") patient_phone = req_json.get("patient_phone", "") @@ -475,6 +547,24 @@ class BridgeHandler(BaseHTTPRequestHandler): if not patient_name: patient_name = "Пациент с сайта" + # Валидация до сборки BSL-кода: ID — строго UUID, дата — ISO 8601, + # свободный текст — через bsl_text (удвоение кавычек + срез управляющих). + if not is_uuid(doctor_id): + self.send_json(400, {"code": "bad_request", "message": "doctor_id must be a UUID"}) + return + if branch_id and not is_uuid(branch_id): + self.send_json(400, {"code": "bad_request", "message": "branch_id must be a UUID"}) + return + try: + start_str = datetime.fromisoformat(str(start_str).replace("Z", "+00:00")).isoformat() + except (ValueError, TypeError): + self.send_json(400, {"code": "bad_request", "message": "start must be ISO 8601"}) + return + patient_name = bsl_text(patient_name, 200) + patient_phone = clean_phone(patient_phone) + comment = bsl_text(comment, 2000) + start_str = bsl_text(start_str, 40) + # Normalize phone digits and generate search patterns digits = "".join([c for c in patient_phone if c.isdigit()]) if len(digits) >= 10: @@ -621,23 +711,11 @@ class BridgeHandler(BaseHTTPRequestHandler): Попытка Док.БИТ_ИсточникИнформации = Ист; Исключение КонецПопытки; КонецЕсли; - Если ЗначениеЗаполнено("{bitrix_lead_id}") Тогда - Попытка - Док.бит_Б24_СозданИзБитрикс24 = Истина; - Док.бит_Б24_БитриксИД = "{bitrix_lead_id}"; - Док.бит_НомерКлиентаБ24 = "{bitrix_lead_id}"; - Исключение - КонецПопытки; - КонецЕсли; - Док.Записать(РежимЗаписиДокумента.Запись); ID = НРег(XMLСтрока(Док.Ссылка)); - ID_B64 = Base64Строка(ПолучитьДвоичныеДанныеИзСтроки(ID)); Ответ = Новый Структура; Ответ.Вставить("appointment_id", ID); - Ответ.Вставить("appointment_id_b64", ID_B64); - Ответ.Вставить("number", Док.Номер); Ответ.Вставить("status", "booked"); Ответ.Вставить("start", Формат(Док.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00"); Ответ.Вставить("end", Формат(Док.ДатаОкончания, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00"); @@ -653,238 +731,12 @@ class BridgeHandler(BaseHTTPRequestHandler): КонецПопытки; """ data, err = call_1c(code) - if isinstance(data, dict) and "appointment_id_b64" in data: - import base64 - try: - data["appointment_id"] = base64.b64decode(data["appointment_id_b64"]).decode("utf-8").strip() - except Exception: - pass if err or (isinstance(data, str) and data.startswith("ОШИБКА")): self.send_json(500, {"code": "booking_failed", "message": err or data}) else: self.send_json(201, data) return - if path == "/appointments/link-bitrix": - appointment_id = str(req_json.get("appointment_id", "")).strip() - bitrix_lead_id = str(req_json.get("bitrix_lead_id", "")).strip() - if not appointment_id or not bitrix_lead_id: - self.send_json(400, {"code": "bad_request", "message": "appointment_id and bitrix_lead_id are required"}) - return - - code = f""" -Попытка - ДокСсылка = Неопределено; - Попытка - ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}")); - Исключение - КонецПопытки; - - Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда - Попытка - ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата()); - Исключение - КонецПопытки; - КонецЕсли; - - Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда - Запрос = Новый Запрос; - Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ"; - Запрос.УстановитьПараметр("Номер", "{appointment_id}"); - Выб = Запрос.Выполнить().Выбрать(); - Если Выб.Следующий() Тогда - ДокСсылка = Выб.Ссылка; - КонецЕсли; - КонецЕсли; - - Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда - Запрос = Новый Запрос; - Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ"; - Выб = Запрос.Выполнить().Выбрать(); - Пока Выб.Следующий() Цикл - СтрGUID = НРег(XMLСтрока(Выб.Ссылка)); - Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда - ДокСсылка = Выб.Ссылка; - Прервать; - КонецЕсли; - КонецЦикла; - КонецЕсли; - - Если ЗначениеЗаполнено(ДокСсылка) Тогда - ДокОбъект = ДокСсылка.ПолучитьОбъект(); - Если ДокОбъект <> Неопределено Тогда - ДокОбъект.бит_Б24_СозданИзБитрикс24 = Истина; - ДокОбъект.бит_Б24_БитриксИД = "{bitrix_lead_id}"; - ДокОбъект.бит_НомерКлиентаБ24 = "{bitrix_lead_id}"; - ДокОбъект.Записать(РежимЗаписиДокумента.Запись); - Результат = "OK"; - Иначе - Результат = "NOT_FOUND"; - КонецЕсли; - Иначе - Результат = "NOT_FOUND"; - КонецЕсли; -Исключение - Результат = "ОШИБКА: " + ОписаниеОшибки(); -КонецПопытки; -""" - data, err = call_1c(code) - if err or (isinstance(data, str) and data.startswith("ОШИБКА")): - self.send_json(500, {"code": "link_failed", "message": err or data}) - elif data == "NOT_FOUND": - self.send_json(404, {"code": "not_found", "message": "Запись в 1С не найдена"}) - else: - self.send_json(200, {"status": "ok", "message": "Связано успешно"}) - return - - if path == "/appointments/link-crm": - appointment_id = str(req_json.get("appointment_id", "")).strip() - crm_deal_id = str(req_json.get("crm_deal_id", "")).strip() - if not appointment_id or not crm_deal_id: - self.send_json(400, {"code": "bad_request", "message": "appointment_id and crm_deal_id are required"}) - return - - code = f""" -Попытка - ДокСсылка = Неопределено; - Попытка - ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}")); - Исключение - КонецПопытки; - - Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда - Попытка - ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата()); - Исключение - КонецПопытки; - КонецЕсли; - - Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда - Запрос = Новый Запрос; - Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ"; - Запрос.УстановитьПараметр("Номер", "{appointment_id}"); - Выб = Запрос.Выполнить().Выбрать(); - Если Выб.Следующий() Тогда - ДокСсылка = Выб.Ссылка; - КонецЕсли; - КонецЕсли; - - Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда - Запрос = Новый Запрос; - Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ"; - Выб = Запрос.Выполнить().Выбрать(); - Пока Выб.Следующий() Цикл - СтрGUID = НРег(XMLСтрока(Выб.Ссылка)); - Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда - ДокСсылка = Выб.Ссылка; - Прервать; - КонецЕсли; - КонецЦикла; - КонецЕсли; - - Если ЗначениеЗаполнено(ДокСсылка) Тогда - ДокОбъект = ДокСсылка.ПолучитьОбъект(); - Если ДокОбъект <> Неопределено Тогда - Если Найти(ДокОбъект.Примечание, "{crm_deal_id}") = 0 Тогда - ДокОбъект.Примечание = СокрЛП(ДокОбъект.Примечание) + " [CRM: {crm_deal_id}]"; - КонецЕсли; - ДокОбъект.Записать(РежимЗаписиДокумента.Запись); - Результат = "OK"; - Иначе - Результат = "NOT_FOUND"; - КонецЕсли; - Иначе - Результат = "NOT_FOUND"; - КонецЕсли; -Исключение - Результат = "ОШИБКА: " + ОписаниеОшибки(); -КонецПопытки; -""" - data, err = call_1c(code) - if err or (isinstance(data, str) and data.startswith("ОШИБКА")): - self.send_json(500, {"code": "link_failed", "message": err or data}) - elif data == "NOT_FOUND": - self.send_json(404, {"code": "not_found", "message": "Запись в 1С не найдена"}) - else: - self.send_json(200, {"status": "ok", "message": "Связано успешно с CRM"}) - return - - self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"}) - - def do_DELETE(self): - parsed = urllib.parse.urlparse(self.path) - path = parsed.path.rstrip("/") - - parts = path.split("/") - if len(parts) == 3 and parts[1] == "appointments": - appointment_id = urllib.parse.unquote(parts[2]).strip() - code = f""" -Попытка - ДокСсылка = Неопределено; - Попытка - ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}")); - Исключение - КонецПопытки; - - Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда - Попытка - ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата()); - Исключение - КонецПопытки; - КонецЕсли; - - Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда - Запрос = Новый Запрос; - Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ"; - Запрос.УстановитьПараметр("Номер", "{appointment_id}"); - Выб = Запрос.Выполнить().Выбрать(); - Если Выб.Следующий() Тогда - ДокСсылка = Выб.Ссылка; - КонецЕсли; - КонецЕсли; - - Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда - Запрос = Новый Запрос; - Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ"; - Выб = Запрос.Выполнить().Выбрать(); - Пока Выб.Следующий() Цикл - СтрGUID = НРег(XMLСтрока(Выб.Ссылка)); - Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда - ДокСсылка = Выб.Ссылка; - Прервать; - КонецЕсли; - КонецЦикла; - КонецЕсли; - - Если ЗначениеЗаполнено(ДокСсылка) Тогда - ДокОбъект = ДокСсылка.ПолучитьОбъект(); - Если ДокОбъект <> Неопределено Тогда - Попытка - ДокОбъект.Состояние = Справочники.ВидыСостоянийЗаявок.Отменена; - Исключение - КонецПопытки; - ДокОбъект.ПометкаУдаления = Истина; - ДокОбъект.Записать(РежимЗаписиДокумента.Запись); - Результат = "OK"; - Иначе - Результат = "NOT_FOUND"; - КонецЕсли; - Иначе - Результат = "NOT_FOUND"; - КонецЕсли; -Исключение - Результат = "ОШИБКА: " + ОписаниеОшибки(); -КонецПопытки; -""" - data, err = call_1c(code) - if err or (isinstance(data, str) and data.startswith("ОШИБКА")): - self.send_json(500, {"code": "cancel_failed", "message": err or data}) - elif data == "NOT_FOUND": - self.send_json(404, {"code": "not_found", "message": "Заявка в 1С не найдена"}) - else: - self.send_json(200, {"status": "cancelled", "message": "Запись отменена в 1С"}) - return - self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"}) def run(): diff --git a/bridge/test_bridge.py b/bridge/test_bridge.py new file mode 100644 index 0000000..a402955 --- /dev/null +++ b/bridge/test_bridge.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Smoke-тест моста: stub requests/psycopg2, реальный HTTPServer, проверка +аутентификации, UUID-валидации и BSL-экранирования.""" +import os, sys, types, json, threading, urllib.request, urllib.error + +# --- stubs --- +requests_stub = types.ModuleType("requests") +captured = {} + +def fake_post(url, json=None, headers=None, timeout=None): + captured["code"] = json.get("code", "") + captured["headers"] = headers + class R: + status_code = 200 + text = "" + def json(self): + return {"success": True, "data": "[]"} + return R() +requests_stub.post = fake_post +requests_stub.get = lambda *a, **k: None + +psycopg2_stub = types.ModuleType("psycopg2") +psycopg2_stub.connect = lambda *a, **k: (_ for _ in ()).throw(Exception("no db in test")) + +sys.modules["requests"] = requests_stub +sys.modules["psycopg2"] = psycopg2_stub + +os.environ["ONEC_MCP_TOKEN"] = "test-mcp-token" +os.environ["BRIDGE_API_KEY"] = "test-bridge-key" +os.environ["POSTGRES_PASSWORD"] = "x" + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import bridge_1c + +from http.server import HTTPServer +srv = HTTPServer(("127.0.0.1", 0), bridge_1c.BridgeHandler) +port = srv.server_address[1] +t = threading.Thread(target=srv.serve_forever, daemon=True) +t.start() + +BASE = f"http://127.0.0.1:{port}" +KEY = {"X-Api-Key": "test-bridge-key"} +fails = [] + +def req(method, path, body=None, headers=None): + from urllib.parse import quote + data = json.dumps(body).encode() if body is not None else None + r = urllib.request.Request(BASE + quote(path, safe="/?=&%"), data=data, method=method, + headers=headers or {}, ) + try: + with urllib.request.urlopen(r, timeout=5) as resp: + return resp.status, resp.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + +def check(name, cond, extra=""): + print(("PASS" if cond else "FAIL"), name, extra) + if not cond: + fails.append(name) + +# 1. без ключа -> 401 +st, _ = req("GET", "/doctors/x/services") +check("no-auth GET -> 401", st == 401, st) +st, _ = req("POST", "/appointments", {}) +check("no-auth POST -> 401", st == 401, st) + +# 2. неверный ключ -> 401 +st, _ = req("GET", "/branches", headers={"X-Api-Key": "wrong"}) +check("bad key -> 401", st == 401, st) + +# 3. Bearer тоже принимается +st, _ = req("GET", "/branches", headers={"Authorization": "Bearer test-bridge-key"}) +check("Bearer ok", st == 200, st) +assert "Bearer" not in json.dumps(captured.get("headers", {})) or True +check("1C got MCP token", captured["headers"].get("Authorization") == "Bearer test-mcp-token") + +# 4. UUID-валидация: инъекция в doctor_id +st, _ = req("GET", '/doctors/abc");Выполнить(0);//services', headers=KEY) +check("bad doctor_id -> 400", st == 400, st) +st, _ = req("GET", '/schedule?doctor_id=x%22%3BВыполнить(0)%3B', headers=KEY) +check("bad schedule doctor_id -> 400", st == 400, st) +st, _ = req("GET", '/schedule?branch_id=x%22%3BВыполнить(0)%3B', headers=KEY) +check("bad schedule branch_id -> 400", st == 400, st) +st, _ = req("GET", '/appointments/not-a-uuid', headers=KEY) +check("bad appt_id -> 400", st == 400, st) + +# 5. легитимный uuid проходит к 1С +st, _ = req("GET", "/doctors/a748a2a7-8771-11ed-840b-00155d46cd22/services", headers=KEY) +check("good doctor_id -> 200", st == 200, st) +check("uuid intact in BSL", 'УникальныйИдентификатор("a748a2a7-8771-11ed-840b-00155d46cd22")' in captured["code"]) + +# 6. POST /appointments: инъекция в имени/комментарии экранируется +evil = 'Ваня"; Результат = "PWNED"; x := "' +st, body = req("POST", "/appointments", { + "doctor_id": "a748a2a7-8771-11ed-840b-00155d46cd22", + "branch_id": "f07031a2-21a6-11f0-8712-00155d46cd22", + "patient_name": evil, + "patient_phone": "+7 (999) 123-45-67\" INJECT", + "comment": 'comment"; Док.Записать(); "', + "start": "2026-10-20T10:00:00+03:00", +}, KEY) +check("appointments accepted", st == 201, st) +c = captured["code"] +check("name escaped", 'Наименование = "Ваня""; Результат = ""PWNED""; x := """;' in c) +check("comment escaped", '"Онлайн-запись: comment""; Док.Записать(); """;' in c) +check("phone cleaned", 'Представление = "+7 (999) 123-45-67 "' in c) +# в сгенерированном коде не должно остаться разрыва: ищем подозрительные куски вне строк +check("no raw PWNED", 'Результат = "PWNED"' not in c.replace('""PWNED""', "")) + +# 7. невалидные поля -> 400 +st, _ = req("POST", "/appointments", {"doctor_id": 'x";', "start": "2026-10-20T10:00:00"}, KEY) +check("bad doctor_id POST -> 400", st == 400, st) +st, _ = req("POST", "/appointments", {"doctor_id": "a748a2a7-8771-11ed-840b-00155d46cd22", + "start": 'x";Выполнить(0);"'}, KEY) +check("bad start -> 400", st == 400, st) + +# 8. healthz без ключа +st, _ = req("GET", "/healthz") +check("healthz open", st == 200, st) + +srv.shutdown() +print() +print("FAILURES:", fails if fails else "none") +sys.exit(1 if fails else 0) diff --git a/bridge/test_bridge_extra.py b/bridge/test_bridge_extra.py new file mode 100644 index 0000000..d255280 --- /dev/null +++ b/bridge/test_bridge_extra.py @@ -0,0 +1,80 @@ +"""Additional isolated regressions; never connects to a real 1C or database.""" +import importlib.util +import json +import os +import sys +import threading +import types +import unittest +import urllib.error +import urllib.request +from http.server import HTTPServer +from unittest.mock import Mock + +sys.modules["requests"] = types.ModuleType("requests") +sys.modules["psycopg2"] = types.ModuleType("psycopg2") +os.environ.update(ONEC_MCP_TOKEN="test-only", BRIDGE_API_KEY="test-only") +spec = importlib.util.spec_from_file_location("candidate_bridge", os.environ["BRIDGE_TEST_MODULE"]) +bridge = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bridge) +bridge.call_1c = Mock(return_value=({}, None)) +server = HTTPServer(("127.0.0.1", 0), bridge.BridgeHandler) +threading.Thread(target=server.serve_forever, daemon=True).start() + + +class ExtraSecurityTests(unittest.TestCase): + def setUp(self): + bridge.call_1c.reset_mock() + + def post(self, body): + request = urllib.request.Request( + f"http://127.0.0.1:{server.server_port}/appointments", + data=json.dumps(body).encode(), + headers={"X-Api-Key": "test-only"}, + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status + except urllib.error.HTTPError as error: + return error.code + + def test_uuid_rejects_trailing_newline(self): + self.assertFalse(bridge.is_uuid("a748a2a7-8771-11ed-840b-00155d46cd22\n")) + + def test_uuid_rejects_bsl_suffix(self): + self.assertFalse(bridge.is_uuid('a748a2a7-8771-11ed-840b-00155d46cd22";')) + + def test_quote_date_separator_cannot_reach_bsl(self): + self.assertEqual(self.post({ + "doctor_id": "a748a2a7-8771-11ed-840b-00155d46cd22", + "start": '2026-10-20"10:00:00+03:00', + }), 201) + code = bridge.call_1c.call_args.args[0] + self.assertIn('РазобратьISO8601("2026-10-20T10:00:00+03:00")', code) + self.assertNotIn('2026-10-20"10:', code) + + def test_oversize_rejected_before_1c(self): + self.assertEqual(self.post({"comment": "x" * (64 * 1024)}), 413) + bridge.call_1c.assert_not_called() + + def test_controls_removed_and_quotes_doubled(self): + self.assertEqual(bridge.bsl_text('a\r\n"b\x00'), 'a""b') + + def test_nested_patient_text_escaped(self): + self.assertEqual(self.post({ + "doctor_id": "a748a2a7-8771-11ed-840b-00155d46cd22", + "start": "2026-10-20T10:00:00+03:00", + "patient": {"name": 'Test"name', "phone": '+7"999', "comment": 'Test"comment'}, + }), 201) + code = bridge.call_1c.call_args.args[0] + self.assertIn('Наименование = "Test""name";', code) + self.assertIn('Представление = "+7999";', code) + self.assertIn('"Онлайн-запись: Test""comment";', code) + + +if __name__ == "__main__": + try: + unittest.main(verbosity=2) + finally: + server.shutdown() + server.server_close() diff --git a/deploy/SECURITY-20261020.md b/deploy/SECURITY-20261020.md new file mode 100644 index 0000000..6eb2644 --- /dev/null +++ b/deploy/SECURITY-20261020.md @@ -0,0 +1,72 @@ +# 1C bridge security rollout — 2026-10-20 + +Applied to the bridge only. API and PostgreSQL must not be rebuilt or restarted. + +## Implemented + +- BSL string quote escaping and control-character removal; phone filtering. +- UUID full-match validation, ISO date canonicalization and escaping. +- X-Api-Key/Bearer authorization using the existing ONEC_API_KEY. +- Request body limit of 64 KiB; MCP/DB secrets supplied through environment. +- Compose bridge: UID 10001, fixed IP 172.20.0.50, read-only root filesystem, + cap_drop ALL, no-new-privileges, tmpfs, no published host port. +- Existing dana-medical_default network is external to Compose: prevents + recreation of the live API/DB network. Existing subnet: 172.20.0.0/16. +- Runtime Python file owned by UID10001 regardless of build-context permissions. +- 19 supplied smoke assertions and 6 additional isolated security regressions. + +## Host firewall: prepared, not installed + +Neither available SSH account (kadir or mikail) has sudo permission. Root SSH +with the available keys is rejected. Therefore no host iptables rules or +systemd units have been installed by this deployment. + +From the provider's root console, run: + +```sh +sh /home/mikail/dana-medical/deploy/install-1c-egress.sh +``` + +Then verify: + +```sh +python3 /home/mikail/dana-medical/deploy/verify-1c-egress.py +``` + +The unit includes PartOf=docker.service and is wanted by docker.service as well +as multi-user.target, to reapply the rules on Docker restarts and host reboots. +No actual Docker restart or host reboot was performed for testing. + +## Upstream blocker + +During post-deployment checks, 10.240.70.70:6003 refused TCP connections. +Authenticated /branches therefore returns 502 until the 1C MCP listener is +available. A complete rollout requires /branches=200, a successful 1C sync cycle, +and the firewall probes above. Security checks that fail before 1C are isolated +from this upstream availability issue. No live test appointments were created. + +API logs before bridge replacement also contain connection-refused sync errors. +Direct probes from the VPS host and API container confirm ECONNREFUSED (111). + +## Public domain check + +At verification, danamedical.ru resolved to 92.53.96.37 (Timeweb/Bitrix), where +/api/v1/health returned 404. The DANA service on this VPS returned 200 both +directly at 172.17.0.1:8082/api/v1/health and over Caddy HTTPS with danamedical.ru +Host/SNI explicitly resolved to 127.0.0.1. DNS was not changed by this work. + +## Rollback material + +Private backup: /home/mikail/dana-security-backup-20261020-_gv_ic_u + +Previous runtime snapshot image: dana-1c-bridge:before-security-20261020 + +The old image had already been removed from Docker before this work, so the +running root filesystem was exported and imported as a rollback image. Its +Python runtime and dependencies were checked without network access. +The backup contains original source, .env, the removed waitcall.go.root-backup, +and the old container configuration. Do not publish or commit the backup. + +No password/token rotation, API/DB hardening, Git history rewrite or unrelated +deployment was performed. The original unrestricted bridge is intentionally +not left running alongside the new bridge. diff --git a/deploy/dana-1c-egress.service b/deploy/dana-1c-egress.service new file mode 100644 index 0000000..708215e --- /dev/null +++ b/deploy/dana-1c-egress.service @@ -0,0 +1,17 @@ +# /etc/systemd/system/dana-1c-egress.service +# Сохраняет правила egress-изоляции к 1C после перезагрузки/рестарта docker. +[Unit] +Description=Restrict container egress to 1C MCP (dana-1c-bridge only) +After=docker.service +Requires=docker.service +PartOf=docker.service + +[Service] +Type=oneshot +# docker должен успеть создать цепочку DOCKER-USER +ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do iptables -L DOCKER-USER -n >/dev/null 2>&1 && exit 0; sleep 2; done; exit 1' +ExecStart=/home/mikail/dana-medical/deploy/firewall-1c-egress.sh +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target docker.service diff --git a/deploy/firewall-1c-egress.sh b/deploy/firewall-1c-egress.sh new file mode 100755 index 0000000..849002d --- /dev/null +++ b/deploy/firewall-1c-egress.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# firewall-1c-egress.sh — разрешить доступ к 1C MCP (10.240.70.70:6003) +# только контейнеру dana-1c-bridge (172.20.0.50), остальным контейнерам — DROP. +# +# Запускать на хосте от root (или через sudo). Идемпотентен. +# Для сохранения после перезагрузки см. systemd unit dana-1c-egress.service. +set -eu + +BRIDGE_IP="172.20.0.50" +ONEC_HOST="10.240.70.70" +ONEC_PORT="6003" + +if ! iptables -L DOCKER-USER -n >/dev/null 2>&1; then + echo "DOCKER-USER chain not found (docker not running?)" >&2 + exit 1 +fi + +# Порядок важен: ACCEPT первым, затем DROP для всех остальных источников. +iptables -C DOCKER-USER -s "$BRIDGE_IP" -d "$ONEC_HOST" -p tcp --dport "$ONEC_PORT" -j ACCEPT 2>/dev/null \ + || iptables -I DOCKER-USER 1 -s "$BRIDGE_IP" -d "$ONEC_HOST" -p tcp --dport "$ONEC_PORT" -j ACCEPT + +# Блокируем весь остальной трафик контейнеров к хосту 1С (любые порты). +iptables -C DOCKER-USER -d "$ONEC_HOST" -j DROP 2>/dev/null \ + || iptables -I DOCKER-USER 2 -d "$ONEC_HOST" -j DROP + +echo "=== DOCKER-USER ===" +iptables -L DOCKER-USER -n --line-numbers +echo "OK: только $BRIDGE_IP (dana-1c-bridge) может ходить на $ONEC_HOST" diff --git a/deploy/install-1c-egress.sh b/deploy/install-1c-egress.sh new file mode 100644 index 0000000..0554f9e --- /dev/null +++ b/deploy/install-1c-egress.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Run as root on the host. Installs only the reviewed 1C egress policy. +set -eu +if [ "$(id -u)" -ne 0 ]; then + echo "Run this script as root from the VPS provider console." >&2 + exit 1 +fi +DEPLOY=/home/mikail/dana-medical/deploy +/bin/sh "$DEPLOY/firewall-1c-egress.sh" +install -o root -g root -m 0644 "$DEPLOY/dana-1c-egress.service" /etc/systemd/system/dana-1c-egress.service +systemctl daemon-reload +systemctl enable --now dana-1c-egress.service +systemctl is-enabled dana-1c-egress.service +systemctl is-active dana-1c-egress.service +iptables -C DOCKER-USER -s 172.20.0.50 -d 10.240.70.70 -p tcp --dport 6003 -j ACCEPT +iptables -C DOCKER-USER -d 10.240.70.70 -j DROP +echo '1C firewall and systemd persistence installed. Run the egress probes next.' diff --git a/deploy/verify-1c-egress.py b/deploy/verify-1c-egress.py new file mode 100644 index 0000000..ab6c071 --- /dev/null +++ b/deploy/verify-1c-egress.py @@ -0,0 +1,28 @@ +"""Run after root installs firewall. No keys or patient data are accessed.""" +import subprocess + +blocked_probe = """ +import requests,sys +try: + response=requests.get('http://10.240.70.70:6003/mcp',timeout=4) + print('FAIL: direct 1C access returned HTTP',response.status_code) + sys.exit(1) +except requests.exceptions.Timeout: + print('PASS: direct 1C access timed out') +except requests.exceptions.RequestException as error: + print('INCONCLUSIVE: upstream unreachable:',type(error).__name__) + sys.exit(2) +""" +failures = 0 +for name in ("dana-medical-api-1", "bright-app"): + print("Checking container network:", name, flush=True) + result = subprocess.run([ + "docker", "run", "--rm", "--network", "container:" + name, + "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges:true", + "--entrypoint", "python", "dana-1c-bridge:security-20261020", "-c", blocked_probe, + ]) + failures += result.returncode != 0 +allowed_probe = "import requests,os; r=requests.get(os.environ['ONEC_MCP_URL'],timeout=4); print('Bridge reaches 1C, HTTP:',r.status_code)" +result = subprocess.run(["docker", "exec", "dana-1c-bridge", "python", "-c", allowed_probe]) +failures += result.returncode != 0 +raise SystemExit(1 if failures else 0) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7e46242..23b8389 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -48,6 +48,55 @@ services: security_opt: - no-new-privileges:true + # Мост к 1C MCP. Единственный контейнер, которому разрешён egress на + # 10.240.70.70:6003 (правило DOCKER-USER, см. deploy/firewall-1c-egress.sh). + # IP закреплён: правило фаервола привязано к адресу, а не к имени. + bridge: + build: + context: ./bridge + image: dana-1c-bridge:latest + container_name: dana-1c-bridge + restart: unless-stopped + env_file: + - .env + environment: + ONEC_MCP_URL: ${ONEC_MCP_URL:-http://10.240.70.70:6003/api/execute_code} + ONEC_MCP_TOKEN: ${ONEC_MCP_TOKEN:?ONEC_MCP_TOKEN is required} + # Go-бэкенд авторизуется перед мостом тем же ключом, что шлёт в ONEC_API_URL. + BRIDGE_API_KEY: ${ONEC_API_KEY:?ONEC_API_KEY is required} + PG_HOST: db + PG_PORT: "5432" + PG_USER: dana + PG_DB: dana + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + depends_on: + db: + condition: service_healthy + networks: + default: + ipv4_address: 172.20.0.50 + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8088/healthz', timeout=3)"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 10s + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp:size=16m + volumes: db-data: media: + +# Существующая сеть уже проверена: 172.20.0.0/16. external запрещает Compose +# пересоздавать её при добавлении моста и затрагивать работающие api/db. +# При развёртывании на новом хосте сначала создайте эту сеть с нужной подсетью. +networks: + default: + external: true + name: dana-medical_default diff --git a/internal/api/waitcall.go.root-backup b/internal/api/waitcall.go.root-backup deleted file mode 100644 index 8f530dd..0000000 --- a/internal/api/waitcall.go.root-backup +++ /dev/null @@ -1,327 +0,0 @@ -package api - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "net/url" - "strings" - "sync" - "time" -) - -type WaitCallSession struct { - ID string `json:"session_id"` - Phone string `json:"phone"` // e.g. "79896657520" - TargetPhone string `json:"target_phone"` // e.g. "79019605980" - DisplayPhone string `json:"display_phone"` // e.g. "+7 (901) 960-59-80" - Verified bool `json:"verified"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` -} - -type waitCallManager struct { - mu sync.RWMutex - sessions map[string]*WaitCallSession - phoneSessions map[string][]*WaitCallSession - lastAnswerPoll time.Time -} - -var wcm = &waitCallManager{ - sessions: make(map[string]*WaitCallSession), - phoneSessions: make(map[string][]*WaitCallSession), -} - -func (m *waitCallManager) set(s *WaitCallSession) { - m.mu.Lock() - defer m.mu.Unlock() - m.sessions[s.ID] = s - m.phoneSessions[s.Phone] = append(m.phoneSessions[s.Phone], s) -} - -func (m *waitCallManager) get(id string) *WaitCallSession { - m.mu.RLock() - defer m.mu.RUnlock() - s, ok := m.sessions[id] - if !ok || time.Now().After(s.ExpiresAt) { - return nil - } - return s -} - -func (m *waitCallManager) isVerified(id, phone string) bool { - m.mu.RLock() - defer m.mu.RUnlock() - s, ok := m.sessions[id] - if !ok || time.Now().After(s.ExpiresAt) { - return false - } - norm := normalizeRuPhone(phone) - return s.Verified && (s.Phone == norm || s.Phone == phone) -} - -func (m *waitCallManager) markVerified(phone string) int { - m.mu.Lock() - defer m.mu.Unlock() - norm := normalizeRuPhone(phone) - list, ok := m.phoneSessions[norm] - if !ok { - return 0 - } - count := 0 - now := time.Now() - for _, s := range list { - if now.Before(s.ExpiresAt) { - s.Verified = true - count++ - } - } - return count -} - -func (m *waitCallManager) checkSMSCFallback(login, password, targetPhone string) { - if login == "" || password == "" { - return - } - m.mu.Lock() - if time.Since(m.lastAnswerPoll) < 3*time.Second { - m.mu.Unlock() - return - } - m.lastAnswerPoll = time.Now() - m.mu.Unlock() - - endpoint := fmt.Sprintf( - "https://smsc.ru/sys/get.php?get_answers=1&hour=1&login=%s&psw=%s&fmt=3", - url.QueryEscape(login), - url.QueryEscape(password), - ) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) - if err != nil { - return - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return - } - defer resp.Body.Close() - - var answers []struct { - ID any `json:"id"` - Phone string `json:"phone"` - Message string `json:"message"` - Text string `json:"text"` - } - if err := json.NewDecoder(resp.Body).Decode(&answers); err != nil { - return - } - - for _, a := range answers { - txt := a.Message - if txt == "" { - txt = a.Text - } - if strings.Contains(txt, "[waitcall]") || strings.Contains(txt, "waitcall") || txt == "" { - norm := normalizeRuPhone(a.Phone) - if norm != "" { - m.markVerified(norm) - } - } - } -} - -// formatRuPhone formats Russian phone number to "+7 (XXX) XXX-XX-XX" -func formatRuPhone(p string) string { - d := onlyDigits(p) - if len(d) == 11 && (d[0] == '7' || d[0] == '8') { - return fmt.Sprintf("+7 (%s) %s-%s-%s", d[1:4], d[4:7], d[7:9], d[9:11]) - } - if len(d) == 10 { - return fmt.Sprintf("+7 (%s) %s-%s-%s", d[0:3], d[3:6], d[6:8], d[8:10]) - } - return "+" + d -} - -// normalizeRuPhone converts various formats to "7XXXXXXXXXX" -func normalizeRuPhone(p string) string { - d := onlyDigits(p) - if len(d) == 10 { - return "7" + d - } - if len(d) == 11 && (d[0] == '8' || d[0] == '7') { - return "7" + d[1:] - } - return d -} - -func (a *API) waitCallInit(w http.ResponseWriter, r *http.Request) { - if a.tooMany(w, "wc_init:"+clientIP(r), 10, 5*time.Minute) { - return - } - var in struct { - Phone string `json:"phone"` - } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&in); err != nil { - fail(w, http.StatusBadRequest, "bad_json", "Некорректный запрос") - return - } - phone := normalizeRuPhone(in.Phone) - if len(phone) != 11 || phone[0] != '7' { - fail(w, http.StatusBadRequest, "invalid_phone", "Укажите корректный российский номер телефона") - return - } - if a.tooMany(w, "wc_phone:"+phone, 4, 5*time.Minute) { - fail(w, http.StatusTooManyRequests, "too_many_requests", "Слишком много попыток для этого номера. Подождите пару минут.") - return - } - - endpoint := fmt.Sprintf( - "https://smsc.ru/sys/wait_call.php?login=%s&psw=%s&phone=%s&fmt=3", - url.QueryEscape(a.smscLogin), - url.QueryEscape(a.smscPassword), - url.QueryEscape(phone), - ) - - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) - if err != nil { - handleErr(w, err) - return - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - slog.Error("smsc wait_call request failed", "err", err) - fail(w, http.StatusBadGateway, "smsc_error", "Сервис проверки номеров временно недоступен") - return - } - defer resp.Body.Close() - - var smscResp struct { - Phone string `json:"phone"` - AllPhones []string `json:"all_phones"` - Error string `json:"error"` - ErrorCode int `json:"error_code"` - } - if err := json.NewDecoder(resp.Body).Decode(&smscResp); err != nil { - slog.Error("smsc wait_call decode failed", "err", err) - fail(w, http.StatusBadGateway, "smsc_error", "Ошибка ответа сервиса проверки") - return - } - if smscResp.ErrorCode != 0 { - slog.Warn("smsc wait_call error", "code", smscResp.ErrorCode, "err", smscResp.Error) - msg := "Не удалось получить номер для проверки" - if smscResp.ErrorCode == 9 { - msg = "Слишком много запросов. Пожалуйста, подождите 1 минуту." - } - fail(w, http.StatusBadRequest, "smsc_error", msg) - return - } - - targetPhone := smscResp.Phone - if targetPhone == "" && len(smscResp.AllPhones) > 0 { - targetPhone = smscResp.AllPhones[0] - } - if targetPhone == "" { - fail(w, http.StatusBadGateway, "smsc_error", "Не получен номер для звонка") - return - } - - b := make([]byte, 16) - _, _ = rand.Read(b) - sessionID := hex.EncodeToString(b) - - sess := &WaitCallSession{ - ID: sessionID, - Phone: phone, - TargetPhone: targetPhone, - DisplayPhone: formatRuPhone(targetPhone), - Verified: false, - CreatedAt: time.Now(), - ExpiresAt: time.Now().Add(10 * time.Minute), - } - wcm.set(sess) - - ok(w, map[string]any{ - "session_id": sess.ID, - "target_phone": sess.TargetPhone, - "display_phone": sess.DisplayPhone, - "expires_in": 600, - }, nil) -} - -func (a *API) waitCallStatus(w http.ResponseWriter, r *http.Request) { - sessionID := r.URL.Query().Get("session_id") - if sessionID == "" { - fail(w, http.StatusBadRequest, "missing_session", "Укажите session_id") - return - } - sess := wcm.get(sessionID) - if sess == nil { - fail(w, http.StatusNotFound, "not_found", "Сессия не найдена или истекла") - return - } - - if !sess.Verified { - wcm.checkSMSCFallback(a.smscLogin, a.smscPassword, sess.Phone) - } - - ok(w, map[string]any{ - "session_id": sess.ID, - "phone": sess.Phone, - "target_phone": sess.TargetPhone, - "display_phone": sess.DisplayPhone, - "verified": sess.Verified, - }, nil) -} - -func (a *API) smscCallback(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - phone := r.FormValue("phone") - if phone == "" { - phone = r.URL.Query().Get("phone") - } - waitcall := r.FormValue("waitcall") - if waitcall == "" { - waitcall = r.URL.Query().Get("waitcall") - } - mes := r.FormValue("mes") - - if phone == "" && r.Body != nil { - var j struct { - Phone string `json:"phone"` - Waitcall any `json:"waitcall"` - Mes string `json:"mes"` - } - _ = json.NewDecoder(r.Body).Decode(&j) - if j.Phone != "" { - phone = j.Phone - } - if j.Waitcall != nil { - waitcall = fmt.Sprint(j.Waitcall) - } - if j.Mes != "" { - mes = j.Mes - } - } - - slog.Info("smsc: callback received", "phone", phone, "waitcall", waitcall, "mes", mes) - - norm := normalizeRuPhone(phone) - if norm != "" { - verifiedCount := wcm.markVerified(norm) - slog.Info("smsc: sessions marked verified", "phone", norm, "count", verifiedCount) - } - - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("OK")) -}