fix(security): BSL injection escaping, bridge auth, secrets to env, egress isolation to 1C

This commit is contained in:
Dana Medical Developer 2026-10-20 08:36:30 +00:00
parent 2c4e374c99
commit 050dc30d92
11 changed files with 520 additions and 578 deletions

View File

@ -1,6 +1,8 @@
FROM python:3.11-alpine FROM python:3.11-alpine
WORKDIR /app WORKDIR /app
RUN pip install --no-cache-dir requests psycopg2-binary RUN pip install --no-cache-dir requests psycopg2-binary \
COPY bridge_1c.py /app/bridge_1c.py && adduser -D -u 10001 app
COPY --chown=10001:10001 bridge_1c.py /app/bridge_1c.py
USER app
EXPOSE 8088 EXPOSE 8088
CMD ["python", "/app/bridge_1c.py"] CMD ["python", "/app/bridge_1c.py"]

View File

@ -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 sys
import os
import re
import json import json
import hmac
import urllib.parse import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime, timezone from datetime import datetime, timezone
import requests import requests
import psycopg2 import psycopg2
MCP_URL = "http://10.240.70.70:6003/api/execute_code" MCP_URL = os.environ.get("ONEC_MCP_URL", "http://10.240.70.70:6003/api/execute_code")
PG_HOST = "db" PG_HOST = os.environ.get("PG_HOST", "db")
PG_PORT = 5432 PG_PORT = int(os.environ.get("PG_PORT", "5432"))
PG_USER = "dana" PG_USER = os.environ.get("PG_USER", "dana")
PG_DB = "dana" PG_DB = os.environ.get("PG_DB", "dana")
PG_PASS = "301ed23c81a8e2317b77f67340f571411ac16a5e5927e4bf17b076e01657f5c0" 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(): def get_linked_doctor_ids():
try: try:
@ -45,8 +79,6 @@ def get_linked_doctor_ids():
"a748a2a7-8771-11ed-840b-00155d46cd22", "a748a2a7-8771-11ed-840b-00155d46cd22",
] ]
AUTH_TOKEN = "747570bf-e7ab-461b-9d30-6f1df275a8df"
def call_1c(code: str): def call_1c(code: str):
try: try:
headers = {"Authorization": f"Bearer {AUTH_TOKEN}", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {AUTH_TOKEN}", "Content-Type": "application/json"}
@ -76,6 +108,19 @@ class BridgeHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args): def log_message(self, format, *args):
sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0], self.log_date_time_string(), 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): def send_json(self, status_code: int, data: any):
body = json.dumps(data, ensure_ascii=False).encode("utf-8") body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(status_code) self.send_response(status_code)
@ -89,6 +134,12 @@ class BridgeHandler(BaseHTTPRequestHandler):
path = parsed.path.rstrip("/") path = parsed.path.rstrip("/")
query = urllib.parse.parse_qs(parsed.query) 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": if path == "/branches":
code = """ code = """
Запрос = Новый Запрос; Запрос = Новый Запрос;
@ -181,6 +232,9 @@ class BridgeHandler(BaseHTTPRequestHandler):
if path.startswith("/doctors/") and path.endswith("/services"): if path.startswith("/doctors/") and path.endswith("/services"):
parts = path.split("/") parts = path.split("/")
doctor_id = parts[2] 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""" code = f"""
ВрачСсылка = Справочники.Сотрудники.ПолучитьСсылку(Новый УникальныйИдентификатор("{doctor_id}")); ВрачСсылка = Справочники.Сотрудники.ПолучитьСсылку(Новый УникальныйИдентификатор("{doctor_id}"));
Запрос = Новый Запрос; Запрос = Новый Запрос;
@ -273,9 +327,17 @@ class BridgeHandler(BaseHTTPRequestHandler):
# Determine target doctors # Determine target doctors
if doctor_id: 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] target_ids = [doctor_id]
else: else:
target_ids = get_linked_doctor_ids() 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]) ids_1c_array = ", ".join([f'Новый УникальныйИдентификатор("{x}")' for x in target_ids])
@ -409,6 +471,9 @@ class BridgeHandler(BaseHTTPRequestHandler):
if path.startswith("/appointments/"): if path.startswith("/appointments/"):
appt_id = path.split("/")[2] 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""" code = f"""
ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appt_id}")); ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appt_id}"));
Ответ = Новый Структура; Ответ = Новый Структура;
@ -446,7 +511,15 @@ class BridgeHandler(BaseHTTPRequestHandler):
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
path = parsed.path.rstrip("/") 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"{}" post_data = self.rfile.read(content_len) if content_len > 0 else b"{}"
try: try:
req_json = json.loads(post_data.decode("utf-8")) req_json = json.loads(post_data.decode("utf-8"))
@ -456,7 +529,6 @@ class BridgeHandler(BaseHTTPRequestHandler):
if path == "/appointments": if path == "/appointments":
doctor_id = req_json.get("doctor_id", "") 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", "") branch_id = req_json.get("branch_id", "")
patient_name = req_json.get("patient_name", "") patient_name = req_json.get("patient_name", "")
patient_phone = req_json.get("patient_phone", "") patient_phone = req_json.get("patient_phone", "")
@ -475,6 +547,24 @@ class BridgeHandler(BaseHTTPRequestHandler):
if not patient_name: if not patient_name:
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 # Normalize phone digits and generate search patterns
digits = "".join([c for c in patient_phone if c.isdigit()]) digits = "".join([c for c in patient_phone if c.isdigit()])
if len(digits) >= 10: 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 = НРег(XMLСтрока(Док.Ссылка));
ID_B64 = Base64Строка(ПолучитьДвоичныеДанныеИзСтроки(ID));
Ответ = Новый Структура; Ответ = Новый Структура;
Ответ.Вставить("appointment_id", ID); Ответ.Вставить("appointment_id", ID);
Ответ.Вставить("appointment_id_b64", ID_B64);
Ответ.Вставить("number", Док.Номер);
Ответ.Вставить("status", "booked"); Ответ.Вставить("status", "booked");
Ответ.Вставить("start", Формат(Док.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00"); Ответ.Вставить("start", Формат(Док.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00");
Ответ.Вставить("end", Формат(Док.ДатаОкончания, "ДФ=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) 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("ОШИБКА")): if err or (isinstance(data, str) and data.startswith("ОШИБКА")):
self.send_json(500, {"code": "booking_failed", "message": err or data}) self.send_json(500, {"code": "booking_failed", "message": err or data})
else: else:
self.send_json(201, data) self.send_json(201, data)
return 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"}) self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"})
def run(): def run():

124
bridge/test_bridge.py Normal file
View File

@ -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)

View File

@ -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()

View File

@ -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.

View File

@ -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

28
deploy/firewall-1c-egress.sh Executable file
View File

@ -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"

View File

@ -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.'

View File

@ -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)

View File

@ -48,6 +48,55 @@ services:
security_opt: security_opt:
- no-new-privileges:true - 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: volumes:
db-data: db-data:
media: media:
# Существующая сеть уже проверена: 172.20.0.0/16. external запрещает Compose
# пересоздавать её при добавлении моста и затрагивать работающие api/db.
# При развёртывании на новом хосте сначала создайте эту сеть с нужной подсетью.
networks:
default:
external: true
name: dana-medical_default

View File

@ -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"))
}