fix(security): BSL injection escaping, bridge auth, secrets to env, egress isolation to 1C
This commit is contained in:
parent
2c4e374c99
commit
050dc30d92
@ -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"]
|
||||
|
||||
@ -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():
|
||||
|
||||
124
bridge/test_bridge.py
Normal file
124
bridge/test_bridge.py
Normal 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)
|
||||
80
bridge/test_bridge_extra.py
Normal file
80
bridge/test_bridge_extra.py
Normal 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()
|
||||
72
deploy/SECURITY-20261020.md
Normal file
72
deploy/SECURITY-20261020.md
Normal 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.
|
||||
17
deploy/dana-1c-egress.service
Normal file
17
deploy/dana-1c-egress.service
Normal 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
28
deploy/firewall-1c-egress.sh
Executable 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"
|
||||
17
deploy/install-1c-egress.sh
Normal file
17
deploy/install-1c-egress.sh
Normal 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.'
|
||||
28
deploy/verify-1c-egress.py
Normal file
28
deploy/verify-1c-egress.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user