diff --git a/compose.yaml b/compose.yaml index 23fccbf..9d780cd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,114 +1,29 @@ -# `docker compose up` поднимает кластер целиком: redis, node-a, node-b за -# nginx-балансировщиком на 8080, плюс Prometheus на 9090 и Grafana на 3000. -# -# Маскирование детерминировано и работает на любом узле, а вот обратный шаг -# требует общего состояния — отсюда общий Redis между node-a и node-b. -# -# Лимиты CPU/RAM: сервер — 4 vCPU. Без лимитов node-a/node-b/prometheus/grafana -# под нагрузкой отжимали CPU у Redis, тот не укладывался в таймаут команд, узлы -# теряли общее состояние и демаскирование съезжало на резервный путь. Лимиты — -# это потолок (docker compose без swarm не умеет в гарантированные reservations), -# но они не дают соседям выесть Redis подчистую. +# Один узел, состояние маскирования/демаскирования — в памяти самого процесса +# (pdguard.store.backend=memory, дефолт). Redis был нужен только чтобы разделить +# состояние между несколькими репликами; кластерная схема на 4-vCPU хосте под +# нагрузкой давала конкуренцию за CPU (см. историю в git) — один узел проще и +# получает весь хост целиком. services: - prometheus: - image: prom/prometheus:v2.54.1 - command: - - --config.file=/etc/prometheus/prometheus.yml - - --storage.tsdb.retention.time=6h - - --web.enable-lifecycle - ports: - - "9090:9090" - volumes: - - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro - - prometheus-data:/prometheus - cpus: 0.5 - mem_limit: 512m - - grafana: - image: grafana/grafana:11.2.0 - ports: - - "3000:3000" - environment: - # Демонстрационный стенд: вход без пароля, чтобы жюри не искало учётные данные. - # Для контура с реальными данными это надо снять. - GF_AUTH_ANONYMOUS_ENABLED: "true" - GF_AUTH_ANONYMOUS_ORG_ROLE: Admin - GF_AUTH_DISABLE_LOGIN_FORM: "true" - GF_USERS_DEFAULT_THEME: light - # Сразу открывать дашборд модуля, а не пустую главную. - GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/pd-guard.json - volumes: - - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro - - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro - - grafana-data:/var/lib/grafana - cpus: 0.3 - mem_limit: 512m - - redis: - image: redis:7-alpine - command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "1gb", "--maxmemory-policy", "allkeys-lru"] - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 2s - retries: 5 - cpus: 1.0 - mem_limit: 1200m - - node-a: &node + pd-guard: image: pd-guard-spring:jvm - cpus: 1.0 - mem_limit: 2200m + ports: + - "8080:8080" environment: - PDGUARD_STORE_BACKEND: redis - SPRING_DATA_REDIS_HOST: redis PDGUARD_MAX_CONCURRENT: "2000" PDGUARD_WARMUP_ITERATIONS: "2000" # Вторая ступень распознавания — две модели под разные задачи (см. # NameCascade.java): WikiNEuRal размечает имена, ruBERT — составляющие # адреса. Модели в репозиторий не входят: ./tools/fetch-ner-model.sh. - # Пока движок не задан, соответствующая часть ступени выключена и - # сервис работает на одних правилах. PDGUARD_NER_NAME_ENGINE: wikineural PDGUARD_NER_NAME_MODEL: /deployments/models/wikineural-ner PDGUARD_NER_ADDRESS_ENGINE: rubert PDGUARD_NER_ADDRESS_MODEL: /deployments/models/rubert-ner - # ВРЕМЕННО для разового разбора формата тестовых payload'ов — пишет сырые ПД - # в логи узла. LOGGING_LEVEL_* не подходит: relaxed binding из env приводит - # имя логгера к нижнему регистру и не совпадает с ru.pdguard.core.Pipeline - # (заглавная P), поэтому уровень задан через -D, где регистр сохраняется. - # Выключить (убрать переменную) перед официальным нагрузочным прогоном. - JAVA_OPTS: "-Dspring.config.additional-location=optional:file:/deployments/config/ -Dlogging.level.ru.pdguard.core.Pipeline=DEBUG" volumes: - ./config:/deployments/config:ro - ./models:/deployments/models:ro - depends_on: - redis: - condition: service_healthy healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] interval: 10s timeout: 2s retries: 3 - - node-b: - <<: *node - - lb: - image: nginx:1.27-alpine - cpus: 0.3 - mem_limit: 128m - ports: - - "8080:80" - volumes: - - ./nginx/lb.conf:/etc/nginx/nginx.conf:ro - depends_on: - node-a: - condition: service_healthy - node-b: - condition: service_healthy - -volumes: - prometheus-data: - grafana-data: \ No newline at end of file diff --git a/src/main/java/ru/pdguard/core/SharedIndex.java b/src/main/java/ru/pdguard/core/SharedIndex.java index 1db06f7..9c10d9a 100644 --- a/src/main/java/ru/pdguard/core/SharedIndex.java +++ b/src/main/java/ru/pdguard/core/SharedIndex.java @@ -32,9 +32,6 @@ public class SharedIndex { private static final Logger LOG = LoggerFactory.getLogger(SharedIndex.class); - private static final String KEY_BY_ID = "pdg:id:"; - private static final String KEY_BY_MASK = "pdg:mask:"; - /** Сколько подряд неудач размыкает предохранитель. */ private static final int FAILURES_TO_OPEN = 3; diff --git a/src/main/java/ru/pdguard/detect/NameDictionary.java b/src/main/java/ru/pdguard/detect/NameDictionary.java index 789a481..12202c9 100644 --- a/src/main/java/ru/pdguard/detect/NameDictionary.java +++ b/src/main/java/ru/pdguard/detect/NameDictionary.java @@ -51,6 +51,9 @@ public final class NameDictionary { private static final Path EXTERNAL_FILE = Path.of("config/well-known.txt"); + /** Разделитель слов: любая последовательность не-буквенных символов. */ + private static final String WORD_SPLIT = "\\P{L}+"; + /** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */ private static final String REGNAL_ORDINALS = "перв|втор|трет|четв[её]рт|пят|шест|седьм|восьм|девят|десят"; @@ -111,7 +114,7 @@ public final class NameDictionary { * приходится не больше четырёх обращений к хеш-таблице. */ public static boolean containsGivenName(String value) { - for (String word : value.split("\\P{L}+")) { + for (String word : value.split(WORD_SPLIT)) { String lower = word.toLowerCase(Locale.ROOT); // Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов», // но это имя, а не фамилия. @@ -138,7 +141,7 @@ public final class NameDictionary { * не подсказывает, что перед нами имя. */ public static boolean containsNamePart(String value) { - for (String word : value.split("\\P{L}+")) { + for (String word : value.split(WORD_SPLIT)) { String lower = word.toLowerCase(Locale.ROOT); if (GIVEN_NAMES.contains(lower)) { return true; @@ -155,9 +158,19 @@ public final class NameDictionary { return lower.matches(".*(?:ович|евич|овна|евна|ична|ичн)$"); } - /** Фамилия по словообразованию: Иванов, Петрова, Троицкий, Шевченко. */ + /** Окончания, по которым слово похоже на фамилию: Иванов, Петрова, Троицкий, Шевченко. */ + private static final Set SURNAME_ENDINGS = Set.of( + "ов", "ев", "ёв", "ин", "ын", "ский", "ская", "ского", "ской", "ском", + "цкий", "цкая", "енко", "ко", "ук", "юк", "ян", "швили", "дзе"); + + /** Фамилия по словообразованию. Набор окончаний вместо regex: проще и без CANON_EQ. */ private static boolean isSurname(String lower) { - return lower.matches(".*(?:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)|енко|ко|ук|юк|ян|швили|дзе)$"); + for (String ending : SURNAME_ENDINGS) { + if (lower.endsWith(ending)) { + return true; + } + } + return false; } /** @@ -174,7 +187,7 @@ public final class NameDictionary { return true; } Set stems = currentWellKnownStems(); - for (String word : value.split("\\P{L}+")) { + for (String word : value.split(WORD_SPLIT)) { String lower = word.toLowerCase(Locale.ROOT); for (int length = lower.length(); length > 0; length--) { if (stems.contains(lower.substring(0, length))) { @@ -235,7 +248,6 @@ public final class NameDictionary { } } return Set.copyOf(merged); - }, - BUNDLED_WELL_KNOWN_STEMS); + }); } } \ No newline at end of file diff --git a/src/main/java/ru/pdguard/detect/ResourceLoader.java b/src/main/java/ru/pdguard/detect/ResourceLoader.java index bd42c61..838b0f7 100644 --- a/src/main/java/ru/pdguard/detect/ResourceLoader.java +++ b/src/main/java/ru/pdguard/detect/ResourceLoader.java @@ -67,10 +67,9 @@ final class ResourceLoader { * @param path путь к файлу * @param state состояние проверки (время последней проверки и mtime файла) * @param reader как превратить строки файла в итоговое значение - * @param fallback значение, если файл недоступен */ static T refreshIfChanged(Path path, FileWatchState state, - Function, T> reader, T fallback) { + Function, T> reader) { long now = System.currentTimeMillis(); if (now - state.lastCheck < state.recheckMillis) { return state.current; diff --git a/src/main/java/ru/pdguard/detect/RuleRegistry.java b/src/main/java/ru/pdguard/detect/RuleRegistry.java index 6313374..fda07c1 100644 --- a/src/main/java/ru/pdguard/detect/RuleRegistry.java +++ b/src/main/java/ru/pdguard/detect/RuleRegistry.java @@ -33,6 +33,9 @@ public class RuleRegistry { */ private static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+"; + /** Якорное слово-основа: держатель карты, держателем и т.п. */ + private static final String HOLDER_STEM = "держател"; + /** * Название улицы: от одного до трёх слов с заглавной буквы либо чисел — * «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно: @@ -285,7 +288,7 @@ public class RuleRegistry { Rule.of(PdTypes.CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)" + "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86) .groups(1) - .anchoredBy("держател", "cardholder", "на имя"), + .anchoredBy(HOLDER_STEM, "cardholder", "на имя"), // --- ФИО --- @@ -322,7 +325,7 @@ public class RuleRegistry { .anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик", "получател", "абонент", "в лице", "представител", "поручител", "заёмщик", "заемщик", "заявител", "доверител", "вкладчик", "ответственн", - "контактное лицо", "исполнител", "держател"), + "контактное лицо", "исполнител", HOLDER_STEM), // «клиент иван иванов», «поручитель петрович» — строчные имена после // ролевого слова. Регистр снимает требование к заглавной букве, а словарь @@ -339,7 +342,7 @@ public class RuleRegistry { .anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик", "получател", "абонент", "в лице", "представител", "поручител", "заёмщик", "заемщик", "заявител", "доверител", "вкладчик", "ответственн", - "контактное лицо", "исполнител", "держател", "отправител", "бенефициар", + "контактное лицо", "исполнител", HOLDER_STEM, "отправител", "бенефициар", "доверенное лицо", "наследник", "созаемщик"), // Фамилия рядом с личным именем из словаря: без словаря правило ловило бы diff --git a/src/main/java/ru/pdguard/detect/Validators.java b/src/main/java/ru/pdguard/detect/Validators.java index 6bc195a..cb1e705 100644 --- a/src/main/java/ru/pdguard/detect/Validators.java +++ b/src/main/java/ru/pdguard/detect/Validators.java @@ -138,13 +138,16 @@ public final class Validators { } String[] parts = value.split("[.\\-/\\s]+"); if (parts.length == 2) { - int a = Integer.parseInt(parts[0]); - int b = Integer.parseInt(parts[1]); - return (a >= 1 && a <= 31 && b >= 1 && b <= 12) || (b >= 1 && b <= 31 && a >= 1 && a <= 12); + return dayAndMonth(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); } if (parts.length != 3) { return false; } + return threePartDate(parts); + } + + /** {@code 12.05.1985}, {@code 1985-05-12}, {@code 15 03 90} — дата из трёх чисел. */ + private static boolean threePartDate(String[] parts) { int[] n = new int[3]; for (int i = 0; i < 3; i++) { if (parts[i].isEmpty() || parts[i].length() > 4) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d896b84..d1cbfc8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -37,7 +37,12 @@ pdguard: systems-file: config/systems.json store: backend: memory - max-chars: 134217728 + # 128MB (было) держал store.chars упёртым в потолок под держащей нагрузкой — + # FIFO-вытеснение выкидывало свежую запись за миллисекунды, раньше, чем + # приходил её собственный демаскирующий запрос: см. k6 на датасете утечек, + # 6.1% неверных демасков при 2000 VU, при этом 0% на низкой конкурентности. + # На одном узле без соседей по хосту память есть — 512MB даёт запас на порядок. + max-chars: 536870912 ttl-minutes: 30 # 32 байта в hex; AES-256 ключ шифрования хранилища encryption-key: "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"