Всем привет! Меня зовут Ростислав и я занимаюсь разработкой мониторинга для сайтов. Это мой пет-проект, если можно его так назвать. Иногда мониторинг сталкивается с проблемой, когда нужно проверить принадлежность сайта конкретному пользователю. Как это делается, я расскажу в статье.
Два месяца назад мой проект занял 3-e место в рейтинге проектов на Product Radar'e. С тех пор меня спрашивают, как именно устроен мониторинг и как можно сделать его самому. Поэтому я решил начать рассказывать о технических деталях проекта в статьях.
Примеры кода будут на Python (FastAPI, SQLAlchemy, mypy) и Java (Spring, Hibernate). Изначально проект был написан на Python, но по мере роста был переписан на Java для упрощения поддержки и развития.
Содержание
Проблема
Мониторинг сайта - это когда сервера мониторинга раз в минуту отправляют запрос сайту (или API эндпойну пользователя). Если пришла ошибка или сработал таймаут — пишут пользователю о сбое в Telegram или по почте.
В теории, у одного сайта (или бекенда) может быть много страниц и API эндпоинтов, которые нужно проверять. Поэтому я поддерживаю возможность добавить несколько страниц одного сайта для проверки.
И тут появляется проблема: если добавить слишком много страниц в мониторинг, можно устроить маленький DDOS. Например, конкуренту. И положить мои сервера за одно.
Некоторые пользователи добавляли по 10 000 страниц для одного сайта в формате: https://ya.ru/1, https://ya.ru/2, https://ya.ru/3 и т.д. Причём с большого количества бесплатных аккаунтов. Так что лимитировать количество доступных страниц для одного аккаунта не вышло.
Решение
Решать эту проблему я начал несколькими способами:
Добавил капчу во время регистрации и входа (если авторизация не через Telegram, VK или Яндекс ID): чтобы усложнить массовую регистрацию ботов.
Ввел бесплатный лимит на 10 сайтов: если кто-то хочет DDOS-сить сайты, через мониторинг это должно быть нерентабельно (да и я должен успевать масштабироваться).
Если сайт недоступен более 2-х дней - снимаю его с мониторинга. Таким образом страницы для спама запросов отключаются. Не выйдет поставить на мониторинг больше страниц, чем фактически находится на сайте.
Запретил мониторинг более 10 страниц одного домена без подтверждения прав на него: если кто-то хочет нагрузить сайт, тогда выйдет нагрузить только свой.
Собственно, про этот пункт и расскажу.
Как можно проверить права на домен?
Проверить, что конкретный домен (или хотя бы сайт) принадлежит пользователю можно следующими способами:
Попросить добавить в домен TXT-запись с уникальным кодом.
Попросить добавить на сайт страницу, которая содержит уникальный код в виде текста.
Попросить добавить head-тег на главную страницу с уникальным кодом.
Я решил использовать первый и второй вариант, а с третьим не заморачиваться.
Далее покажу, как реализовал это в виде кода и отрисовал в пользовательском интерфейсе.
Связываем страницы сайта с доменом
Сначала в базе нужно выделить отдельную сущность: домен. Чтобы проверять, какие страницы или API-методы относятся к нему:
Код на Python
class Domain(Base):
__tablename__ = "domains"
domain_name: Mapped[str] = mapped_column(String, primary_key=True, index=True)
Код на Java
@Entity
@Table(name = "domains",
indexes = {@Index(columnList = "name")})
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class Domain {
@Id
@Column(name = "name",
unique = true,
nullable = false,
columnDefinition = "TEXT")
private String name;
}
Затем связываем страницы с доменом:
Код на Python
class Website(Base):
__tablename__ = "websites"
url: Mapped[str] = mapped_column(String, primary_key=True, index=True)
domain_name: Mapped[str] = mapped_column(
ForeignKey("domains.domain_name"),
index=True,
)
domain: Mapped[Domain] = relationship()
...
Код на Java
@Entity
@Table(name = "pages",
indexes = {@Index(columnList = "url"),
@Index(columnList = "domain_name"),
...})
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class Page {
@Id
@Column(name = "url",
nullable = false,
columnDefinition = "TEXT")
private String url;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "domain_name",
nullable = false)
private Domain domain;
...
}
P.S. В изначальной системе на Python отслеживаемая страница называлась "Website" и мигрировала в Page сущность в Java. Потому что фактически мониторятся не сайты, а конкретные страницы.
Важный момент: в базе существует только одна уникальная страница для каждого уникального URL. На неё может ссылаться несколько пользователей.
Если кто-то хочет следить за сайтом habr.ru, мы создаём только одну страницу с URL https://habr.ru. Затем пользователи могут "подписаться" на уведомления от этой страницы.
Таким образом, если 100 человек хотят следить за https://habr.ru, мониторинг отправляет всего один запрос в минуту этой странице.
Далее добавляем сущность “Проверенный домен”, завязанную на конкретного пользователя. Сразу генерируем код подтверждения (с помощью UUID V4), который нужно будет вставить на сайт или в TXT запись:
Код на Python
class VerifiedDomain(Base):
__tablename__ = "verified_domains"
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id"),
primary_key=True,
index=True,
)
user: Mapped[User] = relationship()
domain_name: Mapped[str] = mapped_column(
String,
primary_key=True,
index=True,
)
is_verified: Mapped[bool] = mapped_column(Boolean)
verification_code: Mapped[str] = mapped_column(String)
class VerifiedDomainsService:
...
async def create_verified_domain(
self,
db: AsyncSession,
domain_name: str,
user_id: int,
) -> models.VerifiedDomain:
verified_domain = models.VerifiedDomain()
verified_domain.user_id = user_id
verified_domain.domain_name = domain_name
verified_domain.is_verified = False
verified_domain.verification_code = str(uuid4())
db.add(verified_domain)
await db.commit()
await db.refresh(verified_domain)
return verified_domain
Код на Java
@Entity
@Getter
@Table(name = "verified_domains",
indexes = {@Index(columnList = "user_id, domain_name")})
@AllArgsConstructor
@NoArgsConstructor
public class VerifiedDomain {
@Id
@GeneratedValue
@Column(name = "id")
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id",
nullable = false)
private User user;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "domain_name",
nullable = false)
private Domain domain;
@Setter
@Column(name = "is_verified")
private boolean isVerified;
@Column(name = "verification_code")
private String verificationCode;
}
@Service
@RequiredArgsConstructor
public class VerifiedDomainService {
...
private VerifiedDomain createVerifiedDomain(User user, Domain domain) {
VerifiedDomain verifiedDomain =
this.verifiedDomainRepository.findVerifiedDomainByUserAndDomain(user, domain);
if (verifiedDomain != null) {
throw new IllegalArgumentException("Domain already exists");
}
verifiedDomain = new VerifiedDomain(null,
user,
domain,
false,
UUID.randomUUID()
.toString());
return this.verifiedDomainRepository.save(verifiedDomain);
}
}
Так мы связали все страницы с доменами.
Вводим ограничение на страницы для одного домена
Если несколько пользователей хотят мониторить страницы одного домена, мы пресекаем попытку создать более 10 запросов на один домен за раз. Сначала нужно подтвердить права на этот домен.
Вот так проверяем условие при добавлении новой страницы:
Код на Python
class WebsiteSubscriptionsService(...):
...
async def add_website_subscription(
...
) -> schemas.WebsiteCreationResponse:
...
ALLOWED_WEBSITES_COUNT_WITHOUT_VERIFICATION = 10
if same_domain_websites_count >= ALLOWED_WEBSITES_COUNT_WITHOUT_VERIFICATION:
is_domain_verified = (
await self._verified_domains_service.is_domain_verified(
db,
user,
website.domain_name,
)
)
if not is_domain_verified:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"domain_verification_needed",
)
...
Код на Java
@Service
@RequiredArgsConstructor
public class PageSubscriptionService {
...
public PageSubscription addPageSubscription(...) throws ... {
...
long sameDomainPagesCount = this.pageService.getDomainPagesCount(loweredUrl);
if (sameDomainPagesCount > ALLOWED_WEBSITES_COUNT_WITHOUT_VERIFICATION) {
boolean isDomainVerified =
this.verifiedDomainService.isDomainVerified(user, loweredUrl);
if (!isDomainVerified) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"domain_verification_needed");
}
}
...
}
P.S. “domain_verification_needed” - это специальный код ошибки, которую понимает фронт и показывает специальный попап.
Запрашиваем подтверждение домена
На прошлом шаге мы выкинули ошибку при добавлении страницы, если на один домен ссылается слишком много страниц. Вот так ошибка выглядит для пользователя на сайте:
Следовательно, теперь пользователь должен подтвердить права на домен: через TXT запись или создание страницы с кодом подтверждения. Напомню, код подтверждения у всех пользователей уникальный и генерируется на сервере. Чужой домен совсем никак не выйдет подтвердить.
Теперь посмотрим на проверку с точки зрения кода.
Проверяем TXT запись
Тут алгоритм простой:
Взять все TXT записи.
Проверить, есть ли хотя бы одна запись с кодом, который сгенерировался для домена.
Но есть два важных момента:
При нажатии “подтвердить права” нужно ставить капчу. Чтобы нельзя было бесконечно создавать задачи на проверку домена и спамить чужой сайт с наших серверов.
Если сайт не подтвердился, нужно выводить сообщение “Обратите внимание, обновление TXT записей может занимать до 24 часов”.
Это неочевидный момент и многие пользователи не могли понять, почему не подтверждается домен.
Проверяем:
Код на Python
...
import dns.resolver
...
class VerifyDomainCommand:
async def verify_domain(
self,
verified_domain: models.VerifiedDomain,
) -> bool:
if await asyncio.to_thread(
self._is_domain_has_code_in_txt_record,
verified_domain.domain_name,
verified_domain.verification_code,
):
return True
...
return False
...
def _is_domain_has_code_in_txt_record(
self,
domain_name: str,
code: str,
) -> bool:
try:
resolved_records = dns.resolver.resolve(
domain_name,
"TXT",
)
if not resolved_records or not resolved_records.rrset:
return False
txt_records = [
dns_record.to_text() for dns_record in resolved_records.rrset
]
return any(code in txt_record for txt_record in txt_records)
except Exception as e:
...
return False
Код на Java
...
import org.xbill.DNS.Lookup;
import org.xbill.DNS.Record;
import org.xbill.DNS.TextParseException;
import org.xbill.DNS.Type;
...
public class VerifyDomainCommand {
public boolean verifyDomain(VerifiedDomain verifiedDomain) {
String domainName = verifiedDomain.getDomain()
.getName();
String verificationCode = verifiedDomain.getVerificationCode();
boolean isDomainTxtVerified = this.isDomainHasCodeInTxtRecord(domainName, verificationCode);
if (isDomainTxtVerified) {
return true;
}
....
}
...
private boolean isDomainHasCodeInTxtRecord(String domainName, String code) {
try {
Record[] records = new Lookup(domainName, Type.TXT).run();
if (records == null) {
return false;
}
for (Record record : records) {
String txt = record.rdataToString();
if (txt.contains(code)) {
return true;
}
}
} catch (TextParseException e) {
// ignore, because domain may not exist
}
return false;
}
}
Проверяем страницу с кодом
Здесь тоже всё просто:
Проверяем, что есть страница /monitoring-verification.
Проверяем, что страница содержит код для подтверждения домена.
Делаем обычные HTTP запросы. При этом проверяем страницу и по протоколу HTTPS, и по протоколу HTTP.
Код на Python
...
import aiohttp
...
class VerifyDomainCommand:
async def verify_domain(
self,
verified_domain: models.VerifiedDomain,
) -> bool:
...
if await self._is_verification_file_present(
verified_domain.domain_name,
verified_domain.verification_code,
):
return True
return False
async def _is_verification_file_present(
self,
domain_name: str,
code: str,
) -> bool:
session_timeout = aiohttp.ClientTimeout(
total=None,
sock_connect=20,
sock_read=20,
)
async with aiohttp.ClientSession(timeout=session_timeout) as session:
try:
verification_page_response = await session.get(
f"https://{domain_name}/monitoring-verification",
headers={"User-Agent": "proverator.ru"},
)
if verification_page_response.status != HTTPStatus.OK:
raise Exception("Status is not OK")
if code not in await verification_page_response.text():
raise Exception("Code has not been found")
except Exception:
try:
verification_page_response = await session.get(
f"http://{domain_name}/monitoring-verification",
headers={"User-Agent": "proverator.ru"},
)
if verification_page_response.status != HTTPStatus.OK:
raise Exception("Status is not OK")
if code not in await verification_page_response.text():
raise Exception("Code has not been found")
except Exception:
return False
return True
...
Код на Java
public class VerifyDomainCommand {
public boolean verifyDomain(VerifiedDomain verifiedDomain) {
...
return this.isVerificationFilePresent(verifiedDomain.getDomain()
.getName(),
verifiedDomain.getVerificationCode());
}
private boolean isVerificationFilePresent(String domainName, String code) {
try {
String url = "https://" + domainName + "/monitoring-verification";
if (this.checkVerificationPage(url, code)) {
return true;
}
} catch (Exception e) {
try {
String url = "http://" + domainName + "/monitoring-verification";
if (this.checkVerificationPage(url, code)) {
return true;
}
} catch (Exception ignored) {
// domain may not exist
}
}
return false;
}
private boolean checkVerificationPage(String urlString, String code) {
try (HttpClient client = HttpClient.newHttpClient()) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(urlString))
.header("User-Agent", "proverator.ru")
.build();
CompletableFuture<HttpResponse<String>> response =
client.sendAsync(request, HttpResponse.BodyHandlers.ofString());
String responseBody = response.thenApply(HttpResponse::body)
.get();
return responseBody.contains(code);
} catch (Exception e) {
return false;
}
}
...
}
В итоге, если домен подтверждён - создаём пользователю запись, что этот конкретный домен для него подтверждён. Пользователь сможет добавлять любое количество страниц для этого домена в мониторинг.
При этом остальным пользователям всё равно нужно подтвердить тот же домен, если они хотят мониторить его страницы.
Заключение
В этой статье я рассказал и показал, как проверяю, что домены действительно принадлежат пользователям моего сервиса. Это помогает избежать перегрузки серверов и DDoS-атак на чужие сайты за счёт мониторинг.
Процесс разработки мониторинга включал много других нюансов (как с технической, так и с пользовательской стороны). Про них я постараюсь рассказать в следующих статьях.
В случае, если вам нужно мониторить ваши сайты или API-методы и получать уведомления о сбоях в Telegram, буду рад видеть вас среди пользователей моего сервиса. Для большинства задач бесплатной версии вполне достаточно.
Кстати, если вы сталкивались с подобными задачами, расскажите, как вы их решали.
Комментарии (11)
rezdm
25.05.2024 15:16А зачем КДПВ убирать, было же так:
Squoworode
25.05.2024 15:16Это
шизаособенность нового хабра: текст до ката показывается только в ленте, текст после ката - только при открытии статьи.Кстати, из-за этого человек, открывший статью по ссылке, никогда не увидит текст до ката.
alfa41
Вариант с созданием поддомена можно рассматривать как проверочный?
RostislavDugin Автор
Не совсем понял, что именно вы имеете в виду
alfa41
Попросить владельца создать новый поддомен типа newsubdomain.domain.com
RostislavDugin Автор
В теории, да. На практике, думаю, нет.
Получается, что нужно создать домен. Подождать, пока он обновится на DNS серверах. Потом этот домен нужно заставить отвечать 200-м кодом (т.е. задеплоить какую-то страницу).
Выйдет слишком сложно и пользователь, скорее всего, просто забьёт на такую проверку.
Проще или с TXT записью (если подтверждение хочется сделать через домен). Или со страницей (если подтверждение хочется сделать через сайт).
alfa41
Если я правильно помню, то страницу создавать не обязательно. Созданный домен будет просто резолвиться в ip. Хотя бывают случаи, когда то же самое происходит для любого поддомена, даже несуществующего
RostislavDugin Автор
В целом, да
Но, чисто моё субъективное мнение (может для кого-то я и не прав), сложновато с поддоменом будет для пользователя в сравнении с текущими способами
lev
Добавить CNAME с поддоменом $hash.domain.tld не сложнее добавления TXT-записи?
RostislavDugin Автор
Да равносильно. Но проверить с моей стороны всё-таки сложнее