Всем привет! Меня зовут Ростислав и я занимаюсь разработкой мониторинга для сайтов. Это мой пет-проект, если можно его так назвать. Иногда мониторинг сталкивается с проблемой, когда нужно проверить принадлежность сайта конкретному пользователю. Как это делается, я расскажу в статье.Два месяца назад мой проект занял 3-e место в рейтинге проектов на Product Radar'e. С тех пор меня спрашивают, как именно устроен мониторинг и как можно сделать его самому. Поэтому я решил начать рассказывать о технических деталях проекта в статьях.Примеры кода будут на Python (FastAPI, SQLAlchemy, mypy) и Java (Spring, Hibernate). Изначально проект был написан на Python, но по мере роста был переписан на Java для упрощения поддержки и развития.СодержаниеПроблемаРешениеКак можно проверить права на домен?Связываем страницы сайта с доменомВводим ограничение на страницы для одного доменаЗапрашиваем подтверждение доменаПроверяем TXT записьПроверяем страницу с кодомЗаключениеПроблемаМониторинг сайта - это когда сервера мониторинга раз в минуту отправляют запрос сайту (или 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, буду рад видеть вас среди пользователей моего сервиса. Для большинства задач бесплатной версии вполне достаточно.Кстати, если вы сталкивались с подобными задачами, расскажите, как вы их решали.Мониторите свои сайты и API?НетДа, разработал самДа, использую сервис мониторингаДа, развернул open source решение
Мониторю бесплатными сервисами свои Wordpress сайты
система нипель)) да нормально