> For the complete documentation index, see [llms.txt](https://docs.ipcheck.ing/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ipcheck.ing/developer/ru/development/adding-a-new-tool.md).

# Добавление нового инструмента

Все «Расширенные инструменты» на главной странице — MAC Lookup, Whois, DNS Resolver, Censorship Check и остальные — следуют одному шаблону подключения. На этой странице пошагово разбирается добавление нового инструмента от начала до конца.

## Пример

Мы добавим гипотетический **Проверка сертификата**: введите домен, и получите обратно из внешнего API сведения о его TLS-сертификате.

| Часть                  | Значение                                           |
| ---------------------- | -------------------------------------------------- |
| Слаг                   | `certcheck`                                        |
| Компонент              | `frontend/components/advanced-tools/CertCheck.vue` |
| Обработчик API         | `api/cert-check.js`                                |
| Маршрут                | `GET /api/certcheck?domain=…`                      |
| Пространство имен i18n | `certcheck.*`                                      |

Он напрямую смоделирован по **MAC Lookup** инструменту (`macchecker` → `MacChecker.vue` → `api/mac-checker.js`), который является самым маленьким полным примером в репозитории. Откройте эти три файла вместе с этой страницей.

{% hint style="info" %}
**Не каждому инструменту нужен бэкенд.** Browser Info и Security Checklist полностью работают в браузере. Если ваш тоже, пропустите шаги 1–3 и сразу переходите к компоненту.
{% endhint %}

## Именование

* **Слаг** — в нижнем регистре, без разделителей: `macchecker`, `dnsresolver`, `censorshipcheck`. Это URL по адресу `/tools/<slug>` и запрос drawer `?tool=<slug>`, так что после выпуска он фактически становится постоянным.
* **Компонент** — PascalCase `.vue` в `frontend/components/advanced-tools/`.
* **Имя файла обработчика** — kebab-case `.js` в `api/`.
* **Путь маршрута** — совпадает со слагом для старых инструментов (`/api/macchecker`), kebab-case для новых (`/api/ooni-blocking`, `/api/service-status`). Оба варианта подходят; выберите один и используйте его последовательно.

***

{% stepper %}
{% step %}

### Напишите обработчик API

Один файл на маршрут в `api/`, начиная с комментария в заголовке, где указаны маршрут и его назначение. Один default export, `fetchUpstream` для запроса к внешнему API, общий логгер — для ошибок.

{% code title="api/cert-check.js" %}

```js
// /api/certcheck — сведения о TLS-сертификате домена, получаемые из
// внешнего API сертификатов. Обеспечивает работу фронтенд-инструмента CertCheck.

import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';

const CERT_API_URL = 'https://example-cert-api.test/v1/cert';

export default async (req, res) => {
    if (req.method !== 'GET') {
        return res.status(405).json({ error: 'Method Not Allowed' });
    }

    // Наличие, форма и приведение к нижнему регистру гарантируются requireValidDomain.
    const domain = req.query.domain;

    const token = process.env.CERT_API_KEY || '';
    if (!token) {
        return res.status(500).json({ error: 'API key missing' });
    }

    try {
        const upstream = await fetchUpstream(`${CERT_API_URL}?host=${domain}&key=${token}`);
        if (!upstream.ok) {
            throw new Error(`Certificate API responded with status ${upstream.status}`);
        }
        res.json(await upstream.json());
    } catch (error) {
        logger.error({ err: error, domain }, 'cert-check handler failed');
        res.status(500).json({ error: error.message });
    }
};
```

{% endcode %}

Четыре вещи, которые не подлежат обсуждению:

* **`fetchUpstream`, никогда не голый `fetch()`.** Он включает 8-секундный таймаут и проектный `User-Agent`.
* **Общий логгер, никогда не `console.*`.** Сначала объект контекста, затем короткое сообщение.
* **Краткие формы ошибок.** `400` при неверном вводе, `500` с `{ error: … }` при сбое.
* **В обработчике нет проверок referer или параметров.** Middleware уже сделало это — см. следующий шаг.

Проверка `req.method !== 'GET'` защитная: маршрут ниже уже ограничивает метод. Она остается, потому что smoke test напрямую проверяет ее.
{% endstep %}

{% step %}

### Используйте guard повторно — или добавьте новый

Валидация параметров находится в `common/guards.js`, никогда внутри обработчиков. Наш инструмент принимает `?domain=`, и такой guard уже существует:

```js
requireValidDomain()   // отклоняет отсутствующие/некорректные домены, приводит к нижнему регистру на месте
```

Приведение к нижнему регистру на месте важно: edge cache использует URL как ключ, поэтому запрос в смешанном регистре не должен становиться отдельной записью кэша.

Полный набор, доступный сегодня:

| Guard                      | Проверяет                                              |
| -------------------------- | ------------------------------------------------------ |
| `requireReferer`           | Глобально на `/api/*` — разрешенные домены + localhost |
| `requireValidIP()`         | `?ip=`                                                 |
| `requireValidDomain()`     | `?domain=` (также приводит к нижнему регистру)         |
| `requireValidPrefix()`     | `?prefix=` (CIDR)                                      |
| `requireValidASN()`        | `?asn=` (удаляет `AS`, переписывает в числовой)        |
| `requireValidProviderId()` | `?id=` по списку слагов service-status                 |
| `requireValidReportId()`   | `/api/report/:id` параметр маршрута                    |

{% hint style="warning" %}
**Новый формат параметра означает новый guard.** Добавьте его как экспортируемую фабрику в `common/guards.js`, подключите его в `backend-server.js`, и покройте его в `tests/guards.test.js`. Не встраивайте проверку прямо в обработчик — именно для предотвращения такого расхождения и существует слой guard.
{% endhint %}
{% endstep %}

{% step %}

### Подключите маршрут в `backend-server.js`

Каждый маршрут в приложении объявлен в этом единственном файле. Импортируйте обработчик вверху, рядом с остальными:

{% code title="backend-server.js" %}

```js
import certCheckHandler from './api/cert-check.js';
```

{% endcode %}

Затем объявите маршрут. Порядок middleware: сначала guard, затем cache, затем handler:

{% code title="backend-server.js" %}

```js
app.get('/api/certcheck', requireValidDomain(), cacheable(ONE_DAY_CACHE), certCheckHandler);
```

{% endcode %}

Выберите TTL, ориентируясь на то, как быстро реально меняются данные во внешнем источнике. В файле уже определены константы — `FIVE_MIN_CACHE`, `ONE_HOUR_CACHE`, `ONE_DAY_CACHE`, `SEVEN_DAYS_CACHE`, `THIRTY_DAYS_CACHE`, `ONE_YEAR_CACHE` — все записаны как выражения с умножением, а не как голые секунды.

Если данные зависят от пользователя, требуют аутентификации или меняются при каждом запросе, **не используйте `cacheable()` вообще**. Все, что находится под `/api/*` по умолчанию `Cache-Control: no-store`, поэтому не включать его — безопасный выбор.

Новая переменная окружения? Добавьте ее в `.env.example` с комментарием и задокументируйте ее в [Переменные окружения](/developer/ru/reference/environment-variables.md) и [Необязательные API-ключи](/developer/ru/configuration/optional-api-keys.md).
{% endstep %}

{% step %}

### Соберите компонент

Создайте `frontend/components/advanced-tools/CertCheck.vue`. Это обычный `<script setup>` компонент — и drawer, и отдельная страница монтируют его как есть, поэтому ему не нужна обертка, заголовок или знание о маршруте.

Копируйте канонические шаблоны, а не изобретайте новые. Из `MacChecker.vue`:

{% code title="frontend/components/advanced-tools/CertCheck.vue" %}

```vue
<template>
    <div class="cert-check-section my-4 space-y-4">
        <p class="text-sm text-muted-foreground leading-relaxed">{{ t('certcheck.Note') }}</p>

        <div class="space-y-2">
            <Label for="queryDomain">{{ t('certcheck.Note2') }}</Label>
            <div class="flex items-center gap-2">
                <Input type="text" id="queryDomain" name="queryDomain"
                    autocomplete="off" autocorrect="off" autocapitalize="off"
                    spellcheck="false" data-1p-ignore data-lpignore="true"
                    :disabled="status === 'running'"
                    :placeholder="t('certcheck.Placeholder')"
                    v-model="queryDomain" @keyup.enter="onSubmit" />
                <Button variant="action" :disabled="status === 'running' || !queryDomain"
                    @click="onSubmit" class="cursor-pointer">
                    <Spinner v-if="status === 'running'" />
                    <Search v-else class="size-4 shrink-0" />
                </Button>
            </div>
            <p v-if="errorMsg" class="text-sm text-destructive">{{ errorMsg }}</p>
        </div>

        <!-- Область результата -->
        <Card v-if="result.subject">…</Card>
    </div>
</template>

<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { Search } from '@lucide/vue';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
import { Label } from '@/components/ui/label';

const { t } = useI18n();

const queryDomain = ref('');
const status = ref('idle');
const result = ref({});
const errorMsg = ref('');

const onSubmit = () => {
    trackEvent('Section', 'StartClick', 'CertCheck');
    errorMsg.value = '';
    result.value = {};
    if (queryDomain.value) fetchCert(queryDomain.value);
};

const fetchCert = async (domain) => {
    status.value = 'running';
    try {
        const response = await fetch(`/api/certcheck?domain=${domain}`);
        if (!response.ok) throw new Error('Network response was not ok');
        result.value = await response.json();
    } catch (error) {
        console.error('Error fetching certificate:', error);
        errorMsg.value = t('certcheck.fetchError');
    } finally {
        status.value = 'idle';
    }
};
</script>
```

{% endcode %}

Стоит отметить:

* **Кнопка запуска** — `variant="action"` с `<Spinner v-if />` и `:disabled` защита. Это общепроектный способ «запустить это».
* **Поля, защищенные от AutoFill** — каждое свободно вводимое `Input` содержит все шесть атрибутов, показанных выше, а placeholder избегает слова «address» (и его переводов), потому что iOS QuickType ориентируется на само слово даже при `autocomplete="off"`.
* **`console.*` здесь подходит.** Фронтенд использует его; ограничение касается только файлов бэкенда.
* **Никаких самописных переключателей состояние→цвет.** Если ваш инструмент сопоставляет бизнес-состояние с цветом, используйте `composables/use-status-tone.js`.
* **Каждая строка — это `t()` вызов.** Ничего видимого пользователю не захардкожено.

Другие канонические шаблоны — карточки состояния, флаги, таблицы против списков, заголовки диалогов, анимации — перечислены в [Frontend](/developer/ru/architecture/frontend.md).
{% endstep %}

{% step %}

### Зарегистрируйте инструмент

`frontend/data/tools.js` является единственным источником истины. Одна запись там дает вам карточку на главной странице, нижний drawer, отдельную `/tools/<slug>` страницу и пункт меню навигации — **вам не нужно трогать роутер**.

{% code title="frontend/data/tools.js" %}

```js
export const ADVANCED_TOOLS = [
  // …existing entries…
  { slug: 'certcheck', emoji: '🔐', titleKey: 'certcheck.Title', noteKey: 'advancedtools.CertCheck', component: () => import('@/components/advanced-tools/CertCheck.vue') },
];
```

{% endcode %}

Форма записи:

<table><thead><tr><th width="230">Поле</th><th>Значение</th></tr></thead><tbody><tr><td><code>slug</code></td><td>Стабильный идентификатор URL — <code>/tools/&#x3C;slug></code> и запрос drawer. <code>?tool=&#x3C;slug></code> query.</td></tr><tr><td><code>emoji</code></td><td>Глиф карточки и глиф заголовка drawer.</td></tr><tr><td><code>titleKey</code></td><td>i18n-ключ для заголовка инструмента.</td></tr><tr><td><code>noteKey</code></td><td>i18n-ключ для однострочного описания карточки.</td></tr><tr><td><code>component</code></td><td>Ленивый импорт <code>.vue</code> файла — используется и drawer, и отдельной страницей.</td></tr><tr><td><code>requiresOriginalSite</code></td><td>Необязательное. <code>true</code> скрывает инструмент на self-hosted инстансах. Опустите его для публичного инструмента.</td></tr></tbody></table>

Порядок в массиве — это порядок карточек на главной странице.

{% hint style="info" %}
**`requiresOriginalSite: true`** предназначено для инструментов, зависящих от приватного IPCheck.ing API и входа в аккаунт — они скрыты на форках и self-hosted инстансах, которые не имеют способа обратиться к этому бэкенду. См. [Возможности, связанные с IPCheck.ing](/developer/ru/configuration/features-tied-to-ipcheck-ing.md). Большинство новых инструментов должны опускать это поле.
{% endhint %}
{% endstep %}

{% step %}

### Добавьте тексты — во всех четырех локалях

Каждой строке, которую вы использовали, нужна запись в **`en.json`, `zh.json`, `fr.json`, и `ru.json`** в `frontend/locales/`. Все четыре — в одном и том же изменении. Это не задача на потом.

Два места для правки в каждой локали — собственное пространство имен вашего инструмента:

{% code title="frontend/locales/en.json" %}

```json
"certcheck": {
  "Title": "Проверка сертификата",
  "Note": "Проверьте TLS-сертификат любого домена: издателя, срок действия и альтернативные имена субъекта.",
  "Note2": "Введите домен, чтобы начать проверку:",
  "Placeholder": "example.com",
  "fetchError": "Не удалось получить сведения о сертификате"
}
```

{% endcode %}

…и описание карточки в общем `advancedtools` пространстве имен:

{% code title="frontend/locales/en.json" %}

```json
"advancedtools": {
  "CertCheck": "Проверить TLS-сертификат домена"
}
```

{% endcode %}

Пространство имен обычно совпадает со слагом (`macchecker`, `dnsresolver`, `censorshipcheck`). Сохраняйте одинаковые имена ключей во всех четырех файлах — меняются только значения.

Сведения о загрузке, субпакетах и цепочке запасного выбора: [i18n](/developer/ru/development/i18n.md).
{% endstep %}

{% step %}

### Добавьте запись в журнал изменений

Добавьте свою запись в **последний** блок версии в `frontend/data/changelog.json` — файл обрабатывается от старых к новым, а интерфейс отображает его в обратном порядке.

{% code title="frontend/data/changelog.json" %}

```json
{
  "type": "добавить",
  "change": {
    "en": "Новый инструмент проверки сертификатов: просматривайте TLS-сертификат любого домена",
    "zh": "Новый инструмент проверки сертификатов: просматривайте TLS-сертификат любого домена",
    "fr": "Новый инструмент проверки сертификатов: просматривайте TLS-сертификат любого домена",
    "ru": "Новый инструмент проверки сертификатов: просмотр TLS-сертификата любого домена"
  }
}
```

{% endcode %}

`тип` должно быть одним из `добавить`, `улучшить`или `исправить`. Все четыре строки локалей должны присутствовать и быть непустыми — `tests/changelog.test.js` иначе сборка завершится с ошибкой.
{% endstep %}

{% step %}

### Напишите тесты

Два вида, оба в `tests/`.

**Смоук-тесты обработчика** находятся в `tests/api-handlers.test.js`, в `describe` блоке рядом с остальными. Проверяйте только ветки, которые возвращают **до** первого `fetchUpstream` вызова — suite никогда не обращается к реальному upstream:

{% code title="tests/api-handlers.test.js" %}

```js
import certCheckHandler from '../api/cert-check.js';

// -- обработчик cert-check ---------------------------------------------------
// Наличие и форма домена обеспечиваются middleware requireValidDomain
// (tests/guards.test.js); собственные ветки handler перед fetch — это
// проверка метода и ранний возврат при отсутствии API-ключа.

describe('обработчик cert-check', () => {
    it('отклоняет не-GET с 405 до обращения к upstream', async () => {
        const res = createResponse();
        await certCheckHandler(createRequest({ method: 'POST', query: { domain: 'example.com' } }), res);
        assert.equal(res.statusCode, 405);
        assert.equal(res.body.error, 'Метод не разрешён');
    });

    it('возвращает 500, когда API-ключ отсутствует', async () => {
        delete process.env.CERT_API_KEY;
        const res = createResponse();
        await certCheckHandler(createRequest({ query: { domain: 'example.com' } }), res);
        assert.equal(res.statusCode, 500);
        assert.equal(res.body.error, 'Отсутствует API-ключ');
    });
});
```

{% endcode %}

Файл уже предоставляет `createRequest()` / `createResponse()` заглушки. Если ваш обработчик читает новую переменную окружения, добавьте её имя в `ENV_KEYS` массив вверху, чтобы хуки резервного копирования/восстановления её охватывали.

**Модульные тесты** должны покрывать любую чистую логику, которую вводит ваш инструмент — валидаторы, парсеры, преобразования, composable с подменяемыми входами. Для каждого создайте свой `tests/<subject>.test.js`. Если вы добавили guard, расширьте `tests/guards.test.js`.

Что *не* получает тест: рендеринг Vue, реальные сетевые запросы, браузерные API. См. [Testing](/developer/ru/development/testing.md).
{% endstep %}

{% step %}

### Запустите самопроверку

```bash
pnpm check
```

Тесты плюс production-сборка. Перед открытием PR всё должно быть зелёным.

Затем посмотрите на инструмент сами в `pnpm dev` — в сетке карточек на главной, в выдвижной панели и по адресу `/tools/certcheck` — потому что это невозможно проверить автоматически. Укажите это в описании PR.
{% endstep %}
{% endstepper %}

***

## Чек-лист

| Шаг                                       | Файл                                               |
| ----------------------------------------- | -------------------------------------------------- |
| Обработчик                                | `api/cert-check.js`                                |
| Guard (только если новая форма параметра) | `common/guards.js`                                 |
| Маршрут                                   | `backend-server.js`                                |
| Компонент                                 | `frontend/components/advanced-tools/CertCheck.vue` |
| Запись в реестре                          | `frontend/data/tools.js`                           |
| Копия × 4                                 | `frontend/locales/{en,zh,fr,ru}.json`              |
| Журнал изменений × 4                      | `frontend/data/changelog.json`                     |
| Смоук-тест                                | `tests/api-handlers.test.js`                       |
| Модульные тесты                           | `tests/*.test.js`                                  |
| Новая переменная окружения                | `.env.example`                                     |

## Дальше

Две необязательные системы, к которым может подключиться ваш инструмент, обе событийно-ориентированные — компоненты отправляют события в `utils/app-events.js` шину и никогда не вызывают системы напрямую:

* **Достижения.** Отправьте доменное событие, сопоставьте его со slug достижения в `frontend/data/achievement-rules.js`, и добавьте достижение в `frontend/data/achievements.js`.
* **Диагностические отчёты, которыми можно поделиться.** Тест «моя сеть» отправляет `<domain>:finished` со своим структурированным результатом; builder в `frontend/utils/report-builders.js` нормализует его, а `common/report-schema.js` определяет допустимые поля. Builder'ы работают мягко, поэтому отсутствующая запись схемы проявится как тихо отсутствующее поле, а не как ошибка — добавьте builder и запись схемы в одно и то же изменение.

Оба описаны подробнее в [Frontend](/developer/ru/architecture/frontend.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.ipcheck.ing/developer/ru/development/adding-a-new-tool.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
